tools: Export - include "all" wählt die ganze KB, index: true schreibt index.md und INDEX.md in den Zielordner (#217); 10.1.0-beta.21
Files changed: - CHANGES.md - VERSION - docs/how-to/export-to-a-synced-folder.md - docs/reference/commands/exports.md - docs/reference/configuration.md - raw/CONTRACT.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/catalog.py - tools/chemenu/commands/export_cmd.py - tools/chemenu/commands/index_build.py - tools/chemenu/config_shapes.py - tools/chemenu/export_index.py - tools/chemenu/guideline_export.py - tools/chemenu/page_export.py - tools/chemenu/tests/test_config_shapes.py - tools/chemenu/tests/test_export_run.py - tools/chemenu/tests/test_index_build.py Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
1 parent
cc990dc89e
commit
db09e89223
18 files changed
+693
-101
No files matched your search
+4
-3
@@ -2441,13 +2441,13 @@ Write the derived, read-only reading copy of `kb/` into the folders the `export`
|
||||
- 0 success
|
||||
- 1 No `export` section, an unknown target name, or a broken section (a predicate that does not parse, a path inside the instance or in the way of another target, an `intake` folder or a folder the backup writes into: `<backup.target>/git` or this machine's folder)
|
||||
- 1 `kb/` or `types/` has uncommitted changes, the instance is not a git checkout, or a page under `kb/` has unreadable frontmatter
|
||||
- 1 A target failed: a foreign folder, a folder in backup data, a file changed in the target, an empty selection, an unknown field, a converter that did not exit 0
|
||||
- 1 A target failed: a foreign folder, a folder in backup data, a file changed in the target, an empty selection, an unknown field, two files that differ only in case (also a page and a generated index file), a converter that did not exit 0
|
||||
|
||||
**ON FAILURE**
|
||||
|
||||
- No `export` section, an unknown target name, or a broken section (a predicate that does not parse, a path inside the instance or in the way of another target, an `intake` folder or a folder the backup writes into: `<backup.target>/git` or this machine's folder) -> Show the message to the user; the section is theirs to write by hand, never yours
|
||||
- `kb/` or `types/` has uncommitted changes, the instance is not a git checkout, or a page under `kb/` has unreadable frontmatter -> Fix the named pages, or publish the changes first, then retry once
|
||||
- A target failed: a foreign folder, a folder in backup data, a file changed in the target, an empty selection, an unknown field, a converter that did not exit 0 -> Show the target's reason to the user; the other targets were still served. Never delete or edit files in a target folder to make a run pass
|
||||
- A target failed: a foreign folder, a folder in backup data, a file changed in the target, an empty selection, an unknown field, two files that differ only in case (also a page and a generated index file), a converter that did not exit 0 -> Show the target's reason to the user; the other targets were still served. Never delete or edit files in a target folder to make a run pass
|
||||
|
||||
**NEVER**
|
||||
|
||||
@@ -2456,7 +2456,8 @@ Write the derived, read-only reading copy of `kb/` into the folders the `export`
|
||||
|
||||
**NOTES**
|
||||
|
||||
- An export is derived: regenerated from `kb/` on every run, never edited, never read back into the wiki. Each target names its pages with an allowlist of `search --field` predicates (`include`: a list of selections, AND within one, OR across them), a handler (`markdown`, or `command` for the operator's converter) and the folder (`path`, outside the instance). Nothing is exported that no selection names.
|
||||
- An export is derived: regenerated from `kb/` on every run, never edited, never read back into the wiki. Each target names its pages with `include`: either the string `"all"` (every page `search` knows, also one directly under `kb/`; never a generated or meta file) or an allowlist of `search --field` predicates (a list of selections, AND within one, OR across them), a handler (`markdown`, or `command` for the operator's converter) and the folder (`path`, outside the instance). A missing or empty `include` is an error, never "all": nothing is exported that the target does not name.
|
||||
- With `index: true` a target also gets `index.md` in its root and an `INDEX.md` per collection (and per area the catalog gives a file of its own), built from the exported pages alone: collections and areas with their counts, then tables of page, type, summary and last modified, with relative Markdown links whatever `links` says. No date of the day and no hint for an agent; the marker is `kind=index` with the newest commit of the pages the file lists, so a commit to one collection rewrites that collection's `INDEX.md` and `index.md` only. The files are ordinary files of the run (manifest, atomic write, removed once no longer generated); the `command` handler receives them with the pages. A page `<collection>/index.md` beside the generated `INDEX.md` stops the target.
|
||||
- Every Markdown file starts with a frontmatter block whose `wikitool_export` line says `kind=pages instance=<origin URL without userinfo, or local> commit=<newest commit touching this page>`, followed by `title`, `type`, `summary`, `modified` where the page has them (`frontmatter: summary`, the default; `minimal` keeps only the marker, `full` the whole frontmatter), then the page text. A file keeps its path below `kb/`. A wikilink to an exported page becomes a relative Markdown link (`links: relative`, the default) or plain text (`links: text`); a link to a page that is not exported is always plain text. Footnote markers stay; code is never rewritten. LF endings, no timestamp: the same pages give the same bytes, and a commit that touches one page rewrites that page's file alone - plus any file whose links change because a page entered or left the selection. The manifest's `commit` is the newest commit touching any selected page; all of them come from one `git log`, however many pages are selected.
|
||||
- Handler `command`: the Markdown is rendered into a staging directory, the program of `export.commands` runs with no shell, stdin closed and a timeout of 300 s, and receives the Markdown directory and an empty output directory as its last two arguments. On exit 0 the regular files in the output directory (recursively) become the target's content; any other end leaves the target unchanged and prints the end of its stderr. The staging directory is always removed.
|
||||
- The folder is only ever touched by what it holds in its manifest, `.wikitool-export.json`: the files an earlier run wrote, each with its sha256. A run removes only manifest files that are no longer exported (and folders it created, once empty), rewrites a manifest file that is missing, leaves every other file byte for byte as it was, and writes nothing - not even a modification time - for a file that did not change. A run that changes nothing writes nothing at all.
|
||||
|
||||
+3
-2
@@ -116,7 +116,7 @@ tools/
|
||||
conventions.py kb/CONVENTIONS.md: what this instance decided about authoring, as opposed to what the stack enforces
|
||||
ownership.py the stack-vs-instance boundary under a content stage - one predicate, so no caller keeps a list of its own
|
||||
type_resolver.py type-spec loading and schema resolution
|
||||
catalog.py how the corpus groups into collections and areas, and the shard threshold - with no CLI attached
|
||||
catalog.py how the corpus groups into collections and areas, the shard threshold and the heading-anchor and type-label helpers the catalog and the export index share - with no CLI attached
|
||||
lint_core.py the lint checks and the report, with no CLI attached
|
||||
types_core.py type-spec listing/description, with no CLI attached
|
||||
review.py the weekly review's five checks - the read-time join of kb/gtd/ against the tracker, with no CLI attached
|
||||
@@ -133,7 +133,8 @@ tools/
|
||||
backup.py `backup init`/`backup run`'s core and the step `publish` runs after a push: the bare repository under `<target>/git`, the machine-data mirror under `<target>/<machine>/` with its manifest, and which synced folders `doctor` knows
|
||||
repo_capture.py `raw capture`/`raw status`'s core: resolve a ref rule, fetch it into a bare cache, select files by glob as blobs, the `_capture.json` manifest - git with the host's credentials, never a prompt; plus the one write back, a single-file commit pushed without force for `export guidelines`
|
||||
guideline_export.py `export guidelines`' core: select pages by `search` predicates, render them into one deterministic GUIDELINES.md, and recognise an export again (`is_export`, for both the guideline comment line and the page frontmatter key) - with no CLI attached
|
||||
page_export.py `export run`'s core and the step `publish` runs after a commit: the `export` section, rendering selected pages as Markdown, the target folder with its manifest, and the converter handler - with no CLI attached
|
||||
page_export.py `export run`'s core and the step `publish` runs after a commit: the `export` section, rendering the selected pages as Markdown (`include: "all"` or a list of selections), the target folder with its manifest, and the converter handler - with no CLI attached
|
||||
export_index.py the routing files of a page export (`index: true`): `index.md` and the `INDEX.md` files, built from the exported pages alone and following the catalog's hierarchy - with no CLI attached
|
||||
search/ pluggable search backends, plus service.py - the search core
|
||||
tasks/ the task-tracker provider layer: protocol.py (TaskReader/TaskWriter), config.py (the tracker sections of the settings), one module per adapter - no instruction ever learns which provider it is
|
||||
commands/ one module per command or command group: the terminal adapters - except `raw/`, a package with one module per subcommand and the helpers several of them share in `_common.py`
|
||||
|
||||
@@ -15,6 +15,7 @@ looks like* - the tables, the map, the shard files - stays in
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -27,11 +28,33 @@ from chemenu.type_resolver import resolver
|
||||
# number so growth is handled by arithmetic rather than by a judgment call.
|
||||
SHARD_THRESHOLD = 50
|
||||
|
||||
# The name `group_pages` gives the pseudo-collection of pages sitting directly
|
||||
# under `kb/`, which no collection directory holds.
|
||||
ROOT_COLLECTION = "(kb root)"
|
||||
|
||||
# Display title for pages sitting directly in a collection root rather than in
|
||||
# an area subdirectory.
|
||||
UNGROUPED_TITLE = "All"
|
||||
|
||||
|
||||
def anchor(title: str) -> str:
|
||||
"""GitHub-style heading anchor, so a map can deep-link into a shard.
|
||||
|
||||
`\\w` rather than `a-z0-9`, which is not cosmetic: an area title follows the
|
||||
KB language, and the first non-English one (`Abläufe`) had its umlaut
|
||||
*deleted* rather than kept, so the map linked at `#ablufe` and the anchor it
|
||||
was aiming at was `#abläufe`. Every deep link into a shard whose title
|
||||
carries a non-ASCII letter was silently dead. Nothing surfaced it while the
|
||||
only areas were entity ones, whose titles happen to be ASCII throughout.
|
||||
"""
|
||||
slug = re.sub(r"[^\w\s-]", "", title.lower(), flags=re.UNICODE)
|
||||
return re.sub(r"\s+", "-", slug.strip())
|
||||
|
||||
|
||||
def type_label(page: Page) -> str:
|
||||
return page.subtype or page.kind or "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Area:
|
||||
"""One grouping inside a collection: a subdirectory, or the collection root
|
||||
@@ -115,7 +138,7 @@ def group_pages(kb_dir: Path, pages: dict[str, Page]) -> list[Collection]:
|
||||
except ValueError: # pragma: no cover - pages always live under kb_dir
|
||||
continue
|
||||
if len(parts) < 2:
|
||||
collection_name, area_name = "(kb root)", ""
|
||||
collection_name, area_name = ROOT_COLLECTION, ""
|
||||
else:
|
||||
collection_name = parts[0]
|
||||
area_name = parts[1] if len(parts) > 2 else ""
|
||||
|
||||
@@ -454,10 +454,22 @@ def export_guidelines_command(
|
||||
),
|
||||
notes=(
|
||||
"An export is derived: regenerated from `kb/` on every run, never edited, never read back "
|
||||
"into the wiki. Each target names its pages with an allowlist of `search --field` "
|
||||
"predicates (`include`: a list of selections, AND within one, OR across them), a handler "
|
||||
"(`markdown`, or `command` for the operator's converter) and the folder (`path`, outside "
|
||||
"the instance). Nothing is exported that no selection names.",
|
||||
"into the wiki. Each target names its pages with `include`: either the string `\"all\"` "
|
||||
"(every page `search` knows, also one directly under `kb/`; never a generated or meta "
|
||||
"file) or an allowlist of `search --field` predicates (a list of selections, AND within "
|
||||
"one, OR across them), a handler (`markdown`, or `command` for the operator's converter) "
|
||||
"and the folder (`path`, outside the instance). A missing or empty `include` is an error, "
|
||||
"never \"all\": nothing is exported that the target does not name.",
|
||||
"With `index: true` a target also gets `index.md` in its root and an `INDEX.md` per "
|
||||
"collection (and per area the catalog gives a file of its own), built from the exported "
|
||||
"pages alone: collections and areas with their counts, then tables of page, type, "
|
||||
"summary and last modified, with relative Markdown links whatever `links` says. No date "
|
||||
"of the day and no hint for an agent; the marker is `kind=index` with the newest commit "
|
||||
"of the pages the file lists, so a commit to one collection rewrites that collection's "
|
||||
"`INDEX.md` and `index.md` only. The files are ordinary files of the run (manifest, "
|
||||
"atomic write, removed once no longer generated); the `command` handler receives them "
|
||||
"with the pages. A page `<collection>/index.md` beside the generated `INDEX.md` stops "
|
||||
"the target.",
|
||||
"Every Markdown file starts with a frontmatter block whose `wikitool_export` line says "
|
||||
"`kind=pages instance=<origin URL without userinfo, or local> commit=<newest commit "
|
||||
"touching this page>`, followed by `title`, `type`, `summary`, `modified` where the "
|
||||
@@ -518,7 +530,8 @@ def export_guidelines_command(
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="A target failed: a foreign folder, a folder in backup data, a file changed in "
|
||||
"the target, an empty selection, an unknown field, a converter that did not exit 0",
|
||||
"the target, an empty selection, an unknown field, two files that differ only in case "
|
||||
"(also a page and a generated index file), a converter that did not exit 0",
|
||||
reaction="Show the target's reason to the user; the other targets were still served. "
|
||||
"Never delete or edit files in a target folder to make a run pass",
|
||||
),
|
||||
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
import typer
|
||||
|
||||
from chemenu import cli_contract, config
|
||||
from chemenu.catalog import SHARD_THRESHOLD, Area, Collection, group_pages
|
||||
from chemenu.catalog import Area, Collection, anchor, group_pages, type_label
|
||||
from chemenu.commands._util import rel_path, success
|
||||
from chemenu.page import Page
|
||||
from chemenu.kb_scan import GENERATED_INDEX, find_nested_pages, load_kb_pages
|
||||
@@ -60,33 +60,15 @@ def _last_modified(page: Page) -> str:
|
||||
return date.fromtimestamp(page.path.stat().st_mtime).isoformat()
|
||||
|
||||
|
||||
def _type_label(page: Page) -> str:
|
||||
return page.subtype or page.kind or "unknown"
|
||||
|
||||
|
||||
def _table(pages: list[Page]) -> list[str]:
|
||||
lines = [TABLE_HEADER, TABLE_SEP]
|
||||
for page in sorted(pages, key=lambda p: p.title.lower()):
|
||||
lines.append(
|
||||
f"| [[{page.title}]] | {_type_label(page)} | {_summary(page)} | {_last_modified(page)} |"
|
||||
f"| [[{page.title}]] | {type_label(page)} | {_summary(page)} | {_last_modified(page)} |"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def _anchor(title: str) -> str:
|
||||
"""GitHub-style heading anchor, so the map can deep-link into a shard.
|
||||
|
||||
`\\w` rather than `a-z0-9`, which is not cosmetic: an area title follows the
|
||||
KB language, and the first non-English one (`Abläufe`) had its umlaut
|
||||
*deleted* rather than kept, so the map linked at `#ablufe` and the anchor it
|
||||
was aiming at was `#abläufe`. Every deep link into a shard whose title
|
||||
carries a non-ASCII letter was silently dead. Nothing surfaced it while the
|
||||
only areas were entity ones, whose titles happen to be ASCII throughout.
|
||||
"""
|
||||
slug = re.sub(r"[^\w\s-]", "", title.lower(), flags=re.UNICODE)
|
||||
return re.sub(r"\s+", "-", slug.strip())
|
||||
|
||||
|
||||
def build_area_shard(area: Area) -> str:
|
||||
lines = [DO_NOT_EDIT, "", f"# {area.title}", "", f"{area.count} page(s).", ""]
|
||||
lines.extend(_table(area.pages))
|
||||
@@ -168,7 +150,7 @@ def build_index_map(collections: list[Collection]) -> str:
|
||||
if area.own_shard:
|
||||
target = f"{collection.name}/{area.name}/{GENERATED_INDEX}"
|
||||
else:
|
||||
target = f"{collection.name}/{GENERATED_INDEX}#{_anchor(area.title)}"
|
||||
target = f"{collection.name}/{GENERATED_INDEX}#{anchor(area.title)}"
|
||||
lines.append(f"| {area.title} | {area.count} | [{target}]({target}) |")
|
||||
lines.append("")
|
||||
|
||||
|
||||
@@ -825,10 +825,15 @@ EXPORT_FRONTMATTER_MODES = ("minimal", "summary", "full")
|
||||
EXPORT_DEFAULT_FRONTMATTER = "summary"
|
||||
EXPORT_LINK_MODES = ("relative", "text")
|
||||
EXPORT_DEFAULT_LINKS = "relative"
|
||||
# The one spelling of "every page" an `include` takes besides the array of
|
||||
# selections. A missing, empty or otherwise spelled `include` stays an error:
|
||||
# reading it as "everything" would let a forgotten field write the whole `kb/`
|
||||
# into a folder others read, at the next `publish` of a target with `after_publish`.
|
||||
EXPORT_INCLUDE_ALL = "all"
|
||||
# What each handler takes besides the keys every target has. A key outside
|
||||
# both sets is an error, so a typo or a field of the other handler is not
|
||||
# silently ignored.
|
||||
_EXPORT_COMMON_KEYS = ("handler", "path", "include", "frontmatter", "links", "after_publish")
|
||||
_EXPORT_COMMON_KEYS = ("handler", "path", "include", "frontmatter", "links", "index", "after_publish")
|
||||
_EXPORT_HANDLER_KEYS = {
|
||||
EXPORT_HANDLER_MARKDOWN: (),
|
||||
EXPORT_HANDLER_COMMAND: ("command",),
|
||||
@@ -836,13 +841,17 @@ _EXPORT_HANDLER_KEYS = {
|
||||
|
||||
|
||||
def _export_include_problem(where: str, include: Any) -> Optional[str]:
|
||||
if include == EXPORT_INCLUDE_ALL:
|
||||
return None
|
||||
forms = f"`include` is the string \"{EXPORT_INCLUDE_ALL}\" (every page) or a non-empty array of selections"
|
||||
if not isinstance(include, list) or not include:
|
||||
return f"{where}: `include` must be a non-empty array of selections"
|
||||
return f"{where}: {forms}"
|
||||
for number, selection in enumerate(include, start=1):
|
||||
if not isinstance(selection, list) or not selection:
|
||||
return f"{where}: `include` selection {number} must be a non-empty array of predicates"
|
||||
return f"{where}: selection {number} of `include` must be a non-empty array of predicates; {forms}"
|
||||
if not all(isinstance(predicate, str) and predicate.strip() for predicate in selection):
|
||||
return f"{where}: `include` selection {number} holds an entry that is not a non-empty string"
|
||||
return (f"{where}: selection {number} of `include` holds an entry that is not a non-empty "
|
||||
f"string; {forms}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -858,23 +867,23 @@ def _export_target_problem(name: str, target: Any) -> Optional[str]:
|
||||
if stray:
|
||||
return (f"{where}: {', '.join(f'`{key}`' for key in stray)} not taken by the handler "
|
||||
f"{handler} (it takes {', '.join(f'`{key}`' for key in allowed)})")
|
||||
for key in ("path", "include"):
|
||||
if key not in target:
|
||||
return f"{where}: `{key}` is required"
|
||||
if "path" not in target:
|
||||
return f"{where}: `path` is required"
|
||||
path = target["path"]
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
return f"{where}: `path` must be a non-empty string"
|
||||
problem = absolute_path_problem(path)
|
||||
if problem:
|
||||
return f"{where}: `path` {problem}"
|
||||
problem = _export_include_problem(where, target["include"])
|
||||
problem = _export_include_problem(where, target.get("include"))
|
||||
if problem:
|
||||
return problem
|
||||
for key, choices in (("frontmatter", EXPORT_FRONTMATTER_MODES), ("links", EXPORT_LINK_MODES)):
|
||||
if key in target and target[key] not in choices:
|
||||
return f"{where}: `{key}` must be one of {', '.join(choices)}"
|
||||
if "after_publish" in target and not isinstance(target["after_publish"], bool):
|
||||
return f"{where}: `after_publish` must be true or false"
|
||||
for key in ("index", "after_publish"):
|
||||
if key in target and not isinstance(target[key], bool):
|
||||
return f"{where}: `{key}` must be true or false"
|
||||
if handler == EXPORT_HANDLER_COMMAND:
|
||||
ref = target.get("command")
|
||||
if not isinstance(ref, str) or not _FETCHER_NAME.fullmatch(ref):
|
||||
@@ -920,14 +929,19 @@ EXPORT = Shape(
|
||||
"checkout), `path` (an absolute path outside the instance; it must not lie in or "
|
||||
"contain another target or an `intake` folder, and not be, lie in or contain "
|
||||
"`<backup.target>/git` or `<backup.target>/<machine>`, nor lie in any other "
|
||||
"machine's folder or a bare repository) and `include` (an "
|
||||
"array of selections, each an array of `search --field` predicates: all predicates of "
|
||||
"a selection must hold, and a page that fits any selection is exported; there is no "
|
||||
"\"everything\"). Optional: `frontmatter` (`minimal`: only the export marker; "
|
||||
"machine's folder or a bare repository) and `include` (the string `\"all\"`: every "
|
||||
"page `search` knows, a page directly under `kb/` included and no generated or "
|
||||
"meta file; or an array of selections, each an array of `search --field` predicates: "
|
||||
"all predicates of a selection must hold, and a page that fits any selection is "
|
||||
"exported. A missing or empty `include` is an error, never \"all\"). Optional: "
|
||||
"`frontmatter` (`minimal`: only the export marker; "
|
||||
f"`summary`, the default: also `title`, `type`, `summary` and `modified` where the page "
|
||||
"has them; `full`: the page's whole frontmatter), `links` (`relative`, the default: a "
|
||||
"wikilink to an exported page becomes a relative Markdown link; `text`: plain text; a "
|
||||
"link to a page that is not exported is always plain text) and `after_publish` "
|
||||
"link to a page that is not exported is always plain text), `index` (default false; "
|
||||
"true: also write `index.md` in the target's root and an `INDEX.md` per collection, "
|
||||
"and per area the catalog gives a file of its own, listing the exported pages only, "
|
||||
"with relative Markdown links whatever `links` says) and `after_publish` "
|
||||
"(default false: refresh this target after every `publish` that committed). The "
|
||||
"handler `command` takes `command` (required: the name of an entry of `commands`). A "
|
||||
"field the handler does not take is an error.",
|
||||
@@ -953,6 +967,11 @@ EXPORT = Shape(
|
||||
"include": [["kind=system"], ["tags=shared", "!draft"]],
|
||||
"frontmatter": "summary", "links": "relative", "after_publish": True,
|
||||
},
|
||||
"whole-kb": {
|
||||
"handler": "markdown",
|
||||
"path": "C:\\Users\\<user>\\OneDrive - <company>\\Chemenu-KB",
|
||||
"include": "all", "index": True,
|
||||
},
|
||||
"as-word": {
|
||||
"handler": "command", "command": "pandoc-docx",
|
||||
"path": "C:\\Users\\<user>\\OneDrive - <company>\\Chemenu-Word",
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""The routing files of a page export, with no CLI attached: `index.md` at the
|
||||
root of a target and an `INDEX.md` per collection and per large area, listing
|
||||
the pages the target exports - and only those.
|
||||
|
||||
They follow the hierarchy of the catalog under `kb/` (`catalog.group_pages`,
|
||||
`catalog.SHARD_THRESHOLD`) but are not copies of it. The catalog names pages a
|
||||
partial selection does not export, so copied files would carry titles out of the
|
||||
`kb/` and link into nothing; these are built from the selection instead.
|
||||
|
||||
What makes them safe to keep in a synced folder:
|
||||
|
||||
- **Deterministic.** No date of the day, no hint for an agent, no comment ahead
|
||||
of the frontmatter. "Last Modified" comes from the page's own frontmatter and
|
||||
is empty where it has none - never from the file's modification time.
|
||||
- **Local.** A file's marker carries the newest commit of the pages *it lists*,
|
||||
so one page's commit rewrites the files above it and no others.
|
||||
- **Marked like a page.** The frontmatter holds one key, `wikitool_export`, with
|
||||
`kind=index`, whatever the target's `frontmatter` says, so `is_export` and
|
||||
`raw accept` treat it as what it is.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import posixpath
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from chemenu import guideline_export
|
||||
from chemenu.catalog import ROOT_COLLECTION, Area, Collection, anchor, group_pages, type_label
|
||||
from chemenu.kb_scan import GENERATED_INDEX
|
||||
from chemenu.page import Page
|
||||
|
||||
ROOT_FILE = "index.md"
|
||||
_BREAK = re.compile(r"\s+")
|
||||
_LINK_TEXT_ESCAPES = re.compile(r"([\\\[\]|])")
|
||||
_DATE_KEYS = ("modified", "date", "created")
|
||||
|
||||
PAGE_HEADER = "| Page | Type | Summary | Last Modified |"
|
||||
PAGE_SEP = "|------|------|---------|---------------|"
|
||||
|
||||
|
||||
def _cell(text: str) -> str:
|
||||
return _BREAK.sub(" ", text).strip().replace("|", "\\|")
|
||||
|
||||
|
||||
def _link(label: str, target: str, here: str, fragment: str = "") -> str:
|
||||
"""A Markdown link to the file `target`, as written in a file in the folder
|
||||
`here` (both below the target root); the destination is percent-encoded."""
|
||||
destination = quote(posixpath.relpath(target, here or "."), safe="/")
|
||||
if fragment:
|
||||
destination += "#" + quote(fragment, safe="-")
|
||||
return f"[{label}]({destination})"
|
||||
|
||||
|
||||
def _last_modified(page: Page) -> str:
|
||||
for key in _DATE_KEYS:
|
||||
value = page.frontmatter.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
|
||||
class _Builder:
|
||||
def __init__(self, pages: list[Page], kb_dir: Path, instance: str, commits: dict[Path, str],
|
||||
rank: dict[str, int]) -> None:
|
||||
self.pages = pages
|
||||
self.kb_dir = kb_dir
|
||||
self.instance = instance
|
||||
self.commits = commits
|
||||
self.rank = rank
|
||||
self.files: dict[str, bytes] = {}
|
||||
|
||||
def add(self, rel: str, lines: list[str], pages: list[Page]) -> None:
|
||||
"""Store `lines` as the file `rel` listing `pages`, with the marker of
|
||||
the newest commit among them."""
|
||||
newest = min((self.commits[page.path] for page in pages), key=lambda commit: self.rank[commit])
|
||||
marker = guideline_export.export_marker(guideline_export.INDEX_KIND, self.instance, newest)
|
||||
head = f"---\nwikitool_export: {json.dumps(marker, ensure_ascii=False)}\n---\n\n"
|
||||
self.files[rel] = (head + "\n".join(lines) + "\n").encode("utf-8")
|
||||
|
||||
def table(self, pages: list[Page], here: str) -> list[str]:
|
||||
lines = [PAGE_HEADER, PAGE_SEP]
|
||||
for page in sorted(pages, key=lambda p: (p.title.casefold(), p.title)):
|
||||
rel = page.path.relative_to(self.kb_dir).as_posix()
|
||||
title = _LINK_TEXT_ESCAPES.sub(r"\\\1", page.title)
|
||||
summary = page.frontmatter.get("summary")
|
||||
lines.append(
|
||||
f"| {_link(title, rel, here)} | {_cell(type_label(page))} "
|
||||
f"| {_cell(str(summary)) if summary else ''} | {_cell(_last_modified(page))} |"
|
||||
)
|
||||
return lines
|
||||
|
||||
def area_file(self, area: Area, collection: str) -> None:
|
||||
here = f"{collection}/{area.name}"
|
||||
self.add(f"{here}/{GENERATED_INDEX}",
|
||||
[f"# {area.title}", "", f"{area.count} page(s).", "", *self.table(area.pages, here)],
|
||||
area.pages)
|
||||
|
||||
def collection_file(self, collection: Collection, pages: list[Page]) -> None:
|
||||
name = collection.name
|
||||
lines = [f"# {name}/ - Index", "", f"{collection.count} page(s).", ""]
|
||||
for area in collection.areas:
|
||||
lines += [f"## {area.title}", ""]
|
||||
if area.own_shard:
|
||||
shard = f"{name}/{area.name}/{GENERATED_INDEX}"
|
||||
lines.append(f"{area.count} page(s) - listed in "
|
||||
f"{_link(f'{area.name}/{GENERATED_INDEX}', shard, name)}.")
|
||||
else:
|
||||
lines += self.table(area.pages, name)
|
||||
lines.append("")
|
||||
self.add(f"{name}/{GENERATED_INDEX}", lines[:-1], pages)
|
||||
|
||||
def root_file(self, collections: list[Collection], strays: list[Page]) -> None:
|
||||
lines = ["# Index", "", f"{len(self.pages)} page(s).", ""]
|
||||
if collections:
|
||||
lines += ["## Collections", "", "| Collection | Pages | Index |", "|------------|------:|-------|"]
|
||||
for collection in collections:
|
||||
target = f"{collection.name}/{GENERATED_INDEX}"
|
||||
lines.append(f"| `{collection.name}/` | {collection.count} | {_link(target, target, '')} |")
|
||||
lines.append("")
|
||||
for collection in collections:
|
||||
named = [area for area in collection.areas if area.name]
|
||||
if not named:
|
||||
continue
|
||||
lines += [f"### {collection.name}/", "", "| Area | Pages | Index |", "|------|------:|-------|"]
|
||||
for area in named:
|
||||
if area.own_shard:
|
||||
target, fragment = f"{collection.name}/{area.name}/{GENERATED_INDEX}", ""
|
||||
else:
|
||||
target, fragment = f"{collection.name}/{GENERATED_INDEX}", anchor(area.title)
|
||||
shown = target + (f"#{fragment}" if fragment else "")
|
||||
lines.append(f"| {_cell(area.title)} | {area.count} | {_link(shown, target, '', fragment)} |")
|
||||
lines.append("")
|
||||
if strays:
|
||||
lines += ["## Pages outside any collection", "", *self.table(strays, ""), ""]
|
||||
self.add(ROOT_FILE, lines[:-1], self.pages)
|
||||
|
||||
|
||||
def build(pages: list[Page], kb_dir: Path, instance: str, commits: dict[Path, str],
|
||||
rank: dict[str, int]) -> dict[str, bytes]:
|
||||
"""The index files for `pages` - the pages a target exports - by path below
|
||||
the target, each as the bytes to write. `commits` and `rank` come from
|
||||
`guideline_export.page_commit_history`. No pages, no files."""
|
||||
if not pages:
|
||||
return {}
|
||||
builder = _Builder(pages, kb_dir, instance, commits, rank)
|
||||
strays: list[Page] = []
|
||||
collections: list[Collection] = []
|
||||
for collection in group_pages(kb_dir, {str(page.path): page for page in pages}):
|
||||
if not collection.count:
|
||||
continue
|
||||
members = [page for area in collection.areas for page in area.pages]
|
||||
if collection.name == ROOT_COLLECTION:
|
||||
strays = members
|
||||
continue
|
||||
collections.append(collection)
|
||||
builder.collection_file(collection, members)
|
||||
for area in collection.areas:
|
||||
if area.own_shard:
|
||||
builder.area_file(area, collection.name)
|
||||
builder.root_file(collections, strays)
|
||||
return builder.files
|
||||
@@ -51,6 +51,7 @@ FRONTMATTER_FENCE = b"---"
|
||||
SCAN_BYTES = 64 * 1024
|
||||
GUIDELINES_KIND = "guidelines"
|
||||
PAGES_KIND = "pages"
|
||||
INDEX_KIND = "index"
|
||||
GUIDELINES_PREFIX = f"<!-- wikitool:export kind={GUIDELINES_KIND}"
|
||||
TARGET_PATH = "GUIDELINES.md"
|
||||
LOCAL_INSTANCE = "local"
|
||||
@@ -244,19 +245,20 @@ def last_commit(pages: list[Page], root: Optional[Path] = None) -> str:
|
||||
return sha
|
||||
|
||||
|
||||
def page_commits(pages: list[Page], root: Optional[Path] = None) -> tuple[str, dict[Path, str]]:
|
||||
"""`last_commit(pages)`, and for each page's `path` the newest commit
|
||||
touching that one file - from a single `git log`, however many pages there
|
||||
are, since every process start costs on Windows. The paths go in on stdin,
|
||||
so a whole corpus does not run into the command-line limit, and literally,
|
||||
so a `[` in a title is not a glob. Without `--follow`, a page `rename` moved
|
||||
gets the commit that moved it. A page no listed commit names - one that
|
||||
only a merge's own resolution touched - falls back to the newest commit."""
|
||||
def page_commit_history(pages: list[Page], root: Optional[Path] = None) -> tuple[dict[Path, str], dict[str, int]]:
|
||||
"""For each page's `path` the newest commit touching that one file, and the
|
||||
rank of every commit named - 0 for the newest, counting up with age - from a
|
||||
single `git log`, however many pages there are, since every process start
|
||||
costs on Windows. The paths go in on stdin, so a whole corpus does not run
|
||||
into the command-line limit, and literally, so a `[` in a title is not a
|
||||
glob. Without `--follow`, a page `rename` moved gets the commit that moved
|
||||
it. A page no listed commit names - one that only a merge's own resolution
|
||||
touched - falls back to the newest commit."""
|
||||
root = root or config.ROOT
|
||||
paths = _repo_paths(pages, root)
|
||||
out = _git(["--literal-pathspecs", "log", "--stdin", "-z", "--format=commit %H", "--name-only"], root,
|
||||
stdin="--\n" + "".join(f"{path}\n" for path in paths)) or ""
|
||||
newest = ""
|
||||
rank: dict[str, int] = {}
|
||||
current = ""
|
||||
first: dict[str, str] = {}
|
||||
# `-z` ends each header and each name with NUL; a commit's first name
|
||||
@@ -264,12 +266,25 @@ def page_commits(pages: list[Page], root: Optional[Path] = None) -> tuple[str, d
|
||||
for token in out.split("\0"):
|
||||
if token.startswith("commit "):
|
||||
current = token[len("commit "):]
|
||||
newest = newest or current
|
||||
rank.setdefault(current, len(rank))
|
||||
elif token.strip("\n"):
|
||||
first.setdefault(token.lstrip("\n"), current)
|
||||
if not newest:
|
||||
if not rank:
|
||||
raise ValidationError(_NOT_COMMITTED)
|
||||
return newest, {page.path: first.get(path, newest) for page, path in zip(pages, paths)}
|
||||
newest = next(iter(rank))
|
||||
return {page.path: first.get(path, newest) for page, path in zip(pages, paths)}, rank
|
||||
|
||||
|
||||
def page_commits(pages: list[Page], root: Optional[Path] = None) -> tuple[str, dict[Path, str]]:
|
||||
"""`last_commit(pages)`, and for each page's `path` the newest commit
|
||||
touching that one file - `page_commit_history` without the ranks."""
|
||||
commits, rank = page_commit_history(pages, root)
|
||||
return next(iter(rank)), commits
|
||||
|
||||
|
||||
def export_marker(kind: str, instance: str, commit: str) -> str:
|
||||
"""The value of the `wikitool_export` frontmatter key of an `export run` file."""
|
||||
return f"kind={kind} instance={instance} commit={commit}"
|
||||
|
||||
|
||||
# --- rendering ----------------------------------------------------------------
|
||||
|
||||
@@ -40,7 +40,7 @@ from pathlib import Path
|
||||
from typing import Any, Optional, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from chemenu import backup, config, config_shapes, guideline_export, settings
|
||||
from chemenu import backup, config, config_shapes, export_index, guideline_export, settings
|
||||
from chemenu.errors import ValidationError
|
||||
from chemenu.frontmatter_io import dump_frontmatter
|
||||
from chemenu.kb_scan import LINK_RE, normalize_link_target
|
||||
@@ -71,12 +71,18 @@ class Target:
|
||||
name: str
|
||||
handler: str
|
||||
path: Path
|
||||
include: tuple[tuple[Predicate, ...], ...]
|
||||
include: "str | tuple[tuple[Predicate, ...], ...]"
|
||||
frontmatter: str
|
||||
links: str
|
||||
after_publish: bool
|
||||
command_name: str = ""
|
||||
command: tuple[str, ...] = ()
|
||||
index: bool = False
|
||||
|
||||
@property
|
||||
def everything(self) -> bool:
|
||||
"""Whether `include` is `"all"` rather than a list of selections."""
|
||||
return self.include == config_shapes.EXPORT_INCLUDE_ALL
|
||||
|
||||
|
||||
def _others_paths(root: Path) -> list[tuple[str, Path]]:
|
||||
@@ -97,7 +103,10 @@ def _others_paths(root: Path) -> list[tuple[str, Path]]:
|
||||
return found
|
||||
|
||||
|
||||
def _parse_include(name: str, include: list[list[str]], problems: list[str]) -> tuple[tuple[Predicate, ...], ...]:
|
||||
def _parse_include(name: str, include: "str | list[list[str]]",
|
||||
problems: list[str]) -> "str | tuple[tuple[Predicate, ...], ...]":
|
||||
if include == config_shapes.EXPORT_INCLUDE_ALL:
|
||||
return include
|
||||
selections = []
|
||||
for number, selection in enumerate(include, start=1):
|
||||
parsed = []
|
||||
@@ -144,6 +153,7 @@ def read_config(root: "Path | str | None" = None) -> Optional[tuple[Target, ...]
|
||||
links=raw.get("links", config_shapes.EXPORT_DEFAULT_LINKS),
|
||||
after_publish=raw.get("after_publish", False),
|
||||
command_name=command_name, command=tuple(commands.get(command_name, ())),
|
||||
index=raw.get("index", False),
|
||||
))
|
||||
if not problems:
|
||||
placed = [(f"export.targets.{t.name}", backup.real_path(t.path)) for t in targets]
|
||||
@@ -221,8 +231,11 @@ def choose(targets: Sequence[Target], names: Sequence[str]) -> list[Target]:
|
||||
|
||||
|
||||
def select(pages: dict[str, Page], target: Target, kb_dir: Path) -> list[Page]:
|
||||
"""The pages of `target`: the union over its selections, each the pages
|
||||
that satisfy every predicate of it, sorted by `(title.casefold(), title)`."""
|
||||
"""The pages of `target`: every page `search` knows for `include: "all"`,
|
||||
else the union over its selections, each the pages that satisfy every
|
||||
predicate of it, sorted by `(title.casefold(), title)`."""
|
||||
if target.everything:
|
||||
return sorted(pages.values(), key=lambda p: (p.title.casefold(), p.title))
|
||||
predicates = tuple(p for selection in target.include for p in selection)
|
||||
filters.validate_fields(predicates, pages)
|
||||
chosen: dict[str, Page] = {}
|
||||
@@ -261,7 +274,7 @@ def _rewrite_links(text: str, here: str, destinations: dict[str, Optional[str]],
|
||||
|
||||
|
||||
def marker_value(instance: str, commit: str) -> str:
|
||||
return f"kind={guideline_export.PAGES_KIND} instance={instance} commit={commit}"
|
||||
return guideline_export.export_marker(guideline_export.PAGES_KIND, instance, commit)
|
||||
|
||||
|
||||
def render_page(page: Page, here: str, destinations: dict[str, Optional[str]], target: Target,
|
||||
@@ -287,11 +300,14 @@ def render_page(page: Page, here: str, destinations: dict[str, Optional[str]], t
|
||||
|
||||
|
||||
def render(pages: list[Page], target: Target, kb_dir: Path, instance: str,
|
||||
commits: dict[Path, str]) -> dict[str, bytes]:
|
||||
commits: dict[Path, str], rank: dict[str, int]) -> dict[str, bytes]:
|
||||
"""The files of a target for `pages`, by path below the target, each
|
||||
marked with its own page's commit from `commits`. A page
|
||||
that cannot be written on every platform, or two that would share a file
|
||||
name in a case-insensitive folder, is an `ExportError`."""
|
||||
marked with its own page's commit from `commits`; with `target.index` also
|
||||
the index files (`export_index`), whose commit is the newest of the pages
|
||||
they list by `rank`. A page that cannot be written on every platform, or two
|
||||
files that would share a name in a case-insensitive folder, is an
|
||||
`ExportError` - an index file counts, so a page `<collection>/index.md`
|
||||
beside the generated `<collection>/INDEX.md` stops the target."""
|
||||
paths = {id(page): file_path(page, kb_dir) for page in pages}
|
||||
seen: dict[str, str] = {}
|
||||
for page in pages:
|
||||
@@ -311,6 +327,13 @@ def render(pages: list[Page], target: Target, kb_dir: Path, instance: str,
|
||||
for page in pages:
|
||||
rel = paths[id(page)]
|
||||
files[rel] = render_page(page, rel, by_title, target, instance, commits[page.path])
|
||||
if target.index:
|
||||
indexes = export_index.build(pages, kb_dir, instance, commits, rank)
|
||||
for rel in indexes:
|
||||
if rel.casefold() in seen:
|
||||
raise ExportError(f"{rel} (generated by `index: true`) and {seen[rel.casefold()]} "
|
||||
"are one file in a synced folder - rename the page or set `index` to false")
|
||||
files.update(indexes)
|
||||
return files
|
||||
|
||||
|
||||
@@ -565,8 +588,9 @@ def run(targets: Sequence[Target], root: "Path | str | None" = None) -> list[Tar
|
||||
if not selected:
|
||||
raise ExportError("no page matches the selection - an empty export would delete the "
|
||||
"earlier one, so nothing was changed")
|
||||
result.commit, commits = guideline_export.page_commits(selected, root)
|
||||
files = render(selected, target, kb_dir, instance, commits)
|
||||
commits, rank = guideline_export.page_commit_history(selected, root)
|
||||
result.commit = next(iter(rank))
|
||||
files = render(selected, target, kb_dir, instance, commits, rank)
|
||||
result.titles = {file_path(p, kb_dir): p.title for p in selected}
|
||||
if target.handler == config_shapes.EXPORT_HANDLER_COMMAND:
|
||||
files = convert(target, files, result)
|
||||
|
||||
@@ -312,3 +312,32 @@ def test_the_configuration_regions_are_checked_in_an_instance_too():
|
||||
regions = [r for r in reference_doc.regions() if r.path == reference_doc.CONFIGURATION]
|
||||
assert [r.name for r in regions] == [name for name, _ in reference_doc.CONFIG_REGIONS]
|
||||
assert not any(r.origin_only for r in regions)
|
||||
|
||||
|
||||
# --- export: include "all" and index ----------------------------------------------
|
||||
|
||||
|
||||
def _export_target(**extra):
|
||||
return {"main": {"handler": "markdown", "path": "/srv/export", "include": "all", **extra}}
|
||||
|
||||
|
||||
def test_export_include_takes_all_or_selections_and_names_both_in_its_error():
|
||||
assert config_shapes.export_targets_problem(_export_target()) is None
|
||||
assert config_shapes.export_targets_problem(_export_target(include=[["tags=docs"]])) is None
|
||||
for bad in ("All", "*", "everything", "", [], [[]], [["tags=docs"], []], [[""]], 7, None):
|
||||
problem = config_shapes.export_targets_problem(_export_target(include=bad))
|
||||
assert problem and '"all"' in problem and "array of selections" in problem, bad
|
||||
|
||||
|
||||
def test_export_without_include_is_a_problem_and_not_all():
|
||||
target = _export_target()
|
||||
del target["main"]["include"]
|
||||
assert "array of selections" in config_shapes.export_targets_problem(target)
|
||||
|
||||
|
||||
def test_export_index_is_a_boolean_for_both_handlers():
|
||||
assert config_shapes.export_targets_problem(_export_target(index=True)) is None
|
||||
assert config_shapes.export_targets_problem(_export_target(index=False)) is None
|
||||
assert "`index` must be true or false" in config_shapes.export_targets_problem(_export_target(index="yes"))
|
||||
command = _export_target(handler="command", command="convert", index=True)
|
||||
assert config_shapes.export_targets_problem(command) is None
|
||||
@@ -6,24 +6,30 @@ The target's path carries a space on purpose: the folder a sync client keeps on
|
||||
Windows is `OneDrive - <company>`."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from chemenu import config, config_shapes, guideline_export, page_export
|
||||
from chemenu import catalog, config, config_shapes, guideline_export, page_export
|
||||
from chemenu.cli import app
|
||||
from chemenu.commands import doctor
|
||||
from chemenu.commands.git_publish import publish_command
|
||||
from chemenu.frontmatter_io import write_page
|
||||
from chemenu.search.service import load_pages_by_path
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -932,3 +938,283 @@ def test_the_marker_key_is_defined_once():
|
||||
and 'b"wikitool_export:"' in path.read_text("utf-8")
|
||||
]
|
||||
assert definitions == [Path(guideline_export.__file__)]
|
||||
|
||||
|
||||
# --- include "all" and the index files -----------------------------------------
|
||||
|
||||
META_FILES = {
|
||||
"index.md": "# Wiki Index\n", "log.md": "# Log\n", "provenance.md": "# Provenance\n",
|
||||
"CONTRACT.md": "# Contract\n", "CONVENTIONS.md": "# Conventions\n",
|
||||
"concepts/INDEX.md": "# concepts/ - Index\n", "concepts/COLLECTION.md": "# Concepts\n",
|
||||
}
|
||||
INDEX_NAMES = ("index.md", "INDEX.md")
|
||||
|
||||
|
||||
def _all(root: Path, **extra) -> dict:
|
||||
return {"include": "all", **extra}
|
||||
|
||||
|
||||
def _add_meta_and_stray(root: Path) -> None:
|
||||
for rel, text in META_FILES.items():
|
||||
(root / "kb" / rel).write_text(text, encoding="utf-8")
|
||||
_page(root, "Stray.md", "\n# Stray\n\nDirectly under kb.\n")
|
||||
_commit(root, "meta and stray")
|
||||
|
||||
|
||||
def _links(folder: Path, rel: str) -> list[str]:
|
||||
text = (folder / rel).read_text(encoding="utf-8")
|
||||
return re.findall(r"\]\(([^)]*)\)", text)
|
||||
|
||||
|
||||
def _resolve(rel: str, link: str) -> tuple[str, str]:
|
||||
path, _, fragment = link.partition("#")
|
||||
return posixpath.normpath(posixpath.join(posixpath.dirname(rel), unquote(path))), unquote(fragment)
|
||||
|
||||
|
||||
def _headings(text: str) -> set[str]:
|
||||
return {catalog.anchor(m.group(1)) for m in re.finditer(r"^#+ (.+)$", text, re.MULTILINE)}
|
||||
|
||||
|
||||
def _index_files(folder: Path) -> list[str]:
|
||||
return sorted(rel for rel in _snapshot(folder) if posixpath.basename(rel) in INDEX_NAMES
|
||||
and guideline_export.file_is_export(folder / rel)
|
||||
and "kind=index" in (folder / rel).read_text(encoding="utf-8").split("\n")[1])
|
||||
|
||||
|
||||
def test_all_writes_every_page_search_knows_and_no_generated_or_meta_file(instance):
|
||||
_add_meta_and_stray(instance)
|
||||
_configure(instance, whole=_all(instance))
|
||||
result = _run()
|
||||
assert result.exit_code == 0, result.output
|
||||
pages = load_pages_by_path(instance / "kb", instance)
|
||||
expected = {page.path.relative_to(instance / "kb").as_posix() for page in pages.values()}
|
||||
assert "Stray.md" in expected and "concepts/Gamma.md" in expected
|
||||
written = set(_snapshot(_out(instance, "whole")))
|
||||
assert written == expected | {page_export.MANIFEST_NAME}
|
||||
for meta in META_FILES:
|
||||
assert meta not in written
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include", [None, [], [[]], "All", "*", "everything", "", 1, True])
|
||||
def test_every_other_include_than_all_or_selections_is_an_error_and_writes_nothing(instance, include):
|
||||
_configure(instance, pages={})
|
||||
path = instance / config.LOCAL_SETTINGS_FILENAME
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if include is None:
|
||||
del data["export"]["targets"]["pages"]["include"]
|
||||
else:
|
||||
data["export"]["targets"]["pages"]["include"] = include
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
result = _run()
|
||||
assert result.exit_code == 1, result.output
|
||||
message = _flat(result.output)
|
||||
assert '"all"' in message and "array of selections" in message
|
||||
assert _check("export").status == "FAIL"
|
||||
assert not _out(instance, "pages").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["yes", 1, "true", None])
|
||||
def test_index_must_be_a_boolean(instance, value):
|
||||
_configure(instance, pages={"index": value})
|
||||
result = _run()
|
||||
assert result.exit_code == 1 and "`index` must be true or false" in _flat(result.output)
|
||||
assert _check("export").status == "FAIL"
|
||||
|
||||
|
||||
def test_without_index_or_with_false_no_index_file_is_written(instance):
|
||||
_add_meta_and_stray(instance)
|
||||
_configure(instance, absent=_all(instance), off=_all(instance, index=False))
|
||||
assert _run().exit_code == 0
|
||||
for name in ("absent", "off"):
|
||||
assert not [rel for rel in _snapshot(_out(instance, name))
|
||||
if posixpath.basename(rel) in INDEX_NAMES]
|
||||
|
||||
|
||||
def _many_pages(root: Path, count: int) -> None:
|
||||
for number in range(count):
|
||||
_page(root, f"concepts/protocols/P{number:02d}.md", f"\n# P{number:02d}\n\nText.\n")
|
||||
_commit(root, "many")
|
||||
|
||||
|
||||
def test_every_link_of_every_index_file_lands_and_every_page_is_reachable(instance):
|
||||
_add_meta_and_stray(instance)
|
||||
_many_pages(instance, catalog.SHARD_THRESHOLD)
|
||||
_page(instance, "concepts/areas/Solo.md", "\n# Solo\n\nSmall area.\n")
|
||||
_commit(instance, "small area")
|
||||
_configure(instance, whole=_all(instance, index=True))
|
||||
assert _run().exit_code == 0
|
||||
folder = _out(instance, "whole")
|
||||
files = _snapshot(folder)
|
||||
indexes = _index_files(folder)
|
||||
assert "index.md" in indexes and "concepts/INDEX.md" in indexes
|
||||
assert "concepts/protocols/INDEX.md" in indexes # over the threshold: a file of its own
|
||||
assert "concepts/areas/INDEX.md" not in indexes # under it: an anchor
|
||||
reached, queue = {"index.md"}, ["index.md"]
|
||||
while queue:
|
||||
rel = queue.pop()
|
||||
for link in _links(folder, rel):
|
||||
target, fragment = _resolve(rel, link)
|
||||
assert target in files, f"{rel} links to {link}, which is not in the target"
|
||||
if fragment:
|
||||
assert fragment in _headings(files[target].decode("utf-8")), f"{rel}: no heading for #{fragment}"
|
||||
if target in indexes and target not in reached:
|
||||
reached.add(target)
|
||||
queue.append(target)
|
||||
pages = set(files) - set(indexes) - {page_export.MANIFEST_NAME}
|
||||
linked = {_resolve(rel, link)[0] for rel in reached for link in _links(folder, rel)}
|
||||
assert pages <= linked
|
||||
root_text = files["index.md"].decode("utf-8")
|
||||
assert "concepts/INDEX.md#" in root_text and "concepts/protocols/INDEX.md" in root_text
|
||||
assert "## Pages outside any collection" in root_text and "(Stray.md)" in root_text
|
||||
|
||||
|
||||
def test_the_index_names_only_what_is_exported(instance):
|
||||
_page(instance, "other/Delta.md", "\n# Delta\n\nNot shared.\n", tags=["private"])
|
||||
_page(instance, "concepts/areas/Hidden.md", "\n# Hidden\n\nNot shared.\n", tags=["private"])
|
||||
_commit(instance, "private pages")
|
||||
_configure(instance, pages={"index": True})
|
||||
assert _run().exit_code == 0
|
||||
folder = _out(instance, "pages")
|
||||
assert not (folder / "other").exists()
|
||||
for rel in _index_files(folder):
|
||||
text = (folder / rel).read_text(encoding="utf-8")
|
||||
assert not any(word in text for word in ("Gamma", "Delta", "Hidden", "other/", "areas")), rel
|
||||
assert "Alpha" in (folder / "concepts" / "INDEX.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_an_index_file_is_marked_and_cannot_come_back_in(instance):
|
||||
_configure(instance, pages={"index": True, "frontmatter": "full"})
|
||||
assert _run().exit_code == 0
|
||||
folder = _out(instance, "pages")
|
||||
commit = _git(instance, "rev-parse", "HEAD")
|
||||
for rel in ("index.md", "concepts/INDEX.md"):
|
||||
text = (folder / rel).read_text(encoding="utf-8")
|
||||
assert text.startswith("---\n")
|
||||
frontmatter = text.split("---\n")[1]
|
||||
assert yaml.safe_load(frontmatter) == {
|
||||
"wikitool_export": f"kind=index instance=local commit={commit}"}
|
||||
assert guideline_export.file_is_export(folder / rel)
|
||||
(instance / "incoming" / "index.md").write_bytes((folder / "index.md").read_bytes())
|
||||
before = (instance / "incoming" / "index.md").read_bytes()
|
||||
result = _accept(instance / "incoming" / "index.md")
|
||||
assert result.exit_code == 1 and "export" in _flat(result.output)
|
||||
assert (instance / "incoming" / "index.md").read_bytes() == before
|
||||
assert not any((instance / "raw").rglob("*.md"))
|
||||
|
||||
|
||||
def test_a_second_run_touches_nothing_and_no_index_holds_a_date_of_its_own(instance):
|
||||
_configure(instance, pages={"index": True})
|
||||
assert _run().exit_code == 0
|
||||
folder = _out(instance, "pages")
|
||||
stamps, before = _mtimes(folder), _snapshot(folder)
|
||||
assert _run().exit_code == 0
|
||||
assert _mtimes(folder) == stamps and _snapshot(folder) == before
|
||||
for rel in _index_files(folder):
|
||||
text = (folder / rel).read_text(encoding="utf-8")
|
||||
assert datetime.date.today().isoformat() not in text.replace("2026-10-01", "")
|
||||
assert "Generated" not in text and "wikitool" not in text.split("---\n", 2)[2]
|
||||
|
||||
|
||||
def test_a_page_without_dates_or_summary_has_empty_cells_and_never_the_file_time(instance):
|
||||
path = instance / "kb" / "concepts" / "Bare.md"
|
||||
write_page(path, {"type": "types/concept.md", "tags": ["docs"]}, "\n# Bare\n\nNothing.\n")
|
||||
_commit(instance, "bare")
|
||||
os.utime(path, (86400 * 365, 86400 * 365))
|
||||
_configure(instance, pages={"index": True})
|
||||
assert _run().exit_code == 0
|
||||
text = (_out(instance, "pages") / "concepts" / "INDEX.md").read_text(encoding="utf-8")
|
||||
row = next(line for line in text.splitlines() if "[Bare]" in line)
|
||||
assert row.endswith("| | |") and "1971" not in text
|
||||
|
||||
|
||||
def test_a_commit_to_one_collection_leaves_the_index_of_another_byte_equal(instance):
|
||||
_page(instance, "other/Delta.md", "\n# Delta\n\nSecond collection.\n")
|
||||
_commit(instance, "second collection")
|
||||
_configure(instance, whole=_all(instance, index=True))
|
||||
assert _run().exit_code == 0
|
||||
folder = _out(instance, "whole")
|
||||
before = _snapshot(folder)
|
||||
_page(instance, "concepts/Alpha.md", ALPHA + "\nMore.\n")
|
||||
_commit(instance, "touch concepts only")
|
||||
assert _run().exit_code == 0
|
||||
after = _snapshot(folder)
|
||||
assert after["other/INDEX.md"] == before["other/INDEX.md"]
|
||||
assert after["other/Delta.md"] == before["other/Delta.md"]
|
||||
assert after["concepts/INDEX.md"] != before["concepts/INDEX.md"]
|
||||
assert after["index.md"] != before["index.md"]
|
||||
|
||||
|
||||
def test_switching_index_off_removes_the_index_files_and_nothing_else(instance):
|
||||
_configure(instance, pages={"index": True})
|
||||
assert _run().exit_code == 0
|
||||
folder = _out(instance, "pages")
|
||||
indexes = _index_files(folder)
|
||||
assert indexes
|
||||
kept = {rel: data for rel, data in _snapshot(folder).items()
|
||||
if rel not in indexes and rel != page_export.MANIFEST_NAME}
|
||||
_configure(instance, pages={"index": False})
|
||||
result = _run()
|
||||
assert result.exit_code == 0, result.output
|
||||
after = _snapshot(folder)
|
||||
assert not [rel for rel in after if posixpath.basename(rel) in INDEX_NAMES]
|
||||
assert {rel: data for rel, data in after.items() if rel != page_export.MANIFEST_NAME} == kept
|
||||
assert f"removed ({len(indexes)})" in _flat(result.output)
|
||||
|
||||
|
||||
def test_a_page_index_md_beside_the_generated_one_stops_the_target_before_any_write(instance):
|
||||
_configure(instance, pages={})
|
||||
assert _run().exit_code == 0
|
||||
folder = _out(instance, "pages")
|
||||
before = _snapshot(folder)
|
||||
_page(instance, "concepts/index.md", "\n# Index\n\nA page called index.\n")
|
||||
_commit(instance, "index page")
|
||||
_configure(instance, pages={"index": True})
|
||||
result = _run()
|
||||
assert result.exit_code == 1
|
||||
message = _flat(result.output)
|
||||
assert "concepts/index.md" in message and "concepts/INDEX.md" in message
|
||||
assert _snapshot(folder) == before
|
||||
|
||||
|
||||
def test_a_stray_page_that_differs_from_index_md_only_in_case_stops_the_target(instance):
|
||||
_page(instance, "Index.md", "\n# Index\n\nStray.\n")
|
||||
_commit(instance, "stray")
|
||||
_configure(instance, whole=_all(instance, index=True))
|
||||
result = _run()
|
||||
assert result.exit_code == 1
|
||||
assert "Index.md" in _flat(result.output) and "index.md" in _flat(result.output)
|
||||
assert not _out(instance, "whole").exists()
|
||||
|
||||
|
||||
def test_with_links_text_pages_hold_no_link_to_an_exported_page_but_the_index_links(instance):
|
||||
_configure(instance, pages={"index": True, "links": "text"})
|
||||
assert _run().exit_code == 0
|
||||
folder = _out(instance, "pages")
|
||||
indexes = set(_index_files(folder))
|
||||
for rel in _snapshot(folder):
|
||||
if rel in indexes or rel == page_export.MANIFEST_NAME:
|
||||
continue
|
||||
assert not re.search(r"\]\([^)]*\.md[^)]*\)", (folder / rel).read_text(encoding="utf-8")), rel
|
||||
assert "](Alpha.md)" in (folder / "concepts" / "INDEX.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_the_command_handler_gets_the_index_files_with_the_pages(instance):
|
||||
record = instance.parent / "files.json"
|
||||
program = _converter(instance, f"""
|
||||
import json, pathlib, sys
|
||||
src, out = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
|
||||
pathlib.Path({str(record)!r}).write_text(json.dumps(sorted(
|
||||
p.relative_to(src).as_posix() for p in src.rglob("*") if p.is_file())))
|
||||
(out / "done.txt").write_text("done")
|
||||
""")
|
||||
_configure(instance, pages={"handler": "command", "command": "convert", "index": True})
|
||||
_set_commands(instance, {"convert": program})
|
||||
assert _run().exit_code == 0
|
||||
seen = json.loads(record.read_text(encoding="utf-8"))
|
||||
assert "index.md" in seen and "concepts/INDEX.md" in seen and "concepts/Alpha.md" in seen
|
||||
|
||||
|
||||
def test_the_run_names_the_index_files_by_path(instance):
|
||||
_configure(instance, pages={"index": True})
|
||||
out = _flat(_run().output)
|
||||
assert "index.md" in out and "concepts/INDEX.md" in out
|
||||
@@ -2,9 +2,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu.catalog import SHARD_THRESHOLD, anchor
|
||||
from chemenu.commands.index_build import (
|
||||
SHARD_THRESHOLD,
|
||||
_anchor,
|
||||
build_index,
|
||||
index_rebuild,
|
||||
plan_index,
|
||||
@@ -86,12 +85,12 @@ def test_map_links_to_each_collection_shard(kb_dir):
|
||||
assert f"[entities/{GENERATED_INDEX}](entities/{GENERATED_INDEX})" in content
|
||||
|
||||
|
||||
def test_map_deep_links_an_inlined_area_by_anchor(kb_dir):
|
||||
def test_map_deep_links_an_inlined_area_byanchor(kb_dir):
|
||||
# The anchor is derived from the area's display title, so it follows the KB
|
||||
# language along with it. Both sides of the link are generated in the same
|
||||
# run, so they stay consistent; only a bookmark to an old anchor would break.
|
||||
anchor = _anchor(_area_title("system"))
|
||||
assert f"entities/{GENERATED_INDEX}#{anchor}" in build_index(kb_dir)
|
||||
slug = anchor(_area_title("system"))
|
||||
assert f"entities/{GENERATED_INDEX}#{slug}" in build_index(kb_dir)
|
||||
|
||||
|
||||
# --- collection shards ------------------------------------------------------
|
||||
@@ -120,11 +119,11 @@ def test_anchor_keeps_non_ascii_letters():
|
||||
"""An area title follows the KB language, so it may carry a letter outside
|
||||
`a-z`. Deleting it - which is what the old `[^a-z0-9\\s-]` did - produced a
|
||||
map link (`#ablufe`) that pointed at no heading in the shard it named."""
|
||||
assert _anchor("Abläufe") == "abläufe"
|
||||
assert _anchor("Größere Muster") == "größere-muster"
|
||||
assert anchor("Abläufe") == "abläufe"
|
||||
assert anchor("Größere Muster") == "größere-muster"
|
||||
# Punctuation is still dropped and any run of whitespace still collapses to
|
||||
# a single hyphen.
|
||||
assert _anchor("Tools & Utilities (v2)") == "tools-utilities-v2"
|
||||
assert anchor("Tools & Utilities (v2)") == "tools-utilities-v2"
|
||||
|
||||
|
||||
def test_area_titles_are_read_from_every_type_spec_not_only_entity(plan, kb_dir):
|
||||
|
||||
Reference in new issue
Block a user