+142
-15
@@ -21,10 +21,33 @@ _log = logging.getLogger(__name__)
|
||||
|
||||
SCHEMES_FILENAME = "md2gost.schemes.json"
|
||||
SCHEME_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
APP_SCHEME_AUTHOR = "md2gost"
|
||||
BUILTIN_DIAGRAM_LANGS = frozenset({"uml", "plantuml"})
|
||||
# Rendered via Kroki only (not PlantUML jar / schemes).
|
||||
KROKI_DIAGRAM_LANGS = frozenset({"mermaid", "mmd"})
|
||||
KROKI_TYPE_BY_LANG = {"mermaid": "mermaid", "mmd": "mermaid"}
|
||||
# Local native renderers (not PlantUML / Kroki / schemes.json).
|
||||
NATIVE_DIAGRAM_LANGS = {
|
||||
"idef0": "idef0",
|
||||
"uml-idef0": "idef0",
|
||||
"dfd": "dfd",
|
||||
"uml-dfd": "dfd",
|
||||
"data-flow-diagram": "dfd",
|
||||
"yourdon": "dfd",
|
||||
"uml-yourdon": "dfd",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchemesMigrationResult:
|
||||
added: list[str] = field(default_factory=list)
|
||||
updated: list[str] = field(default_factory=list)
|
||||
removed: list[str] = field(default_factory=list)
|
||||
written: bool = False
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return bool(self.added or self.updated or self.removed)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -82,22 +105,84 @@ def user_schemes_path(base: Path | None = None) -> Path:
|
||||
return (base or app_dir()) / SCHEMES_FILENAME
|
||||
|
||||
|
||||
def is_app_owned_scheme(scheme: DiagramScheme) -> bool:
|
||||
"""True if scheme is managed by the app template (author=md2gost)."""
|
||||
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.
|
||||
|
||||
- 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.
|
||||
"""
|
||||
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()
|
||||
return result
|
||||
|
||||
save_schemes(out, path)
|
||||
result.written = True
|
||||
return result
|
||||
|
||||
|
||||
def ensure_user_schemes(base: Path | None = None) -> Path:
|
||||
"""
|
||||
On first run copy bundled template next to the app.
|
||||
Never overwrite an existing user file.
|
||||
Ensure md2gost.schemes.json exists next to the app, then sync app-owned
|
||||
schemes from the bundled template (add / update / remove orphans).
|
||||
"""
|
||||
dest = user_schemes_path(base)
|
||||
if dest.is_file():
|
||||
return dest
|
||||
src = bundled_schemes_path()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if src.is_file():
|
||||
shutil.copyfile(src, dest)
|
||||
_log.info("Создан файл схем: %s", dest)
|
||||
else:
|
||||
dest.write_text("{}\n", encoding="utf-8")
|
||||
_log.warning("Шаблон схем не найден (%s), создан пустой %s", src, dest)
|
||||
if not dest.is_file():
|
||||
src = bundled_schemes_path()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if src.is_file():
|
||||
shutil.copyfile(src, dest)
|
||||
_log.info("Создан файл схем: %s", dest)
|
||||
else:
|
||||
dest.write_text("{}\n", encoding="utf-8")
|
||||
_log.warning("Шаблон схем не найден (%s), создан пустой %s", src, dest)
|
||||
migrate_user_schemes(base)
|
||||
return dest
|
||||
|
||||
|
||||
@@ -129,7 +214,7 @@ def load_schemes_from_path(path: Path) -> dict[str, DiagramScheme]:
|
||||
|
||||
def save_schemes(schemes: dict[str, DiagramScheme], path: Path | None = None) -> Path:
|
||||
dest = path or user_schemes_path()
|
||||
payload = {sid: scheme.to_dict() for sid, scheme in sorted(schemes.items())}
|
||||
payload = _schemes_payload(schemes)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
@@ -138,12 +223,50 @@ def save_schemes(schemes: dict[str, DiagramScheme], path: Path | None = None) ->
|
||||
return dest
|
||||
|
||||
|
||||
def claim_user_scheme_if_diverged(
|
||||
scheme: DiagramScheme,
|
||||
*,
|
||||
bundled: dict[str, DiagramScheme] | None = None,
|
||||
) -> 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.
|
||||
"""
|
||||
stock = (bundled if bundled is not None else _load_schemes_file(bundled_schemes_path())).get(
|
||||
scheme.id
|
||||
)
|
||||
if stock is None:
|
||||
return scheme
|
||||
if not is_app_owned_scheme(scheme):
|
||||
return scheme
|
||||
if scheme.to_dict() == stock.to_dict():
|
||||
return scheme
|
||||
return DiagramScheme(
|
||||
id=scheme.id,
|
||||
title=scheme.title,
|
||||
version=scheme.version,
|
||||
author="",
|
||||
docs=scheme.docs,
|
||||
ai_prompt=scheme.ai_prompt,
|
||||
includes=list(scheme.includes),
|
||||
prefix=scheme.prefix,
|
||||
postfix=scheme.postfix,
|
||||
theme=scheme.theme,
|
||||
)
|
||||
|
||||
|
||||
def native_diagram_type(lang: str) -> str | None:
|
||||
"""Kroki/PlantUML-independent diagram type, or None."""
|
||||
lang = (lang or "").lower().strip()
|
||||
return NATIVE_DIAGRAM_LANGS.get(lang)
|
||||
|
||||
|
||||
def scheme_id_from_lang(lang: str) -> str | None:
|
||||
"""Normalize fence lang to scheme id, or None for bare uml/plantuml."""
|
||||
lang = (lang or "").lower().strip()
|
||||
if not lang:
|
||||
return None
|
||||
if lang in BUILTIN_DIAGRAM_LANGS:
|
||||
if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS or lang in NATIVE_DIAGRAM_LANGS:
|
||||
return None
|
||||
if lang.startswith("uml-"):
|
||||
sid = lang[4:]
|
||||
@@ -224,7 +347,7 @@ def get_scheme(scheme_id: str) -> DiagramScheme | None:
|
||||
def is_diagram_lang(lang: str) -> bool:
|
||||
"""True if fence should render as a diagram (not a plain listing)."""
|
||||
lang = (lang or "").lower().strip()
|
||||
if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS:
|
||||
if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS or lang in NATIVE_DIAGRAM_LANGS:
|
||||
return True
|
||||
if lang.startswith("uml-"):
|
||||
# Force diagram path so missing scheme becomes an error, not a listing.
|
||||
@@ -316,6 +439,10 @@ def prepare_with_schemes(lang: str, source: str, *, base: Path | None = None) ->
|
||||
if lang in KROKI_DIAGRAM_LANGS:
|
||||
return text, KROKI_TYPE_BY_LANG[lang]
|
||||
|
||||
native = native_diagram_type(lang)
|
||||
if native:
|
||||
return text, native
|
||||
|
||||
sid = scheme_id_from_lang(lang)
|
||||
|
||||
if sid is None:
|
||||
|
||||
Reference in New Issue
Block a user