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
CI / verify (push) Successful in 8m17s
CI / pwsh (push) Successful in 2m54s
Release / converters (push) Successful in 28s
Release / release (push) Successful in 36s
Release / wiki-mirror (push) Skipped

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:
torbenandClaude Sonnet 5.5 committed 2026-10-16 15:44:21 +02:00
1 parent cc990dc89e
commit db09e89223
18 files changed
+693 -101

No files matched your search

+2 -1
View File
@@ -14,7 +14,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model.
--- ---
## 10.1.0-beta.20 - 2026-10-16 - publish-and-ci: Wartezeit zählt ab freiem Runner, nicht ab dem Push - offene Läufe eines früheren Pushs laufen zuerst, und der Runner hält die Push-Reihenfolge nicht ein ## 10.1.0-beta.21 - 2026-10-16 - Export: include "all" wählt die ganze KB, index: true schreibt index.md und INDEX.md im Ziel
**Author:** Torben Nehmer **Author:** Torben Nehmer
@@ -30,6 +30,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model.
- wiki-ingest: ein Lauf endet nach einer Quelle mit Stopp und /clear-Empfehlung - keine Schleife über mehrere Einträge im selben Kontext; capture-session delegiert wiki-ingest nicht mehr an Subagents (#219) - wiki-ingest: ein Lauf endet nach einer Quelle mit Stopp und /clear-Empfehlung - keine Schleife über mehrere Einträge im selben Kontext; capture-session delegiert wiki-ingest nicht mehr an Subagents (#219)
- tools/bugreport: eine nach dem Sammeln korrigierte Chronologie wird mit --bundle/--chronology in das vorhandene Bündel getauscht (gleiche Verarbeitung, kein zweiter Lauf, kein zweites Bündel); bug-report.md prüft die Chronologie vor dem Sammeln (#220) - tools/bugreport: eine nach dem Sammeln korrigierte Chronologie wird mit --bundle/--chronology in das vorhandene Bündel getauscht (gleiche Verarbeitung, kein zweiter Lauf, kein zweites Bündel); bug-report.md prüft die Chronologie vor dem Sammeln (#220)
- Export- und Eingangsordner dürfen im Backup-Ziel neben git/ und den Maschinenordnern liegen: die Überschneidungsregel gilt nur noch für <ziel>/git und den eigenen Maschinenordner, upload collect und export run weisen Ordner in oder unter Backup-/Export-Daten fremder Maschinen zur Laufzeit ab - Export- und Eingangsordner dürfen im Backup-Ziel neben git/ und den Maschinenordnern liegen: die Überschneidungsregel gilt nur noch für <ziel>/git und den eigenen Maschinenordner, upload collect und export run weisen Ordner in oder unter Backup-/Export-Daten fremder Maschinen zur Laufzeit ab
- Export: include "all" wählt die ganze KB, index: true schreibt index.md und INDEX.md im Ziel
**Low impact** **Low impact**
- instructions/dev: Konverter-Vergleich – eigene Ableitung gegen Referenzwerkzeuge am privaten Korpus, mit Grundlinie - instructions/dev: Konverter-Vergleich – eigene Ableitung gegen Referenzwerkzeuge am privaten Korpus, mit Grundlinie
+1 -1
View File
@@ -1 +1 @@
10.1.0-beta.20 10.1.0-beta.21
+24 -7
View File
@@ -20,10 +20,17 @@ look like: [configuration reference](../reference/configuration.md#export-to-a-f
## Pick the pages and the format ## Pick the pages and the format
- **Which pages.** Only what a target's `include` selects is written; there is no "everything". A - **Which pages.** Only what a target's `include` selects is written. `"include": "all"` selects
selection is a list of `search` predicates that must all hold - `tags=shared`, `kind=system`, every page, also one that lies directly under `kb/`, and no generated file. Otherwise a selection
`!draft` - and a page that fits any selection of the target is exported. Try a selection with is a list of `search` predicates that must all hold - `tags=shared`, `kind=system`, `!draft` - and
`tools/wikitool search --field tags=shared` first. a page that fits any selection of the target is exported: `"include": [["tags=shared"]]`. Try a
selection with `tools/wikitool search --field tags=shared` first. A missing or empty `include` is
an error, not "all". With `"all"`, every page you add later goes into the export too - on a target
with `after_publish` at the next `publish`.
- **An entry point.** The folder holds the pages only. `"index": true` adds `index.md` in the root
of the folder and an `INDEX.md` per collection, so someone who opens the folder finds the
collections, their areas and the pages with their summaries by following links. The files list what
the target exports and nothing else, and the link text is the page's title.
- **Which format.** Two ways, equally valid: - **Which format.** Two ways, equally valid:
- `markdown` writes the pages as `.md` files. It needs nothing but the folder. - `markdown` writes the pages as `.md` files. It needs nothing but the folder.
- `command` hands those files to a converter of your own and exports what it produces - Word - `command` hands those files to a converter of your own and exports what it produces - Word
@@ -69,6 +76,10 @@ look like: [configuration reference](../reference/configuration.md#export-to-a-f
(the default) the title, type, summary and modified date, `full` the page's whole frontmatter. (the default) the title, type, summary and modified date, `full` the page's whole frontmatter.
`links: relative` (the default) turns a link to an exported page into a relative Markdown link; `links: relative` (the default) turns a link to an exported page into a relative Markdown link;
`links: text` leaves the page's name. A link to a page that is not exported is always plain text. `links: text` leaves the page's name. A link to a page that is not exported is always plain text.
The index files link relatively whatever `links` says.
To put the whole of `kb/` into the folder with an entry point, write `"include": "all"` and
`"index": true` instead of the `include` selection above.
3. **Tell the agent:** 3. **Tell the agent:**
> Export the pages. > Export the pages.
@@ -84,8 +95,8 @@ every file names the commit its page comes from. Publish first.
## What a run does to the folder ## What a run does to the folder
The folder holds the pages and one file of the stack's own, `.wikitool-export.json`, which lists The folder holds the pages, the index files if the target asks for them, and one file of the stack's
what the last run wrote. own, `.wikitool-export.json`, which lists what the last run wrote.
- **Only its own files.** A run removes a file only when it is listed and no longer exported, and a - **Only its own files.** A run removes a file only when it is listed and no longer exported, and a
folder only when the run created it. Anything else in the folder stays as it is. folder only when the run created it. Anything else in the folder stays as it is.
@@ -102,6 +113,11 @@ what the last run wrote.
A file of a page the commit did not touch changes only when its text does: a link to a page that A file of a page the commit did not touch changes only when its text does: a link to a page that
has just entered the selection becomes a relative link, one to a page that left it plain text. has just entered the selection becomes a relative link, one to a page that left it plain text.
The first run after updating to a version that brought this rewrites every file once. The first run after updating to a version that brought this rewrites every file once.
- **An index follows the pages.** A collection's `INDEX.md` changes when one of its pages does, and
`index.md`, which lists them all, with every commit to an exported page. Setting `index` back to
`false` removes the index files at the next run and leaves every other file as it is. A page
`index.md` in a collection, next to the generated `INDEX.md`, stops the target: a synced folder on
Windows would make them one file.
- **A growing selection needs no confirmation.** New pages are reported as new; read the list - **A growing selection needs no confirmation.** New pages are reported as new; read the list
before the sync client uploads them. before the sync client uploads them.
@@ -150,7 +166,8 @@ program's stderr. The staging directory is always removed.
## What does not come back in ## What does not come back in
An export is never a source. Every Markdown file starts with a frontmatter block whose first key is An export is never a source. Every Markdown file starts with a frontmatter block whose first key is
`wikitool_export`, and `tools/wikitool raw accept` and `raw capture` refuse a file that carries it. `wikitool_export` - the index files too - and `tools/wikitool raw accept` and `raw capture` refuse a
file that carries it.
A converter may lose that key, so `raw accept` also refuses any file in `incoming/` that is byte A converter may lose that key, so `raw accept` also refuses any file in `incoming/` that is byte
for byte one of the files in the manifest of a configured target - and warns, without refusing, for byte one of the files in the manifest of a configured target - and warns, without refusing,
when a target has no manifest yet or the `export` section is broken. when a target has no manifest yet or the `export` section is broken.
+3 -2
View File
@@ -109,7 +109,8 @@ tools/wikitool export run work-notes
**Behaviour** **Behaviour**
- 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. - 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. - 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. - 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.
@@ -132,7 +133,7 @@ tools/wikitool export run work-notes
- `0` - success - `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` - 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` - `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
**See also** **See also**
+25 -7
View File
@@ -644,7 +644,7 @@ them into a staging directory and hands that to a converter of this checkout.
<!-- wikitool:reference-config-export --> <!-- wikitool:reference-config-export -->
| Field | Tier | Type | Required | Default | Description | | Field | Tier | Type | Required | Default | Description |
|-------|------|------|----------|---------|-------------| |-------|------|------|----------|---------|-------------|
| `targets` | checkout, gate | object | yes | - | Target name (lower-case letters, digits and hyphens) to target; the name is the argument of `export run`. Every target has `handler` (`markdown`: the pages as Markdown files; `command`: the same Markdown run through a converter of this 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; `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` (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. | | `targets` | checkout, gate | object | yes | - | Target name (lower-case letters, digits and hyphens) to target; the name is the argument of `export run`. Every target has `handler` (`markdown`: the pages as Markdown files; `command`: the same Markdown run through a converter of this 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` (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; `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), `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. |
| `commands` | checkout, names a command | object | no | `{}` | Command name to command: an array of the program and its arguments, run by the `command` handler without a shell, with stdin closed and a timeout of 300 s. The last two arguments appended are the directory holding the Markdown and an empty output directory. Exit 0 makes the regular files in the output directory the target's content; anything else leaves the target unchanged and prints the end of the program's stderr. | | `commands` | checkout, names a command | object | no | `{}` | Command name to command: an array of the program and its arguments, run by the `command` handler without a shell, with stdin closed and a timeout of 300 s. The last two arguments appended are the directory holding the Markdown and an empty output directory. Exit 0 makes the regular files in the output directory the target's content; anything else leaves the target unchanged and prints the end of the program's stderr. |
- **Absent:** No export: `export run` stops with exit 1 naming the section, `publish` exports nothing and `doctor` reports `OK`. - **Absent:** No export: `export run` stops with exit 1 naming the section, `publish` exports nothing and `doctor` reports `OK`.
@@ -672,6 +672,12 @@ Example:
"links": "relative", "links": "relative",
"after_publish": true "after_publish": true
}, },
"whole-kb": {
"handler": "markdown",
"path": "C:\\Users\\<user>\\OneDrive - <company>\\Chemenu-KB",
"include": "all",
"index": true
},
"as-word": { "as-word": {
"handler": "command", "handler": "command",
"command": "pandoc-docx", "command": "pandoc-docx",
@@ -696,12 +702,24 @@ Example:
``` ```
<!-- /wikitool:reference-config-export --> <!-- /wikitool:reference-config-export -->
Within one target, `include` is a list of selections, each a list of `search` predicates (`tags=docs`, Within one target, `include` is either the string `"all"` or a list of selections, each a list of
`entity_type=system`, `!source_url`): a page is exported when it satisfies every predicate of at least `search` predicates (`tags=docs`, `entity_type=system`, `!source_url`): a page is exported when it
one selection. Only what `include` selects is written - there is no way to exclude by default. The satisfies every predicate of at least one selection. `"all"` selects every page `search` knows without
syntax is checked when the section is read, the field names against the corpus when `export run` a predicate - one directly under `kb/` too, never a generated or meta file (`kb/index.md`, `kb/log.md`,
runs. `path` must not lie in or contain the instance, another target, an `intake` folder or `kb/provenance.md`, `kb/CONTRACT.md`, `kb/CONVENTIONS.md`, an `INDEX.md`, a `COLLECTION.md`). Only what
`backup.target`. `include` selects is written - there is no way to exclude by default, and a missing or empty `include`
is an error, never "all". The syntax is checked when the section is read, the field names against the
corpus when `export run` runs. `path` must not lie in or contain the instance, another target, an
`intake` folder or `backup.target`.
With `"index": true` a run also writes `index.md` in the root of the target and an `INDEX.md` per
collection - and per area the catalog under `kb/` would give a file of its own. They list the exported
pages only (Page, Type, Summary, Last Modified), link with relative Markdown links whatever `links`
says, and carry no date of the day: Last Modified is the page's own `modified`, `date` or `created`,
empty where it has none. The column and heading labels are English. Each starts with a frontmatter
block whose only key is `wikitool_export` (`kind=index`), so `raw accept` refuses it like a page. A
page `<collection>/index.md` beside the generated `<collection>/INDEX.md` stops the target, since a
synced folder on Windows would make them one file.
Every Markdown file starts with a frontmatter block whose first key is `wikitool_export`, carrying Every Markdown file starts with a frontmatter block whose first key is `wikitool_export`, carrying
the kind, the instance and the newest commit that touched that page. `frontmatter` decides what follows: the kind, the instance and the newest commit that touched that page. `frontmatter` decides what follows:
+3 -3
View File
@@ -99,9 +99,9 @@ both ways out - the whole folder, or the file moved up into `incoming/`.
own output and `raw accept` refuses it, given on its own, inside a folder, or as own output and `raw accept` refuses it, given on its own, inside a folder, or as
`--replaces`/`--replaces-bundle` material, naming each one. The marker is either a first line `--replaces`/`--replaces-bundle` material, naming each one. The marker is either a first line
(after an optional BOM) that starts with `<!-- wikitool:export` - the generated `GUIDELINES.md` of (after an optional BOM) that starts with `<!-- wikitool:export` - the generated `GUIDELINES.md` of
`export guidelines` - or, in a leading frontmatter block, a `wikitool_export:` key - a page `export guidelines` - or, in a leading frontmatter block, a `wikitool_export:` key - a file
`export run` wrote. A converter of an `export` target may drop the marker, so `raw accept` also `export run` wrote, a page or an index file. A converter of an `export` target may drop the marker,
refuses a file whose sha256 is listed in the manifest of a configured target; a missing manifest so `raw accept` also refuses a file whose sha256 is listed in the manifest of a configured target; a missing manifest
or a broken `export` section is a `WARN` and the check is skipped. Taken in, an export would turn or a broken `export` section is a `WARN` and the check is skipped. Taken in, an export would turn
the wiki's knowledge on its way out into a source the wiki then cites for the same claims. the wiki's knowledge on its way out into a source the wiki then cites for the same claims.
`raw capture` leaves a file with the marker out of a repository for the same reason `raw capture` leaves a file with the marker out of a repository for the same reason
+4 -3
View File
@@ -2441,13 +2441,13 @@ Write the derived, read-only reading copy of `kb/` into the folders the `export`
- 0 success - 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 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 `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** **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 - 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 - `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** **NEVER**
@@ -2456,7 +2456,8 @@ Write the derived, read-only reading copy of `kb/` into the folders the `export`
**NOTES** **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. - 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. - 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. - 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
View File
@@ -116,7 +116,7 @@ tools/
conventions.py kb/CONVENTIONS.md: what this instance decided about authoring, as opposed to what the stack enforces 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 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 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 lint_core.py the lint checks and the report, with no CLI attached
types_core.py type-spec listing/description, 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 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 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` 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 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 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 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` 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`
+24 -1
View File
@@ -15,6 +15,7 @@ looks like* - the tables, the map, the shard files - stays in
""" """
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path 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. # number so growth is handled by arithmetic rather than by a judgment call.
SHARD_THRESHOLD = 50 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 # Display title for pages sitting directly in a collection root rather than in
# an area subdirectory. # an area subdirectory.
UNGROUPED_TITLE = "All" 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 @dataclass
class Area: class Area:
"""One grouping inside a collection: a subdirectory, or the collection root """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 except ValueError: # pragma: no cover - pages always live under kb_dir
continue continue
if len(parts) < 2: if len(parts) < 2:
collection_name, area_name = "(kb root)", "" collection_name, area_name = ROOT_COLLECTION, ""
else: else:
collection_name = parts[0] collection_name = parts[0]
area_name = parts[1] if len(parts) > 2 else "" area_name = parts[1] if len(parts) > 2 else ""
+18 -5
View File
@@ -454,10 +454,22 @@ def export_guidelines_command(
), ),
notes=( notes=(
"An export is derived: regenerated from `kb/` on every run, never edited, never read back " "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` " "into the wiki. Each target names its pages with `include`: either the string `\"all\"` "
"predicates (`include`: a list of selections, AND within one, OR across them), a handler " "(every page `search` knows, also one directly under `kb/`; never a generated or meta "
"(`markdown`, or `command` for the operator's converter) and the folder (`path`, outside " "file) or an allowlist of `search --field` predicates (a list of selections, AND within "
"the instance). Nothing is exported that no selection names.", "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 " "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 " "`kind=pages instance=<origin URL without userinfo, or local> commit=<newest commit "
"touching this page>`, followed by `title`, `type`, `summary`, `modified` where the " "touching this page>`, followed by `title`, `type`, `summary`, `modified` where the "
@@ -518,7 +530,8 @@ def export_guidelines_command(
), ),
cli_contract.Failure( cli_contract.Failure(
cause="A target failed: a foreign folder, a folder in backup data, a file changed in " 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. " 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", "Never delete or edit files in a target folder to make a run pass",
), ),
+3 -21
View File
@@ -24,7 +24,7 @@ from pathlib import Path
import typer import typer
from chemenu import cli_contract, config 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.commands._util import rel_path, success
from chemenu.page import Page from chemenu.page import Page
from chemenu.kb_scan import GENERATED_INDEX, find_nested_pages, load_kb_pages 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() 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]: def _table(pages: list[Page]) -> list[str]:
lines = [TABLE_HEADER, TABLE_SEP] lines = [TABLE_HEADER, TABLE_SEP]
for page in sorted(pages, key=lambda p: p.title.lower()): for page in sorted(pages, key=lambda p: p.title.lower()):
lines.append( 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 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: def build_area_shard(area: Area) -> str:
lines = [DO_NOT_EDIT, "", f"# {area.title}", "", f"{area.count} page(s).", ""] lines = [DO_NOT_EDIT, "", f"# {area.title}", "", f"{area.count} page(s).", ""]
lines.extend(_table(area.pages)) lines.extend(_table(area.pages))
@@ -168,7 +150,7 @@ def build_index_map(collections: list[Collection]) -> str:
if area.own_shard: if area.own_shard:
target = f"{collection.name}/{area.name}/{GENERATED_INDEX}" target = f"{collection.name}/{area.name}/{GENERATED_INDEX}"
else: 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(f"| {area.title} | {area.count} | [{target}]({target}) |")
lines.append("") lines.append("")
+34 -15
View File
@@ -825,10 +825,15 @@ EXPORT_FRONTMATTER_MODES = ("minimal", "summary", "full")
EXPORT_DEFAULT_FRONTMATTER = "summary" EXPORT_DEFAULT_FRONTMATTER = "summary"
EXPORT_LINK_MODES = ("relative", "text") EXPORT_LINK_MODES = ("relative", "text")
EXPORT_DEFAULT_LINKS = "relative" 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 # 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 # both sets is an error, so a typo or a field of the other handler is not
# silently ignored. # 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_KEYS = {
EXPORT_HANDLER_MARKDOWN: (), EXPORT_HANDLER_MARKDOWN: (),
EXPORT_HANDLER_COMMAND: ("command",), EXPORT_HANDLER_COMMAND: ("command",),
@@ -836,13 +841,17 @@ _EXPORT_HANDLER_KEYS = {
def _export_include_problem(where: str, include: Any) -> Optional[str]: 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: 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): for number, selection in enumerate(include, start=1):
if not isinstance(selection, list) or not selection: 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): 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 return None
@@ -858,23 +867,23 @@ def _export_target_problem(name: str, target: Any) -> Optional[str]:
if stray: if stray:
return (f"{where}: {', '.join(f'`{key}`' for key in stray)} not taken by the handler " 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)})") f"{handler} (it takes {', '.join(f'`{key}`' for key in allowed)})")
for key in ("path", "include"): if "path" not in target:
if key not in target: return f"{where}: `path` is required"
return f"{where}: `{key}` is required"
path = target["path"] path = target["path"]
if not isinstance(path, str) or not path.strip(): if not isinstance(path, str) or not path.strip():
return f"{where}: `path` must be a non-empty string" return f"{where}: `path` must be a non-empty string"
problem = absolute_path_problem(path) problem = absolute_path_problem(path)
if problem: if problem:
return f"{where}: `path` {problem}" return f"{where}: `path` {problem}"
problem = _export_include_problem(where, target["include"]) problem = _export_include_problem(where, target.get("include"))
if problem: if problem:
return problem return problem
for key, choices in (("frontmatter", EXPORT_FRONTMATTER_MODES), ("links", EXPORT_LINK_MODES)): for key, choices in (("frontmatter", EXPORT_FRONTMATTER_MODES), ("links", EXPORT_LINK_MODES)):
if key in target and target[key] not in choices: if key in target and target[key] not in choices:
return f"{where}: `{key}` must be one of {', '.join(choices)}" return f"{where}: `{key}` must be one of {', '.join(choices)}"
if "after_publish" in target and not isinstance(target["after_publish"], bool): for key in ("index", "after_publish"):
return f"{where}: `after_publish` must be true or false" if key in target and not isinstance(target[key], bool):
return f"{where}: `{key}` must be true or false"
if handler == EXPORT_HANDLER_COMMAND: if handler == EXPORT_HANDLER_COMMAND:
ref = target.get("command") ref = target.get("command")
if not isinstance(ref, str) or not _FETCHER_NAME.fullmatch(ref): 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 " "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 " "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 " "`<backup.target>/git` or `<backup.target>/<machine>`, nor lie in any other "
"machine's folder or a bare repository) and `include` (an " "machine's folder or a bare repository) and `include` (the string `\"all\"`: every "
"array of selections, each an array of `search --field` predicates: all predicates of " "page `search` knows, a page directly under `kb/` included and no generated or "
"a selection must hold, and a page that fits any selection is exported; there is no " "meta file; or an array of selections, each an array of `search --field` predicates: "
"\"everything\"). Optional: `frontmatter` (`minimal`: only the export marker; " "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 " 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 " "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 " "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 " "(default false: refresh this target after every `publish` that committed). The "
"handler `command` takes `command` (required: the name of an entry of `commands`). A " "handler `command` takes `command` (required: the name of an entry of `commands`). A "
"field the handler does not take is an error.", "field the handler does not take is an error.",
@@ -953,6 +967,11 @@ EXPORT = Shape(
"include": [["kind=system"], ["tags=shared", "!draft"]], "include": [["kind=system"], ["tags=shared", "!draft"]],
"frontmatter": "summary", "links": "relative", "after_publish": True, "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": { "as-word": {
"handler": "command", "command": "pandoc-docx", "handler": "command", "command": "pandoc-docx",
"path": "C:\\Users\\<user>\\OneDrive - <company>\\Chemenu-Word", "path": "C:\\Users\\<user>\\OneDrive - <company>\\Chemenu-Word",
+163
View File
@@ -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
+27 -12
View File
@@ -51,6 +51,7 @@ FRONTMATTER_FENCE = b"---"
SCAN_BYTES = 64 * 1024 SCAN_BYTES = 64 * 1024
GUIDELINES_KIND = "guidelines" GUIDELINES_KIND = "guidelines"
PAGES_KIND = "pages" PAGES_KIND = "pages"
INDEX_KIND = "index"
GUIDELINES_PREFIX = f"<!-- wikitool:export kind={GUIDELINES_KIND}" GUIDELINES_PREFIX = f"<!-- wikitool:export kind={GUIDELINES_KIND}"
TARGET_PATH = "GUIDELINES.md" TARGET_PATH = "GUIDELINES.md"
LOCAL_INSTANCE = "local" LOCAL_INSTANCE = "local"
@@ -244,19 +245,20 @@ def last_commit(pages: list[Page], root: Optional[Path] = None) -> str:
return sha return sha
def page_commits(pages: list[Page], root: Optional[Path] = None) -> tuple[str, dict[Path, str]]: def page_commit_history(pages: list[Page], root: Optional[Path] = None) -> tuple[dict[Path, str], dict[str, int]]:
"""`last_commit(pages)`, and for each page's `path` the newest commit """For each page's `path` the newest commit touching that one file, and the
touching that one file - from a single `git log`, however many pages there rank of every commit named - 0 for the newest, counting up with age - from a
are, since every process start costs on Windows. The paths go in on stdin, single `git log`, however many pages there are, since every process start
so a whole corpus does not run into the command-line limit, and literally, costs on Windows. The paths go in on stdin, so a whole corpus does not run
so a `[` in a title is not a glob. Without `--follow`, a page `rename` moved into the command-line limit, and literally, so a `[` in a title is not a
gets the commit that moved it. A page no listed commit names - one that glob. Without `--follow`, a page `rename` moved gets the commit that moved
only a merge's own resolution touched - falls back to the newest commit.""" 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 root = root or config.ROOT
paths = _repo_paths(pages, root) paths = _repo_paths(pages, root)
out = _git(["--literal-pathspecs", "log", "--stdin", "-z", "--format=commit %H", "--name-only"], 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 "" stdin="--\n" + "".join(f"{path}\n" for path in paths)) or ""
newest = "" rank: dict[str, int] = {}
current = "" current = ""
first: dict[str, str] = {} first: dict[str, str] = {}
# `-z` ends each header and each name with NUL; a commit's first name # `-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"): for token in out.split("\0"):
if token.startswith("commit "): if token.startswith("commit "):
current = token[len("commit "):] current = token[len("commit "):]
newest = newest or current rank.setdefault(current, len(rank))
elif token.strip("\n"): elif token.strip("\n"):
first.setdefault(token.lstrip("\n"), current) first.setdefault(token.lstrip("\n"), current)
if not newest: if not rank:
raise ValidationError(_NOT_COMMITTED) 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 ---------------------------------------------------------------- # --- rendering ----------------------------------------------------------------
+36 -12
View File
@@ -40,7 +40,7 @@ from pathlib import Path
from typing import Any, Optional, Sequence from typing import Any, Optional, Sequence
from urllib.parse import quote 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.errors import ValidationError
from chemenu.frontmatter_io import dump_frontmatter from chemenu.frontmatter_io import dump_frontmatter
from chemenu.kb_scan import LINK_RE, normalize_link_target from chemenu.kb_scan import LINK_RE, normalize_link_target
@@ -71,12 +71,18 @@ class Target:
name: str name: str
handler: str handler: str
path: Path path: Path
include: tuple[tuple[Predicate, ...], ...] include: "str | tuple[tuple[Predicate, ...], ...]"
frontmatter: str frontmatter: str
links: str links: str
after_publish: bool after_publish: bool
command_name: str = "" command_name: str = ""
command: tuple[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]]: def _others_paths(root: Path) -> list[tuple[str, Path]]:
@@ -97,7 +103,10 @@ def _others_paths(root: Path) -> list[tuple[str, Path]]:
return found 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 = [] selections = []
for number, selection in enumerate(include, start=1): for number, selection in enumerate(include, start=1):
parsed = [] 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), links=raw.get("links", config_shapes.EXPORT_DEFAULT_LINKS),
after_publish=raw.get("after_publish", False), after_publish=raw.get("after_publish", False),
command_name=command_name, command=tuple(commands.get(command_name, ())), command_name=command_name, command=tuple(commands.get(command_name, ())),
index=raw.get("index", False),
)) ))
if not problems: if not problems:
placed = [(f"export.targets.{t.name}", backup.real_path(t.path)) for t in targets] 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]: 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 """The pages of `target`: every page `search` knows for `include: "all"`,
that satisfy every predicate of it, sorted by `(title.casefold(), title)`.""" 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) predicates = tuple(p for selection in target.include for p in selection)
filters.validate_fields(predicates, pages) filters.validate_fields(predicates, pages)
chosen: dict[str, Page] = {} 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: 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, 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, 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 """The files of a target for `pages`, by path below the target, each
marked with its own page's commit from `commits`. A page marked with its own page's commit from `commits`; with `target.index` also
that cannot be written on every platform, or two that would share a file the index files (`export_index`), whose commit is the newest of the pages
name in a case-insensitive folder, is an `ExportError`.""" 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} paths = {id(page): file_path(page, kb_dir) for page in pages}
seen: dict[str, str] = {} seen: dict[str, str] = {}
for page in pages: for page in pages:
@@ -311,6 +327,13 @@ def render(pages: list[Page], target: Target, kb_dir: Path, instance: str,
for page in pages: for page in pages:
rel = paths[id(page)] rel = paths[id(page)]
files[rel] = render_page(page, rel, by_title, target, instance, commits[page.path]) 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 return files
@@ -565,8 +588,9 @@ def run(targets: Sequence[Target], root: "Path | str | None" = None) -> list[Tar
if not selected: if not selected:
raise ExportError("no page matches the selection - an empty export would delete the " raise ExportError("no page matches the selection - an empty export would delete the "
"earlier one, so nothing was changed") "earlier one, so nothing was changed")
result.commit, commits = guideline_export.page_commits(selected, root) commits, rank = guideline_export.page_commit_history(selected, root)
files = render(selected, target, kb_dir, instance, commits) 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} result.titles = {file_path(p, kb_dir): p.title for p in selected}
if target.handler == config_shapes.EXPORT_HANDLER_COMMAND: if target.handler == config_shapes.EXPORT_HANDLER_COMMAND:
files = convert(target, files, result) files = convert(target, files, result)
+29
View File
@@ -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] 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 [r.name for r in regions] == [name for name, _ in reference_doc.CONFIG_REGIONS]
assert not any(r.origin_only for r in 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
+287 -1
View File
@@ -6,24 +6,30 @@ The target's path carries a space on purpose: the folder a sync client keeps on
Windows is `OneDrive - <company>`.""" Windows is `OneDrive - <company>`."""
from __future__ import annotations from __future__ import annotations
import datetime
import hashlib import hashlib
import json import json
import os import os
import posixpath
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import textwrap import textwrap
from pathlib import Path from pathlib import Path
from urllib.parse import unquote
import pytest import pytest
import typer import typer
import yaml
from typer.testing import CliRunner 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.cli import app
from chemenu.commands import doctor from chemenu.commands import doctor
from chemenu.commands.git_publish import publish_command from chemenu.commands.git_publish import publish_command
from chemenu.frontmatter_io import write_page from chemenu.frontmatter_io import write_page
from chemenu.search.service import load_pages_by_path
from chemenu.type_resolver import resolver from chemenu.type_resolver import resolver
runner = CliRunner() runner = CliRunner()
@@ -932,3 +938,283 @@ def test_the_marker_key_is_defined_once():
and 'b"wikitool_export:"' in path.read_text("utf-8") and 'b"wikitool_export:"' in path.read_text("utf-8")
] ]
assert definitions == [Path(guideline_export.__file__)] 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
+7 -8
View File
@@ -2,9 +2,8 @@ from pathlib import Path
import pytest import pytest
from chemenu.catalog import SHARD_THRESHOLD, anchor
from chemenu.commands.index_build import ( from chemenu.commands.index_build import (
SHARD_THRESHOLD,
_anchor,
build_index, build_index,
index_rebuild, index_rebuild,
plan_index, 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 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 # 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 # 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. # run, so they stay consistent; only a bookmark to an old anchor would break.
anchor = _anchor(_area_title("system")) slug = anchor(_area_title("system"))
assert f"entities/{GENERATED_INDEX}#{anchor}" in build_index(kb_dir) assert f"entities/{GENERATED_INDEX}#{slug}" in build_index(kb_dir)
# --- collection shards ------------------------------------------------------ # --- 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 """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 `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.""" map link (`#ablufe`) that pointed at no heading in the shard it named."""
assert _anchor("Abläufe") == "abläufe" assert anchor("Abläufe") == "abläufe"
assert _anchor("Größere Muster") == "größere-muster" assert anchor("Größere Muster") == "größere-muster"
# Punctuation is still dropped and any run of whitespace still collapses to # Punctuation is still dropped and any run of whitespace still collapses to
# a single hyphen. # 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): def test_area_titles_are_read_from_every_type_spec_not_only_entity(plan, kb_dir):