feat(raw): raw derive turns Office files and mail into a folder with the original and a derived text
CI / verify (push) Successful in 6m44s
CI / pwsh (push) Successful in 2m23s
Release / release (push) Successful in 34s
Release / wiki-mirror (push) Skipped

Adds the import-filter registry (tools/chemenu/filters), the raw derive command with folder form,
path-budget and name checks, DOCX/PPTX/XLSX and EML filters, the pending and accept hints, the
wiki-ingest skill step and the raw/CONTRACT.md section. First of three cuts; containers and
PDF/MSG follow.

Files changed:
- CHANGES.md
- VERSION
- docs/how-to/ingest-a-source.md
- docs/reference/commands/README.md
- docs/reference/commands/raw-material-and-uploads.md
- instructions/wiki-ingest/SKILL.md
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/filters/__init__.py
- tools/chemenu/filters/html_page.py
- tools/chemenu/filters/mail.py
- tools/chemenu/filters/ooxml.py
- tools/chemenu/tests/test_raw_derive.py
- tools/chemenu/web_capture.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Code committed 2026-10-08 12:12:31 +02:00
1 parent 765ebe97d7
commit 9c0bb6bfbf
17 files changed
+2304 -17

No files matched your search

+2 -1
View File
@@ -14,7 +14,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model.
--- ---
## 9.0.0-beta.13 - 2026-10-07 - Gitea-Wiki als generierter Spiegel von docs/: Konverter, Push-Skript, Workflows ## 9.0.0-beta.14 - 2026-10-08 - raw derive reads PDF-free documents: Office files and mail become a folder with the original and a derived text
**Author:** Torben Nehmer **Author:** Torben Nehmer
@@ -35,6 +35,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model.
- Die Formen der vier handgeschriebenen .wikitool-*.json stehen je in einem Datensatz (config_shapes), aus dem Lader, doctor und die Konfigurationsreferenz lesen - Die Formen der vier handgeschriebenen .wikitool-*.json stehen je in einem Datensatz (config_shapes), aus dem Lader, doctor und die Konfigurationsreferenz lesen
- Gate-Liste: AGENTS.md § Gates ist die eine Liste (mit Rebase-Review Gate), cli_contract.GATES hält sie, docs verify vergleicht beide - Gate-Liste: AGENTS.md § Gates ist die eine Liste (mit Rebase-Review Gate), cli_contract.GATES hält sie, docs verify vergleicht beide
- Asset-Preflight: Verschieben aus dem Entpackordner wird bis ca. 30 s wiederholt, nennt die Systemmeldung und rollt nach endgültigem Scheitern zurück - Asset-Preflight: Verschieben aus dem Entpackordner wird bis ca. 30 s wiederholt, nennt die Systemmeldung und rollt nach endgültigem Scheitern zurück
- raw derive reads PDF-free documents: Office files and mail become a folder with the original and a derived text
**Low impact** **Low impact**
- version bump: Abschlussmeldung nennt Stichpunkt oder Themenabsatz statt pauschal Prosa - version bump: Abschlussmeldung nennt Stichpunkt oder Themenabsatz statt pauschal Prosa
+1 -1
View File
@@ -1 +1 @@
9.0.0-beta.13 9.0.0-beta.14
+4
View File
@@ -16,6 +16,10 @@ pick a directory:
- **A web page** needs no download of your own. Name the URL to the agent, and - **A web page** needs no download of your own. Name the URL to the agent, and
`tools/wikitool raw fetch` puts two files into `incoming/`: the HTML exactly as received, and a `tools/wikitool raw fetch` puts two files into `incoming/`: the HTML exactly as received, and a
text derived from it with a header recording where and when it was fetched. text derived from it with a header recording where and when it was fetched.
- **A document that is not text** - a Word, PowerPoint or Excel file, or a mail - is put into
`incoming/` as it is. `tools/wikitool raw derive incoming/<file>` turns it into a folder with the
untouched original and a readable text beside it, with a header saying how it was made. The
folder is accepted whole; the agent runs this itself when it meets such a file.
- **A page behind a paywall or a login** is saved from your browser into `incoming/`, HTML only. The - **A page behind a paywall or a login** is saved from your browser into `incoming/`, HTML only. The
agent derives the same text from that file with `raw fetch --html`. agent derives the same text from that file with `raw fetch --html`.
+1
View File
@@ -79,6 +79,7 @@ Full records: [provenance.md](provenance.md)
Full records: [raw-material-and-uploads.md](raw-material-and-uploads.md) Full records: [raw-material-and-uploads.md](raw-material-and-uploads.md)
- [`raw fetch`](raw-material-and-uploads.md#raw-fetch) - Capture a web page the user names into `incoming/`: the HTML as received plus a derived text, for `raw accept` to promote. - [`raw fetch`](raw-material-and-uploads.md#raw-fetch) - Capture a web page the user names into `incoming/`: the HTML as received plus a derived text, for `raw accept` to promote.
- [`raw derive`](raw-material-and-uploads.md#raw-derive) - Derive reading text from a PDF, e-mail, Office file or web page in `incoming/` and leave the original beside it, for `raw accept` to promote.
- [`raw capture`](raw-material-and-uploads.md#raw-capture) - Capture documentation from a git repository into `incoming/<bundle>/`, with a manifest naming the repository, ref rule and commit, for `raw accept` to promote. - [`raw capture`](raw-material-and-uploads.md#raw-capture) - Capture documentation from a git repository into `incoming/<bundle>/`, with a manifest naming the repository, ref rule and commit, for `raw accept` to promote.
- [`raw status`](raw-material-and-uploads.md#raw-status) - Report which captured bundles under `raw/` have fallen behind their repository, with each changed file as `A`/`M`/`D`. - [`raw status`](raw-material-and-uploads.md#raw-status) - Report which captured bundles under `raw/` have fallen behind their repository, with each changed file as `A`/`M`/`D`.
- [`raw pending`](raw-material-and-uploads.md#raw-pending) - List what waits in `incoming/`, oldest first, and name the entry an ingest without an argument takes next. - [`raw pending`](raw-material-and-uploads.md#raw-pending) - List what waits in `incoming/`, oldest first, and name the entry an ingest without an argument takes next.
@@ -6,6 +6,7 @@ Bringing new material into `raw/`: fetching and capturing it, seeing what is wai
## Contents ## Contents
- [`raw fetch`](#raw-fetch) - [`raw fetch`](#raw-fetch)
- [`raw derive`](#raw-derive)
- [`raw capture`](#raw-capture) - [`raw capture`](#raw-capture)
- [`raw status`](#raw-status) - [`raw status`](#raw-status)
- [`raw pending`](#raw-pending) - [`raw pending`](#raw-pending)
@@ -82,6 +83,66 @@ tools/wikitool raw fetch --html incoming/post.html --url https://example.org/blo
- `wikitool raw accept` - promotes the written files into `raw/` - `wikitool raw accept` - promotes the written files into `raw/`
- `instructions/wiki-ingest/SKILL.md` - where a URL to ingest starts - `instructions/wiki-ingest/SKILL.md` - where a URL to ingest starts
## `raw derive`
Derive reading text from a PDF, e-mail, Office file or web page in `incoming/` and leave the original beside it, for `raw accept` to promote.
**Synopsis**
- `wikitool raw derive incoming/<file> [--name <stem>]` - Move the file into the new folder `incoming/<stem>/` as `original.<ext>` and write `incoming/<stem>/derived.md`
- `wikitool raw derive incoming/<folder>` - Write a `<stem>.md` beside every file in the folder that has a filter
**Options**
```text
Arguments:
path A file or a folder directly in incoming/ [required]
Options:
--name <str> Folder stem for a single file instead of the file's own stem
-h, --help Show this message and exit.
```
**Examples**
```bash
tools/wikitool raw derive incoming/report.docx
tools/wikitool raw derive incoming/Angebot-XYZ.xlsx --name angebot-xyz
tools/wikitool raw derive incoming/meeting-files
```
**Behaviour**
- Writes into `incoming/` only, never into `raw/`, and reads no network: `raw accept` promotes the folder afterwards as one source under `raw/<YYYY>/<MM>/<stem>/`.
- A single file directly in `incoming/` becomes a folder named after its stem (or `--name`) holding `original.<ext>` - the original bytes, moved - and `derived.md`. The fixed names keep the path inside the path budget; the header of `derived.md` records the file's own name as `original_name`.
- A folder is derived in place: each file with a filter gets `<stem>.md` beside it, nothing is renamed, and the files without a filter are named in the output.
- `derived.md` starts with a fixed header (`derived_by`, `filter`, `engine`, `derived`, `derived_from`, `original_name` for a renamed file, `title`, then the fields of the format) followed by the derived text. The same bytes always give the same text; only `derived` differs between runs. `engine` names the tool version and, where a package reads the format, its version.
- Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, `.xlsm`) and e-mail (`.eml`) read with the standard library only. A mail with attachments is refused.
- A name already taken under `raw/` is a WARN, not a refusal: whether the folder is a later edition of that source or a separate one is the human's call. A later edition of a derived source is replaced file by file with `raw accept --replaces`; there is no `--replaces-bundle` for it.
- After a success the output prints the warnings the filters raised, at most three `CHECK` lines per format that was derived in this run - what to look at before the text is trusted - and the `raw accept` line for the result.
- Never edit `derived.md` by hand: a better derivation is a new `raw derive` of the original.
**Properties**
- Changes files: yes
- Idempotent: no
- Atomic: Yes for what it leaves behind - everything is read, derived and checked before the first write; the files are created exclusively, and a failure part-way puts back what this call changed
- Counts toward the session call budget: yes
- Can reach the network: no
**Exit codes**
- `0` - success
- `1` - The path is not directly in `incoming/`, does not exist, or no filter reads the file's extension
- `1` - The target folder or a `.md` already exists, or the path would be over the path budget
- `1` - The document cannot be read (damaged, encrypted, a mail with attachments) or the filter's package is not installed
**See also**
- `raw/CONTRACT.md` "Getting a document in: `raw derive`" - the rules and why
- `wikitool raw accept` - promotes the folder into `raw/`
- `instructions/wiki-ingest/SKILL.md` - where a PDF, mail or Office file to ingest starts
## `raw capture` ## `raw capture`
Capture documentation from a git repository into `incoming/<bundle>/`, with a manifest naming the repository, ref rule and commit, for `raw accept` to promote. Capture documentation from a git repository into `incoming/<bundle>/`, with a manifest naming the repository, ref rule and commit, for `raw accept` to promote.
@@ -232,7 +293,8 @@ tools/wikitool raw pending --json
- Order: oldest first by modification time. A bundle or folder counts as new as its newest file; a tie goes by name. The mtime is when a document last changed only if it was copied with its timestamps kept (`cp -p`, `rsync -a`, an unpacked archive) - for a download or a `raw fetch` it is merely when it was dropped. - Order: oldest first by modification time. A bundle or folder counts as new as its newest file; a tie goes by name. The mtime is when a document last changed only if it was copied with its timestamps kept (`cp -p`, `rsync -a`, an unpacked archive) - for a download or a `raw fetch` it is merely when it was dropped.
- Each candidate shows its path(s), kind, file count and mtime, and whether `raw accept` would take it as it stands - the same checks, minus `--fidelity`/`--authority`. One it would refuse is listed with the reason and skipped: it needs a human. - Each candidate shows its path(s), kind, file count and mtime, and whether `raw accept` would take it as it stands - the same checks, minus `--fidelity`/`--authority`. One it would refuse is listed with the reason and skipped: it needs a human.
- The default is the first candidate `raw accept` would take, and the output names it. - The default is the first candidate `raw accept` would take, and the output names it.
- `--json` prints the same candidates in the same order: `kind`, `paths`, `files`, `mtime`, `acceptable`, `reason`, `default`. - `--json` prints the same candidates in the same order: `kind`, `paths`, `files`, `mtime`, `acceptable`, `reason`, `default`, `hint`.
- A lone file that a `raw derive` filter reads (a PDF, mail, Office file or web page) is listed with the `raw derive` line for it, in text output and as `hint` in JSON (`null` for every other candidate). The hint changes nothing about the order or the default.
- Reads directory listings and `lstat` only, never a file's content; an empty `incoming/` is exit 0 with nothing to do. - Reads directory listings and `lstat` only, never a file's content; an empty `incoming/` is exit 0 with nothing to do.
**Properties** **Properties**
+5 -4
View File
@@ -44,8 +44,9 @@ validator complains - and the ticked list is the only record that they happened.
1. **Read the source.** Read the file completely, wherever it currently sits - `incoming/` for 1. **Read the source.** Read the file completely, wherever it currently sits - `incoming/` for
the normal path, or already under `raw/` when the run started there (a file `capture-session` the normal path, or already under `raw/` when the run started there (a file `capture-session`
just wrote, for instance, which skips step 5 entirely). If it is binary or an image, note its just wrote, for instance, which skips step 5 entirely). A PDF, mail, Office file or ZIP: run
presence and what it shows. `tools/wikitool raw derive <path>` first and read the `.md` - its output names what to check.
Any other binary or image: note its presence and what it shows.
**The user named nothing** ("ingest", "process the inbox")? Pick the entry from the queue: **The user named nothing** ("ingest", "process the inbox")? Pick the entry from the queue:
@@ -250,7 +251,7 @@ validator complains - and the ticked list is the only record that they happened.
incoming/<file> [incoming/<other-file> ...] incoming/<file> [incoming/<other-file> ...]
``` ```
List every file this one source produced (e.g. an uploaded PDF plus its converted Markdown) List every file this one source produced (e.g. a `raw fetch` pair)
in the same call, so they land bundled together rather than as two independent promotions. A in the same call, so they land bundled together rather than as two independent promotions. A
file already in `raw/` skips this step entirely. A folder is accepted as a whole, on its own: file already in `raw/` skips this step entirely. A folder is accepted as a whole, on its own:
@@ -461,7 +462,7 @@ validator complains - and the ticked list is the only record that they happened.
## wikitool commands used ## wikitool commands used
`raw pending`, `raw fetch`, `raw accept`, `raw status`, `raw capture`, `search`, `types describe`, `task new`, `task list`, `task close`, `raw pending`, `raw fetch`, `raw derive`, `raw accept`, `raw status`, `raw capture`, `search`, `types describe`, `task new`, `task list`, `task close`,
`new project`, `new source`, `new entity`, `new concept`, `touch`, `cite add`, `xref add`, `new project`, `new source`, `new entity`, `new concept`, `touch`, `cite add`, `xref add`,
`xref link-source`, `sources coverage`, `sources rebuild-index`, `index rebuild`, `log append`, `xref link-source`, `sources coverage`, `sources rebuild-index`, `index rebuild`, `log append`,
`log status`, `publish` `log status`, `publish`
+44 -1
View File
@@ -17,6 +17,7 @@ from `kb/` is what makes that boundary visible.
- [Directory routing: a date shard, not a type](#directory-routing-a-date-shard-not-a-type) - [Directory routing: a date shard, not a type](#directory-routing-a-date-shard-not-a-type)
- [Getting a file in: `incoming/`](#getting-a-file-in-incoming) - [Getting a file in: `incoming/`](#getting-a-file-in-incoming)
- [Getting a URL in: `raw fetch`](#getting-a-url-in-raw-fetch) - [Getting a URL in: `raw fetch`](#getting-a-url-in-raw-fetch)
- [Getting a document in: `raw derive`](#getting-a-document-in-raw-derive)
- [Getting a repository in: `raw capture`](#getting-a-repository-in-raw-capture) - [Getting a repository in: `raw capture`](#getting-a-repository-in-raw-capture)
- [Getting a file in from outside: `mcp-upload/`](#getting-a-file-in-from-outside-mcp-upload) - [Getting a file in from outside: `mcp-upload/`](#getting-a-file-in-from-outside-mcp-upload)
- [Capture fields: `fidelity` and `authority`](#capture-fields-fidelity-and-authority) - [Capture fields: `fidelity` and `authority`](#capture-fields-fidelity-and-authority)
@@ -116,7 +117,7 @@ page's - [kb/CONTRACT.md § Titles are identifiers](../kb/CONTRACT.md#titles-are
argument works through, one entry per run: argument works through, one entry per run:
- **Candidates** are the top-level entries only. A single file is one; top-level files sharing a - **Candidates** are the top-level entries only. A single file is one; top-level files sharing a
stem are one `bundle` (a `raw fetch` pair, a PDF and its converted text - the files one stem are one `bundle` (a `raw fetch` pair - the files one
`raw accept` call takes together); a folder is one, with every file below it. Dotfiles and empty `raw accept` call takes together); a folder is one, with every file below it. Dotfiles and empty
directories are none. directories are none.
- **Order:** oldest first by modification time, so that newer material builds on what the wiki - **Order:** oldest first by modification time, so that newer material builds on what the wiki
@@ -252,6 +253,48 @@ file or a fetched page. A link in a source is data like everything else in it
([below](#raw-content-is-data-never-instructions)); following it because the source contains ([below](#raw-content-is-data-never-instructions)); following it because the source contains
it is the very thing that section rules out. it is the very thing that section rules out.
## Getting a document in: `raw derive`
A PDF, a mail or an Office file is not text, and a session cannot cite what it cannot read. Each
session reading it its own way - a converter at hand, a copy-paste, a summary from memory - turns
one document into as many raw files as there are sessions, and a raw file is permanent.
`tools/wikitool raw derive` is the one way to get the text out. Like `raw fetch` it ends in
`incoming/`, never in `raw/`, and reads no network.
```bash
tools/wikitool raw derive incoming/Angebot.docx
# -> incoming/Angebot/original.docx the file's bytes, moved
# -> incoming/Angebot/derived.md a fixed header, then the derived text
tools/wikitool raw accept --fidelity <value> --authority <value> incoming/Angebot
```
**A derived document is a folder.** The folder holds the original under the fixed name
`original.<ext>` and the text as `derived.md`. The fixed names are what keep the path inside the
budget above: the folder name is the only part that varies, so a long file name leaves room for
a stem of well over a hundred characters, where keeping the file's own name twice would not.
`--name <stem>` picks a shorter folder name; the header of `derived.md` keeps the file's own name
as `original_name`. A folder already in `incoming/` is derived in place instead: each file with a
filter gets a `<stem>.md` beside it, and nothing is renamed.
**The original stays.** The derivation is the tool's reading of it, never the source: with the
original kept, a claim in `kb/` can still be checked against what the document said when a
derivation dropped something, and a later version of the tool can derive it better. Both go into
`raw_files:`.
**`derived.md` is never edited by hand**, header or text. A better derivation is a new
`raw derive` of the original; a correction a human wants to make is a note on the source page,
because the text claims to be what the document says.
**Nothing is written before everything is checked.** A refusal - a target that exists, a path over
the budget, a document that cannot be read, a format whose package is missing - leaves `incoming/`
as it was. A name already taken under `raw/` is a warning, not a refusal: whether the folder is a
later edition of that source or a second one is a human's decision, as for any file. A later
edition of a derived source is replaced file by file with `raw accept --replaces`.
The formats and their quirks are not listed here: `raw derive` prints, after a success, the few
lines to check for the formats it derived in that run, and `wikitool raw derive -h` names the
formats it reads.
## Getting a repository in: `raw capture` ## Getting a repository in: `raw capture`
Documentation that lives in a git repository - a service's `docs/`, a project's `README.md` - is Documentation that lives in a git repository - a service's `docs/`, a project's `README.md` - is
+61 -1
View File
@@ -123,6 +123,7 @@ sources coverage read idempotent budget:counted exit:0
sources trace read idempotent budget:counted exit:0,1 Trace provenance in either direction: raw file, or page. sources trace read idempotent budget:counted exit:0,1 Trace provenance in either direction: raw file, or page.
sources rebuild-index write idempotent budget:counted exit:0,1 Regenerate the `kb/provenance.md` reverse index. sources rebuild-index write idempotent budget:counted exit:0,1 Regenerate the `kb/provenance.md` reverse index.
raw fetch write non-idempotent budget:counted exit:0,1 Capture a web page the user names into `incoming/`: the HTML as received plus a derived text, for `raw accept` to promote. raw fetch write non-idempotent budget:counted exit:0,1 Capture a web page the user names into `incoming/`: the HTML as received plus a derived text, for `raw accept` to promote.
raw derive write non-idempotent budget:counted exit:0,1 Derive reading text from a PDF, e-mail, Office file or web page in `incoming/` and leave the original beside it, for `raw accept` to promote.
raw capture write non-idempotent budget:counted exit:0,1 Capture documentation from a git repository into `incoming/<bundle>/`, with a manifest naming the repository, ref rule and commit, for `raw accept` to promote. raw capture write non-idempotent budget:counted exit:0,1 Capture documentation from a git repository into `incoming/<bundle>/`, with a manifest naming the repository, ref rule and commit, for `raw accept` to promote.
raw status read idempotent budget:counted exit:0 Report which captured bundles under `raw/` have fallen behind their repository, with each changed file as `A`/`M`/`D`. raw status read idempotent budget:counted exit:0 Report which captured bundles under `raw/` have fallen behind their repository, with each changed file as `A`/`M`/`D`.
raw pending read idempotent budget:counted exit:0 List what waits in `incoming/`, oldest first, and name the entry an ingest without an argument takes next. raw pending read idempotent budget:counted exit:0 List what waits in `incoming/`, oldest first, and name the entry an ingest without an argument takes next.
@@ -1466,6 +1467,64 @@ Capture a web page the user names into `incoming/`: the HTML as received plus a
- `wikitool raw accept` - promotes the written files into `raw/` - `wikitool raw accept` - promotes the written files into `raw/`
- `instructions/wiki-ingest/SKILL.md` - where a URL to ingest starts - `instructions/wiki-ingest/SKILL.md` - where a URL to ingest starts
#### `raw derive`
Derive reading text from a PDF, e-mail, Office file or web page in `incoming/` and leave the original beside it, for `raw accept` to promote.
**SYNOPSIS**
- `wikitool raw derive incoming/<file> [--name <stem>]` - Move the file into the new folder `incoming/<stem>/` as `original.<ext>` and write `incoming/<stem>/derived.md`
- `wikitool raw derive incoming/<folder>` - Write a `<stem>.md` beside every file in the folder that has a filter
**PROPERTIES**
- effect: write
- idempotent: no
- atomic: Yes for what it leaves behind - everything is read, derived and checked before the first write; the files are created exclusively, and a failure part-way puts back what this call changed
- budget: counted
- network: no
**EXAMPLES**
- `tools/wikitool raw derive incoming/report.docx`
- `tools/wikitool raw derive incoming/Angebot-XYZ.xlsx --name angebot-xyz`
- `tools/wikitool raw derive incoming/meeting-files`
**EXIT STATUS**
- 0 success
- 1 The path is not directly in `incoming/`, does not exist, or no filter reads the file's extension
- 1 The target folder or a `.md` already exists, or the path would be over the path budget
- 1 The document cannot be read (damaged, encrypted, a mail with attachments) or the filter's package is not installed
**ON FAILURE**
- The path is not directly in `incoming/`, does not exist, or no filter reads the file's extension -> Nothing was written. Fix the argument and retry once - a file in a subfolder is moved up, or its folder is derived whole
- The target folder or a `.md` already exists, or the path would be over the path budget -> Nothing was written. Remove what is there or pass `--name <shorter stem>`, then retry once. An existing `.md` is never overwritten
- The document cannot be read (damaged, encrypted, a mail with attachments) or the filter's package is not installed -> Nothing was written. A missing package is reported with the preflight hint - show it to the user and stop, never install it. Any other filter keeps working
**NEVER**
- Never edit `derived.md` or its header by hand.
- Never derive a file the user did not put into `incoming/` for this purpose.
**NOTES**
- Writes into `incoming/` only, never into `raw/`, and reads no network: `raw accept` promotes the folder afterwards as one source under `raw/<YYYY>/<MM>/<stem>/`.
- A single file directly in `incoming/` becomes a folder named after its stem (or `--name`) holding `original.<ext>` - the original bytes, moved - and `derived.md`. The fixed names keep the path inside the path budget; the header of `derived.md` records the file's own name as `original_name`.
- A folder is derived in place: each file with a filter gets `<stem>.md` beside it, nothing is renamed, and the files without a filter are named in the output.
- `derived.md` starts with a fixed header (`derived_by`, `filter`, `engine`, `derived`, `derived_from`, `original_name` for a renamed file, `title`, then the fields of the format) followed by the derived text. The same bytes always give the same text; only `derived` differs between runs. `engine` names the tool version and, where a package reads the format, its version.
- Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, `.xlsm`) and e-mail (`.eml`) read with the standard library only. A mail with attachments is refused.
- A name already taken under `raw/` is a WARN, not a refusal: whether the folder is a later edition of that source or a separate one is the human's call. A later edition of a derived source is replaced file by file with `raw accept --replaces`; there is no `--replaces-bundle` for it.
- After a success the output prints the warnings the filters raised, at most three `CHECK` lines per format that was derived in this run - what to look at before the text is trusted - and the `raw accept` line for the result.
- Never edit `derived.md` by hand: a better derivation is a new `raw derive` of the original.
**SEE ALSO**
- `raw/CONTRACT.md` "Getting a document in: `raw derive`" - the rules and why
- `wikitool raw accept` - promotes the folder into `raw/`
- `instructions/wiki-ingest/SKILL.md` - where a PDF, mail or Office file to ingest starts
#### `raw capture` #### `raw capture`
Capture documentation from a git repository into `incoming/<bundle>/`, with a manifest naming the repository, ref rule and commit, for `raw accept` to promote. Capture documentation from a git repository into `incoming/<bundle>/`, with a manifest naming the repository, ref rule and commit, for `raw accept` to promote.
@@ -1611,7 +1670,8 @@ List what waits in `incoming/`, oldest first, and name the entry an ingest witho
- Order: oldest first by modification time. A bundle or folder counts as new as its newest file; a tie goes by name. The mtime is when a document last changed only if it was copied with its timestamps kept (`cp -p`, `rsync -a`, an unpacked archive) - for a download or a `raw fetch` it is merely when it was dropped. - Order: oldest first by modification time. A bundle or folder counts as new as its newest file; a tie goes by name. The mtime is when a document last changed only if it was copied with its timestamps kept (`cp -p`, `rsync -a`, an unpacked archive) - for a download or a `raw fetch` it is merely when it was dropped.
- Each candidate shows its path(s), kind, file count and mtime, and whether `raw accept` would take it as it stands - the same checks, minus `--fidelity`/`--authority`. One it would refuse is listed with the reason and skipped: it needs a human. - Each candidate shows its path(s), kind, file count and mtime, and whether `raw accept` would take it as it stands - the same checks, minus `--fidelity`/`--authority`. One it would refuse is listed with the reason and skipped: it needs a human.
- The default is the first candidate `raw accept` would take, and the output names it. - The default is the first candidate `raw accept` would take, and the output names it.
- `--json` prints the same candidates in the same order: `kind`, `paths`, `files`, `mtime`, `acceptable`, `reason`, `default`. - `--json` prints the same candidates in the same order: `kind`, `paths`, `files`, `mtime`, `acceptable`, `reason`, `default`, `hint`.
- A lone file that a `raw derive` filter reads (a PDF, mail, Office file or web page) is listed with the `raw derive` line for it, in text output and as `hint` in JSON (`null` for every other candidate). The hint changes nothing about the order or the default.
- Reads directory listings and `lstat` only, never a file's content; an empty `incoming/` is exit 0 with nothing to do. - Reads directory listings and `lstat` only, never a file's content; an empty `incoming/` is exit 0 with nothing to do.
**SEE ALSO** **SEE ALSO**
+1
View File
@@ -112,6 +112,7 @@ tools/
kb_state.py the KB version (.wikitool-kb.json) and the migration chain kb_state.py the KB version (.wikitool-kb.json) and the migration chain
corpus_diff.py invariant comparison of kb/ between two revisions corpus_diff.py invariant comparison of kb/ between two revisions
web_capture.py `raw fetch`'s core: fetch a page, decide its charset, derive Markdown-like text from the HTML - standard library only, deterministic web_capture.py `raw fetch`'s core: fetch a page, decide its charset, derive Markdown-like text from the HTML - standard library only, deterministic
filters/ the import filters behind `raw derive`: a registry (`__init__.py`) and one module per format family (HTML, mail, Office), each registering the extensions it reads - standard library, or a package imported lazily so a missing one fails only its own format
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 (`EXPORT_MARKER`) - 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 (`EXPORT_MARKER`) - with no CLI attached
search/ pluggable search backends, plus service.py - the search core search/ pluggable search backends, plus service.py - the search core
+1 -1
View File
@@ -287,7 +287,7 @@ GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
"sources coverage", "sources trace", "sources rebuild-index", "sources coverage", "sources trace", "sources rebuild-index",
)), )),
("Raw material and uploads", ( ("Raw material and uploads", (
"raw fetch", "raw capture", "raw status", "raw pending", "raw accept", "raw fetch", "raw derive", "raw capture", "raw status", "raw pending", "raw accept",
"upload list", "upload show", "upload accept", "upload reject", "upload list", "upload show", "upload accept", "upload reject",
)), )),
("Git", ( ("Git", (
+380 -5
View File
@@ -25,6 +25,12 @@ sit directly in `incoming/`:
today's shard, so a bundle never mixes an old capture date with today's today's shard, so a bundle never mixes an old capture date with today's
(Gitea #67 decision, "Datums-Shard" § "Bündelort"). (Gitea #67 decision, "Datums-Shard" § "Bündelort").
`raw derive` is the way in for a document that is not text (PDF, mail, Office
file): it turns one file in `incoming/` into a folder `<stem>/` holding the
untouched `original.<ext>` and a `derived.md` made by the registered filter
(`chemenu.filters`), so `raw accept` takes the folder as one source. Everything
is checked before the first write; the original moves in last.
`raw pending` reads the same queue without changing it: the candidates in `raw pending` reads the same queue without changing it: the candidates in
`incoming/` oldest first, each judged by the very checks `raw accept` runs `incoming/` oldest first, each judged by the very checks `raw accept` runs
before it moves anything (`_Refused` is how those checks report without before it moves anything (`_Refused` is how those checks report without
@@ -87,7 +93,7 @@ from typing import Annotated, Optional
import typer import typer
from rich.markup import escape from rich.markup import escape
from chemenu import cli_contract, config, guideline_export, repo_capture, web_capture from chemenu import cli_contract, config, filters, guideline_export, repo_capture, web_capture
from chemenu.commands._util import fail, path_budget_problem_for, rel_path, success from chemenu.commands._util import fail, path_budget_problem_for, rel_path, success
from chemenu.errors import BackendError, ChemenuError, ValidationError from chemenu.errors import BackendError, ChemenuError, ValidationError
from chemenu.frontmatter_io import write_page from chemenu.frontmatter_io import write_page
@@ -962,6 +968,20 @@ def _replace_bundle(
"the list above - they are still untracked.") "the list above - they are still untracked.")
def _warn_underived(resolved: list[Path]) -> None:
"""A file `raw derive` could read, promoted with no `.md` beside it, leaves
the source without a text the wiki can read - say so, and carry on."""
stems_with_text = {p.stem for p in resolved if p.suffix.lower() == ".md"}
for path in resolved:
hint = _derive_hint(path)
if hint is not None and path.stem not in stems_with_text:
typer.echo(
f"WARN {rel_path(path)} has a text filter and no derived .md beside it - the "
f"source would have no text to read. Run {hint} first and accept the folder it "
"makes, unless the file is meant to be kept as it is."
)
@cli_contract.record(cli_contract.CommandRecord( @cli_contract.record(cli_contract.CommandRecord(
path="raw accept", path="raw accept",
summary="Promote one or more files, or one folder, from `incoming/` into `raw/`.", summary="Promote one or more files, or one folder, from `incoming/` into `raw/`.",
@@ -1264,6 +1284,7 @@ def raw_accept_command(
return return
_refusals_fail(_check_files, resolved) _refusals_fail(_check_files, resolved)
_warn_underived(resolved)
pages = None pages = None
target_page = None target_page = None
@@ -1378,6 +1399,7 @@ class _Candidate:
files: int files: int
mtime_ns: int mtime_ns: int
reason: Optional[str] # None when `raw accept` would take it as it stands reason: Optional[str] # None when `raw accept` would take it as it stands
hint: Optional[str] = None # the `raw derive` line for a lone file a filter reads
def label(self) -> str: def label(self) -> str:
return ", ".join(rel_path(p) + ("/" if self.kind == "folder" else "") for p in self.paths) return ", ".join(rel_path(p) + ("/" if self.kind == "folder" else "") for p in self.paths)
@@ -1449,6 +1471,7 @@ def _pending_candidates() -> list[_Candidate]:
len(paths), len(paths),
max(p.lstat().st_mtime_ns for p in paths), max(p.lstat().st_mtime_ns for p in paths),
_refusal(_plain_files, paths), _refusal(_plain_files, paths),
_derive_hint(paths[0]) if len(paths) == 1 else None,
)) ))
candidates.sort(key=lambda c: (c.mtime_ns, os.fsencode(c.paths[0].name))) candidates.sort(key=lambda c: (c.mtime_ns, os.fsencode(c.paths[0].name)))
return candidates return candidates
@@ -1480,7 +1503,11 @@ def _pending_candidates() -> list[_Candidate]:
"human.", "human.",
"The default is the first candidate `raw accept` would take, and the output names it.", "The default is the first candidate `raw accept` would take, and the output names it.",
"`--json` prints the same candidates in the same order: `kind`, `paths`, `files`, " "`--json` prints the same candidates in the same order: `kind`, `paths`, `files`, "
"`mtime`, `acceptable`, `reason`, `default`.", "`mtime`, `acceptable`, `reason`, `default`, `hint`.",
"A lone file that a `raw derive` filter reads (a PDF, mail, Office file or web page) is "
"listed with the `raw derive` line for it, in text output and as `hint` in JSON "
"(`null` for every other candidate). The hint changes nothing about the order or the "
"default.",
"Reads directory listings and `lstat` only, never a file's content; an empty " "Reads directory listings and `lstat` only, never a file's content; an empty "
"`incoming/` is exit 0 with nothing to do.", "`incoming/` is exit 0 with nothing to do.",
), ),
@@ -1514,6 +1541,7 @@ def raw_pending_command(
"acceptable": c.reason is None, "acceptable": c.reason is None,
"reason": c.reason, "reason": c.reason,
"default": c is default, "default": c is default,
"hint": c.hint,
} }
for c in candidates for c in candidates
], indent=2, ensure_ascii=False)) ], indent=2, ensure_ascii=False))
@@ -1531,6 +1559,8 @@ def raw_pending_command(
typer.echo(f" not acceptable: {first}") typer.echo(f" not acceptable: {first}")
for line in rest: for line in rest:
typer.echo(f" {line}") typer.echo(f" {line}")
if c.hint is not None:
typer.echo(f" text: {c.hint} (derives readable text, keeps the original)")
if default is None: if default is None:
typer.echo("No candidate can be accepted as it stands - each needs a human (reasons above).") typer.echo("No candidate can be accepted as it stands - each needs a human (reasons above).")
return return
@@ -1800,6 +1830,345 @@ def raw_fetch_command(
)) ))
# --- raw derive ---------------------------------------------------------------
#
# The sanctioned intake for a document that is not text: a PDF, a mail, an
# Office file. Like `raw fetch` it ends in `incoming/`, never in `raw/`. The
# formats live in `chemenu.filters`; this adapter decides names and folders and
# what the caller is told. Everything is read, derived and checked before the
# first write, so a refusal leaves `incoming/` as it was.
DERIVE_MAX_BYTES = 100 * 1024 * 1024
ORIGINAL_STEM = "original"
DERIVED_NAME = "derived.md"
@dataclass(frozen=True)
class _Derived:
"""One finished derivation, not yet written."""
filter: filters.Filter
derivation: filters.Derivation
source: Path # the file in incoming/
markdown: bytes
target: Path # where the .md goes
def _derive_hint(path: Path) -> Optional[str]:
"""The `raw derive` line for a file a filter reads, else None."""
if filters.filter_for(path.name) is None:
return None
return f"tools/wikitool raw derive {rel_path(path)}"
def _read_for_derive(path: Path) -> bytes:
size = path.stat().st_size
if size > DERIVE_MAX_BYTES:
raise _Refused(
f"{rel_path(path)} is {size // (1024 * 1024)} MiB; raw derive reads at most "
f"{DERIVE_MAX_BYTES // (1024 * 1024)} MiB."
)
return path.read_bytes()
def _run_filter(candidate: filters.Filter, data: bytes, path: Path) -> filters.Derivation:
try:
filters.require_available(candidate)
return candidate.derive(data, path.name)
except ValidationError as exc:
raise _Refused(f"{rel_path(path)}: {exc}") from None
def _collision_warning(name: str) -> Optional[str]:
holder = _occupied_stems(config.RAW_DIR).get(name)
if holder is None:
return None
return _folder_collision_message(name, holder)
def _plan_derive_file(path: Path, name: Optional[str]):
"""The single-file form: `incoming/<stem>/` with the original moved in as
`original.<ext>` and the derived text beside it."""
incoming = _incoming_dir()
if not path.is_file():
raise _Refused(f"{rel_path(path)} does not exist or is not a file.")
_check_directly_in_incoming(path, incoming)
if path.name.startswith(".") or path.is_symlink():
raise _Refused(f"{rel_path(path)} is hidden or a symlink - raw derive does not take it.")
candidate = filters.filter_for(path.name)
if candidate is None:
listed = ", ".join(filters.supported_extensions())
raise _Refused(
f"No filter reads {filters.extension_of(path.name) or 'a file without an extension'} "
f"({rel_path(path)}). raw derive reads: {listed}. Any other file is accepted as it is "
"- note what it shows."
)
stem = name if name is not None else path.stem
if _unusable_name(stem):
raise _Refused(
f"{stem!r} is not a usable folder name: one directory name - no path separator, not "
f"hidden, not {repo_capture.MANIFEST_NAME}. Pass --name <stem>."
)
folder = incoming / stem
if os.path.lexists(folder):
raise _Refused(
f"{rel_path(folder)} already exists in incoming/ - raw derive never overwrites. Accept "
"or remove what is there, or pass --name <stem>."
)
extension = filters.extension_of(path.name)
original = folder / f"{ORIGINAL_STEM}{extension}"
derived = folder / DERIVED_NAME
for target in (original, derived):
problem = path_budget_problem_for(_shard_dir() / stem / target.name)
if problem:
raise _Refused(
f"Cannot write {problem}. The folder name comes from the file's name: pass "
"--name <shorter stem>."
)
derivation = _run_filter(candidate, _read_for_derive(path), path)
markdown = filters.render(
candidate,
derivation,
derived_from=original.name,
original_name=path.name,
)
return folder, original, derived, candidate, derivation, markdown, stem
def _move_in(source: Path, folder: Path, original: Path, derived: Path, markdown: bytes) -> None:
"""Create `folder`, write the derived text, then move the original in - last,
so a failure before it has taken nothing from `incoming/`. A failure part-way
puts back what this call changed."""
moved = False
try:
folder.mkdir()
with derived.open("xb") as handle:
handle.write(markdown)
source.rename(original)
moved = True
except OSError as exc:
if moved:
original.rename(source)
derived.unlink(missing_ok=True)
if folder.is_dir():
try:
folder.rmdir()
except OSError:
pass
fail(escape(f"Could not write {rel_path(folder)}: {exc}. incoming/ was left as it was."))
def _plan_derive_folder(folder: Path):
"""The folder form: a `<stem>.md` beside every file that has a filter."""
incoming = _incoming_dir()
try:
rel = folder.relative_to(incoming)
except ValueError:
raise _Refused(
f"{rel_path(folder)} is not under incoming/ - raw derive only takes material from there."
) from None
if len(rel.parts) != 1:
raise _Refused(
f"incoming/{rel.as_posix()} is not directly in incoming/ - a folder is derived from "
f"the top: raw derive incoming/{rel.parts[0]}"
)
if folder.name.startswith(".") or folder.is_symlink():
raise _Refused(f"{rel_path(folder)} is hidden or a symlink - raw derive does not take it.")
if (folder / repo_capture.MANIFEST_NAME).is_file():
raise _Refused(f"{rel_path(folder)}/ is a captured bundle; a captured bundle is not derived.")
files, _dirs, refused = _walk_folder(folder)
if refused:
listed = "\n".join(f" - {entry}" for entry in refused)
raise _Refused(
f"{rel_path(folder)}/ holds entries raw accept would not take - hidden entries, "
f"symlinks and special files:\n{listed}\n Remove or replace them in incoming/, then "
"derive the folder again."
)
plan: list[tuple[Path, filters.Filter, Path]] = []
untouched: list[Path] = []
for file in files:
candidate = filters.filter_for(file.name)
if candidate is None:
untouched.append(file)
else:
plan.append((file, candidate, file.with_suffix(".md")))
if not plan:
raise _Refused(
f"No file in {rel_path(folder)}/ has a filter. raw derive reads: "
f"{', '.join(filters.supported_extensions())}."
)
targets: dict[Path, Path] = {}
for file, _candidate, md in plan:
if md in targets:
raise _Refused(
f"{rel_path(targets[md])} and {rel_path(file)} would both derive to {rel_path(md)}. "
"Rename one of them in incoming/ and derive the folder again."
)
targets[md] = file
if os.path.lexists(md):
raise _Refused(
f"{rel_path(md)} already exists - raw derive never overwrites. Remove it or "
"rename it, then derive the folder again."
)
problem = path_budget_problem_for(_shard_dir() / folder.name / md.relative_to(folder))
if problem:
raise _Refused(
f"Cannot write {problem}. The path comes from the folder in incoming/: shorten the "
"folder's name or the names inside it."
)
derived: list[_Derived] = []
for file, candidate, md in plan:
derivation = _run_filter(candidate, _read_for_derive(file), file)
markdown = filters.render(candidate, derivation, derived_from=file.name)
derived.append(_Derived(candidate, derivation, file, markdown, md))
return derived, untouched
def _echo_notes(derivations: list[filters.Derivation]) -> None:
for derivation in derivations:
for warning in derivation.warnings:
typer.echo(f"WARN {warning}")
for line in filters.unique(c for d in derivations for c in d.checks):
typer.echo(f"CHECK {line}")
def _derive_next(target: Path) -> str:
return (
" Next (after the commitment question in wiki-ingest):\n"
f" tools/wikitool raw accept --fidelity <value> --authority <value> {rel_path(target)}"
)
@cli_contract.record(cli_contract.CommandRecord(
path="raw derive",
summary="Derive reading text from a PDF, e-mail, Office file or web page in `incoming/` and "
"leave the original beside it, for `raw accept` to promote.",
synopsis=(
cli_contract.Variant(
usage="raw derive incoming/<file> [--name <stem>]",
notes="Move the file into the new folder `incoming/<stem>/` as `original.<ext>` and "
"write `incoming/<stem>/derived.md`",
),
cli_contract.Variant(
usage="raw derive incoming/<folder>",
notes="Write a `<stem>.md` beside every file in the folder that has a filter",
),
),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="Yes for what it leaves behind - everything is read, derived and checked before "
"the first write; the files are created exclusively, and a failure part-way puts back "
"what this call changed",
budget=cli_contract.Budget.COUNTED,
network=cli_contract.Network.NO,
),
notes=(
"Writes into `incoming/` only, never into `raw/`, and reads no network: `raw accept` "
"promotes the folder afterwards as one source under `raw/<YYYY>/<MM>/<stem>/`.",
"A single file directly in `incoming/` becomes a folder named after its stem (or "
"`--name`) holding `original.<ext>` - the original bytes, moved - and `derived.md`. The "
"fixed names keep the path inside the path budget; the header of `derived.md` records "
"the file's own name as `original_name`.",
"A folder is derived in place: each file with a filter gets `<stem>.md` beside it, "
"nothing is renamed, and the files without a filter are named in the output.",
"`derived.md` starts with a fixed header (`derived_by`, `filter`, `engine`, `derived`, "
"`derived_from`, `original_name` for a renamed file, `title`, then the fields of the "
"format) followed by the derived text. The same bytes always give the same text; only "
"`derived` differs between runs. `engine` names the tool version and, where a package "
"reads the format, its version.",
"Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, "
"`.xlsm`) and e-mail (`.eml`) read with the standard library only. A mail with "
"attachments is refused.",
"A name already taken under `raw/` is a WARN, not a refusal: whether the folder is a "
"later edition of that source or a separate one is the human's call. A later edition of "
"a derived source is replaced file by file with `raw accept --replaces`; there is no "
"`--replaces-bundle` for it.",
"After a success the output prints the warnings the filters raised, at most three "
"`CHECK` lines per format that was derived in this run - what to look at before the "
"text is trusted - and the `raw accept` line for the result.",
"Never edit `derived.md` by hand: a better derivation is a new `raw derive` of the original.",
),
failures=(
cli_contract.Failure(
cause="The path is not directly in `incoming/`, does not exist, or no filter reads "
"the file's extension",
reaction="Nothing was written. Fix the argument and retry once - a file in a "
"subfolder is moved up, or its folder is derived whole",
),
cli_contract.Failure(
cause="The target folder or a `.md` already exists, or the path would be over the "
"path budget",
reaction="Nothing was written. Remove what is there or pass `--name <shorter stem>`, "
"then retry once. An existing `.md` is never overwritten",
),
cli_contract.Failure(
cause="The document cannot be read (damaged, encrypted, a mail with attachments) or "
"the filter's package is not installed",
reaction="Nothing was written. A missing package is reported with the preflight "
"hint - show it to the user and stop, never install it. Any other filter keeps "
"working",
),
),
examples=(
"tools/wikitool raw derive incoming/report.docx",
"tools/wikitool raw derive incoming/Angebot-XYZ.xlsx --name angebot-xyz",
"tools/wikitool raw derive incoming/meeting-files",
),
never=(
"Never edit `derived.md` or its header by hand.",
"Never derive a file the user did not put into `incoming/` for this purpose.",
),
see_also=(
"`raw/CONTRACT.md` \"Getting a document in: `raw derive`\" - the rules and why",
"`wikitool raw accept` - promotes the folder into `raw/`",
"`instructions/wiki-ingest/SKILL.md` - where a PDF, mail or Office file to ingest starts",
),
))
@app.command("derive")
def raw_derive_command(
path: Path = typer.Argument(..., help="A file or a folder directly in incoming/"),
name: Annotated[Optional[str], typer.Option(
"--name", help="Folder stem for a single file instead of the file's own stem"
)] = None,
):
"""Derive reading text from a document in incoming/ and keep the original.
See raw/CONTRACT.md "Getting a document in: `raw derive`"."""
resolved = _resolve(path)
if resolved.is_dir():
if name is not None:
fail("--name applies to a single file; a folder keeps its own name.")
derived, untouched = _refusals_fail(_plan_derive_folder, resolved)
_write_exclusive([(d.target, d.markdown) for d in derived])
lines = [f"Derived {len(derived)} file(s) in {rel_path(resolved)}/:"]
lines += [f" {rel_path(d.source)} -> {rel_path(d.target)} ({d.filter.name})" for d in derived]
if untouched:
lines.append(
" No filter for, left as they are: " + ", ".join(rel_path(p) for p in untouched)
)
success(escape("\n".join(lines)))
warning = _collision_warning(resolved.name)
if warning:
typer.echo("WARN " + warning)
_echo_notes([d.derivation for d in derived])
typer.echo(_derive_next(resolved))
return
folder, original, derived_md, candidate, derivation, markdown, stem = _refusals_fail(
_plan_derive_file, resolved, name
)
_move_in(resolved, folder, original, derived_md, markdown)
success(escape(
f"Derived {rel_path(derived_md)} from {resolved.name} ({candidate.name}); the original is "
f"now {rel_path(original)}."
))
warning = _collision_warning(stem)
if warning:
typer.echo("WARN " + warning)
_echo_notes([derivation])
typer.echo(_derive_next(folder))
# --- raw capture / raw status ------------------------------------------------- # --- raw capture / raw status -------------------------------------------------
# #
# Documentation from a git repository, as one bundle per repository and ref # Documentation from a git repository, as one bundle per repository and ref
@@ -1809,15 +2178,21 @@ def raw_fetch_command(
# `raw/`: promoting stays `raw accept`'s job. # `raw/`: promoting stays `raw accept`'s job.
def _check_bundle_name(name: str) -> None: def _unusable_name(name: str) -> bool:
if ( """Not one plain directory name: empty, padded, hidden, a path, a control
character, or the manifest's reserved name."""
return (
not name.strip() not name.strip()
or name != name.strip() or name != name.strip()
or name.startswith(".") or name.startswith(".")
or name == repo_capture.MANIFEST_NAME or name == repo_capture.MANIFEST_NAME
or any(c in name for c in "/\\:") or any(c in name for c in "/\\:")
or any(ord(c) < 0x20 for c in name) or any(ord(c) < 0x20 for c in name)
): )
def _check_bundle_name(name: str) -> None:
if _unusable_name(name):
fail(escape( fail(escape(
f"--name {name!r} is not a usable bundle name: one directory name - no path separator, " f"--name {name!r} is not a usable bundle name: one directory name - no path separator, "
f"not hidden, not {repo_capture.MANIFEST_NAME}." f"not hidden, not {repo_capture.MANIFEST_NAME}."
+172
View File
@@ -0,0 +1,172 @@
"""The import filters behind `wikitool raw derive`: one module per format family,
each registering the extensions it reads.
A filter turns the bytes of a document into reading text plus the header fields
that describe it. The registry is code in this stack - a new format is a module
here, a registration and tests; the command, the header and the skill stay as
they are. The core knows neither `typer` nor `rich` and raises `ValidationError`.
A filter's third-party package (if any) is imported inside the filter, never at
module level: a checkout whose environment predates the package fails only the
derivation of that one format, and every other command keeps running.
"""
from __future__ import annotations
import importlib
import importlib.metadata
from dataclasses import dataclass
from typing import Iterable, Optional
from chemenu import web_capture
from chemenu.errors import ValidationError
from chemenu.version import VersionError, read_version
MAX_CHECKS = 3
PREFLIGHT_HINT = (
"Run the preflight to install it (tools/preflight.sh, or tools/preflight.ps1 on Windows; "
"instructions/preflight.md). Every other format keeps working."
)
@dataclass(frozen=True)
class Derivation:
"""What a filter makes of one document.
`fields` are the format-specific header lines, after `title`; a value is a
string or a list of strings. `checks` are the short English lines `raw derive`
prints after a success - already narrowed to the case that occurred.
"""
text: str
title: str = ""
fields: tuple[tuple[str, "str | list[str]"], ...] = ()
warnings: tuple[str, ...] = ()
checks: tuple[str, ...] = ()
def __post_init__(self) -> None:
if len(self.checks) > MAX_CHECKS:
raise ValueError(f"a filter prints at most {MAX_CHECKS} CHECK lines, got {len(self.checks)}")
class Filter:
"""One format family. Subclasses set the class attributes and implement `derive`."""
name: str = ""
extensions: tuple[str, ...] = ()
requires: tuple[str, ...] = ()
def available(self) -> bool:
for package in self.requires:
try:
importlib.import_module(package)
except ImportError:
return False
return True
def missing(self) -> list[str]:
missing = []
for package in self.requires:
try:
importlib.import_module(package)
except ImportError:
missing.append(package)
return missing
def engine(self) -> str:
try:
parts = [f"wikitool {read_version()}"]
except VersionError:
parts = ["wikitool unknown"]
for package in self.requires:
try:
parts.append(f"{package} {importlib.metadata.version(package)}")
except importlib.metadata.PackageNotFoundError:
parts.append(package)
return " + ".join(parts)
def derive(self, data: bytes, name: str) -> Derivation:
raise NotImplementedError
_REGISTRY: list[Filter] = []
def register(candidate: Filter) -> Filter:
taken = {ext: f.name for f in _REGISTRY for ext in f.extensions}
for ext in candidate.extensions:
if ext in taken:
raise ValueError(f"extension {ext} is already read by the {taken[ext]} filter")
_REGISTRY.append(candidate)
return candidate
def all_filters() -> list[Filter]:
return list(_REGISTRY)
def extension_of(filename: str) -> str:
"""The lowercase extension with its dot, or `""` for a name without one
(a leading dot alone is a hidden file, not an extension)."""
stripped = filename.lstrip(".")
return "." + stripped.rsplit(".", 1)[1].lower() if "." in stripped else ""
def filter_for(filename: str) -> Optional[Filter]:
ext = extension_of(filename)
for candidate in _REGISTRY:
if ext in candidate.extensions:
return candidate
return None
def supported_extensions() -> list[str]:
return sorted(ext for f in _REGISTRY for ext in f.extensions)
def require_available(candidate: Filter) -> None:
missing = candidate.missing()
if missing:
raise ValidationError(
f"The {candidate.name} filter needs the package {', '.join(missing)}, which this "
f"checkout's tool environment does not have. {PREFLIGHT_HINT}"
)
def render(
candidate: Filter,
derivation: Derivation,
*,
derived_from: str,
original_name: Optional[str] = None,
derived_at: Optional[str] = None,
) -> bytes:
"""The `.md` for one document: the fixed header, then the derived text.
`original_name` is left out for a file that was not renamed."""
fields: list[tuple[str, "str | list[str]"]] = [
("derived_by", "wikitool raw derive"),
("filter", candidate.name),
("engine", candidate.engine()),
("derived", derived_at or web_capture.utc_now()),
("derived_from", derived_from),
]
if original_name is not None:
fields.append(("original_name", original_name))
fields.append(("title", derivation.title))
fields.extend(derivation.fields)
text = derivation.text.strip("\n")
content = web_capture.header(fields) + "\n" + (text + "\n" if text else "")
return content.encode("utf-8")
def unique(lines: Iterable[str]) -> list[str]:
seen: set[str] = set()
out = []
for line in lines:
if line not in seen:
seen.add(line)
out.append(line)
return out
from chemenu.filters import html_page, mail, ooxml # noqa: E402,F401 (register themselves)
+34
View File
@@ -0,0 +1,34 @@
"""HTML pages: an adapter on `web_capture` for a page that arrives without a URL."""
from __future__ import annotations
from chemenu import web_capture
from chemenu.filters import Derivation, Filter, register
class HtmlPageFilter(Filter):
name = "html"
extensions = (".html", ".htm", ".xhtml")
def derive(self, data: bytes, name: str) -> Derivation:
decoded = web_capture.decode(data, None)
text = web_capture.derive_text(decoded.text, "")
warnings = []
if decoded.replaced:
warnings.append(
f"{decoded.replaced} undecodable byte(s) under {decoded.charset} were replaced "
"with U+FFFD."
)
if len(text) < web_capture.SHORT_TEXT_CHARS:
warnings.append(
f"The derived text is only {len(text)} characters - likely a script-rendered "
"page or an empty document."
)
return Derivation(
text=text,
title=web_capture.title_of(web_capture.parse(decoded.text)),
fields=(("charset", f"{decoded.charset} (from: {decoded.source})"),),
warnings=tuple(warnings),
)
register(HtmlPageFilter())
+177
View File
@@ -0,0 +1,177 @@
"""E-mail: `.eml` (RFC 5322 / MIME), read with the standard library."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from email import message_from_bytes, policy
from email.message import EmailMessage
from typing import Optional
from chemenu import web_capture
from chemenu.errors import ValidationError
from chemenu.filters import Derivation, Filter, register
_QUOTED = re.compile(
r"^(>|-{2,}\s*(Original Message|Ursprüngliche Nachricht)\s*-{2,}|On .{5,200} wrote:\s*$|Am .{5,200} schrieb .{0,200}:\s*$)",
re.IGNORECASE | re.MULTILINE,
)
@dataclass(frozen=True)
class Attachment:
name: str
data: bytes
content_type: str
@property
def is_message(self) -> bool:
return self.content_type == "message/rfc822" or self.name.lower().endswith((".eml", ".msg"))
@dataclass
class ParsedMail:
headers: dict[str, "str | list[str]"] = field(default_factory=dict)
body: str = ""
warnings: list[str] = field(default_factory=list)
attachments: list[Attachment] = field(default_factory=list)
inline_images: int = 0
def _header(msg: EmailMessage, name: str) -> str:
try:
return re.sub(r"\s+", " ", str(msg[name] or "")).strip()
except Exception: # a malformed header must not stop the derivation
raw = msg.get(name)
return re.sub(r"\s+", " ", str(raw or "")).strip() if raw is not None else ""
def _addresses(msg: EmailMessage, name: str) -> list[str]:
value = msg[name]
if value is None:
return []
try:
found = [str(a).strip() for a in value.addresses]
if found:
return found
except Exception:
pass
text = _header(msg, name)
return [text] if text else []
def _date(msg: EmailMessage) -> str:
value = msg["date"]
if value is None:
return ""
try:
return value.datetime.isoformat()
except Exception:
return _header(msg, "date")
def _text_of(part: EmailMessage) -> str:
try:
return part.get_content()
except (LookupError, UnicodeDecodeError, AssertionError):
payload = part.get_payload(decode=True) or b""
return payload.decode(part.get_content_charset() or "utf-8", errors="replace")
def _filename(part: EmailMessage, number: int) -> str:
try:
name = part.get_filename()
except Exception:
name = None
if not name:
ext = ".eml" if part.get_content_type() == "message/rfc822" else ""
name = f"attachment-{number}{ext}"
return name
def parse(data: bytes) -> ParsedMail:
msg = message_from_bytes(data, policy=policy.default)
if not isinstance(msg, EmailMessage) or not (msg.keys() or msg.get_payload()):
raise ValidationError("Not a readable e-mail: no headers or body found.")
out = ParsedMail()
out.headers = {
"from": _header(msg, "from"),
"to": _addresses(msg, "to"),
"cc": _addresses(msg, "cc"),
"date": _date(msg),
"subject": _header(msg, "subject"),
"message_id": _header(msg, "message-id"),
}
body_part: Optional[EmailMessage] = msg.get_body(preferencelist=("plain", "html"))
if body_part is None:
out.warnings.append("The mail has no text/plain or text/html body (RTF-only or empty); the text is empty.")
elif body_part.get_content_type() == "text/html":
out.body = web_capture.derive_text(_text_of(body_part), "").strip()
else:
out.body = _text_of(body_part).replace("\r\n", "\n").replace("\r", "\n").strip()
for number, part in enumerate(msg.walk(), 1):
if part.is_multipart() or part is body_part or part is msg:
continue
disposition = (part.get_content_disposition() or "").lower()
if (
disposition != "attachment"
and part.get_content_maintype() == "image"
and part["content-id"] is not None
):
out.inline_images += 1
continue
if part.get_content_type() == "message/rfc822":
inner = part.get_payload()
payload = inner[0].as_bytes(policy=policy.SMTP) if inner else b""
else:
payload = part.get_payload(decode=True) or b""
out.attachments.append(Attachment(_filename(part, number), payload, part.get_content_type()))
if out.inline_images:
out.warnings.append(f"{out.inline_images} inline image(s) are not in the text.")
return out
def mail_fields(parsed: ParsedMail) -> tuple[tuple[str, "str | list[str]"], ...]:
h = parsed.headers
return (
("from", h["from"]),
("to", h["to"]),
("cc", h["cc"]),
("date", h["date"]),
("subject", h["subject"]),
("message_id", h["message_id"]),
("attachments", [a.name for a in parsed.attachments]),
)
def mail_checks(parsed: ParsedMail) -> tuple[str, ...]:
checks = []
if _QUOTED.search(parsed.body):
checks.append("Quoted earlier messages in the body are not the sender's words - attribute only the new text.")
checks.append("A mail often opens or closes a commitment - see wiki-ingest step 4.")
checks.append("Remove secrets before raw accept (raw/CONTRACT.md, 'What does not belong here').")
return tuple(checks)
class EmlFilter(Filter):
name = "eml"
extensions = (".eml",)
def derive(self, data: bytes, name: str) -> Derivation:
parsed = parse(data)
if parsed.attachments:
names = ", ".join(a.name for a in parsed.attachments)
raise ValidationError(
f"The mail carries {len(parsed.attachments)} attachment(s) ({names}); deriving a mail "
"with attachments is not supported yet. Save the attachments as files beside the "
"mail and derive each file on its own."
)
return Derivation(
text=parsed.body,
title=parsed.headers["subject"] if isinstance(parsed.headers["subject"], str) else "",
fields=mail_fields(parsed),
warnings=tuple(parsed.warnings),
checks=mail_checks(parsed),
)
register(EmlFilter())
+673
View File
@@ -0,0 +1,673 @@
"""Office Open XML: DOCX, PPTX and XLSX. A package is a ZIP of XML parts, so the
standard library reads all three; no third-party package is involved.
Tag and attribute names are matched by their local name, which keeps the
"transitional" and "strict" namespace flavours of the format one code path.
"""
from __future__ import annotations
import datetime
import io
import posixpath
import re
import xml.etree.ElementTree as ET
import zipfile
from typing import Iterator, Optional
from chemenu.errors import ValidationError
from chemenu.filters import Derivation, Filter, register
PART_LIMIT = 100 * 1024 * 1024
CELL_LIMIT = 2_000_000
def _local(tag) -> str:
return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""
def _attr(el: ET.Element, name: str) -> Optional[str]:
for key, value in el.attrib.items():
if key.rsplit("}", 1)[-1] == name:
return value
return None
def _rid(el: ET.Element) -> Optional[str]:
"""The namespaced `r:id`, never the plain `id` an element may carry beside it."""
for key, value in el.attrib.items():
if key.endswith("}id"):
return value
return None
def _children(el: ET.Element, name: str) -> list[ET.Element]:
return [c for c in el if _local(c.tag) == name]
def _child(el: ET.Element, name: str) -> Optional[ET.Element]:
for c in el:
if _local(c.tag) == name:
return c
return None
def _cell(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().replace("|", "\\|")
def _table(rows: list[list[str]]) -> str:
width = max((len(r) for r in rows), default=0)
if not width:
return ""
padded = [[_cell(c) for c in r] + [""] * (width - len(r)) for r in rows]
lines = ["| " + " | ".join(padded[0]) + " |", "|" + " --- |" * width]
lines += ["| " + " | ".join(r) + " |" for r in padded[1:]]
return "\n".join(lines)
class _Package:
def __init__(self, data: bytes, kind: str) -> None:
self.kind = kind
try:
self.zf = zipfile.ZipFile(io.BytesIO(data))
except zipfile.BadZipFile:
raise ValidationError(
f"Not a readable {kind} file: it is not a ZIP package. A password-protected "
"Office file is not one - remove the password in the application and save a copy."
) from None
self.names = set(self.zf.namelist())
def has(self, name: str) -> bool:
return name in self.names
def read(self, name: str) -> bytes:
try:
info = self.zf.getinfo(name)
except KeyError:
raise ValidationError(f"The {self.kind} file has no part {name}.") from None
if info.flag_bits & 0x1:
raise ValidationError(f"The {self.kind} file is encrypted - part {name} cannot be read.")
if info.file_size > PART_LIMIT:
raise ValidationError(f"Part {name} of the {self.kind} file is over 100 MiB unpacked.")
try:
return self.zf.read(name)
except (zipfile.BadZipFile, RuntimeError, OSError, EOFError, NotImplementedError) as exc:
raise ValidationError(f"Part {name} of the {self.kind} file is unreadable: {exc}") from None
def xml(self, name: str) -> ET.Element:
try:
return ET.fromstring(self.read(name))
except ET.ParseError as exc:
raise ValidationError(f"Part {name} of the {self.kind} file is not valid XML: {exc}") from None
def rels(self, part: str) -> dict[str, tuple[str, str]]:
"""`{id: (type, absolute part name)}` of the relationships a part declares."""
directory, base = posixpath.split(part)
rels_name = posixpath.join(directory, "_rels", base + ".rels")
if not self.has(rels_name):
return {}
out = {}
for rel in self.xml(rels_name):
if _local(rel.tag) != "Relationship" or rel.get("TargetMode") == "External":
continue
target = rel.get("Target") or ""
absolute = target.lstrip("/") if target.startswith("/") else posixpath.normpath(
posixpath.join(directory, target)
)
out[rel.get("Id") or ""] = (rel.get("Type") or "", absolute)
return out
def main_part(self, default: str) -> str:
for _id, (kind, target) in self.rels("").items():
if kind.endswith("/officeDocument") and self.has(target):
return target
if self.has(default):
return default
raise ValidationError(f"The {self.kind} file has no main document part ({default}).")
def title(self) -> str:
if not self.has("docProps/core.xml"):
return ""
try:
core = self.xml("docProps/core.xml")
except ValidationError:
return ""
node = _child(core, "title")
return (node.text or "").strip() if node is not None else ""
# --- DOCX ---------------------------------------------------------------------
_TRACKED = {"ins", "del", "moveFrom", "moveTo"}
class _Docx:
def __init__(self, pkg: _Package, main: str) -> None:
self.pkg = pkg
self.root = pkg.xml(main)
directory = posixpath.dirname(main)
self.styles = self._styles(posixpath.join(directory, "styles.xml"))
self.numbering = self._numbering(posixpath.join(directory, "numbering.xml"))
self.counters: dict[tuple[str, int], int] = {}
self.tracked = sum(1 for e in self.root.iter() if _local(e.tag) in _TRACKED)
def _styles(self, name: str) -> dict[str, str]:
if not self.pkg.has(name):
return {}
out = {}
for style in self.pkg.xml(name).iter():
if _local(style.tag) == "style":
label = _child(style, "name")
out[_attr(style, "styleId") or ""] = (_attr(label, "val") or "").lower() if label is not None else ""
return out
def _numbering(self, name: str) -> dict[str, dict[int, str]]:
"""`{numId: {ilvl: numFmt}}`."""
if not self.pkg.has(name):
return {}
root = self.pkg.xml(name)
abstract: dict[str, dict[int, str]] = {}
for node in _children(root, "abstractNum"):
levels = {}
for lvl in _children(node, "lvl"):
fmt = _child(lvl, "numFmt")
levels[int(_attr(lvl, "ilvl") or 0)] = (_attr(fmt, "val") or "") if fmt is not None else ""
abstract[_attr(node, "abstractNumId") or ""] = levels
out = {}
for num in _children(root, "num"):
ref = _child(num, "abstractNumId")
if ref is not None:
out[_attr(num, "numId") or ""] = abstract.get(_attr(ref, "val") or "", {})
return out
# inline text ---------------------------------------------------------------
def _run(self, run: ET.Element, out: list[str]) -> None:
for c in run:
tag = _local(c.tag)
if tag == "t":
out.append(c.text or "")
elif tag == "tab":
out.append("\t")
elif tag == "br":
if _attr(c, "type") != "page":
out.append("\n")
elif tag == "cr":
out.append("\n")
elif tag == "noBreakHyphen":
out.append("-")
def _inline(self, el: ET.Element, out: list[str]) -> None:
for c in el:
tag = _local(c.tag)
if tag == "r":
self._run(c, out)
elif tag in ("ins", "moveTo", "hyperlink", "smartTag", "fldSimple", "sdt", "sdtContent", "customXml"):
self._inline(c, out)
def paragraph_text(self, p: ET.Element, hard_breaks: bool = False) -> str:
parts: list[str] = []
self._inline(p, parts)
text = "".join(parts).replace("\t", " ")
text = re.sub(r"[ ]*\n[ ]*", " \n" if hard_breaks else " ", text)
return text.strip()
# blocks --------------------------------------------------------------------
def _heading_level(self, p: ET.Element) -> Optional[int]:
ppr = _child(p, "pPr")
style = _child(ppr, "pStyle") if ppr is not None else None
if style is None:
return None
style_id = _attr(style, "val") or ""
label = self.styles.get(style_id) or style_id.lower()
match = re.fullmatch(r"heading\s*(\d)", label)
return min(int(match.group(1)), 6) if match and int(match.group(1)) else None
def _list_marker(self, p: ET.Element) -> Optional[tuple[int, str]]:
ppr = _child(p, "pPr")
num = _child(ppr, "numPr") if ppr is not None else None
if num is None:
return None
num_id_node, lvl_node = _child(num, "numId"), _child(num, "ilvl")
num_id = (_attr(num_id_node, "val") or "") if num_id_node is not None else ""
if num_id in ("", "0"):
return None
level = int(_attr(lvl_node, "val") or 0) if lvl_node is not None else 0
fmt = self.numbering.get(num_id, {}).get(level, "bullet")
if fmt in ("bullet", "none"):
return level, "-"
for key in [k for k in self.counters if k[0] == num_id and k[1] > level]:
del self.counters[key]
self.counters[(num_id, level)] = self.counters.get((num_id, level), 0) + 1
return level, f"{self.counters[(num_id, level)]}."
def _cell_text(self, tc: ET.Element) -> str:
parts = []
for c in tc:
tag = _local(c.tag)
if tag == "p":
parts.append(self.paragraph_text(c))
elif tag == "tbl":
parts.append(" ".join(self._cell_text(inner) for row in _children(c, "tr") for inner in _children(row, "tc")))
return " ".join(p for p in parts if p)
def _rows(self, tbl: ET.Element) -> list[list[str]]:
rows = []
for tr in _children(tbl, "tr"):
cells: list[str] = []
for tc in _children(tr, "tc"):
props = _child(tc, "tcPr")
span = _child(props, "gridSpan") if props is not None else None
cells.append(self._cell_text(tc))
cells.extend([""] * (int(_attr(span, "val") or 1) - 1 if span is not None else 0))
rows.append(cells)
return rows
def blocks(self, container: ET.Element) -> Iterator[tuple[str, str]]:
for child in container:
tag = _local(child.tag)
if tag == "p":
level = self._heading_level(child)
if level is not None:
text = self.paragraph_text(child)
if text:
yield "block", "#" * level + " " + text
continue
marker = self._list_marker(child)
if marker is not None:
text = self.paragraph_text(child)
if text:
yield "item", " " * marker[0] + marker[1] + " " + text
continue
text = self.paragraph_text(child, hard_breaks=True)
if text:
yield "block", text
elif tag == "tbl":
table = _table(self._rows(child))
if table:
yield "block", table
elif tag in ("sdt", "sdtContent"):
yield from self.blocks(child)
def comments(self) -> list[tuple[str, str, str]]:
name = posixpath.join(posixpath.dirname(self.pkg.main_part("word/document.xml")), "comments.xml")
if not self.pkg.has(name):
return []
out = []
for comment in _children(self.pkg.xml(name), "comment"):
text = " ".join(t for t in (self.paragraph_text(p) for p in comment.iter() if _local(p.tag) == "p") if t)
out.append((_attr(comment, "author") or "", _attr(comment, "date") or "", text))
return out
def _join_blocks(blocks: list[tuple[str, str]]) -> str:
out: list[str] = []
previous = ""
for kind, text in blocks:
if out:
out.append("\n" if kind == "item" and previous == "item" else "\n\n")
out.append(text)
previous = kind
return "".join(out)
class DocxFilter(Filter):
name = "docx"
extensions = (".docx",)
def derive(self, data: bytes, name: str) -> Derivation:
pkg = _Package(data, "DOCX")
main = pkg.main_part("word/document.xml")
doc = _Docx(pkg, main)
body = _child(doc.root, "body")
if body is None:
raise ValidationError("The DOCX file has no document body.")
blocks = list(doc.blocks(body))
comments = doc.comments()
if comments:
blocks.append(("block", "## Comments"))
for author, date, text in comments:
label = ", ".join(x for x in (author, date) if x)
blocks.append(("item", f"- {label}: {text}" if label else f"- {text}"))
checks = []
if doc.tracked:
checks.append(
"Tracked changes are taken as accepted (insertions in, deletions out) - the "
"original still holds the history."
)
if comments:
checks.append("Comments sit under '## Comments' at the end - discussion, not the document's claims.")
return Derivation(
text=_join_blocks(blocks),
title=pkg.title(),
fields=(("tracked_changes", str(doc.tracked)), ("comments", str(len(comments)))),
checks=tuple(checks),
)
# --- PPTX ---------------------------------------------------------------------
_SKIPPED_PLACEHOLDERS = {"sldNum", "dt", "ftr", "hdr"}
_TITLES = {"title", "ctrTitle"}
def _a_paragraph(p: ET.Element) -> str:
parts = []
for c in p:
tag = _local(c.tag)
if tag in ("r", "fld"):
parts.extend((t.text or "") for t in c if _local(t.tag) == "t")
elif tag == "br":
parts.append(" ")
return re.sub(r"\s+", " ", "".join(parts)).strip()
def _placeholder(shape: ET.Element) -> Optional[str]:
for node in shape.iter():
if _local(node.tag) == "ph":
return _attr(node, "type") or "body"
return None
class _Slide:
def __init__(self) -> None:
self.blocks: list[str] = []
self.opaque = 0
def shapes(self, tree: ET.Element) -> None:
for child in tree:
tag = _local(child.tag)
if tag == "sp":
self.shape(child)
elif tag in ("grpSp", "AlternateContent", "Choice"):
self.shapes(child)
elif tag == "graphicFrame":
tbl = next((e for e in child.iter() if _local(e.tag) == "tbl"), None)
if tbl is None:
self.opaque += 1
else:
rows = [
[" ".join(_a_paragraph(p) for p in tc.iter() if _local(p.tag) == "p") for tc in _children(tr, "tc")]
for tr in _children(tbl, "tr")
]
table = _table(rows)
if table:
self.blocks.append(table)
elif tag == "pic":
self.opaque += 1
def shape(self, sp: ET.Element) -> None:
kind = _placeholder(sp)
if kind in _SKIPPED_PLACEHOLDERS:
return
body = _child(sp, "txBody")
if body is None:
return
paragraphs = [(int(_attr(_child(p, "pPr"), "lvl") or 0) if _child(p, "pPr") is not None else 0, _a_paragraph(p))
for p in _children(body, "p")]
paragraphs = [(lvl, text) for lvl, text in paragraphs if text]
if not paragraphs:
return
if kind in _TITLES:
self.blocks.append("**" + " ".join(text for _lvl, text in paragraphs) + "**")
elif len(paragraphs) == 1:
self.blocks.append(paragraphs[0][1])
else:
self.blocks.append("\n".join(" " * lvl + "- " + text for lvl, text in paragraphs))
class PptxFilter(Filter):
name = "pptx"
extensions = (".pptx",)
def derive(self, data: bytes, name: str) -> Derivation:
pkg = _Package(data, "PPTX")
main = pkg.main_part("ppt/presentation.xml")
rels = pkg.rels(main)
slide_parts = []
for node in pkg.xml(main).iter():
if _local(node.tag) == "sldId":
target = rels.get(_rid(node) or "")
if target and pkg.has(target[1]):
slide_parts.append(target[1])
if not slide_parts:
raise ValidationError("The PPTX file lists no readable slides.")
sections: list[str] = []
opaque = 0
for number, part in enumerate(slide_parts, 1):
slide = _Slide()
tree = next((e for e in pkg.xml(part).iter() if _local(e.tag) == "spTree"), None)
if tree is not None:
slide.shapes(tree)
opaque += slide.opaque
block = [f"## Slide {number}"] + slide.blocks
notes = self._notes(pkg, part)
if notes:
block += ["### Notes", notes]
sections.append("\n\n".join(block))
checks = []
if opaque:
checks.append(
"Pictures, charts and diagrams are not in the text - open the original where a "
"slide's meaning rests on them."
)
return Derivation(
text="\n\n".join(sections),
title=pkg.title(),
fields=(("slides", str(len(slide_parts))),),
checks=tuple(checks),
)
def _notes(self, pkg: _Package, slide_part: str) -> str:
for kind, target in pkg.rels(slide_part).values():
if kind.endswith("/notesSlide") and pkg.has(target):
tree = next((e for e in pkg.xml(target).iter() if _local(e.tag) == "spTree"), None)
if tree is None:
return ""
texts = []
for sp in (e for e in tree.iter() if _local(e.tag) == "sp"):
if _placeholder(sp) == "body":
body = _child(sp, "txBody")
if body is not None:
texts += [t for t in (_a_paragraph(p) for p in _children(body, "p")) if t]
return "\n\n".join(texts)
return ""
# --- XLSX ---------------------------------------------------------------------
_BUILTIN_DATE = {**{i: (True, False) for i in (*range(14, 18), *range(27, 37), *range(50, 59))},
**{i: (False, True) for i in (18, 19, 20, 21, 45, 46, 47)}, 22: (True, True)}
def _col_index(letters: str) -> int:
n = 0
for ch in letters:
n = n * 26 + ord(ch) - 64
return n
def _format_kind(code: str) -> Optional[tuple[bool, bool]]:
code = re.sub(r'"[^"]*"|\[[^\]]*\]|\\.|_.|\*.', "", code).lower()
has_time = "h" in code or "s" in code or "am/pm" in code
has_date = "y" in code or "d" in code or ("m" in code and not has_time)
return (has_date, has_time) if has_date or has_time else None
class _Workbook:
def __init__(self, pkg: _Package, main: str) -> None:
self.pkg = pkg
self.directory = posixpath.dirname(main)
self.root = pkg.xml(main)
self.rels = pkg.rels(main)
self.date1904 = any(_local(e.tag) == "workbookPr" and _attr(e, "date1904") in ("1", "true") for e in self.root.iter())
self.shared = self._shared_strings()
self.date_styles = self._date_styles()
self.formulas_without_value = 0
def _shared_strings(self) -> list[str]:
for kind, target in self.rels.values():
if kind.endswith("/sharedStrings") and self.pkg.has(target):
return [self._si_text(si) for si in _children(self.pkg.xml(target), "si")]
return []
def _si_text(self, si: ET.Element) -> str:
parts = []
for c in si:
tag = _local(c.tag)
if tag == "t":
parts.append(c.text or "")
elif tag == "r":
parts += [(t.text or "") for t in c if _local(t.tag) == "t"]
return "".join(parts)
def _date_styles(self) -> dict[int, tuple[bool, bool]]:
"""`{cellXfs index: (has_date, has_time)}` for the styles that format a date."""
for kind, target in self.rels.values():
if kind.endswith("/styles") and self.pkg.has(target):
root = self.pkg.xml(target)
custom = {}
for fmts in _children(root, "numFmts"):
for fmt in _children(fmts, "numFmt"):
found = _format_kind(_attr(fmt, "formatCode") or "")
if found:
custom[int(_attr(fmt, "numFmtId") or -1)] = found
xfs = _child(root, "cellXfs")
out = {}
for index, xf in enumerate(_children(xfs, "xf") if xfs is not None else []):
fmt_id = int(_attr(xf, "numFmtId") or 0)
found = custom.get(fmt_id) or _BUILTIN_DATE.get(fmt_id)
if found:
out[index] = found
return out
return {}
def sheets(self) -> list[tuple[str, str, str]]:
"""`(name, state, part)` in sheet order."""
out = []
for node in self.root.iter():
if _local(node.tag) == "sheet":
target = self.rels.get(_rid(node) or "")
out.append((_attr(node, "name") or "", _attr(node, "state") or "visible", target[1] if target else ""))
return out
def _date(self, raw: str, kind: tuple[bool, bool]) -> str:
try:
serial = float(raw)
except ValueError:
return raw
seconds = round(serial * 86400)
has_date, has_time = kind
if not has_date:
return f"{seconds // 3600:02d}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}"
if self.date1904:
base = datetime.datetime(1904, 1, 1)
elif serial < 60:
base = datetime.datetime(1899, 12, 31)
elif serial < 61:
return raw
else:
base = datetime.datetime(1899, 12, 30)
try:
moment = base + datetime.timedelta(seconds=seconds)
except OverflowError:
return raw
return moment.strftime("%Y-%m-%d %H:%M:%S" if has_time else "%Y-%m-%d")
def _value(self, c: ET.Element) -> str:
kind = c.get("t") or "n"
v = _child(c, "v")
raw = (v.text or "") if v is not None else ""
has_formula = _child(c, "f") is not None
if kind == "inlineStr":
node = _child(c, "is")
text = self._si_text(node) if node is not None else ""
elif kind == "s":
text = self.shared[int(raw)] if raw.isdigit() and int(raw) < len(self.shared) else ""
elif kind == "b":
text = "" if raw == "" else ("TRUE" if raw == "1" else "FALSE")
elif kind in ("str", "e", "d"):
text = raw
else:
style = int(c.get("s") or 0)
text = self._date(raw, self.date_styles[style]) if raw and style in self.date_styles else raw
if has_formula and raw == "" and kind != "inlineStr":
self.formulas_without_value += 1
return text
def table(self, part: str) -> str:
cells: dict[tuple[int, int], str] = {}
row_no, last_col = 0, 0
for _event, el in ET.iterparse(io.BytesIO(self.pkg.read(part)), events=("end",)):
tag = _local(el.tag)
if tag == "c":
ref = re.fullmatch(r"([A-Za-z]+)(\d+)", el.get("r") or "")
if ref:
col, row = _col_index(ref.group(1).upper()), int(ref.group(2))
else:
col, row = last_col + 1, row_no + 1
last_col = col
text = self._value(el)
if text.strip():
cells[(row, col)] = text
el.clear()
elif tag == "row":
row_no = int(el.get("r") or row_no + 1)
last_col = 0
el.clear()
if not cells:
return "*(empty sheet)*"
rows = [r for r, _c in cells]
cols = [c for _r, c in cells]
min_r, max_r, min_c, max_c = min(rows), max(rows), min(cols), max(cols)
if (max_r - min_r + 1) * (max_c - min_c + 1) > CELL_LIMIT:
raise ValidationError(
f"A sheet spans {max_r - min_r + 1} rows by {max_c - min_c + 1} columns - too large "
"or too sparse to derive."
)
grid = [[cells.get((r, c), "") for c in range(min_c, max_c + 1)] for r in range(min_r, max_r + 1)]
return _table(grid)
class XlsxFilter(Filter):
name = "xlsx"
extensions = (".xlsx", ".xlsm")
def derive(self, data: bytes, name: str) -> Derivation:
pkg = _Package(data, "XLSX")
book = _Workbook(pkg, pkg.main_part("xl/workbook.xml"))
sheets = book.sheets()
if not sheets:
raise ValidationError("The XLSX file lists no sheets.")
sections, hidden, warnings = [], [], []
for sheet_name, state, part in sheets:
if state != "visible":
hidden.append(sheet_name)
if not part or not pkg.has(part):
warnings.append(f"Sheet '{sheet_name}' has no readable worksheet part and was left out.")
continue
if _child(pkg.xml(part), "sheetData") is None and _local(pkg.xml(part).tag) == "chartsheet":
sections.append(f"## {sheet_name}\n\n*(chart sheet, no cell data)*")
continue
sections.append(f"## {sheet_name}\n\n{book.table(part)}")
if book.formulas_without_value:
warnings.append(
f"{book.formulas_without_value} formula cell(s) have no stored value (the file was "
"never calculated) and are empty."
)
checks = ["Values are as last saved; formulas were not recalculated."]
if hidden:
checks.append("Hidden sheets are included and named in hidden_sheets.")
return Derivation(
text="\n\n".join(sections),
title=pkg.title(),
fields=(("sheets", str(len(sheets))), ("hidden_sheets", hidden)),
warnings=tuple(warnings),
checks=tuple(checks),
)
register(DocxFilter())
register(PptxFilter())
register(XlsxFilter())
+676
View File
@@ -0,0 +1,676 @@
"""`wikitool raw derive` - reading text from documents that are not text.
The documents are built here from `zipfile` and `email`, so every fixture is
readable in the test that uses it and no binary sits in the repository.
"""
from __future__ import annotations
import io
import json
import zipfile
from email.message import EmailMessage
from pathlib import Path
import pytest
import typer
import yaml
from chemenu import filters, web_capture
from chemenu.commands.raw_cmd import (
raw_accept_command,
raw_derive_command,
raw_pending_command,
)
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
A = "http://schemas.openxmlformats.org/drawingml/2006/main"
P = "http://schemas.openxmlformats.org/presentationml/2006/main"
S = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
REL = "http://schemas.openxmlformats.org/package/2006/relationships"
DOC_REL = R + "/officeDocument"
def _zip(parts: dict[str, str]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for name, content in parts.items():
zf.writestr(name, content)
return buffer.getvalue()
def _rels(target: str, kind: str = DOC_REL, extra: str = "") -> str:
return (
f'<Relationships xmlns="{REL}"><Relationship Id="rId1" Type="{kind}" '
f'Target="{target}"/>{extra}</Relationships>'
)
def make_docx(body: str, *, comments: str = "", title: str = "") -> bytes:
parts = {
"_rels/.rels": _rels("word/document.xml"),
"word/document.xml": f'<w:document xmlns:w="{W}"><w:body>{body}</w:body></w:document>',
"word/styles.xml": (
f'<w:styles xmlns:w="{W}"><w:style w:styleId="Titel"><w:name w:val="heading 1"/></w:style>'
"</w:styles>"
),
"word/numbering.xml": (
f'<w:numbering xmlns:w="{W}"><w:abstractNum w:abstractNumId="0">'
'<w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/></w:lvl></w:abstractNum>'
'<w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/></w:lvl>'
'</w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>'
'<w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num></w:numbering>'
),
}
if comments:
parts["word/comments.xml"] = f'<w:comments xmlns:w="{W}">{comments}</w:comments>'
if title:
parts["docProps/core.xml"] = (
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/'
'core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/">'
f"<dc:title>{title}</dc:title></cp:coreProperties>"
)
return _zip(parts)
def _p(text: str, *, style: str = "", num: tuple[int, int] | None = None) -> str:
props = ""
if style:
props += f'<w:pStyle w:val="{style}"/>'
if num:
props += f'<w:numPr><w:ilvl w:val="0"/><w:numId w:val="{num[1]}"/></w:numPr>'
return f"<w:p><w:pPr>{props}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"
def make_pptx(slides: list[tuple[str, list[str], str]]) -> bytes:
"""`(title, bullets, notes)` per slide."""
parts = {"_rels/.rels": _rels("ppt/presentation.xml")}
ids, rels = "", ""
for number, (title, bullets, notes) in enumerate(slides, 1):
ids += f'<p:sldId id="{255 + number}" r:id="rId{number}"/>'
rels += (
f'<Relationship Id="rId{number}" Type="{R}/slide" Target="slides/slide{number}.xml"/>'
)
body = "".join(f"<a:p><a:r><a:t>{b}</a:t></a:r></a:p>" for b in bullets)
parts[f"ppt/slides/slide{number}.xml"] = (
f'<p:sld xmlns:p="{P}" xmlns:a="{A}"><p:cSld><p:spTree>'
f'<p:sp><p:nvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr><p:txBody>'
f"<a:p><a:r><a:t>{title}</a:t></a:r></a:p></p:txBody></p:sp>"
f'<p:sp><p:nvSpPr><p:nvPr><p:ph type="body"/></p:nvPr></p:nvSpPr><p:txBody>{body}</p:txBody></p:sp>'
f'<p:sp><p:nvSpPr><p:nvPr><p:ph type="sldNum"/></p:nvPr></p:nvSpPr><p:txBody>'
f"<a:p><a:r><a:t>FOOTER-NUMBER</a:t></a:r></a:p></p:txBody></p:sp>"
"</p:spTree></p:cSld></p:sld>"
)
if notes:
parts[f"ppt/slides/_rels/slide{number}.xml.rels"] = (
f'<Relationships xmlns="{REL}"><Relationship Id="rId1" Type="{R}/notesSlide" '
f'Target="../notesSlides/notes{number}.xml"/></Relationships>'
)
parts[f"ppt/notesSlides/notes{number}.xml"] = (
f'<p:notes xmlns:p="{P}" xmlns:a="{A}"><p:cSld><p:spTree><p:sp><p:nvSpPr><p:nvPr>'
'<p:ph type="body"/></p:nvPr></p:nvSpPr><p:txBody>'
f"<a:p><a:r><a:t>{notes}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:notes>"
)
parts["ppt/presentation.xml"] = (
f'<p:presentation xmlns:p="{P}" xmlns:r="{R}"><p:sldIdLst>{ids}</p:sldIdLst></p:presentation>'
)
parts["ppt/_rels/presentation.xml.rels"] = f'<Relationships xmlns="{REL}">{rels}</Relationships>'
return _zip(parts)
def make_xlsx() -> bytes:
sheet_rel = lambda n: f'<Relationship Id="rId{n}" Type="{R}/worksheet" Target="worksheets/sheet{n}.xml"/>' # noqa: E731
return _zip({
"_rels/.rels": _rels("xl/workbook.xml"),
"xl/workbook.xml": (
f'<workbook xmlns="{S}" xmlns:r="{R}"><sheets>'
'<sheet name="Budget" sheetId="1" r:id="rId1"/>'
'<sheet name="Geheim" sheetId="2" state="hidden" r:id="rId2"/></sheets></workbook>'
),
"xl/_rels/workbook.xml.rels": (
f'<Relationships xmlns="{REL}">{sheet_rel(1)}{sheet_rel(2)}'
f'<Relationship Id="rId3" Type="{R}/sharedStrings" Target="sharedStrings.xml"/>'
f'<Relationship Id="rId4" Type="{R}/styles" Target="styles.xml"/></Relationships>'
),
"xl/sharedStrings.xml": (
f'<sst xmlns="{S}"><si><t>Posten</t></si><si><t>Miete</t></si>'
"<si><t>Wert</t></si></sst>"
),
"xl/styles.xml": (
f'<styleSheet xmlns="{S}"><cellXfs><xf numFmtId="0"/><xf numFmtId="14"/></cellXfs></styleSheet>'
),
"xl/worksheets/sheet1.xml": (
f'<worksheet xmlns="{S}"><sheetData>'
'<row r="2"><c r="B2" t="s"><v>0</v></c><c r="C2" t="s"><v>2</v></c></row>'
'<row r="3"><c r="B3" t="s"><v>1</v></c><c r="C3"><f>1200+0</f><v>1200</v></c></row>'
'<row r="4"><c r="B4" t="inlineStr"><is><t>Stichtag</t></is></c><c r="C4" s="1"><v>45292</v></c></row>'
'<row r="5"><c r="B5" t="inlineStr"><is><t>Fertig</t></is></c><c r="C5" t="b"><v>1</v></c></row>'
"</sheetData></worksheet>"
),
"xl/worksheets/sheet2.xml": (
f'<worksheet xmlns="{S}"><sheetData><row r="1"><c r="A1" t="inlineStr"><is>'
"<t>VERSTECKT-INHALT</t></is></c></row></sheetData></worksheet>"
),
})
def make_eml(body: str = "Hallo Welt\n", *, html: str = "", attach: bool = False, inline: bool = False,
subject: str = "Angebot") -> bytes:
msg = EmailMessage()
msg["From"] = "Anna Beispiel <anna@example.org>"
msg["To"] = "bob@example.org, Carla <carla@example.org>"
msg["Subject"] = subject
msg["Date"] = "Wed, 07 Oct 2026 09:30:00 +0200"
msg["Message-ID"] = "<abc@example.org>"
if html:
msg.set_content(html, subtype="html")
else:
msg.set_content(body)
if inline:
msg.add_related(b"\x89PNG", maintype="image", subtype="png", cid="<logo@x>")
if attach:
msg.add_attachment(b"data", maintype="application", subtype="octet-stream", filename="a.bin")
return msg.as_bytes()
@pytest.fixture
def tree(kb_dir):
root = kb_dir.parent
(root / "raw").mkdir()
(root / "incoming").mkdir(exist_ok=True)
return root
def _derive(path, name=None):
return raw_derive_command(path=Path(path), name=name)
def _refused(path, name=None) -> None:
with pytest.raises(typer.Exit) as excinfo:
_derive(path, name)
assert excinfo.value.exit_code == 1
def _snapshot(root: Path) -> dict[str, bytes]:
return {str(p.relative_to(root)): p.read_bytes() for p in sorted(root.rglob("*")) if p.is_file()}
def _header_and_text(md: Path) -> tuple[dict, str]:
content = md.read_text(encoding="utf-8")
assert content.startswith("---\n")
head, _, text = content[4:].partition("\n---\n")
return yaml.safe_load(head), text
def _put(tree: Path, name: str, data: bytes) -> Path:
path = tree / "incoming" / name
path.write_bytes(data)
return path
# --- the single-file form ---------------------------------------------------
def test_a_single_file_becomes_a_folder_with_original_and_derived_text(tree):
data = make_docx(_p("Hello"))
_put(tree, "Angebot XYZ.docx", data)
_derive("incoming/Angebot XYZ.docx")
folder = tree / "incoming/Angebot XYZ"
assert sorted(p.name for p in folder.iterdir()) == ["derived.md", "original.docx"]
assert (folder / "original.docx").read_bytes() == data
assert not (tree / "incoming/Angebot XYZ.docx").exists()
def test_the_header_is_valid_yaml_and_records_the_derivation(tree):
_put(tree, "Angebot XYZ.docx", make_docx(_p("Hello"), title="Offer"))
_derive("incoming/Angebot XYZ.docx")
header, text = _header_and_text(tree / "incoming/Angebot XYZ/derived.md")
assert header["derived_by"] == "wikitool raw derive"
assert header["filter"] == "docx"
assert header["engine"].startswith("wikitool ")
assert header["derived_from"] == "original.docx"
assert header["original_name"] == "Angebot XYZ.docx"
assert header["title"] == "Offer"
assert header["derived"] is not None
assert text.strip() == "Hello"
def test_two_derivations_of_the_same_bytes_differ_only_in_stamp_and_file_name(tree):
data = make_docx(_p("Hello") + _p("World"))
_put(tree, "a.docx", data)
_put(tree, "b.docx", data)
_derive("incoming/a.docx", "same")
_derive("incoming/b.docx", "same-too")
def stripped(name):
lines = (tree / "incoming" / name / "derived.md").read_text(encoding="utf-8").splitlines()
return [line for line in lines if not line.startswith(("derived:", "original_name:"))]
assert stripped("same") == stripped("same-too")
def test_raw_accept_takes_the_derived_folder_as_one_source(tree):
data = make_docx(_p("Hello"))
_put(tree, "report.docx", data)
_derive("incoming/report.docx")
raw_accept_command(
files=[Path("incoming/report")], fidelity="verbatim", authority="reporting",
page=None, replaces=None, dry_run=False,
)
[original] = list((tree / "raw").rglob("original.docx"))
assert original.read_bytes() == data
assert (original.parent / "derived.md").is_file()
assert original.parent.name == "report"
assert not (tree / "incoming/report").exists()
def test_name_picks_the_folder_stem(tree):
_put(tree, "x.docx", make_docx(_p("Hello")))
_derive("incoming/x.docx", "kurz")
assert (tree / "incoming/kurz/original.docx").is_file()
def test_a_120_character_stem_fits_and_an_over_budget_one_is_refused(tree, capsys):
long_stem = "s" * 120
_put(tree, f"{long_stem}.docx", make_docx(_p("Hello")))
_derive(f"incoming/{long_stem}.docx")
assert (tree / "incoming" / long_stem / "derived.md").is_file()
capsys.readouterr()
too_long = "t" * 170
_put(tree, f"{too_long}.docx", make_docx(_p("Hello")))
before = _snapshot(tree / "incoming")
_refused(f"incoming/{too_long}.docx")
out = capsys.readouterr().out
assert "--name" in out and "raw/" in out
assert _snapshot(tree / "incoming") == before
_derive(f"incoming/{too_long}.docx", "short")
assert (tree / "incoming/short/original.docx").is_file()
def test_a_stem_taken_under_raw_is_a_warning_not_a_refusal(tree, capsys):
(tree / "raw/2026/01").mkdir(parents=True)
(tree / "raw/2026/01/report.txt").write_text("x", encoding="utf-8")
_put(tree, "report.docx", make_docx(_p("Hello")))
_derive("incoming/report.docx")
out = capsys.readouterr().out
assert "WARN" in out and "already claims" in out
assert (tree / "incoming/report/derived.md").is_file()
def test_an_existing_target_folder_is_refused_and_nothing_changes(tree):
_put(tree, "report.docx", make_docx(_p("Hello")))
(tree / "incoming/report").mkdir()
before = _snapshot(tree / "incoming")
_refused("incoming/report.docx")
assert _snapshot(tree / "incoming") == before
def test_a_file_outside_incoming_or_in_a_subfolder_is_refused(tree):
outside = tree / "elsewhere.docx"
outside.write_bytes(make_docx(_p("Hello")))
_refused(outside)
(tree / "incoming/sub").mkdir()
(tree / "incoming/sub/in.docx").write_bytes(make_docx(_p("Hello")))
_refused("incoming/sub/in.docx")
assert (tree / "incoming/sub/in.docx").is_file()
def test_a_file_without_a_filter_is_refused_and_names_what_is_read(tree, capsys):
_put(tree, "notes.md", b"# notes\n")
_refused("incoming/notes.md")
out = capsys.readouterr().out
assert ".docx" in out and ".xlsx" in out
assert (tree / "incoming/notes.md").is_file()
def test_a_failure_while_moving_the_original_leaves_incoming_as_it_was(tree, monkeypatch):
_put(tree, "report.docx", make_docx(_p("Hello")))
before = _snapshot(tree / "incoming")
def broken(self, target):
raise OSError("disk says no")
monkeypatch.setattr(Path, "rename", broken)
_refused("incoming/report.docx")
monkeypatch.undo()
assert _snapshot(tree / "incoming") == before
assert not (tree / "incoming/report").exists()
def test_a_damaged_document_is_refused_untouched(tree):
_put(tree, "broken.docx", b"this is not a zip")
before = _snapshot(tree / "incoming")
_refused("incoming/broken.docx")
assert _snapshot(tree / "incoming") == before
def test_a_lone_markdown_file_stays_folderless(tree):
_put(tree, "notes.md", b"# notes\n")
raw_accept_command(
files=[tree / "incoming/notes.md"], fidelity="verbatim", authority="opinion",
page=None, replaces=None, dry_run=False,
)
[moved] = [p for p in (tree / "raw").rglob("*") if p.is_file()]
assert moved.name == "notes.md" and moved.parent.name.isdigit()
# --- the folder form --------------------------------------------------------
def test_a_folder_gets_a_md_beside_each_file_with_a_filter(tree, capsys):
folder = tree / "incoming/mappe"
folder.mkdir()
(folder / "plan.docx").write_bytes(make_docx(_p("Plan")))
(folder / "zahlen.xlsx").write_bytes(make_xlsx())
(folder / "foto.jpg").write_bytes(b"\xff\xd8")
_derive("incoming/mappe")
out = capsys.readouterr().out
assert sorted(p.name for p in folder.iterdir()) == ["foto.jpg", "plan.docx", "plan.md", "zahlen.md", "zahlen.xlsx"]
header, _ = _header_and_text(folder / "plan.md")
assert header["derived_from"] == "plan.docx"
assert "original_name" not in header
assert "foto.jpg" in out
def test_a_folder_with_an_existing_md_is_refused_untouched(tree):
folder = tree / "incoming/mappe"
folder.mkdir()
(folder / "plan.docx").write_bytes(make_docx(_p("Plan")))
(folder / "plan.md").write_text("mine\n", encoding="utf-8")
before = _snapshot(tree / "incoming")
_refused("incoming/mappe")
assert _snapshot(tree / "incoming") == before
def test_a_folder_where_two_files_derive_to_one_md_is_refused(tree):
folder = tree / "incoming/mappe"
folder.mkdir()
(folder / "plan.docx").write_bytes(make_docx(_p("Plan")))
(folder / "plan.xlsx").write_bytes(make_xlsx())
before = _snapshot(tree / "incoming")
_refused("incoming/mappe")
assert _snapshot(tree / "incoming") == before
def test_a_folder_without_any_filter_file_is_refused(tree):
folder = tree / "incoming/mappe"
folder.mkdir()
(folder / "foto.jpg").write_bytes(b"\xff\xd8")
_refused("incoming/mappe")
def test_a_folder_with_a_hidden_entry_is_refused(tree):
folder = tree / "incoming/mappe"
folder.mkdir()
(folder / "plan.docx").write_bytes(make_docx(_p("Plan")))
(folder / ".DS_Store").write_bytes(b"x")
_refused("incoming/mappe")
def test_name_does_not_apply_to_a_folder(tree):
(tree / "incoming/mappe").mkdir()
(tree / "incoming/mappe/plan.docx").write_bytes(make_docx(_p("Plan")))
_refused("incoming/mappe", "other")
# --- DOCX -------------------------------------------------------------------
def test_docx_headings_lists_tables_and_text(tree):
body = (
_p("Titel", style="Titel")
+ _p("Absatz eins.")
+ _p("Erster", num=(0, 1)) + _p("Zweiter", num=(0, 1))
+ _p("Punkt", num=(0, 2))
+ f'<w:tbl><w:tr><w:tc>{_p("A")}</w:tc><w:tc>{_p("B")}</w:tc></w:tr>'
f'<w:tr><w:tc>{_p("1")}</w:tc><w:tc>{_p("2")}</w:tc></w:tr></w:tbl>'
)
_put(tree, "d.docx", make_docx(body))
_derive("incoming/d.docx")
_, text = _header_and_text(tree / "incoming/d/derived.md")
assert "# Titel" in text
assert "1. Erster\n2. Zweiter" in text
assert "- Punkt" in text
assert "| A | B |" in text and "| 1 | 2 |" in text
def test_docx_tracked_changes_and_comments(tree, capsys):
body = (
"<w:p><w:r><w:t>Alt </w:t></w:r>"
'<w:del w:id="1"><w:r><w:delText>GELOESCHT </w:delText></w:r></w:del>'
'<w:ins w:id="2"><w:r><w:t>NEU</w:t></w:r></w:ins></w:p>'
)
comments = '<w:comment w:id="0" w:author="Anna" w:date="2026-10-01"><w:p><w:r><w:t>Prüfen</w:t></w:r></w:p></w:comment>'
_put(tree, "d.docx", make_docx(body, comments=comments))
_derive("incoming/d.docx")
out = capsys.readouterr().out
header, text = _header_and_text(tree / "incoming/d/derived.md")
assert "Alt NEU" in text and "GELOESCHT" not in text
assert "## Comments" in text and "Anna" in text and "Prüfen" in text
assert header["tracked_changes"] == 2 and header["comments"] == 1
assert "CHECK" in out and "Tracked changes" in out and "Comments" in out
def test_docx_without_tracked_changes_prints_no_such_check(tree, capsys):
_put(tree, "d.docx", make_docx(_p("Plain")))
_derive("incoming/d.docx")
out = capsys.readouterr().out
assert "Tracked changes" not in out and "Comments" not in out
# --- PPTX -------------------------------------------------------------------
def test_pptx_slides_notes_and_footer_placeholders(tree):
_put(tree, "deck.pptx", make_pptx([("Eins", ["a", "b"], "Sprechnotiz"), ("Zwei", ["c"], "")]))
_derive("incoming/deck.pptx")
header, text = _header_and_text(tree / "incoming/deck/derived.md")
assert header["slides"] == 2
assert text.index("## Slide 1") < text.index("## Slide 2")
assert "**Eins**" in text and "- a" in text and "- b" in text
assert "### Notes" in text and "Sprechnotiz" in text
assert "FOOTER-NUMBER" not in text
# --- XLSX -------------------------------------------------------------------
def test_xlsx_values_hidden_sheet_and_trimmed_edges(tree, capsys):
_put(tree, "zahlen.xlsx", make_xlsx())
_derive("incoming/zahlen.xlsx")
out = capsys.readouterr().out
header, text = _header_and_text(tree / "incoming/zahlen/derived.md")
assert header["sheets"] == 2 and header["hidden_sheets"] == ["Geheim"]
assert "## Budget" in text and "## Geheim" in text and "VERSTECKT-INHALT" in text
assert "| Posten | Wert |" in text
assert "| Miete | 1200 |" in text
assert "2024-01-01" in text
assert "TRUE" in text
assert text.splitlines()[text.splitlines().index("## Budget") + 2].startswith("| Posten")
assert "Values are as last saved" in out and "Hidden sheets" in out
def test_xlsx_check_is_printed_only_for_the_formats_derived_in_the_run(tree, capsys):
_put(tree, "d.docx", make_docx(_p("Plain")))
_derive("incoming/d.docx")
assert "last saved" not in capsys.readouterr().out
def test_check_lines_are_at_most_three_per_format_derived(tree, capsys):
folder = tree / "incoming/mappe"
folder.mkdir()
(folder / "z.xlsx").write_bytes(make_xlsx())
(folder / "m.eml").write_bytes(make_eml("> quoted\nnew\n"))
_derive("incoming/mappe")
out = capsys.readouterr().out
per_filter = filters.MAX_CHECKS
assert len([line for line in out.splitlines() if line.startswith("CHECK")]) <= 2 * per_filter
# --- EML --------------------------------------------------------------------
def test_eml_headers_and_plain_body(tree):
_put(tree, "mail.eml", make_eml("Hallo Welt\n"))
_derive("incoming/mail.eml")
header, text = _header_and_text(tree / "incoming/mail/derived.md")
assert header["filter"] == "eml"
assert header["from"] == "Anna Beispiel <anna@example.org>"
assert header["to"] == ["bob@example.org", "Carla <carla@example.org>"]
assert header["cc"] == []
assert header["subject"] == "Angebot" and header["title"] == "Angebot"
assert header["message_id"] == "<abc@example.org>"
assert str(header["date"]).startswith("2026-10-07")
assert header["attachments"] == []
assert text.strip() == "Hallo Welt"
def test_eml_html_only_is_derived_through_the_web_filter(tree):
_put(tree, "mail.eml", make_eml(html="<html><body><h1>Gruß</h1><p>Text</p></body></html>"))
_derive("incoming/mail.eml")
_, text = _header_and_text(tree / "incoming/mail/derived.md")
assert "# Gruß" in text and "Text" in text
def test_eml_check_lines_cover_quotes_only_when_they_occur(tree, capsys):
_put(tree, "plain.eml", make_eml("Fresh text\n"))
_derive("incoming/plain.eml")
out = capsys.readouterr().out
assert "Quoted earlier messages" not in out
assert "commitment" in out and "secrets" in out
_put(tree, "reply.eml", make_eml("Yes.\n\n> earlier\n"))
_derive("incoming/reply.eml")
out = capsys.readouterr().out
assert "Quoted earlier messages" in out
lines = [line for line in out.splitlines() if line.startswith("CHECK")]
assert len(lines) == filters.MAX_CHECKS
def test_eml_with_inline_image_warns_and_is_not_an_attachment(tree, capsys):
_put(tree, "mail.eml", make_eml(html="<p>Hi</p>", inline=True))
_derive("incoming/mail.eml")
assert "inline image" in capsys.readouterr().out
def test_eml_with_an_attachment_is_refused_untouched(tree, capsys):
_put(tree, "mail.eml", make_eml(attach=True))
before = _snapshot(tree / "incoming")
_refused("incoming/mail.eml")
assert "a.bin" in capsys.readouterr().out
assert _snapshot(tree / "incoming") == before
# --- the registry -----------------------------------------------------------
class _Needy(filters.Filter):
name = "needy"
extensions = (".needy",)
requires = ("no_such_package_for_wikitool",)
def test_a_missing_package_fails_that_format_only(tree, monkeypatch, capsys):
monkeypatch.setattr(filters, "_REGISTRY", list(filters.all_filters()) + [_Needy()])
_put(tree, "x.needy", b"data")
before = _snapshot(tree / "incoming")
_refused("incoming/x.needy")
out = capsys.readouterr().out
assert "no_such_package_for_wikitool" in out and "preflight" in out
assert _snapshot(tree / "incoming") == before
_put(tree, "ok.docx", make_docx(_p("Fine")))
_derive("incoming/ok.docx")
assert (tree / "incoming/ok/derived.md").is_file()
def test_registering_a_second_reader_for_an_extension_is_refused():
class Twin(filters.Filter):
name = "twin"
extensions = (".docx",)
with pytest.raises(ValueError):
filters.register(Twin())
def test_a_filter_cannot_ask_for_more_than_three_check_lines():
with pytest.raises(ValueError):
filters.Derivation(text="", checks=("a", "b", "c", "d"))
def test_extension_matching_is_case_insensitive_and_ignores_leading_dots():
assert filters.extension_of("Report.DOCX") == ".docx"
assert filters.extension_of(".hidden") == ""
assert filters.filter_for("Report.DOCX").name == "docx"
assert filters.filter_for("noextension") is None
def test_header_renders_a_list_as_a_flow_sequence_and_a_string_as_before():
rendered = web_capture.header([("a", "plain"), ("b", ["x y", "z"]), ("c", [])])
assert rendered == '---\na: plain\nb: ["x y", "z"]\nc: []\n---\n'
assert yaml.safe_load(rendered.strip("-\n").replace("---", "")) == {"a": "plain", "b": ["x y", "z"], "c": []}
# --- the nets in raw pending and raw accept ---------------------------------
def test_pending_names_the_derive_line_for_a_lone_file_with_a_filter(tree, capsys):
_put(tree, "report.docx", make_docx(_p("Hello")))
_put(tree, "photo.jpg", b"\xff")
raw_pending_command(json_out=True)
by_path = {c["paths"][0]: c for c in json.loads(capsys.readouterr().out)}
assert by_path["incoming/report.docx"]["hint"] == "tools/wikitool raw derive incoming/report.docx"
assert by_path["incoming/photo.jpg"]["hint"] is None
raw_pending_command(json_out=False)
assert "raw derive incoming/report.docx" in capsys.readouterr().out
def test_pending_gives_a_fetch_pair_no_hint(tree, capsys):
_put(tree, "page.html", b"<p>x</p>")
_put(tree, "page.md", b"x\n")
raw_pending_command(json_out=True)
[candidate] = json.loads(capsys.readouterr().out)
assert candidate["hint"] is None
def test_accept_warns_about_an_underived_file_but_still_promotes_it(tree, capsys):
path = _put(tree, "report.docx", make_docx(_p("Hello")))
raw_accept_command(
files=[path], fidelity="verbatim", authority="reporting",
page=None, replaces=None, dry_run=False,
)
out = capsys.readouterr().out
assert "WARN" in out and "raw derive incoming/report.docx" in out
assert [p for p in (tree / "raw").rglob("report.docx")]
def test_accept_is_quiet_when_a_derived_md_sits_beside_the_file(tree, capsys):
page = _put(tree, "page.html", b"<p>x</p>")
md = _put(tree, "page.md", b"x\n")
raw_accept_command(
files=[page, md], fidelity="published", authority="reporting",
page=None, replaces=None, dry_run=False,
)
assert "WARN" not in capsys.readouterr().out
def test_the_web_page_filter_derives_a_saved_html_file(tree):
_put(tree, "seite.html", b"<html><head><title>T</title></head><body><main><h1>Kopf</h1><p>Text</p></main></body></html>")
_derive("incoming/seite.html")
header, text = _header_and_text(tree / "incoming/seite/derived.md")
assert header["filter"] == "html" and header["title"] == "T"
assert header["charset"].startswith("utf-8")
assert "# Kopf" in text
+9 -2
View File
@@ -572,8 +572,15 @@ def _scalar(value: str) -> str:
return json.dumps(value, ensure_ascii=False) return json.dumps(value, ensure_ascii=False)
def header(fields: list[tuple[str, str]]) -> str: def _value(value: "str | list[str]") -> str:
lines = ["---"] + [f"{key}: {_scalar(value)}" for key, value in fields] + ["---"] """A list becomes a YAML flow sequence (a JSON array is valid YAML)."""
if isinstance(value, list):
return json.dumps(value, ensure_ascii=False)
return _scalar(value)
def header(fields: "list[tuple[str, str | list[str]]]") -> str:
lines = ["---"] + [f"{key}: {_value(value)}" for key, value in fields] + ["---"]
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"