v0.6.1
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled

Fix gui_schemes
This commit is contained in:
Igor20264
2026-10-20 21:53:34 +03:00
parent 8223e0ac14
commit 8fc1f9e096
6 changed files with 253 additions and 86 deletions
+95 -55
View File
@@ -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
+69 -17
View File
@@ -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}")
+4 -4
View File
@@ -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)