Fix Windows MAX_PATH failures for percent-encoded wiki filenames.

Cap encoded slug length and use \\?\ long-path writes (0.3.6).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mihailkudravcev
2026-07-24 10:55:31 +03:00
parent d91c454099
commit dc8be1bd1a
6 changed files with 106 additions and 49 deletions
+6
View File
@@ -2,6 +2,12 @@
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.6] - 2026-07-24
### Fixed
- Windows: percent-encoded Cyrillic wiki filenames no longer exceed MAX_PATH (~260) under deep roots (e.g. SynologyDrive); encoded slug capped, plus `\\?\` long-path writes
## [0.3.5] - 2026-07-24 ## [0.3.5] - 2026-07-24
### Fixed ### Fixed
+1 -1
View File
@@ -1 +1 @@
0.3.5 0.3.6
+14 -15
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import json import json
import re import re
import shutil
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -13,6 +12,7 @@ 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_title_map, discover_help_pages
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,
PageRecord, PageRecord,
@@ -247,6 +247,8 @@ def export_help(
link_map = build_link_title_map(pages, titles) link_map = build_link_title_map(pages, titles)
if clean and out_dir.exists(): if clean and out_dir.exists():
import shutil
for child in out_dir.iterdir(): for child in out_dir.iterdir():
if child.name == ".git" or child.name.startswith("wiki_clone"): if child.name == ".git" or child.name.startswith("wiki_clone"):
# never wipe an in-tree wiki working clone # never wipe an in-tree wiki working clone
@@ -254,8 +256,8 @@ def export_help(
if child.is_dir(): if child.is_dir():
shutil.rmtree(child) shutil.rmtree(child)
else: else:
child.unlink() remove_file(child)
out_dir.mkdir(parents=True, exist_ok=True) ensure_dir(out_dir)
def rewrite(href: str, text: str) -> str: def rewrite(href: str, text: str) -> str:
return default_link_rewrite(href, text, link_map) return default_link_rewrite(href, text, link_map)
@@ -300,7 +302,7 @@ def export_help(
for rec in life.active: for rec in life.active:
body = body_by_meta[rec.meta_key] body = body_by_meta[rec.meta_key]
md = body.rstrip() + _page_footer(rec, identity=identity) md = body.rstrip() + _page_footer(rec, identity=identity)
(out_dir / rec.filename).write_text(md, encoding="utf-8") write_text(out_dir / rec.filename, md)
stats.pages_written += 1 stats.pages_written += 1
help_dir = src_root / Path(rec.source).parent help_dir = src_root / Path(rec.source).parent
@@ -309,7 +311,7 @@ def export_help(
if img.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}: if img.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}:
target = out_dir / img.name target = out_dir / img.name
if not target.exists(): if not target.exists():
shutil.copy2(img, target) copy_file(img, target)
stats.images_copied += 1 stats.images_copied += 1
label = source_label or str(src_root) label = source_label or str(src_root)
@@ -343,7 +345,8 @@ def export_help(
deduped.append(row) deduped.append(row)
prev_exports = deduped[:50] prev_exports = deduped[:50]
(out_dir / "Home.md").write_text( write_text(
out_dir / "Home.md",
build_home( build_home(
written_pages, written_pages,
titles, titles,
@@ -351,19 +354,15 @@ def export_help(
identity=identity, identity=identity,
life=life, life=life,
), ),
encoding="utf-8",
) )
(out_dir / "_Sidebar.md").write_text( write_text(out_dir / "_Sidebar.md", build_sidebar(written_pages, titles))
build_sidebar(written_pages, titles), write_text(
encoding="utf-8", out_dir / "History.md",
)
(out_dir / "History.md").write_text(
build_history( build_history(
identity=identity, identity=identity,
life=life, life=life,
prev_exports=prev_exports, prev_exports=prev_exports,
), ),
encoding="utf-8",
) )
exported_at = datetime.now(timezone.utc).isoformat() exported_at = datetime.now(timezone.utc).isoformat()
@@ -403,8 +402,8 @@ def export_help(
"pages": [r.to_dict() for r in life.active], "pages": [r.to_dict() for r in life.active],
"deleted_pages": [r.to_dict() for r in life.deleted], "deleted_pages": [r.to_dict() for r in life.deleted],
} }
(out_dir / "manifest.json").write_text( write_text(
out_dir / "manifest.json",
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
) )
return stats return stats
+53
View File
@@ -0,0 +1,53 @@
"""Filesystem helpers (Windows MAX_PATH / long paths)."""
from __future__ import annotations
import os
import shutil
from pathlib import Path
def fs_path(path: Path | str) -> Path:
"""
On Windows, prefix with ``\\\\?\\`` so open/mkdir work beyond MAX_PATH (~260).
No-op on other platforms. Idempotent if already prefixed.
"""
p = Path(path)
if os.name != "nt":
return p
s = os.fspath(p)
if s.startswith("\\\\?\\"):
return p
if not p.is_absolute():
p = p.resolve()
s = os.fspath(p)
if s.startswith("\\\\"):
# UNC: \\server\share\... → \\?\UNC\server\share\...
return Path("\\\\?\\UNC\\" + s[2:])
return Path("\\\\?\\" + s)
def ensure_dir(path: Path) -> Path:
p = fs_path(path)
p.mkdir(parents=True, exist_ok=True)
return p
def write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None:
p = fs_path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(text, encoding=encoding)
def copy_file(src: Path, dst: Path) -> None:
s = fs_path(src)
d = fs_path(dst)
d.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(s, d)
def remove_file(path: Path) -> None:
p = fs_path(path)
if p.is_file():
p.unlink()
+25 -27
View File
@@ -10,8 +10,9 @@ from export1c_help.discover import HelpPage
_INVALID = re.compile(r'[\\/:*?"<>|#\[\]]+') _INVALID = re.compile(r'[\\/:*?"<>|#\[\]]+')
_SPACES = re.compile(r"\s+") _SPACES = re.compile(r"\s+")
# Keep slug short so percent-encoded form fits in 255-byte FS names # Encoded «Slug.md» must leave room under Windows MAX_PATH (~260) for deep
_MAX_SLUG_BYTES = 72 # project roots (e.g. D:\SynologyDrive\projects_syn\…\out\).
_MAX_ENCODED_FILENAME = 100
def sanitize_title(title: str) -> str: def sanitize_title(title: str) -> str:
@@ -21,40 +22,35 @@ def sanitize_title(title: str) -> str:
return title or "untitled" return title or "untitled"
def _truncate_utf8(s: str, max_bytes: int) -> str:
raw = s.encode("utf-8")
if len(raw) <= max_bytes:
return s
raw = raw[:max_bytes]
while raw:
try:
return raw.decode("utf-8").rstrip("- ")
except UnicodeDecodeError:
raw = raw[:-1]
return "page"
def _short_hash(s: str) -> str: 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:
return quote(slug, safe="-_.") + ".md"
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». Gitea wiki stores non-ASCII page names as percent-encoded «Slug.md».
Slug = title with spaces → «-», truncated so encoded length ≤ 255. 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 = sanitize_title(title).replace(" ", "-")
slug = _truncate_utf8(slug, _MAX_SLUG_BYTES) encoded = _encoded_filename(slug)
if meta_key: if len(encoded) <= _MAX_ENCODED_FILENAME:
# stable disambiguator when truncated
pass
encoded = quote(slug, safe="-_.") + ".md"
if len(encoded) > 255 and meta_key:
slug = _truncate_utf8(slug, 50) + "-" + _short_hash(meta_key)
encoded = quote(slug, safe="-_.") + ".md"
return encoded return encoded
suffix = "-" + _short_hash(meta_key or title)
# ASCII suffix: encoded length == visible length
budget = _MAX_ENCODED_FILENAME - len(suffix) - 3 # ".md"
while slug and len(quote(slug, safe="-_.")) > budget:
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 base = name[:-3] if name.endswith(".md") else name
@@ -90,11 +86,13 @@ def assign_titles(
fname = wiki_filename(title, meta_key=p.meta_key) fname = wiki_filename(title, meta_key=p.meta_key)
if fname.casefold() in used_file: if fname.casefold() in used_file:
slug = _truncate_utf8(sanitize_title(title).replace(" ", "-"), 50) fname = wiki_filename(
fname = quote(f"{slug}-{_short_hash(p.meta_key)}", safe="-_.") + ".md" f"{title}-{_short_hash(p.meta_key)}",
meta_key=p.meta_key,
)
guard = 0 guard = 0
while fname.casefold() in used_file: while fname.casefold() in used_file:
fname = quote(f"page-{_short_hash(p.meta_key + str(guard))}", safe="-_.") + ".md" fname = _encoded_filename(f"page-{_short_hash(p.meta_key + str(guard))}")
guard += 1 guard += 1
used_title.add(title.casefold()) used_title.add(title.casefold())
+6 -5
View File
@@ -7,6 +7,8 @@ import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from export1c_help.fscompat import copy_file, ensure_dir, remove_file
# Always owned by export_1c_help (not user articles). # Always owned by export_1c_help (not user articles).
_MANAGED_FIXED = frozenset({"Home.md", "History.md", "_Sidebar.md", "manifest.json"}) _MANAGED_FIXED = frozenset({"Home.md", "History.md", "_Sidebar.md", "manifest.json"})
_IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}) _IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"})
@@ -46,7 +48,7 @@ def prepare_wiki_clone(
else: else:
if work_dir.exists(): if work_dir.exists():
shutil.rmtree(work_dir) shutil.rmtree(work_dir)
work_dir.parent.mkdir(parents=True, exist_ok=True) ensure_dir(work_dir.parent)
_run( _run(
[ [
"git", "git",
@@ -115,13 +117,13 @@ def push_wiki(
continue continue
suf = path.suffix.lower() suf = path.suffix.lower()
if suf in {".md", ".json"} or suf in _IMAGE_SUFFIXES: if suf in {".md", ".json"} or suf in _IMAGE_SUFFIXES:
path.unlink() remove_file(path)
else: else:
# Drop help pages that disappeared from the configuration export. # Drop help pages that disappeared from the configuration export.
for name in sorted(prev_managed - new_files): for name in sorted(prev_managed - new_files):
path = work_dir / name path = work_dir / name
if path.is_file(): if path.is_file():
path.unlink() remove_file(path)
for src in content_dir.iterdir(): for src in content_dir.iterdir():
if src.name == ".git": if src.name == ".git":
@@ -140,8 +142,7 @@ def push_wiki(
shutil.rmtree(dst) shutil.rmtree(dst)
shutil.copytree(src, dst) shutil.copytree(src, dst)
else: else:
dst.parent.mkdir(parents=True, exist_ok=True) copy_file(src, dst)
shutil.copy2(src, dst)
_run(["git", "add", "-A"], cwd=work_dir) _run(["git", "add", "-A"], cwd=work_dir)
status = _run(["git", "status", "--porcelain"], cwd=work_dir) status = _run(["git", "status", "--porcelain"], cwd=work_dir)