fix(legacy): repoint a stored dictionary path even when no file was copied

A stored dictionary path to an importer-owned legacy file was rewritten
only when this run copied that file. A user who never created a legacy
dictionary, or who already has a new dictionary.txt, kept the legacy path,
and the first edit was written back into the legacy directory. The path is
now rewritten to the file's new location whenever it names a payload file,
copied this run or not. Paths outside the legacy payload are kept as stored.

Refs #18
This commit is contained in:
2026-11-06 20:02:44 +05:30
parent c3c7f98117
commit 15a527beb1
2 changed files with 80 additions and 21 deletions
+22 -19
View File
@@ -9,9 +9,11 @@ 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.
A stored custom-dictionary path that names an importer-owned legacy file (one of
the payload files above) is rewritten in the copied configuration to that file's
new location, whether or not this run copied it. Edits then never land in the
legacy file. Any other path, including a user-chosen file elsewhere or another
file inside the legacy directory, is kept as stored.
"""
import json
@@ -83,20 +85,20 @@ 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."""
def _payload_destination(stored: str, payload: dict[str, str]) -> Optional[str]:
"""Return the new location of the importer-owned legacy file ``stored`` names."""
try:
target = os.path.realpath(os.path.expanduser(stored))
except (OSError, ValueError):
return None
for source, destination in copied.items():
for source, destination in payload.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.
def _repoint_dictionary_path(config_file: str, payload: dict[str, str]) -> None:
"""Rewrite the copied config's dictionary path when it names a payload file.
Only the new configuration copy is written; the legacy file is only read.
"""
@@ -112,7 +114,7 @@ def _repoint_dictionary_path(config_file: str, copied: dict[str, str]) -> None:
stored = dictionary.get("file_path")
if not isinstance(stored, str) or not stored.strip():
return
destination = _copied_destination(stored, copied)
destination = _payload_destination(stored, payload)
if destination is None:
return
dictionary["file_path"] = destination
@@ -146,24 +148,25 @@ def import_legacy_once() -> list[str]:
return []
imported: list[str] = []
copied: dict[str, str] = {}
# Every importer-owned legacy file and its new location, copied this run or
# not: a stored path to one of them must never resolve to the legacy file.
payload: dict[str, str] = {}
for filename in CONFIG_PAYLOAD:
src = os.path.join(legacy_config, filename)
dst = os.path.join(new_config_dir(), filename)
payload[src] = dst
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:
src = os.path.join(legacy_data, filename)
dst = os.path.join(new_data_dir(), filename)
if _copy_once(src, dst):
imported.append(dst)
copied[src] = dst
for filename in DATA_PAYLOAD:
src = os.path.join(legacy_data, filename)
dst = os.path.join(new_data_dir(), filename)
payload[src] = dst
if os.path.isdir(legacy_data) and _copy_once(src, dst):
imported.append(dst)
copied_config = os.path.join(new_config_dir(), CONFIG_FILENAME)
if copied_config in imported:
_repoint_dictionary_path(copied_config, copied)
_repoint_dictionary_path(copied_config, payload)
if imported:
logger.info("Legacy Vocalinux import complete: %d file(s)", len(imported))
return imported
+58 -2
View File
@@ -169,13 +169,25 @@ def _import_with_stored_dictionary_path(
encoding="utf-8",
)
import_legacy_once()
_point_config_manager_at(monkeypatch, config_root / "voculinux")
return seeded["dictionary.txt"]
new_config_dir = config_root / "voculinux"
def _point_config_manager_at(monkeypatch, new_config_dir: Path) -> None:
"""Make ConfigManager read and write only the given (scratch) configuration dir."""
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"]
def _add_term_through_migrated_config(monkeypatch, new_config_dir: Path, term: str) -> None:
"""Add a dictionary term exactly as the app does, using the migrated configuration."""
_point_config_manager_at(monkeypatch, new_config_dir)
from vocalinux.custom_dictionary import CustomDictionaryManager
from vocalinux.ui.config_manager import ConfigManager
assert CustomDictionaryManager(ConfigManager()).add_term(term) is True
@pytest.mark.parametrize("form", ["absolute-legacy", "tilde-legacy"])
@@ -221,3 +233,47 @@ def test_dictionary_path_outside_the_legacy_directory_is_kept(monkeypatch, tmp_p
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"
def test_absent_legacy_dictionary_edit_lands_in_the_new_file(monkeypatch, tmp_path) -> None:
"""The default dictionary path was saved but the file was never created."""
config_root, _data_root = _isolate(monkeypatch, tmp_path)
legacy_config = config_root / "vocalinux"
legacy_config.mkdir(parents=True)
legacy_terms = legacy_config / "dictionary.txt"
(legacy_config / "config.json").write_text(
json.dumps({"dictionary": {"enabled": True, "file_path": str(legacy_terms)}}),
encoding="utf-8",
)
import_legacy_once()
_add_term_through_migrated_config(monkeypatch, config_root / "voculinux", "Kubernetes")
assert not legacy_terms.exists()
assert "Kubernetes" in (config_root / "voculinux" / "dictionary.txt").read_text(
encoding="utf-8"
)
def test_existing_new_dictionary_takes_the_edit_and_legacy_stays_put(monkeypatch, tmp_path) -> None:
"""A new dictionary already present wins; the legacy file is never written."""
config_root, data_root = _isolate(monkeypatch, tmp_path)
seeded = _seed_legacy(config_root, data_root)
legacy_terms = seeded["dictionary.txt"]
seeded["config.json"].write_text(
json.dumps({"dictionary": {"enabled": True, "file_path": str(legacy_terms)}}),
encoding="utf-8",
)
new_config_dir = config_root / "voculinux"
new_config_dir.mkdir(parents=True)
(new_config_dir / "dictionary.txt").write_text("Mine\n", encoding="utf-8")
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
import_legacy_once()
_add_term_through_migrated_config(monkeypatch, new_config_dir, "Kubernetes")
assert legacy_terms.read_bytes() == legacy_bytes
assert legacy_terms.stat().st_mtime_ns == legacy_mtime
assert (new_config_dir / "dictionary.txt").read_text(encoding="utf-8") == "Mine\nKubernetes\n"