From dc8be1bd1a64243afb315e09ab6e5775b4176d64 Mon Sep 17 00:00:00 2001 From: mihailkudravcev Date: Fri, 24 Jul 2026 10:55:31 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 6 +++++ VERSION | 2 +- export1c_help/export.py | 29 ++++++++++----------- export1c_help/fscompat.py | 53 ++++++++++++++++++++++++++++++++++++++ export1c_help/naming.py | 54 +++++++++++++++++++-------------------- export1c_help/wiki.py | 11 ++++---- 6 files changed, 106 insertions(+), 49 deletions(-) create mode 100644 export1c_help/fscompat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a019ba1..ea68ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index c2c0004..449d7e7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.5 +0.3.6 diff --git a/export1c_help/export.py b/export1c_help/export.py index 330d20a..a766653 100644 --- a/export1c_help/export.py +++ b/export1c_help/export.py @@ -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 diff --git a/export1c_help/fscompat.py b/export1c_help/fscompat.py new file mode 100644 index 0000000..ba4c4e9 --- /dev/null +++ b/export1c_help/fscompat.py @@ -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() diff --git a/export1c_help/naming.py b/export1c_help/naming.py index aff835e..eac7153 100644 --- a/export1c_help/naming.py +++ b/export1c_help/naming.py @@ -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()) diff --git a/export1c_help/wiki.py b/export1c_help/wiki.py index 6129513..6e36640 100644 --- a/export1c_help/wiki.py +++ b/export1c_help/wiki.py @@ -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)