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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user