diff --git a/docs/archimate.md b/docs/archimate.md index e52c9be..0c2fc5c 100644 --- a/docs/archimate.md +++ b/docs/archimate.md @@ -655,7 +655,7 @@ Rel_Triggering(investigate, close) - Полная Appendix B в sample отсутствует — матрица пар здесь практическая, не нормативная таблица B.5. - PDF спецификации в git не хранятся. - Старый PlantUML/Kroki без stdlib 3.2: fallback — `` ```uml `` + keyword `archimate`. -- Пользовательский `md2gost.schemes.json`: встроенные схемы с `author: md2gost` синхронизируются с бандлом; свои правки (другой author) не затираются. +- Пользовательский `md2gost.schemes.json` при запуске только дополняется отсутствующими встроенными схемами. Уже записанные правки не затираются. --- diff --git a/docs/schemes.md b/docs/schemes.md index b360e1f..f2215a7 100644 --- a/docs/schemes.md +++ b/docs/schemes.md @@ -38,7 +38,7 @@ Rel(user, app, "логин") ## Встроенные схемы -Файл шаблона: [`md2gost/diagrams/schemes.json`](../md2gost/diagrams/schemes.json). При первом запуске копируется в `md2gost.schemes.json` рядом с приложением. При каждом запуске встроенные схемы (`author: md2gost`) синхронизируются с шаблоном: новые id добавляются, устаревшие (например удалённый `bpmn`) убираются, содержимое обновляется. Схемы с пустым или другим `author` не трогаются. Если правите встроенную в GUI и сохраняете — `author` сбрасывается, чтобы миграция не затёрла правки. +Файл шаблона: [`md2gost/diagrams/schemes.json`](../md2gost/diagrams/schemes.json). При первом запуске копируется в `md2gost.schemes.json` рядом с приложением. При каждом запуске в файл добавляются только отсутствующие встроенные схемы. Уже записанные схемы не перезаписываются и не удаляются: правки и свои шаблоны сохраняются. Файл, который не удалось разобрать, не затирается. Если правите встроенную схему в GUI и сохраняете — `author` сбрасывается, схема помечается как ваша. | id | Название | Оградка | |----|----------|---------| @@ -291,4 +291,4 @@ id схемы: латиница, цифры, `_`; начинается с бук 2. Или править `md2gost.schemes.json` вручную («Открыть JSON»). 3. CLI: `--schemes путь.json`. -Чтобы правка встроенной схемы не сбрасывалась при обновлении приложения, оставьте пустой `author` (GUI делает это сама при сохранении изменённой встроенной схемы). +Запуск программы не сбрасывает правки в `md2gost.schemes.json`. В GUI кнопка «Добавить» сразу записывает новую схему (`custom1`, `custom2`, …) в этот файл. diff --git a/md2gost/diagram_schemes.py b/md2gost/diagram_schemes.py index 5eae79d..f33e420 100644 --- a/md2gost/diagram_schemes.py +++ b/md2gost/diagram_schemes.py @@ -114,67 +114,48 @@ def is_app_owned_scheme(scheme: DiagramScheme) -> bool: return scheme.author.strip().lower() == APP_SCHEME_AUTHOR -def _schemes_payload(schemes: dict[str, DiagramScheme]) -> dict[str, Any]: - return {sid: scheme.to_dict() for sid, scheme in sorted(schemes.items())} - - def migrate_user_schemes(base: Path | None = None) -> SchemesMigrationResult: """ - Sync app-owned schemes in the user file with the bundled template. + Add bundled schemes that are missing from the user file. - - Bundled ids: always present; overwrite user entries with author=md2gost. - - User overrides of bundled ids (empty/other author): kept as-is. - - Orphaned author=md2gost ids no longer in the template: removed. - - Other custom schemes: kept. + Existing entries are kept as written: edits of built-in ids, custom + schemes, and schemes with author=md2gost are not overwritten or removed. + A file that cannot be parsed is left untouched. """ result = SchemesMigrationResult() path = user_schemes_path(base) if not path.is_file(): return result - bundled = _load_schemes_file(bundled_schemes_path()) - user = _load_schemes_file(path) - out: dict[str, DiagramScheme] = {} - - for sid, scheme in user.items(): - if sid in bundled: - if is_app_owned_scheme(scheme): - continue # step below writes bundled - out[sid] = scheme - continue - if is_app_owned_scheme(scheme): - result.removed.append(sid) - _log.info("Миграция схем: удалена устаревшая встроенная «%s»", sid) - continue - out[sid] = scheme - - for sid, scheme in bundled.items(): - if sid in out: - continue # user-owned override - prev = user.get(sid) - if prev is None: - result.added.append(sid) - _log.info("Миграция схем: добавлена «%s»", sid) - elif prev.to_dict() != scheme.to_dict(): - result.updated.append(sid) - _log.info("Миграция схем: обновлена «%s»", sid) - out[sid] = scheme - - if _schemes_payload(out) == _schemes_payload(user): - result.added.clear() - result.updated.clear() - result.removed.clear() + raw, err = _read_json_object(path) + if err or raw is None: + _log.warning("Миграция схем пропущена, файл не изменён (%s): %s", path, err) return result - save_schemes(out, path) + bundled = _load_schemes_file(bundled_schemes_path()) + existing = {str(key).lower().strip() for key in raw} + for sid, scheme in bundled.items(): + if sid in existing: + continue + raw[sid] = scheme.to_dict() + result.added.append(sid) + _log.info("Миграция схем: добавлена «%s»", sid) + + if not result.added: + return result + + path.write_text( + json.dumps(raw, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) result.written = True return result def ensure_user_schemes(base: Path | None = None) -> Path: """ - Ensure md2gost.schemes.json exists next to the app, then sync app-owned - schemes from the bundled template (add / update / remove orphans). + Ensure md2gost.schemes.json exists next to the app, then add any + bundled schemes that are not already in the user file. """ dest = user_schemes_path(base) if not dest.is_file(): @@ -190,16 +171,21 @@ def ensure_user_schemes(base: Path | None = None) -> Path: return dest -def _load_schemes_file(path: Path) -> dict[str, DiagramScheme]: +def _read_json_object(path: Path) -> tuple[dict[str, Any] | None, str | None]: + """Return (object, None). Missing file is ({}, None). Unreadable is (None, error).""" if not path.is_file(): - return {} + return {}, None try: - raw = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - _log.warning("Не удалось прочитать схемы %s: %s", path, exc) - return {} + # utf-8-sig: Notepad on Windows often writes a BOM, which json.loads rejects. + raw = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + return None, str(exc) if not isinstance(raw, dict): - return {} + return None, "корень JSON должен быть объектом" + return raw, None + + +def _schemes_from_raw(raw: dict[str, Any]) -> dict[str, DiagramScheme]: out: dict[str, DiagramScheme] = {} for key, val in raw.items(): sid = str(key).lower().strip() @@ -212,16 +198,70 @@ def _load_schemes_file(path: Path) -> dict[str, DiagramScheme]: return out +def _load_schemes_file(path: Path) -> dict[str, DiagramScheme]: + raw, err = _read_json_object(path) + if err or raw is None: + if path.is_file(): + _log.warning("Не удалось прочитать схемы %s: %s", path, err) + return {} + return _schemes_from_raw(raw) + + def load_schemes_from_path(path: Path) -> dict[str, DiagramScheme]: return _load_schemes_file(path) -def save_schemes(schemes: dict[str, DiagramScheme], path: Path | None = None) -> Path: +def load_schemes_report(path: Path) -> tuple[dict[str, DiagramScheme], str | None]: + """Load schemes. Error string is set when the file exists but cannot be parsed.""" + raw, err = _read_json_object(path) + if err or raw is None: + if path.is_file(): + _log.warning("Не удалось прочитать схемы %s: %s", path, err) + return {}, err + return _schemes_from_raw(raw), None + + +def save_schemes( + schemes: dict[str, DiagramScheme], + path: Path | None = None, + *, + removed_ids: set[str] | None = None, +) -> Path: + """ + Write schemes, keeping JSON entries the loader does not understand + (unknown ids, extra fields). ``removed_ids`` are normalized ids the user + deleted or renamed away. + """ dest = path or user_schemes_path() - payload = _schemes_payload(schemes) dest.parent.mkdir(parents=True, exist_ok=True) + raw, err = _read_json_object(dest) + if err or raw is None: + raw = {} + removed = {sid.lower() for sid in (removed_ids or set())} + norm_to_key: dict[str, str] = {} + for key in list(raw): + sid = str(key).lower().strip() + if sid in removed: + del raw[key] + continue + norm_to_key.setdefault(sid, str(key)) + + for sid in sorted(schemes): + scheme = schemes[sid] + data = scheme.to_dict() + key = norm_to_key.get(sid) + if key is None: + raw[sid] = data + continue + base = raw.get(key) + merged = dict(base) if isinstance(base, dict) else {} + merged.update(data) + if not scheme.theme: + merged.pop("theme", None) + raw[key] = merged + dest.write_text( - json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) return dest @@ -234,7 +274,7 @@ def claim_user_scheme_if_diverged( ) -> DiagramScheme: """ If scheme id is in the app template and content differs while still - author=md2gost, clear author so migration will not overwrite user edits. + author=md2gost, clear author so the copy is marked as the user's. """ stock = (bundled if bundled is not None else _load_schemes_file(bundled_schemes_path())).get( scheme.id diff --git a/md2gost/gui.py b/md2gost/gui.py index 465e415..7d36099 100644 --- a/md2gost/gui.py +++ b/md2gost/gui.py @@ -16,7 +16,7 @@ from .diagram_schemes import ( claim_user_scheme_if_diverged, ensure_user_schemes, get_schemes, - load_schemes_from_path, + load_schemes_report, reload_schemes, save_schemes, user_schemes_path, @@ -263,7 +263,9 @@ class Md2GostApp: self._last_md = "" self._schemes: dict[str, DiagramScheme] = {} self._scheme_ids: list[str] = [] + self._scheme_file_keys: set[str] = set() self._scheme_selecting = False + self._schemes_unreadable: str | None = None self._debug_next = False self._win_files: tk.Toplevel | None = None @@ -1336,12 +1338,23 @@ class Md2GostApp: if not hasattr(self, "_scheme_list"): return path = ensure_user_schemes() - self._schemes = load_schemes_from_path(path) + self._schemes, err = load_schemes_report(path) + self._schemes_unreadable = err + self._scheme_file_keys = set() if err else set(self._schemes) self._scheme_ids = sorted(self._schemes.keys()) self._scheme_list.delete(0, tk.END) for sid in self._scheme_ids: title = self._schemes[sid].title or sid self._scheme_list.insert(tk.END, title) + if err: + self._clear_scheme_form() + self._scheme_fence_hint.configure( + text=f"Файл не разобран и не изменён: {err}" + ) + self._append_log( + f"Файл схем не прочитан, он не перезаписан: {path} ({err})" + ) + return if not self._scheme_ids: self._clear_scheme_form() return @@ -1352,6 +1365,22 @@ class Md2GostApp: self._scheme_list.see(idx) self._fill_scheme_form(self._schemes[target]) + def _persist_schemes(self, select_id: str | None = None) -> str | None: + """Write self._schemes to disk. Refuses to overwrite a file that did not parse.""" + if self._schemes_unreadable: + messagebox.showerror( + "md2gost", + "Файл схем не разобран, поэтому он не перезаписан.\n" + f"{self._schemes_unreadable}", + ) + return None + path = user_schemes_path() + removed = set(self._scheme_file_keys) - set(self._schemes) + save_schemes(self._schemes, path, removed_ids=removed) + reload_schemes() + self._reload_schemes_ui(select_id=select_id) + return str(path) + def _clear_scheme_form(self) -> None: self._scheme_selecting = True self._sch_id.set("") @@ -1396,6 +1425,9 @@ class Md2GostApp: return None includes_raw = self._text_get(self._sch_includes) includes = [ln.strip() for ln in includes_raw.splitlines() if ln.strip()] + theme = "" + if old_id and old_id in self._schemes: + theme = self._schemes[old_id].theme return DiagramScheme( id=sid, title=self._sch_title.get().strip() or sid, @@ -1406,6 +1438,7 @@ class Md2GostApp: includes=includes, prefix=self._text_get(self._sch_prefix), postfix=self._text_get(self._sch_postfix), + theme=theme, ) def _scheme_commit_current(self) -> str | None: @@ -1422,6 +1455,15 @@ class Md2GostApp: return scheme.id def _scheme_add(self) -> None: + if self._schemes_unreadable: + messagebox.showerror( + "md2gost", + "Файл схем не разобран, новая схема не записана.\n" + f"{self._schemes_unreadable}", + ) + return + if self._scheme_ids and self._scheme_commit_current() is None: + return n = 1 while f"custom{n}" in self._schemes: n += 1 @@ -1437,13 +1479,24 @@ class Md2GostApp: prefix="@startuml\n", postfix="\n@enduml", ) - self._reload_schemes_ui(select_id=sid) + path = self._persist_schemes(select_id=sid) + if path: + self._append_log(f"Схема добавлена: {sid} ({path})") def _scheme_dup(self) -> None: - sel = self._scheme_list.curselection() - if not sel: + if not self._scheme_list.curselection(): return - src = self._schemes[self._scheme_ids[sel[0]]] + if self._schemes_unreadable: + messagebox.showerror( + "md2gost", + "Файл схем не разобран, копия не записана.\n" + f"{self._schemes_unreadable}", + ) + return + src_id = self._scheme_commit_current() + if src_id is None: + return + src = self._schemes[src_id] n = 1 while f"{src.id}_copy{n}" in self._schemes: n += 1 @@ -1452,7 +1505,7 @@ class Md2GostApp: id=sid, title=src.title, version=src.version, - author=src.author, + author="", docs=src.docs, ai_prompt=src.ai_prompt, includes=list(src.includes), @@ -1460,7 +1513,9 @@ class Md2GostApp: postfix=src.postfix, theme=src.theme, ) - self._reload_schemes_ui(select_id=sid) + path = self._persist_schemes(select_id=sid) + if path: + self._append_log(f"Схема скопирована: {sid} ({path})") def _scheme_del(self) -> None: sel = self._scheme_list.curselection() @@ -1470,20 +1525,17 @@ class Md2GostApp: if not messagebox.askyesno("md2gost", f"Удалить схему «{sid}» из файла пользователя?"): return del self._schemes[sid] - path = user_schemes_path() - save_schemes(self._schemes, path) - reload_schemes() - self._reload_schemes_ui() - self._append_log(f"Схема удалена: {sid}") + path = self._persist_schemes() + if path: + self._append_log(f"Схема удалена: {sid}") def _scheme_save_all(self) -> None: sid = self._scheme_commit_current() if sid is None and self._scheme_ids: return - path = user_schemes_path() - save_schemes(self._schemes, path) - reload_schemes() - self._reload_schemes_ui(select_id=sid) + path = self._persist_schemes(select_id=sid) + if not path: + return self._append_log(f"Схемы сохранены: {path}") messagebox.showinfo("md2gost", f"Сохранено:\n{path}") diff --git a/md2gost/help_content.py b/md2gost/help_content.py index 937064a..ab986ab 100644 --- a/md2gost/help_content.py +++ b/md2gost/help_content.py @@ -205,11 +205,11 @@ SCHEMES_HELP = """СХЕМЫ ДИАГРАММ (PlantUML) Первый запуск Рядом с программой (рядом с md2gost.exe или в текущей папке при python -m) создаётся md2gost.schemes.json из встроенного шаблона. -При каждом запуске схемы с author=md2gost синхронизируются с шаблоном приложения -(добавление новых id, обновление, удаление исчезнувших вроде bpmn). -Схемы с пустым или другим author не трогаются. +При каждом запуске в файл добавляются только те встроенные схемы, которых ещё нет. +Уже записанные схемы не перезаписываются и не удаляются: правки и свои шаблоны +сохраняются. Файл с ошибкой JSON или с BOM не затирается. Если правите встроенную схему в GUI и сохраняете — author сбрасывается, -чтобы следующая миграция не затёрла ваши правки. +схема помечается как ваша. Оградка в markdown ```uml — обычный PlantUML (или ```plantuml) diff --git a/tests/test_diagram_renderer.py b/tests/test_diagram_renderer.py index 4f8865e..d13e032 100644 --- a/tests/test_diagram_renderer.py +++ b/tests/test_diagram_renderer.py @@ -39,6 +39,7 @@ from md2gost.diagram_schemes import ( ensure_user_schemes, load_schemes_from_path, migrate_user_schemes, + save_schemes, prepare_with_schemes, scheme_id_from_lang, user_schemes_path, @@ -125,7 +126,7 @@ def test_ensure_user_schemes_once(tmp_path): assert path == user_schemes_path(tmp_path) -def test_migrate_removes_orphaned_builtin_and_adds_missing(tmp_path, monkeypatch): +def test_migrate_keeps_custom_schemes_and_adds_missing(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) path = user_schemes_path(tmp_path) path.write_text( @@ -147,10 +148,11 @@ def test_migrate_removes_orphaned_builtin_and_adds_missing(tmp_path, monkeypatch ) result = migrate_user_schemes(tmp_path) assert result.written - assert "bpmn" in result.removed + assert result.removed == [] assert "c4" in result.added schemes = load_schemes_from_path(path) - assert "bpmn" not in schemes + assert schemes["bpmn"].title == "BPMN 2.0" + assert schemes["bpmn"].author == "md2gost" assert "mine" in schemes assert schemes["mine"].title == "x" bundled = load_schemes_from_path(bundled_schemes_path()) @@ -186,7 +188,7 @@ def test_migrate_preserves_user_override_of_builtin(tmp_path, monkeypatch): assert "usecase" in schemes # other bundled ids still added -def test_migrate_updates_app_owned_builtin(tmp_path, monkeypatch): +def test_migrate_keeps_edited_builtin(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) path = user_schemes_path(tmp_path) path.write_text( @@ -208,10 +210,83 @@ def test_migrate_updates_app_owned_builtin(tmp_path, monkeypatch): encoding="utf-8", ) result = migrate_user_schemes(tmp_path) - assert "c4" in result.updated + assert "c4" not in result.updated + assert "c4" not in result.added schemes = load_schemes_from_path(path) - bundled = load_schemes_from_path(bundled_schemes_path()) - assert schemes["c4"].to_dict() == bundled["c4"].to_dict() + assert schemes["c4"].title == "Old C4" + assert schemes["c4"].docs == "stale" + assert schemes["c4"].author == "md2gost" + assert "usecase" in schemes + + +def test_migrate_does_not_overwrite_broken_json(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + path = user_schemes_path(tmp_path) + original = "{\n \"c4\": {\"title\": \"x\",}\n}\n" + path.write_text(original, encoding="utf-8") + result = migrate_user_schemes(tmp_path) + assert not result.written + assert not result.changed + assert path.read_text(encoding="utf-8") == original + + +def test_migrate_reads_utf8_bom(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + path = user_schemes_path(tmp_path) + body = json.dumps( + { + "c4": { + "title": "My C4", + "author": "md2gost", + "docs": "mine", + "prefix": "", + "postfix": "", + } + }, + ensure_ascii=False, + ) + path.write_bytes(b"\xef\xbb\xbf" + body.encode("utf-8")) + result = migrate_user_schemes(tmp_path) + assert "usecase" in result.added + schemes = load_schemes_from_path(path) + assert schemes["c4"].title == "My C4" + assert schemes["c4"].docs == "mine" + text = path.read_text(encoding="utf-8-sig") + assert "mine" in text + + +def test_save_schemes_keeps_unknown_entries(tmp_path): + path = user_schemes_path(tmp_path) + path.write_text( + json.dumps( + { + "my-scheme": {"title": "hyphen", "note": "keep"}, + "mine": {"title": "x", "author": "", "extra": "field", "prefix": "A"}, + } + ) + + "\n", + encoding="utf-8", + ) + loaded = load_schemes_from_path(path) + assert "mine" in loaded + assert "my-scheme" not in loaded + loaded["mine"] = DiagramScheme( + id="mine", + title="y", + author="", + prefix="A", + ) + save_schemes(loaded, path, removed_ids=set()) + raw = json.loads(path.read_text(encoding="utf-8")) + assert raw["my-scheme"]["title"] == "hyphen" + assert raw["my-scheme"]["note"] == "keep" + assert raw["mine"]["title"] == "y" + assert raw["mine"]["extra"] == "field" + del loaded["mine"] + save_schemes(loaded, path, removed_ids={"mine"}) + raw = json.loads(path.read_text(encoding="utf-8")) + assert "mine" not in raw + assert raw["my-scheme"]["note"] == "keep" def test_migrate_idempotent(tmp_path, monkeypatch):