tools: Export - include "all" wählt die ganze KB, index: true schreibt index.md und INDEX.md in den Zielordner (#217); 10.1.0-beta.21
Files changed: - CHANGES.md - VERSION - docs/how-to/export-to-a-synced-folder.md - docs/reference/commands/exports.md - docs/reference/configuration.md - raw/CONTRACT.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/catalog.py - tools/chemenu/commands/export_cmd.py - tools/chemenu/commands/index_build.py - tools/chemenu/config_shapes.py - tools/chemenu/export_index.py - tools/chemenu/guideline_export.py - tools/chemenu/page_export.py - tools/chemenu/tests/test_config_shapes.py - tools/chemenu/tests/test_export_run.py - tools/chemenu/tests/test_index_build.py Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
1 parent
cc990dc89e
commit
db09e89223
18 files changed
+693
-101
No files matched your search
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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`
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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("")
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""The routing files of a page export, with no CLI attached: `index.md` at the
|
||||||
|
root of a target and an `INDEX.md` per collection and per large area, listing
|
||||||
|
the pages the target exports - and only those.
|
||||||
|
|
||||||
|
They follow the hierarchy of the catalog under `kb/` (`catalog.group_pages`,
|
||||||
|
`catalog.SHARD_THRESHOLD`) but are not copies of it. The catalog names pages a
|
||||||
|
partial selection does not export, so copied files would carry titles out of the
|
||||||
|
`kb/` and link into nothing; these are built from the selection instead.
|
||||||
|
|
||||||
|
What makes them safe to keep in a synced folder:
|
||||||
|
|
||||||
|
- **Deterministic.** No date of the day, no hint for an agent, no comment ahead
|
||||||
|
of the frontmatter. "Last Modified" comes from the page's own frontmatter and
|
||||||
|
is empty where it has none - never from the file's modification time.
|
||||||
|
- **Local.** A file's marker carries the newest commit of the pages *it lists*,
|
||||||
|
so one page's commit rewrites the files above it and no others.
|
||||||
|
- **Marked like a page.** The frontmatter holds one key, `wikitool_export`, with
|
||||||
|
`kind=index`, whatever the target's `frontmatter` says, so `is_export` and
|
||||||
|
`raw accept` treat it as what it is.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import posixpath
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from chemenu import guideline_export
|
||||||
|
from chemenu.catalog import ROOT_COLLECTION, Area, Collection, anchor, group_pages, type_label
|
||||||
|
from chemenu.kb_scan import GENERATED_INDEX
|
||||||
|
from chemenu.page import Page
|
||||||
|
|
||||||
|
ROOT_FILE = "index.md"
|
||||||
|
_BREAK = re.compile(r"\s+")
|
||||||
|
_LINK_TEXT_ESCAPES = re.compile(r"([\\\[\]|])")
|
||||||
|
_DATE_KEYS = ("modified", "date", "created")
|
||||||
|
|
||||||
|
PAGE_HEADER = "| Page | Type | Summary | Last Modified |"
|
||||||
|
PAGE_SEP = "|------|------|---------|---------------|"
|
||||||
|
|
||||||
|
|
||||||
|
def _cell(text: str) -> str:
|
||||||
|
return _BREAK.sub(" ", text).strip().replace("|", "\\|")
|
||||||
|
|
||||||
|
|
||||||
|
def _link(label: str, target: str, here: str, fragment: str = "") -> str:
|
||||||
|
"""A Markdown link to the file `target`, as written in a file in the folder
|
||||||
|
`here` (both below the target root); the destination is percent-encoded."""
|
||||||
|
destination = quote(posixpath.relpath(target, here or "."), safe="/")
|
||||||
|
if fragment:
|
||||||
|
destination += "#" + quote(fragment, safe="-")
|
||||||
|
return f"[{label}]({destination})"
|
||||||
|
|
||||||
|
|
||||||
|
def _last_modified(page: Page) -> str:
|
||||||
|
for key in _DATE_KEYS:
|
||||||
|
value = page.frontmatter.get(key)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class _Builder:
|
||||||
|
def __init__(self, pages: list[Page], kb_dir: Path, instance: str, commits: dict[Path, str],
|
||||||
|
rank: dict[str, int]) -> None:
|
||||||
|
self.pages = pages
|
||||||
|
self.kb_dir = kb_dir
|
||||||
|
self.instance = instance
|
||||||
|
self.commits = commits
|
||||||
|
self.rank = rank
|
||||||
|
self.files: dict[str, bytes] = {}
|
||||||
|
|
||||||
|
def add(self, rel: str, lines: list[str], pages: list[Page]) -> None:
|
||||||
|
"""Store `lines` as the file `rel` listing `pages`, with the marker of
|
||||||
|
the newest commit among them."""
|
||||||
|
newest = min((self.commits[page.path] for page in pages), key=lambda commit: self.rank[commit])
|
||||||
|
marker = guideline_export.export_marker(guideline_export.INDEX_KIND, self.instance, newest)
|
||||||
|
head = f"---\nwikitool_export: {json.dumps(marker, ensure_ascii=False)}\n---\n\n"
|
||||||
|
self.files[rel] = (head + "\n".join(lines) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
def table(self, pages: list[Page], here: str) -> list[str]:
|
||||||
|
lines = [PAGE_HEADER, PAGE_SEP]
|
||||||
|
for page in sorted(pages, key=lambda p: (p.title.casefold(), p.title)):
|
||||||
|
rel = page.path.relative_to(self.kb_dir).as_posix()
|
||||||
|
title = _LINK_TEXT_ESCAPES.sub(r"\\\1", page.title)
|
||||||
|
summary = page.frontmatter.get("summary")
|
||||||
|
lines.append(
|
||||||
|
f"| {_link(title, rel, here)} | {_cell(type_label(page))} "
|
||||||
|
f"| {_cell(str(summary)) if summary else ''} | {_cell(_last_modified(page))} |"
|
||||||
|
)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def area_file(self, area: Area, collection: str) -> None:
|
||||||
|
here = f"{collection}/{area.name}"
|
||||||
|
self.add(f"{here}/{GENERATED_INDEX}",
|
||||||
|
[f"# {area.title}", "", f"{area.count} page(s).", "", *self.table(area.pages, here)],
|
||||||
|
area.pages)
|
||||||
|
|
||||||
|
def collection_file(self, collection: Collection, pages: list[Page]) -> None:
|
||||||
|
name = collection.name
|
||||||
|
lines = [f"# {name}/ - Index", "", f"{collection.count} page(s).", ""]
|
||||||
|
for area in collection.areas:
|
||||||
|
lines += [f"## {area.title}", ""]
|
||||||
|
if area.own_shard:
|
||||||
|
shard = f"{name}/{area.name}/{GENERATED_INDEX}"
|
||||||
|
lines.append(f"{area.count} page(s) - listed in "
|
||||||
|
f"{_link(f'{area.name}/{GENERATED_INDEX}', shard, name)}.")
|
||||||
|
else:
|
||||||
|
lines += self.table(area.pages, name)
|
||||||
|
lines.append("")
|
||||||
|
self.add(f"{name}/{GENERATED_INDEX}", lines[:-1], pages)
|
||||||
|
|
||||||
|
def root_file(self, collections: list[Collection], strays: list[Page]) -> None:
|
||||||
|
lines = ["# Index", "", f"{len(self.pages)} page(s).", ""]
|
||||||
|
if collections:
|
||||||
|
lines += ["## Collections", "", "| Collection | Pages | Index |", "|------------|------:|-------|"]
|
||||||
|
for collection in collections:
|
||||||
|
target = f"{collection.name}/{GENERATED_INDEX}"
|
||||||
|
lines.append(f"| `{collection.name}/` | {collection.count} | {_link(target, target, '')} |")
|
||||||
|
lines.append("")
|
||||||
|
for collection in collections:
|
||||||
|
named = [area for area in collection.areas if area.name]
|
||||||
|
if not named:
|
||||||
|
continue
|
||||||
|
lines += [f"### {collection.name}/", "", "| Area | Pages | Index |", "|------|------:|-------|"]
|
||||||
|
for area in named:
|
||||||
|
if area.own_shard:
|
||||||
|
target, fragment = f"{collection.name}/{area.name}/{GENERATED_INDEX}", ""
|
||||||
|
else:
|
||||||
|
target, fragment = f"{collection.name}/{GENERATED_INDEX}", anchor(area.title)
|
||||||
|
shown = target + (f"#{fragment}" if fragment else "")
|
||||||
|
lines.append(f"| {_cell(area.title)} | {area.count} | {_link(shown, target, '', fragment)} |")
|
||||||
|
lines.append("")
|
||||||
|
if strays:
|
||||||
|
lines += ["## Pages outside any collection", "", *self.table(strays, ""), ""]
|
||||||
|
self.add(ROOT_FILE, lines[:-1], self.pages)
|
||||||
|
|
||||||
|
|
||||||
|
def build(pages: list[Page], kb_dir: Path, instance: str, commits: dict[Path, str],
|
||||||
|
rank: dict[str, int]) -> dict[str, bytes]:
|
||||||
|
"""The index files for `pages` - the pages a target exports - by path below
|
||||||
|
the target, each as the bytes to write. `commits` and `rank` come from
|
||||||
|
`guideline_export.page_commit_history`. No pages, no files."""
|
||||||
|
if not pages:
|
||||||
|
return {}
|
||||||
|
builder = _Builder(pages, kb_dir, instance, commits, rank)
|
||||||
|
strays: list[Page] = []
|
||||||
|
collections: list[Collection] = []
|
||||||
|
for collection in group_pages(kb_dir, {str(page.path): page for page in pages}):
|
||||||
|
if not collection.count:
|
||||||
|
continue
|
||||||
|
members = [page for area in collection.areas for page in area.pages]
|
||||||
|
if collection.name == ROOT_COLLECTION:
|
||||||
|
strays = members
|
||||||
|
continue
|
||||||
|
collections.append(collection)
|
||||||
|
builder.collection_file(collection, members)
|
||||||
|
for area in collection.areas:
|
||||||
|
if area.own_shard:
|
||||||
|
builder.area_file(area, collection.name)
|
||||||
|
builder.root_file(collections, strays)
|
||||||
|
return builder.files
|
||||||
@@ -51,6 +51,7 @@ FRONTMATTER_FENCE = b"---"
|
|||||||
SCAN_BYTES = 64 * 1024
|
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 ----------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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):
|
||||||
|
|||||||
Reference in new issue
Block a user