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:
+14
-15
@@ -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
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user