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.
## [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
### 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 re
import shutil
from dataclasses import dataclass
from datetime import datetime, timezone
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.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.fscompat import copy_file, ensure_dir, remove_file, write_text
from export1c_help.lifecycle import (
LifecycleResult,
PageRecord,
@@ -247,6 +247,8 @@ def export_help(
link_map = build_link_title_map(pages, titles)
if clean and out_dir.exists():
import shutil
for child in out_dir.iterdir():
if child.name == ".git" or child.name.startswith("wiki_clone"):
# never wipe an in-tree wiki working clone
@@ -254,8 +256,8 @@ def export_help(
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
out_dir.mkdir(parents=True, exist_ok=True)
remove_file(child)
ensure_dir(out_dir)
def rewrite(href: str, text: str) -> str:
return default_link_rewrite(href, text, link_map)
@@ -300,7 +302,7 @@ def export_help(
for rec in life.active:
body = body_by_meta[rec.meta_key]
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
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"}:
target = out_dir / img.name
if not target.exists():
shutil.copy2(img, target)
copy_file(img, target)
stats.images_copied += 1
label = source_label or str(src_root)
@@ -343,7 +345,8 @@ def export_help(
deduped.append(row)
prev_exports = deduped[:50]
(out_dir / "Home.md").write_text(
write_text(
out_dir / "Home.md",
build_home(
written_pages,
titles,
@@ -351,19 +354,15 @@ def export_help(
identity=identity,
life=life,
),
encoding="utf-8",
)
(out_dir / "_Sidebar.md").write_text(
build_sidebar(written_pages, titles),
encoding="utf-8",
)
(out_dir / "History.md").write_text(
write_text(out_dir / "_Sidebar.md", build_sidebar(written_pages, titles))
write_text(
out_dir / "History.md",
build_history(
identity=identity,
life=life,
prev_exports=prev_exports,
),
encoding="utf-8",
)
exported_at = datetime.now(timezone.utc).isoformat()
@@ -403,8 +402,8 @@ def export_help(
"pages": [r.to_dict() for r in life.active],
"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",
encoding="utf-8",
)
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()
+26 -28
View File
@@ -10,8 +10,9 @@ from export1c_help.discover import HelpPage
_INVALID = re.compile(r'[\\/:*?"<>|#\[\]]+')
_SPACES = re.compile(r"\s+")
# Keep slug short so percent-encoded form fits in 255-byte FS names
_MAX_SLUG_BYTES = 72
# Encoded «Slug.md» must leave room under Windows MAX_PATH (~260) for deep
# project roots (e.g. D:\SynologyDrive\projects_syn\…\out\).
_MAX_ENCODED_FILENAME = 100
def sanitize_title(title: str) -> str:
@@ -21,39 +22,34 @@ def sanitize_title(title: str) -> str:
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:
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:
"""
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 = _truncate_utf8(slug, _MAX_SLUG_BYTES)
if meta_key:
# 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
encoded = _encoded_filename(slug)
if len(encoded) <= _MAX_ENCODED_FILENAME:
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:
@@ -90,11 +86,13 @@ def assign_titles(
fname = wiki_filename(title, meta_key=p.meta_key)
if fname.casefold() in used_file:
slug = _truncate_utf8(sanitize_title(title).replace(" ", "-"), 50)
fname = quote(f"{slug}-{_short_hash(p.meta_key)}", safe="-_.") + ".md"
fname = wiki_filename(
f"{title}-{_short_hash(p.meta_key)}",
meta_key=p.meta_key,
)
guard = 0
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
used_title.add(title.casefold())
+6 -5
View File
@@ -7,6 +7,8 @@ import shutil
import subprocess
from pathlib import Path
from export1c_help.fscompat import copy_file, ensure_dir, remove_file
# Always owned by export_1c_help (not user articles).
_MANAGED_FIXED = frozenset({"Home.md", "History.md", "_Sidebar.md", "manifest.json"})
_IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"})
@@ -46,7 +48,7 @@ def prepare_wiki_clone(
else:
if work_dir.exists():
shutil.rmtree(work_dir)
work_dir.parent.mkdir(parents=True, exist_ok=True)
ensure_dir(work_dir.parent)
_run(
[
"git",
@@ -115,13 +117,13 @@ def push_wiki(
continue
suf = path.suffix.lower()
if suf in {".md", ".json"} or suf in _IMAGE_SUFFIXES:
path.unlink()
remove_file(path)
else:
# Drop help pages that disappeared from the configuration export.
for name in sorted(prev_managed - new_files):
path = work_dir / name
if path.is_file():
path.unlink()
remove_file(path)
for src in content_dir.iterdir():
if src.name == ".git":
@@ -140,8 +142,7 @@ def push_wiki(
shutil.rmtree(dst)
shutil.copytree(src, dst)
else:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
copy_file(src, dst)
_run(["git", "add", "-A"], cwd=work_dir)
status = _run(["git", "status", "--porcelain"], cwd=work_dir)