diff --git a/src/vocalinux/legacy_import.py b/src/vocalinux/legacy_import.py index 760b71e..1383874 100644 --- a/src/vocalinux/legacy_import.py +++ b/src/vocalinux/legacy_import.py @@ -8,11 +8,17 @@ always wins: no merging, no write-back to the legacy location. Exactly-once falls out of existence checks — a copied file is never copied again, and a run that already has new locations imports nothing. + +A stored custom-dictionary path that names a legacy file copied in this run is +rewritten in the copied configuration to the new location, so edits never land +in the legacy file. Any other path (a location the user chose elsewhere) is kept. """ +import json import logging import os import shutil +from typing import Optional from .app_identity import APP_DIR_NAME, LEGACY_APP_DIR_NAME @@ -77,6 +83,50 @@ def _copy_once(src: str, dst: str) -> bool: return True +def _copied_destination(stored: str, copied: dict[str, str]) -> Optional[str]: + """Return the new location of the copied legacy file ``stored`` names, if any.""" + try: + target = os.path.realpath(os.path.expanduser(stored)) + except (OSError, ValueError): + return None + for source, destination in copied.items(): + if target == os.path.realpath(source): + return destination + return None + + +def _repoint_dictionary_path(config_file: str, copied: dict[str, str]) -> None: + """Rewrite the copied config's dictionary path when it names a copied legacy file. + + Only the new configuration copy is written; the legacy file is only read. + """ + try: + with open(config_file, encoding="utf-8") as handle: + config = json.load(handle) + except (OSError, ValueError) as error: + logger.warning("Legacy import could not read the copied configuration: %s", error) + return + dictionary = config.get("dictionary") if isinstance(config, dict) else None + if not isinstance(dictionary, dict): + return + stored = dictionary.get("file_path") + if not isinstance(stored, str) or not stored.strip(): + return + destination = _copied_destination(stored, copied) + if destination is None: + return + dictionary["file_path"] = destination + temporary = config_file + ".tmp" + try: + with open(temporary, "w", encoding="utf-8") as handle: + json.dump(config, handle, indent=4) + os.replace(temporary, config_file) + except OSError as error: + logger.warning("Legacy import could not point the dictionary at %s: %s", destination, error) + return + logger.info("Legacy import pointed the custom dictionary at %s", destination) + + def import_legacy_once() -> list[str]: """Copy legacy Vocalinux user data into the new locations, once. @@ -96,11 +146,13 @@ def import_legacy_once() -> list[str]: return [] imported: list[str] = [] + copied: dict[str, str] = {} for filename in CONFIG_PAYLOAD: src = os.path.join(legacy_config, filename) dst = os.path.join(new_config_dir(), filename) if _copy_once(src, dst): imported.append(dst) + copied[src] = dst legacy_data = legacy_data_dir() if os.path.isdir(legacy_data): for filename in DATA_PAYLOAD: @@ -108,6 +160,10 @@ def import_legacy_once() -> list[str]: dst = os.path.join(new_data_dir(), filename) if _copy_once(src, dst): imported.append(dst) + copied[src] = dst + copied_config = os.path.join(new_config_dir(), CONFIG_FILENAME) + if copied_config in imported: + _repoint_dictionary_path(copied_config, copied) if imported: logger.info("Legacy Vocalinux import complete: %d file(s)", len(imported)) return imported diff --git a/tests/test_legacy_import.py b/tests/test_legacy_import.py index 6e99ced..a0a4bf6 100644 --- a/tests/test_legacy_import.py +++ b/tests/test_legacy_import.py @@ -11,8 +11,11 @@ Seams under test (all offline, no hardware): """ import json +import os from pathlib import Path +import pytest + from vocalinux import legacy_import from vocalinux.legacy_import import import_legacy_once @@ -145,3 +148,76 @@ def test_new_dictionary_never_overwritten_by_legacy(monkeypatch, tmp_path) -> No # dictionary file must survive untouched. assert (new_config / "dictionary.txt").read_text() == "Mine\n" assert legacy_import.CONFIG_FILENAME == "config.json" + + +#: A fixed old timestamp, so any write to the legacy file moves its mtime visibly. +_OLD_MTIME_NS = 1_000_000_000_000_000_000 + + +def _import_with_stored_dictionary_path( + monkeypatch, config_root: Path, data_root: Path, stored_path: str +) -> Path: + """Seed a legacy install whose config stores ``stored_path``, then import it. + + Returns the legacy dictionary file. The ConfigManager paths are pointed at + the copied configuration, so nothing outside the scratch directories is read + or written. + """ + seeded = _seed_legacy(config_root, data_root) + seeded["config.json"].write_text( + json.dumps({"dictionary": {"enabled": True, "file_path": stored_path}}), + encoding="utf-8", + ) + import_legacy_once() + + new_config_dir = config_root / "voculinux" + from vocalinux.ui import config_manager + + monkeypatch.setattr(config_manager, "CONFIG_DIR", str(new_config_dir)) + monkeypatch.setattr(config_manager, "CONFIG_FILE", str(new_config_dir / "config.json")) + return seeded["dictionary.txt"] + + +@pytest.mark.parametrize("form", ["absolute-legacy", "tilde-legacy"]) +def test_migrated_dictionary_edit_never_writes_the_legacy_file(monkeypatch, tmp_path, form) -> None: + """The copied config points the dictionary at the copy, so an edit leaves legacy alone.""" + if form == "tilde-legacy": + # The tilde form names the legacy file only when XDG_CONFIG_HOME is ~/.config. + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + config_root = home / ".config" + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_root)) + data_root = tmp_path / "data" + monkeypatch.setenv("XDG_DATA_HOME", str(data_root)) + stored = "~/.config/vocalinux/dictionary.txt" + else: + config_root, data_root = _isolate(monkeypatch, tmp_path) + stored = str(config_root / "vocalinux" / "dictionary.txt") + legacy_terms = _import_with_stored_dictionary_path(monkeypatch, config_root, data_root, stored) + os.utime(legacy_terms, ns=(_OLD_MTIME_NS, _OLD_MTIME_NS)) + legacy_bytes = legacy_terms.read_bytes() + legacy_mtime = legacy_terms.stat().st_mtime_ns + + from vocalinux.custom_dictionary import CustomDictionaryManager + from vocalinux.ui.config_manager import ConfigManager + + dictionary = CustomDictionaryManager(ConfigManager()) + assert dictionary.add_term("Kubernetes") is True + + assert legacy_terms.read_bytes() == legacy_bytes + assert legacy_terms.stat().st_mtime_ns == legacy_mtime + new_terms = config_root / "voculinux" / "dictionary.txt" + assert "Kubernetes" in new_terms.read_text(encoding="utf-8") + + +def test_dictionary_path_outside_the_legacy_directory_is_kept(monkeypatch, tmp_path) -> None: + """A path the user chose outside the legacy directory is not rewritten.""" + chosen = tmp_path / "shared" / "terms.txt" + chosen.parent.mkdir(parents=True) + chosen.write_text("Chosen\n", encoding="utf-8") + config_root, data_root = _isolate(monkeypatch, tmp_path) + _import_with_stored_dictionary_path(monkeypatch, config_root, data_root, str(chosen)) + + migrated = json.loads((config_root / "voculinux" / "config.json").read_text(encoding="utf-8")) + assert migrated["dictionary"]["file_path"] == str(chosen) + assert chosen.read_text(encoding="utf-8") == "Chosen\n"