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