Release 0.3.8: translit wiki slugs and relative md links
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
All notable changes to **export_1c_help** are documented in this file.
|
All notable changes to **export_1c_help** are documented in this file.
|
||||||
|
|
||||||
|
## [0.3.8] - 2026-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Wiki page filenames switched to short ASCII transliteration slugs (with stable hash suffix on truncation), while visible page titles remain full Russian.
|
||||||
|
- Internal links now use Markdown format with human-readable Russian labels and relative `.md` targets (no `wiki/` prefix), matching Gitea wiki routing.
|
||||||
|
- Generated article body now always starts with `# <Полное русское наименование>`.
|
||||||
|
|
||||||
## [0.3.7] - 2026-07-24
|
## [0.3.7] - 2026-07-24
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -346,7 +346,11 @@ def _toc_paragraphs_to_list(md: str) -> str:
|
|||||||
return "\n".join(out)
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
def default_link_rewrite(href: str, text: str, meta_to_title: dict[str, str]) -> str:
|
def default_link_rewrite(
|
||||||
|
href: str,
|
||||||
|
text: str,
|
||||||
|
meta_to_page: dict[str, tuple[str, str]],
|
||||||
|
) -> str:
|
||||||
"""Rewrite href into markdown / wiki link."""
|
"""Rewrite href into markdown / wiki link."""
|
||||||
href = href.strip()
|
href = href.strip()
|
||||||
text = text.strip() or href
|
text = text.strip() or href
|
||||||
@@ -368,11 +372,13 @@ def default_link_rewrite(href: str, text: str, meta_to_title: dict[str, str]) ->
|
|||||||
)
|
)
|
||||||
if m:
|
if m:
|
||||||
meta_key = m.group(1)
|
meta_key = m.group(1)
|
||||||
title = meta_to_title.get(meta_key)
|
page = meta_to_page.get(meta_key)
|
||||||
if title:
|
if page:
|
||||||
if text == title or text == meta_key:
|
title, filename = page
|
||||||
return f"[[{title}]]"
|
label = text
|
||||||
return f"[[{text}|{title}]]"
|
if text == meta_key:
|
||||||
|
label = title
|
||||||
|
return f"[{label}]({filename})"
|
||||||
return f"{text} (`{meta_key}`)"
|
return f"{text} (`{meta_key}`)"
|
||||||
|
|
||||||
if re.search(r"\.(png|jpe?g|gif|webp|bmp)$", href, re.I):
|
if re.search(r"\.(png|jpe?g|gif|webp|bmp)$", href, re.I):
|
||||||
|
|||||||
@@ -216,19 +216,24 @@ def discover_help_pages(
|
|||||||
return pages
|
return pages
|
||||||
|
|
||||||
|
|
||||||
def build_link_title_map(pages: list[HelpPage], titles: dict[str, str]) -> dict[str, str]:
|
def build_link_map(
|
||||||
|
pages: list[HelpPage],
|
||||||
|
titles: dict[str, str],
|
||||||
|
files: dict[str, str],
|
||||||
|
) -> dict[str, tuple[str, str]]:
|
||||||
"""
|
"""
|
||||||
meta_key / alias → wiki title for href rewrite.
|
meta_key / alias → (wiki title, filename) for href rewrite.
|
||||||
|
|
||||||
Leaf aliases (Subsystem.X) are registered only when unique.
|
Leaf aliases (Subsystem.X) are registered only when unique.
|
||||||
"""
|
"""
|
||||||
result: dict[str, str] = {}
|
result: dict[str, tuple[str, str]] = {}
|
||||||
alias_owners: dict[str, list[str]] = {}
|
alias_owners: dict[str, list[str]] = {}
|
||||||
for p in pages:
|
for p in pages:
|
||||||
result[p.meta_key] = titles[p.meta_key]
|
result[p.meta_key] = (titles[p.meta_key], files[p.meta_key])
|
||||||
for alias in p.link_aliases:
|
for alias in p.link_aliases:
|
||||||
alias_owners.setdefault(alias, []).append(p.meta_key)
|
alias_owners.setdefault(alias, []).append(p.meta_key)
|
||||||
for alias, owners in alias_owners.items():
|
for alias, owners in alias_owners.items():
|
||||||
if len(owners) == 1:
|
if len(owners) == 1:
|
||||||
result[alias] = titles[owners[0]]
|
key = owners[0]
|
||||||
|
result[alias] = (titles[key], files[key])
|
||||||
return result
|
return result
|
||||||
|
|||||||
+17
-10
@@ -11,7 +11,7 @@ from pathlib import Path
|
|||||||
from export1c_help.__version__ import __version__
|
from export1c_help.__version__ import __version__
|
||||||
from export1c_help.config_version import ConfigIdentity, read_config_identity
|
from export1c_help.config_version import ConfigIdentity, read_config_identity
|
||||||
from export1c_help.convert import default_link_rewrite, extract_h1, html_to_markdown
|
from export1c_help.convert import default_link_rewrite, extract_h1, html_to_markdown
|
||||||
from export1c_help.discover import HelpPage, build_link_title_map, discover_help_pages
|
from export1c_help.discover import HelpPage, build_link_map, discover_help_pages
|
||||||
from export1c_help.fscompat import copy_file, ensure_dir, remove_file, write_text
|
from export1c_help.fscompat import copy_file, ensure_dir, remove_file, write_text
|
||||||
from export1c_help.lifecycle import (
|
from export1c_help.lifecycle import (
|
||||||
LifecycleResult,
|
LifecycleResult,
|
||||||
@@ -70,6 +70,7 @@ def _page_footer(rec: PageRecord, *, identity: ConfigIdentity) -> str:
|
|||||||
def build_home(
|
def build_home(
|
||||||
pages: list[HelpPage],
|
pages: list[HelpPage],
|
||||||
titles: dict[str, str],
|
titles: dict[str, str],
|
||||||
|
files: dict[str, str],
|
||||||
*,
|
*,
|
||||||
source_label: str,
|
source_label: str,
|
||||||
identity: ConfigIdentity,
|
identity: ConfigIdentity,
|
||||||
@@ -104,20 +105,24 @@ def build_home(
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
for p in group:
|
for p in group:
|
||||||
title = titles[p.meta_key]
|
title = titles[p.meta_key]
|
||||||
lines.append(f"- [[{title}]]")
|
lines.append(f"- [{title}]({files[p.meta_key]})")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
return "\n".join(lines).rstrip() + "\n"
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
def build_sidebar(pages: list[HelpPage], titles: dict[str, str]) -> str:
|
def build_sidebar(
|
||||||
|
pages: list[HelpPage],
|
||||||
|
titles: dict[str, str],
|
||||||
|
files: dict[str, str],
|
||||||
|
) -> str:
|
||||||
by_type: dict[str, list[HelpPage]] = {}
|
by_type: dict[str, list[HelpPage]] = {}
|
||||||
for p in pages:
|
for p in pages:
|
||||||
by_type.setdefault(p.type_dir, []).append(p)
|
by_type.setdefault(p.type_dir, []).append(p)
|
||||||
lines = [
|
lines = [
|
||||||
"# Справка",
|
"# Справка",
|
||||||
"",
|
"",
|
||||||
"[[Home|Оглавление]]",
|
"[Оглавление](Home.md)",
|
||||||
"[[History|История выгрузок]]",
|
"[История выгрузок](History.md)",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
for type_dir in sorted(by_type, key=lambda d: DIR_TO_RU.get(d, d)):
|
for type_dir in sorted(by_type, key=lambda d: DIR_TO_RU.get(d, d)):
|
||||||
@@ -127,9 +132,9 @@ def build_sidebar(pages: list[HelpPage], titles: dict[str, str]) -> str:
|
|||||||
objs = [p for p in by_type[type_dir] if not p.is_form]
|
objs = [p for p in by_type[type_dir] if not p.is_form]
|
||||||
objs.sort(key=lambda p: titles[p.meta_key].casefold())
|
objs.sort(key=lambda p: titles[p.meta_key].casefold())
|
||||||
for p in objs[:80]:
|
for p in objs[:80]:
|
||||||
lines.append(f"- [[{titles[p.meta_key]}]]")
|
lines.append(f"- [{titles[p.meta_key]}]({files[p.meta_key]})")
|
||||||
if len(objs) > 80:
|
if len(objs) > 80:
|
||||||
lines.append(f"- … ещё {len(objs) - 80} (см. [[Home]])")
|
lines.append(f"- … ещё {len(objs) - 80} (см. [Оглавление](Home.md))")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
return "\n".join(lines).rstrip() + "\n"
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
@@ -244,7 +249,7 @@ def export_help(
|
|||||||
h1_by_meta[p.meta_key] = extract_h1(html)
|
h1_by_meta[p.meta_key] = extract_h1(html)
|
||||||
|
|
||||||
titles, files = assign_titles(pages, h1_by_meta)
|
titles, files = assign_titles(pages, h1_by_meta)
|
||||||
link_map = build_link_title_map(pages, titles)
|
link_map = build_link_map(pages, titles, files)
|
||||||
|
|
||||||
if clean and out_dir.exists():
|
if clean and out_dir.exists():
|
||||||
import shutil
|
import shutil
|
||||||
@@ -268,7 +273,8 @@ def export_help(
|
|||||||
for p in pages:
|
for p in pages:
|
||||||
html = html_cache[p.meta_key]
|
html = html_cache[p.meta_key]
|
||||||
title = titles[p.meta_key]
|
title = titles[p.meta_key]
|
||||||
md = html_to_markdown(html, rewrite_link=rewrite, strip_first_h1=title)
|
md_body = html_to_markdown(html, rewrite_link=rewrite, strip_first_h1=title)
|
||||||
|
md = f"# {title}\n\n{md_body.lstrip()}"
|
||||||
if skip_empty and _is_effectively_empty(md):
|
if skip_empty and _is_effectively_empty(md):
|
||||||
stats.pages_skipped_empty += 1
|
stats.pages_skipped_empty += 1
|
||||||
continue
|
continue
|
||||||
@@ -350,12 +356,13 @@ def export_help(
|
|||||||
build_home(
|
build_home(
|
||||||
written_pages,
|
written_pages,
|
||||||
titles,
|
titles,
|
||||||
|
files,
|
||||||
source_label=label,
|
source_label=label,
|
||||||
identity=identity,
|
identity=identity,
|
||||||
life=life,
|
life=life,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
write_text(out_dir / "_Sidebar.md", build_sidebar(written_pages, titles))
|
write_text(out_dir / "_Sidebar.md", build_sidebar(written_pages, titles, files))
|
||||||
write_text(
|
write_text(
|
||||||
out_dir / "History.md",
|
out_dir / "History.md",
|
||||||
build_history(
|
build_history(
|
||||||
|
|||||||
+57
-26
@@ -1,18 +1,53 @@
|
|||||||
"""Wiki page titles and Gitea filename encoding."""
|
"""Wiki page titles and transliterated wiki filenames."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
from urllib.parse import quote, unquote
|
|
||||||
|
|
||||||
from export1c_help.discover import HelpPage
|
from export1c_help.discover import HelpPage
|
||||||
|
|
||||||
_INVALID = re.compile(r'[\\/:*?"<>|#\[\]]+')
|
_INVALID = re.compile(r'[\\/:*?"<>|#\[\]]+')
|
||||||
_SPACES = re.compile(r"\s+")
|
_SPACES = re.compile(r"\s+")
|
||||||
# Encoded «Slug.md» must leave room under Windows MAX_PATH (~260) for deep
|
_NON_SLUG = re.compile(r"[^a-z0-9._-]+")
|
||||||
# project roots (e.g. D:\SynologyDrive\projects_syn\…\out\).
|
_DASH_RUNS = re.compile(r"-+")
|
||||||
_MAX_ENCODED_FILENAME = 100
|
_MAX_SLUG = 48
|
||||||
|
|
||||||
|
_TRANSLIT = {
|
||||||
|
"а": "a",
|
||||||
|
"б": "b",
|
||||||
|
"в": "v",
|
||||||
|
"г": "g",
|
||||||
|
"д": "d",
|
||||||
|
"е": "e",
|
||||||
|
"ё": "e",
|
||||||
|
"ж": "zh",
|
||||||
|
"з": "z",
|
||||||
|
"и": "i",
|
||||||
|
"й": "y",
|
||||||
|
"к": "k",
|
||||||
|
"л": "l",
|
||||||
|
"м": "m",
|
||||||
|
"н": "n",
|
||||||
|
"о": "o",
|
||||||
|
"п": "p",
|
||||||
|
"р": "r",
|
||||||
|
"с": "s",
|
||||||
|
"т": "t",
|
||||||
|
"у": "u",
|
||||||
|
"ф": "f",
|
||||||
|
"х": "h",
|
||||||
|
"ц": "ts",
|
||||||
|
"ч": "ch",
|
||||||
|
"ш": "sh",
|
||||||
|
"щ": "sch",
|
||||||
|
"ъ": "",
|
||||||
|
"ы": "y",
|
||||||
|
"ь": "",
|
||||||
|
"э": "e",
|
||||||
|
"ю": "yu",
|
||||||
|
"я": "ya",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def sanitize_title(title: str) -> str:
|
def sanitize_title(title: str) -> str:
|
||||||
@@ -26,35 +61,31 @@ def _short_hash(s: str) -> str:
|
|||||||
return hashlib.sha1(s.encode("utf-8")).hexdigest()[:8]
|
return hashlib.sha1(s.encode("utf-8")).hexdigest()[:8]
|
||||||
|
|
||||||
|
|
||||||
def _encoded_filename(slug: str) -> str:
|
def _translit_slug(title: str) -> str:
|
||||||
return quote(slug, safe="-_.") + ".md"
|
chars: list[str] = []
|
||||||
|
for ch in title.lower():
|
||||||
|
chars.append(_TRANSLIT.get(ch, ch))
|
||||||
|
raw = "".join(chars).replace(" ", "-")
|
||||||
|
raw = _NON_SLUG.sub("-", raw)
|
||||||
|
raw = _DASH_RUNS.sub("-", raw).strip("-._")
|
||||||
|
return raw or "page"
|
||||||
|
|
||||||
|
|
||||||
def wiki_filename(title: str, *, meta_key: str | None = None) -> str:
|
def wiki_filename(title: str, *, meta_key: str | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Gitea wiki stores non-ASCII page names as percent-encoded «Slug.md».
|
Build ASCII transliterated ``slug.md`` with bounded slug length.
|
||||||
|
|
||||||
Encoded length is capped so typical Windows project paths still fit
|
|
||||||
under MAX_PATH; when truncated, a short hash of meta_key (or title)
|
|
||||||
is appended for stability.
|
|
||||||
"""
|
"""
|
||||||
slug = sanitize_title(title).replace(" ", "-")
|
slug = _translit_slug(sanitize_title(title))
|
||||||
encoded = _encoded_filename(slug)
|
if len(slug) <= _MAX_SLUG:
|
||||||
if len(encoded) <= _MAX_ENCODED_FILENAME:
|
return f"{slug}.md"
|
||||||
return encoded
|
|
||||||
|
|
||||||
suffix = "-" + _short_hash(meta_key or title)
|
suffix = "-" + _short_hash(meta_key or title)
|
||||||
# ASCII suffix: encoded length == visible length
|
keep = max(8, _MAX_SLUG - len(suffix))
|
||||||
budget = _MAX_ENCODED_FILENAME - len(suffix) - 3 # ".md"
|
slug = slug[:keep].rstrip("-._") or "page"
|
||||||
while slug and len(quote(slug, safe="-_.")) > budget:
|
return f"{slug}{suffix}.md"
|
||||||
slug = slug[:-1]
|
|
||||||
slug = slug.rstrip("-") or "page"
|
|
||||||
return quote(slug, safe="-_.") + suffix + ".md"
|
|
||||||
|
|
||||||
|
|
||||||
def decode_wiki_filename(name: str) -> str:
|
def decode_wiki_filename(name: str) -> str:
|
||||||
base = name[:-3] if name.endswith(".md") else name
|
return name[:-3] if name.endswith(".md") else name
|
||||||
return unquote(base)
|
|
||||||
|
|
||||||
|
|
||||||
def assign_titles(
|
def assign_titles(
|
||||||
@@ -92,7 +123,7 @@ def assign_titles(
|
|||||||
)
|
)
|
||||||
guard = 0
|
guard = 0
|
||||||
while fname.casefold() in used_file:
|
while fname.casefold() in used_file:
|
||||||
fname = _encoded_filename(f"page-{_short_hash(p.meta_key + str(guard))}")
|
fname = f"page-{_short_hash(p.meta_key + str(guard))}.md"
|
||||||
guard += 1
|
guard += 1
|
||||||
|
|
||||||
used_title.add(title.casefold())
|
used_title.add(title.casefold())
|
||||||
|
|||||||
Reference in New Issue
Block a user