fix(legacy): point a migrated dictionary path at the copied file

The one-time import copied config.json and dictionary.txt, but a stored
dictionary path that still named the legacy file kept pointing there, so
dictionary edits were written back to the legacy location. After the copy,
the new configuration copy now has its dictionary path rewritten when it
names a legacy file copied in this run. Only the new copy is written, and
a path chosen outside the legacy directory is kept as stored.

Refs #18
This commit is contained in:
2026-11-06 19:58:32 +05:30
parent 51ecd686d8
commit c3c7f98117
2 changed files with 132 additions and 0 deletions
+56
View File
@@ -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
+76
View File
@@ -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"