bindery-cli 0.36.0__tar.gz → 0.37.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/.github/workflows/publish.yml +23 -0
  2. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/CLAUDE.md +3 -2
  3. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/PKG-INFO +36 -1
  4. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/README.md +35 -0
  5. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/patchnotes.md +46 -0
  6. bindery_cli-0.37.0/plugin/__init__.py +239 -0
  7. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/pyproject.toml +1 -1
  8. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/roadmap.md +58 -4
  9. bindery_cli-0.37.0/scripts/build_plugin.py +89 -0
  10. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/spec.md +46 -0
  11. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/__init__.py +1 -1
  12. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/transforms.py +9 -2
  13. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_cli.py +9 -5
  14. bindery_cli-0.37.0/tests/test_plugin.py +360 -0
  15. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_transforms.py +27 -0
  16. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/uv.lock +1 -1
  17. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/.github/workflows/ci.yml +0 -0
  18. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/.gitignore +0 -0
  19. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/AGENTS.md +0 -0
  20. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/LICENSE +0 -0
  21. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/REPORT-12-Sept.md +0 -0
  22. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/docs/screenshots/library-sweep.png +0 -0
  23. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/logo.svg +0 -0
  24. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/run_tests.sh +0 -0
  25. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/scripts/FastSweep.java +0 -0
  26. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/scripts/fast_sweep.py +0 -0
  27. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/__main__.py +0 -0
  28. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/audit.py +0 -0
  29. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/cli.py +0 -0
  30. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/epub.py +0 -0
  31. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/library.py +0 -0
  32. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/pagination.py +0 -0
  33. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/reserialize.py +0 -0
  34. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/validate.py +0 -0
  35. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/watermark.py +0 -0
  36. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/testing_facility/books_to_fix/.gitkeep +0 -0
  37. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/testing_facility/top500candidates/REPORT.md +0 -0
  38. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_audit.py +0 -0
  39. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_epub.py +0 -0
  40. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_library.py +0 -0
  41. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_pagination.py +0 -0
  42. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_reserialize.py +0 -0
  43. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_validate.py +0 -0
  44. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_version.py +0 -0
  45. {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_watermark.py +0 -0
@@ -43,3 +43,26 @@ jobs:
43
43
 
44
44
  - name: Publish to PyPI
45
45
  uses: pypa/gh-action-pypi-publish@release/v1
46
+
47
+ plugin-zip:
48
+ name: Build the Bindery Repair Calibre plugin zip
49
+ needs: test
50
+ runs-on: ubuntu-latest
51
+ steps:
52
+ - uses: actions/checkout@v4
53
+ - uses: actions/setup-python@v5
54
+ with:
55
+ python-version: "3.14"
56
+
57
+ - name: Run the suite
58
+ run: |
59
+ pip install .[reserialize]
60
+ PYTHONPATH=src python -m unittest discover -s tests
61
+
62
+ - name: Build the plugin zip from the tagged tree
63
+ run: python scripts/build_plugin.py
64
+
65
+ - name: Attach BinderyRepair zip to the release
66
+ uses: softprops/action-gh-release@v2
67
+ with:
68
+ files: dist/BinderyRepair-*.zip
@@ -73,7 +73,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
73
73
 
74
74
  ## Layout
75
75
 
76
- - `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms.
76
+ - `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms. Since v0.37.0: `fix_ncx_playorder` strips quotes before its already-correct comparison (regex groups carry quote characters; comparing quoted values against bare numbers counted phantom fixes on every sequential NCX and rewrote single-quoted correct attributes to double quotes).
77
77
  - `src/bindery/pagination.py`: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).
78
78
  - `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
79
79
  - `src/bindery/reserialize.py`: structural repair via `html5lib`.
@@ -82,7 +82,8 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
82
82
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
83
83
  - `src/bindery/library.py`: Calibre walk, atomic replace, backups, and native format installation. Since v0.19.0 `CalibreIdResolver` resolves the book id from `metadata.db` through cquarry (one lazy path→id map per run; since v0.20.0 the map comes from `CalibreDB.format_path_index()`, re-normalized with `resolve().lower()` for the resolver's symlinked-directory and case-insensitive matching); the `(id)` directory-name regex is only the no-catalog fallback. Since v0.24.0 `install_format()` places the repaired file atomically and updates the `data` row through `cquarry.write.WritableCalibreDB` (since v0.31.0 via `set_format`, cquarry 1.17's sanctioned remove+add in one transaction); the external `calibredb` CLI is gone. Since v0.32.0 a guessed id drives a row update only when metadata.db corroborates it (`_verify_guess`: books row exists, file in the book's own directory, catalogued `data.name` when a row exists); anything else saves in place and leaves the catalog untouched — never reintroduce directory-name guessing as the primary source: renamed/mismatched directories would replace the wrong book.
84
84
  - `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; since v0.29.0 also the `run` verbs: `phase1` (pre-import vetting over loose files: the audit battery composed with one gated repair sweep; read-only until `--apply-lossy`, which IS the lossy-strip consent) and `phase3` (the scoped post-import apply step: `library --id --sweep --only all --apply --all --install-to-calibre` plus a pre/post summary; mechanically refuses unscoped sweeps with exit 2). Both take `--non-interactive` and surface open questions as `decisions_needed` in their JSON, never prompts. They drive the shipped `run_library` through the real parser, so no flag can drift between the verb and the subcommand it wraps. `library --workers N` (v0.30.0) parallelizes only the sweep's candidate pass, in windows of N consumed in order so `--limit` stays lazy; the repair phase is never parallel (shared workdir, atomic-replacement contract).
85
- - `tests/`: transforms, end-to-end repair, atomic replace, pagination, watermarks, the audit analyzers (content/pagenumbers/emptytext/ocr/monolithic/completeness, archive integrity, spine classification), the library sweep and `CalibreIdResolver`, validate, reserialize, CLI wiring, and the version pin.
85
+ - `plugin/__init__.py` + `scripts/build_plugin.py`: the Bindery Repair Calibre plugin (v0.37.0; identity `Bindery Repair` / `bindery_repair`, approved and built per the scoped spec in roadmap.md Phase 3). The builder vendors `transforms.py`, `epub.py`, `pagination.py`, `watermark.py`, `reserialize.py` byte-identically into the zip (the zip root is a package, so their relative imports resolve unchanged; the suite's drift test pins the equality) with the version tuple substituted from the single-source VERSION; `publish.yml` attaches `BinderyRepair-v<VERSION>.zip` to each release. The plugin runs ONLY the default pass (epubcheck cannot gate inside Calibre), never raises, is byte-idempotent (zero fixes returns the original path), testzip()-verifies its output, logs one line per book, and takes JSON config via `site_customization` (`log`, `log_path`, `max_size_mb` default 150, `epubcheck_path` experimental validation default OFF). Load it in tests through the `tests/test_plugin.py` stubs: the calibre namespace is installed in sys.modules and the package loads with a two-dir `__path__` (plugin dir first, then src/bindery) exactly like calibre's zipplugin loader shape.
86
+ - `tests/`: transforms, end-to-end repair, atomic replace, pagination, watermarks, the audit analyzers (content/pagenumbers/emptytext/ocr/monolithic/completeness, archive integrity, spine classification), the library sweep and `CalibreIdResolver`, validate, reserialize, CLI wiring, the version pin, and the Calibre plugin (build drift + run behavior under calibre stubs).
86
87
 
87
88
  ## Conventions
88
89
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.36.0
3
+ Version: 0.37.0
4
4
  Summary: Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library.
5
5
  Author: vrnvctss
6
6
  License: MIT
@@ -95,6 +95,41 @@ uv tool install bindery-cli # core tools
95
95
  uv tool install "bindery-cli[reserialize]" # incl. --reserialize
96
96
  ```
97
97
 
98
+ ## The Calibre plugin (Bindery Repair)
99
+
100
+ Each release also ships **BinderyRepair-v<VERSION>.zip**, a Calibre plugin that
101
+ repairs EPUBs as they are imported: it is attached to the GitHub release beside
102
+ the PyPI wheel (install it via Calibre's Preferences, Plugins, "Load plugin
103
+ from file"). The plugin is the CLI's always-on core pass only: the five
104
+ well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
105
+ strips stay CLI-only, because their acceptance is the epubcheck gate and
106
+ epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
107
+
108
+ Behavior and guarantees:
109
+
110
+ - `run()` never raises and never touches the original file or `metadata.db`: a
111
+ repaired copy is built in a persistent temp file and handed back for import;
112
+ any trouble returns the original path and logs one line.
113
+ - Byte-idempotent: an already-clean book yields zero fixes and the original
114
+ path, so re-adding a format re-runs the plugin harmlessly.
115
+ - The rewritten archive is `testzip()`-verified before it is handed back.
116
+ - One log line per book (fixed / no fixes / refused / errored), appended to
117
+ `bindery_repair.log` in Calibre's config directory by default.
118
+ - Files over the size cap are refused (default 150MB) rather than stalling an
119
+ import.
120
+
121
+ Configuration is the plugin's customization string, parsed as JSON:
122
+
123
+ ```json
124
+ {"log": true, "log_path": "/path/bindery_repair.log", "max_size_mb": 150,
125
+ "epubcheck_path": null}
126
+ ```
127
+
128
+ `epubcheck_path` opts into the experimental on-PATH validation mode: the
129
+ repaired copy is re-measured with your epubcheck binary and refused unless it
130
+ is no worse than the original. It defaults to off; the default pass is safe
131
+ without a gate, and the mode costs two epubcheck runs per imported book.
132
+
98
133
  ## Usage
99
134
 
100
135
  Four verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
@@ -73,6 +73,41 @@ uv tool install bindery-cli # core tools
73
73
  uv tool install "bindery-cli[reserialize]" # incl. --reserialize
74
74
  ```
75
75
 
76
+ ## The Calibre plugin (Bindery Repair)
77
+
78
+ Each release also ships **BinderyRepair-v<VERSION>.zip**, a Calibre plugin that
79
+ repairs EPUBs as they are imported: it is attached to the GitHub release beside
80
+ the PyPI wheel (install it via Calibre's Preferences, Plugins, "Load plugin
81
+ from file"). The plugin is the CLI's always-on core pass only: the five
82
+ well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
83
+ strips stay CLI-only, because their acceptance is the epubcheck gate and
84
+ epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
85
+
86
+ Behavior and guarantees:
87
+
88
+ - `run()` never raises and never touches the original file or `metadata.db`: a
89
+ repaired copy is built in a persistent temp file and handed back for import;
90
+ any trouble returns the original path and logs one line.
91
+ - Byte-idempotent: an already-clean book yields zero fixes and the original
92
+ path, so re-adding a format re-runs the plugin harmlessly.
93
+ - The rewritten archive is `testzip()`-verified before it is handed back.
94
+ - One log line per book (fixed / no fixes / refused / errored), appended to
95
+ `bindery_repair.log` in Calibre's config directory by default.
96
+ - Files over the size cap are refused (default 150MB) rather than stalling an
97
+ import.
98
+
99
+ Configuration is the plugin's customization string, parsed as JSON:
100
+
101
+ ```json
102
+ {"log": true, "log_path": "/path/bindery_repair.log", "max_size_mb": 150,
103
+ "epubcheck_path": null}
104
+ ```
105
+
106
+ `epubcheck_path` opts into the experimental on-PATH validation mode: the
107
+ repaired copy is re-measured with your epubcheck binary and refused unless it
108
+ is no worse than the original. It defaults to off; the default pass is safe
109
+ without a gate, and the mode costs two epubcheck runs per imported book.
110
+
76
111
  ## Usage
77
112
 
78
113
  Four verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
@@ -1,4 +1,50 @@
1
1
  # bindery-cli Patch Notes
2
+ ## v0.37.0 (2026-09-12)
3
+
4
+ ### The Bindery Repair Calibre plugin (Phase 3)
5
+
6
+ - **Books are repaired as they are imported.** Each release now attaches
7
+ BinderyRepair-v<VERSION>.zip to the GitHub release: a Calibre
8
+ FileTypePlugin (on_import) that runs the CLI's always-on core pass, the
9
+ five well-formedness fixes plus the NCX pipeline, on *.epub files as they
10
+ are added. run() never raises and never touches the original file or
11
+ metadata.db: a repaired copy is built in a persistent temp file,
12
+ testzip()-verified, and handed back for import; any trouble returns the
13
+ original path and logs one line. The plugin is byte-idempotent, so
14
+ re-adding a format re-runs it harmlessly.
15
+ - **The vendor slice is byte-identical to the repo.** The zip carries
16
+ transforms.py, epub.py, pagination.py, watermark.py, and reserialize.py
17
+ unchanged (the zip root is a package, so their relative imports resolve);
18
+ a suite drift test pins the equality, and publish.yml regenerates the zip
19
+ from the tag and attaches it to the release.
20
+ - **Nothing runs ungated, ever.** The structural repairs and the lossy
21
+ strips stay CLI-only because their acceptance is the epubcheck gate,
22
+ which cannot run inside Calibre. Identity: Bindery Repair /
23
+ bindery_repair. Config via the plugin's customization string as JSON:
24
+ log, log_path (default <config dir>/bindery_repair.log), max_size_mb
25
+ (recorded default 150MB), and epubcheck_path, the experimental on-PATH
26
+ validation mode, default OFF (the three open questions, answered
27
+ 2026-09-11).
28
+
29
+ ### Fixed: fix_ncx_playorder counted phantom fixes (found by the plugin)
30
+
31
+ - The already-correct comparison read the attribute value with its
32
+ quotes, so every sequential navPoint counted as a fix on every pass
33
+ while the rewritten bytes stayed identical. The Long Patrol reported
34
+ fix_ncx_playorder=59 on every run of a repaired book; a book whose
35
+ only fix was phantom was rewritten instead of reported as nochange;
36
+ and a single-quoted sequential attribute was rewritten to double
37
+ quotes, touching already-correct markup. Quotes are now stripped
38
+ before the comparison and an already-correct attribute is returned
39
+ untouched: honest counts, single quotes survive, and the plugin's
40
+ zero-fixes contract holds end to end.
41
+
42
+ ### Repo records
43
+
44
+ - Phase 16's FastSweep prevalence study (5,228 books) and the obfuscation
45
+ magic-byte validation are recorded in the roadmap with real counts for
46
+ every Phase 16 repair box.
47
+
2
48
  ## v0.36.0 (2026-09-12)
3
49
 
4
50
  ### The completeness spot-check: `audit completeness` (Phase 15)
@@ -0,0 +1,239 @@
1
+ """Bindery Repair: repair EPUBs on import with bindery-cli's gate-safe pass.
2
+
3
+ A Calibre FileTypePlugin running bindery-cli's always-on core pass (the five
4
+ well-formedness transforms plus the NCX pipeline) on *.epub files as they are
5
+ imported. The structural repairs and the lossy strips stay CLI-only: their
6
+ acceptance IS the epubcheck gate, which cannot run inside Calibre (no jar, no
7
+ JVM, and seconds-per-book latency), so their flags are never enabled here and
8
+ nothing runs ungated, ever.
9
+
10
+ The on_import contract: run(path) returns the path to a repaired copy built
11
+ with the plugin's own temporary_file(), and Calibre imports that instead. The
12
+ original on disk is never touched and nothing writes to metadata.db. No
13
+ exception ever escapes run(): a raising plugin cannot abort an import (Calibre
14
+ falls back to the original silently), so every failure returns the original
15
+ path and logs one line. The plugin is byte-idempotent: an already-clean book
16
+ yields zero fixes and the original path, which db.add_format's
17
+ run_hooks=True re-entry requires.
18
+
19
+ Configuration is the plain site_customization string, parsed as JSON::
20
+
21
+ {"log": true, "log_path": "/path/bindery_repair.log",
22
+ "max_size_mb": 150, "epubcheck_path": null}
23
+
24
+ log_path defaults to <calibre config dir>/bindery_repair.log. The size cap
25
+ refuses absurd files rather than stalling an import; 150MB is the recorded
26
+ default. epubcheck_path opts into the experimental on-PATH validation mode:
27
+ the repaired copy is re-measured with the epubcheck binary and refused unless
28
+ it is no worse than the original (the CLI's no_worse bar). It defaults to
29
+ off, keeping the import fast; the default pass is safe without a gate.
30
+
31
+ The vendored bindery modules ride in this zip as submodules of the plugin
32
+ package (their relative imports resolve unchanged), so the zip's module bytes
33
+ are identical to the repo's src/bindery/ sources; scripts/build_plugin.py
34
+ generates the zip at release and the suite's drift test pins the equality.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import os
41
+ import subprocess
42
+ import zipfile
43
+ from collections import namedtuple
44
+ from pathlib import Path
45
+
46
+ __PLUGIN_VERSION__ = (0, 0, 0) # substituted by scripts/build_plugin.py
47
+
48
+ # The vendored bindery modules load as submodules of this plugin package
49
+ # (calibre_plugins.bindery_repair.epub and siblings), so their own relative
50
+ # imports resolve unchanged.
51
+ from calibre.customize import FileTypePlugin
52
+
53
+ from . import epub as _bindery_epub
54
+
55
+ DEFAULT_MAX_SIZE_MB = 150
56
+ _EPUB_SUFFIXES = (".epub",)
57
+ # the slice of validate.CheckResult the experimental mode needs
58
+ _EcCounts = namedtuple("_EcCounts", "fatals errors warnings")
59
+
60
+
61
+ class BinderyRepair(FileTypePlugin):
62
+ name = "Bindery Repair"
63
+ description = (
64
+ "Repair well-formedness defects in EPUB files as they are imported, "
65
+ "running bindery-cli's epubcheck-safe default pass"
66
+ )
67
+ author = "Brandon LaRocque"
68
+ version = __PLUGIN_VERSION__
69
+ minimum_calibre_version = (2, 0, 0)
70
+ supported_platforms = ["linux"]
71
+ type = "File type"
72
+ file_types = {"epub"}
73
+ on_import = True
74
+ priority = 1
75
+
76
+ # ---- run() never raises: the contract is the original path on trouble
77
+
78
+ def run(self, path_to_ebook):
79
+ try:
80
+ return self._run(path_to_ebook)
81
+ except Exception as e:
82
+ # the last-resort sink re-reads the config, whose parsing is
83
+ # itself exception-safe; run() must never raise
84
+ self._log(
85
+ f"errored: {path_to_ebook}: {type(e).__name__}: {e}",
86
+ self._resolve_log_path(self._config()),
87
+ )
88
+ return path_to_ebook
89
+
90
+ def _run(self, path_to_ebook: str) -> str:
91
+ src = Path(path_to_ebook)
92
+ if src.suffix.lower() not in _EPUB_SUFFIXES:
93
+ return path_to_ebook
94
+ cfg = self._config()
95
+ log_path = self._resolve_log_path(cfg)
96
+ try:
97
+ size_mb = src.stat().st_size / (1024 * 1024)
98
+ except OSError as e:
99
+ self._log(f"errored: {src}: {e}", log_path)
100
+ return path_to_ebook
101
+ cap = cfg.get("max_size_mb", DEFAULT_MAX_SIZE_MB)
102
+ if isinstance(cap, (int, float)) and cap > 0 and size_mb > cap:
103
+ self._log(
104
+ f"refused: {src.name}: {size_mb:.0f}MB over the {cap:g}MB cap",
105
+ log_path,
106
+ )
107
+ return path_to_ebook
108
+
109
+ with self.temporary_file(".epub") as tf:
110
+ dst = Path(tf.name)
111
+ try:
112
+ report = _bindery_epub.repair_epub(src, dst)
113
+ except Exception as e:
114
+ self._log(
115
+ f"errored: {src.name}: repair failed: {type(e).__name__}: {e}",
116
+ log_path,
117
+ )
118
+ return path_to_ebook
119
+ if not report:
120
+ # zero fixes: hand back the original byte-for-byte (the
121
+ # add_format(run_hooks=True) re-entry depends on this)
122
+ dst.unlink(missing_ok=True)
123
+ self._log(f"no fixes: {src.name}", log_path)
124
+ return path_to_ebook
125
+ with zipfile.ZipFile(dst) as z:
126
+ damaged = z.testzip()
127
+ if damaged is not None:
128
+ dst.unlink(missing_ok=True)
129
+ self._log(
130
+ f"refused: {src.name}: rewritten archive failed testzip() "
131
+ f"at {damaged}",
132
+ log_path,
133
+ )
134
+ return path_to_ebook
135
+ if "epubcheck_path" in cfg and cfg["epubcheck_path"]:
136
+ verdict = self._epubcheck_no_worse(src, dst, cfg["epubcheck_path"])
137
+ if verdict is False:
138
+ dst.unlink(missing_ok=True)
139
+ self._log(
140
+ f"refused: {src.name}: epubcheck measured the repair "
141
+ "no better or worse; keeping the original",
142
+ log_path,
143
+ )
144
+ return path_to_ebook
145
+ detail = ", ".join(f"{k}={v}" for k, v in sorted(report.fixes.items()))
146
+ if report.ncx_uid_synced:
147
+ detail = (detail + ", " if detail else "") + "ncx_uid_synced"
148
+ self._log(f"fixed: {src.name}: {detail}", log_path)
149
+ return str(dst)
150
+
151
+ # ---- configuration and logging
152
+
153
+ def _config(self) -> dict:
154
+ raw = getattr(self, "site_customization", None)
155
+ if not raw:
156
+ return {}
157
+ try:
158
+ cfg = json.loads(raw)
159
+ except ValueError:
160
+ # a broken config lands one line in the default log and the run
161
+ # proceeds on the defaults; an import must never be lost to config
162
+ self._log(
163
+ "config: site_customization is not valid JSON; using defaults",
164
+ self._default_log_path(),
165
+ )
166
+ return {}
167
+ return cfg if isinstance(cfg, dict) else {}
168
+
169
+ def _resolve_log_path(self, cfg: dict) -> str | None:
170
+ if not cfg.get("log", True):
171
+ return None
172
+ path = cfg.get("log_path") or self._default_log_path()
173
+ return path if isinstance(path, str) else None
174
+
175
+ def _default_log_path(self) -> str | None:
176
+ try:
177
+ from calibre.constants import config_dir
178
+
179
+ return os.path.join(config_dir, "bindery_repair.log")
180
+ except Exception:
181
+ return None
182
+
183
+ def _log(self, line: str, log_path: str | None) -> None:
184
+ """One line per book, appended; logging must never break an import."""
185
+ if not log_path:
186
+ return
187
+ try:
188
+ with open(log_path, "a", encoding="utf-8") as f:
189
+ f.write(line.rstrip() + "\n")
190
+ except OSError:
191
+ pass
192
+
193
+ # ---- experimental epubcheck-on-PATH validation (default off)
194
+
195
+ def _epubcheck_no_worse(self, src: Path, dst: Path, epubcheck: str) -> bool | None:
196
+ """Measure both files with the epubcheck binary; True when the repair
197
+ is no worse (no net-new fatals, no new errors unless fatals masked
198
+ them), False when it regressed, None when the binary cannot answer
199
+ (an unanswered check never refuses the repair; the pass is safe)."""
200
+ before = self._epubcheck_counts(src, epubcheck)
201
+ after = self._epubcheck_counts(dst, epubcheck)
202
+ if before is None or after is None:
203
+ return None
204
+ if after.fatals > before.fatals:
205
+ return False
206
+ if before.fatals == 0 and after.errors > before.errors:
207
+ return False
208
+ return True
209
+
210
+ def _epubcheck_counts(self, path: Path, epubcheck: str):
211
+ try:
212
+ proc = subprocess.run(
213
+ [epubcheck, "--json", "-", str(path)],
214
+ capture_output=True,
215
+ text=True,
216
+ timeout=300,
217
+ )
218
+ except OSError, subprocess.TimeoutExpired:
219
+ return None
220
+ try:
221
+ data = json.loads(proc.stdout)
222
+ checker = data["checker"]
223
+ return _EcCounts(
224
+ int(checker["nFatal"]),
225
+ int(checker["nError"]),
226
+ int(checker["nWarning"]),
227
+ )
228
+ except json.JSONDecodeError, KeyError, TypeError, ValueError:
229
+ return None
230
+
231
+ # ---- plugin housekeeping
232
+
233
+ def customization_help(self, gui=False):
234
+ return (
235
+ 'Enter a JSON object: {"log": true, "log_path": ..., '
236
+ '"max_size_mb": 150, "epubcheck_path": null}. '
237
+ "epubcheck_path enables the experimental post-repair validation "
238
+ "with the epubcheck binary (slow; off by default)."
239
+ )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.36.0"
7
+ version = "0.37.0"
8
8
  description = "Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library."
9
9
  readme = "README.md"
10
10
  classifiers = [
@@ -278,8 +278,16 @@ locale hardening (5.2), in v0.10.0. Phase 5 is closed.*
278
278
 
279
279
  ## Phase 3: integration (maybe)
280
280
 
281
- - [ ] Calibre plugin so books are repaired on add, as a `FileTypePlugin` with
282
- `on_import = True`.
281
+ - [x] Calibre plugin so books are repaired on add, as a `FileTypePlugin` with
282
+ `on_import = True`. *(SHIPPED 2026-09-12 as v0.37.0: "Bindery Repair" /
283
+ `bindery_repair`, built to the scoped spec below. Vendor slice byte-
284
+ identical with a suite drift test; default pass only; testzip()
285
+ verification; byte-idempotent; one log line per book; site_customization
286
+ JSON config with the size cap at the recorded default 150MB and the
287
+ epubcheck-on-PATH experimental mode default OFF; publish.yml attaches
288
+ BinderyRepair-v<VERSION>.zip to each release. The plugin's smoke test
289
+ found the fix_ncx_playorder phantom-count bug (Bug Reports), which the
290
+ same release fixes.)*
283
291
  - [x] ~~Optional metadata.db nudge so Calibre notices the new file size~~
284
292
  **Dropped 2026-08-09: unnecessary under `on_import`.** See the decision below.
285
293
 
@@ -370,6 +378,12 @@ Open questions (Brandon): epubcheck-on-PATH experimental mode (default no, the
370
378
  latency ruling stands); plugin identity `Bindery Repair` / `bindery_repair`;
371
379
  size-cap default.
372
380
 
381
+ *(ANSWERED 2026-09-11 and SHIPPED 2026-09-12 as v0.37.0: epubcheck-on-PATH
382
+ mode exists behind the `epubcheck_path` config key and defaults OFF; identity
383
+ is `Bindery Repair` / `bindery_repair`; the size cap keeps the documented
384
+ default, now recorded as 150MB in README and spec. See the plugin box above
385
+ and the spec's "The Calibre plugin" section.)*
386
+
373
387
 
374
388
  ## Supplementary Phase (Based on Library Audit)
375
389
  Following a comprehensive epubcheck sweep of a 5,043-book Calibre library on 2026-08-22 (~3,228 candidates for repair), several recurring error schemas were identified; all have since shipped. (This section absorbs the one-off `BINDERY_REPORT.md`, retired in v0.17.0 — its performance notes live in the v0.13.0 patchnotes, and its findings are the checked items below.)
@@ -696,6 +710,26 @@ driven alternative was rejected because it would still silently mutate legal
696
710
  markup whenever an unrelated finding existed; the gate keeps EPUB3 books
697
711
  byte-identical.)*
698
712
 
713
+ ### fix_ncx_playorder counts phantom fixes on already-sequential NCXs (2026-09-12, plugin work)
714
+ **Bug in the default pass's `fix_ncx_playorder`:** the already-correct
715
+ comparison read the captured attribute value WITH its quotes
716
+ (`m.group(2) != str(playorder)`), so every navPoint counted as a fix on
717
+ every pass. The rewritten bytes stayed identical (which is why the
718
+ deterministic-output tests and every gate run never caught it), but the
719
+ fix counts were phantom: The Long Patrol reported `fix_ncx_playorder=59`
720
+ on every run of an already-repaired book, and a book whose only "fix"
721
+ was phantom was rewritten instead of being reported as nochange. A
722
+ single-quoted sequential attribute was also rewritten to double quotes,
723
+ touching already-correct markup. Found by the Calibre plugin's smoke
724
+ test: the plugin's zero-fixes contract (return the original path) broke
725
+ on real books. `transforms.py` `fix_ncx_playorder`.
726
+ **Fix:** strip the quotes before comparing; an already-correct attribute
727
+ is returned untouched (its quote style preserved) and not counted.
728
+ *(Fixed in v0.37.0: honest counts on every NCX, single quotes survive,
729
+ and the plugin's byte-idempotence holds end to end. The sweep's
730
+ apply-path behavior changes only in that a phantom-only repair is now
731
+ correctly a noop.)*
732
+
699
733
  ## Phase 11: Migrate install-to-calibre to native cquarry API (proposed 2026-08-31)
700
734
 
701
735
  *Context: `bindery library --install-to-calibre` currently shells out to the external `calibredb add_format` CLI binary. This is fragile (it crashed on 2026-08-31 due to a non-existent `--replace` flag) and bypasses the transaction and trigger-safety guarantees built into the `cquarry` library. Since bindery-cli already imports `cquarry` for path resolution, it should adopt the native write module.*
@@ -1418,14 +1452,34 @@ an opt-in structural repair under the existing gate contract.
1418
1452
 
1419
1453
  ### A. Research first (boxes cannot be honestly sized without these)
1420
1454
 
1421
- - [ ] **FastSweep prevalence study**: run `fast_sweep.py
1455
+ - [x] **FastSweep prevalence study**: run `fast_sweep.py
1422
1456
  --mode=extract` aggregation over the full library for the new
1423
1457
  classes — container.xml presence/validity, encryption.xml
1424
1458
  algorithms, manifest media-type warnings, case-mismatch RSC-007s.
1425
1459
  Each Phase 16 repair box gets its real count before it opens.
1426
- - [ ] **Obfuscation magic-byte validation**: confirm the IDPF
1460
+ *(done 2026-09-12: 5,228 books through epubcheck extract (664s) plus
1461
+ a direct zip-level pass. Real counts: container.xml valid on
1462
+ 5,228/5,228 (zero gateway-defect books in-library); OPF-029
1463
+ media-type mismatches 275 occurrences in 15 books; RSC-007 14,156
1464
+ occurrences in 600 books and PKG-010 in 280 (the prune surface,
1465
+ 1,538 truly-absent manifest hrefs); case-mismatch hrefs exactly 1 in
1466
+ 1 book; backslash hrefs 0; duplicate zip entries 0 books; encryption.xml
1467
+ in 86 books carrying 429 entries (426 Adobe `ns.adobe.com/pdf/enc#RC`
1468
+ obfuscation + 3 IDPF `2008/embedding`), and NO real-DRM algorithm
1469
+ anywhere in the library.)*
1470
+ - [x] **Obfuscation magic-byte validation**: confirm the IDPF
1427
1471
  (`2008/embedding`) and Adobe (`pdf/enc#RC`) font prefixes at offset 0
1428
1472
  against real samples before the encryption-repair box opens.
1473
+ *(done 2026-09-12, same pass: all 424 readable obfuscation-declared
1474
+ fonts carry scrambled, non-font bytes at offset 0 (no OTTO/ttf/ttc/
1475
+ woff magic anywhere) — every in-library obfuscated font is genuinely
1476
+ obfuscated, so the "decrypted in place" stale-entry class has ZERO
1477
+ prevalence here; the valid-magic-at-offset-0 test is validated as the
1478
+ right discriminator for the repair's precondition. Two CipherReference
1479
+ targets are absent from their archives. Real-sample correction: the
1480
+ Adobe algorithm URI in the wild is `http://ns.adobe.com/pdf/enc#RC`,
1481
+ not the spec-text `http://www.adobe.com/2005/pdf/enc#RC`; recognition
1482
+ must carry both.)*
1429
1483
 
1430
1484
  ### B. The repairs (each: opt-in flag, dry-run default, gate-accepted)
1431
1485
 
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+ """Build the Bindery Repair Calibre plugin zip from the repo tree.
3
+
4
+ The plugin vendors bindery-cli's gate-safe repair core: transforms.py,
5
+ epub.py, pagination.py, watermark.py, reserialize.py (byte-identical copies;
6
+ the zip root is a package, so their relative imports resolve unchanged) plus
7
+ plugin/__init__.py with the version tuple substituted from
8
+ src/bindery/__init__.py. A `plugin-import-name-bindery_repair.txt` marker
9
+ names the import package calibre_plugins.bindery_repair.
10
+
11
+ stdlib only; a release-time tool (publish.yml runs it against the tag), not
12
+ part of the repair contract.
13
+
14
+ Example:
15
+ python scripts/build_plugin.py --out dist/BinderyRepair-v0.36.0.zip
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import re
22
+ import sys
23
+ import zipfile
24
+ from pathlib import Path
25
+
26
+ REPO = Path(__file__).resolve().parent.parent
27
+ PLUGIN_ENTRY = REPO / "plugin" / "__init__.py"
28
+ VENDOR_MODULES = (
29
+ "transforms.py",
30
+ "epub.py",
31
+ "pagination.py",
32
+ "watermark.py",
33
+ "reserialize.py",
34
+ )
35
+ IMPORT_NAME_MARKER = "plugin-import-name-bindery_repair.txt"
36
+
37
+ # Keep in sync with __PLUGIN_VERSION__ in plugin/__init__.py.
38
+ _VERSION_LINE = re.compile(r"^__PLUGIN_VERSION__ = .*$", re.MULTILINE)
39
+
40
+
41
+ def repo_version() -> tuple[int, int, int]:
42
+ """The single source of truth: src/bindery/__init__.py's VERSION."""
43
+ text = (REPO / "src" / "bindery" / "__init__.py").read_text(encoding="utf-8")
44
+ match = re.search(r'^VERSION = "(\d+)\.(\d+)\.(\d+)"$', text, re.MULTILINE)
45
+ if not match:
46
+ sys.exit('error: no VERSION = "X.Y.Z" line in src/bindery/__init__.py')
47
+ return tuple(int(part) for part in match.groups())
48
+
49
+
50
+ def plugin_entry_bytes(version: tuple[int, int, int]) -> bytes:
51
+ text = PLUGIN_ENTRY.read_text(encoding="utf-8")
52
+ if "__PLUGIN_VERSION__ = " not in text:
53
+ sys.exit("error: plugin/__init__.py carries no __PLUGIN_VERSION__ line")
54
+ substituted = _VERSION_LINE.sub(f"__PLUGIN_VERSION__ = {version!r}", text, count=1)
55
+ return substituted.encode("utf-8")
56
+
57
+
58
+ def build(out: Path) -> Path:
59
+ version = repo_version()
60
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
61
+ z.writestr(IMPORT_NAME_MARKER, "bindery_repair\n")
62
+ z.writestr("__init__.py", plugin_entry_bytes(version))
63
+ for name in VENDOR_MODULES:
64
+ src = REPO / "src" / "bindery" / name
65
+ z.writestr(name, src.read_bytes())
66
+ return out
67
+
68
+
69
+ def main() -> int:
70
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
71
+ ap.add_argument(
72
+ "--out",
73
+ type=Path,
74
+ default=None,
75
+ help="output zip path (default: dist/BinderyRepair-v<VERSION>.zip)",
76
+ )
77
+ args = ap.parse_args()
78
+ version = repo_version()
79
+ out = (
80
+ args.out or REPO / "dist" / f"BinderyRepair-v{'.'.join(map(str, version))}.zip"
81
+ )
82
+ out.parent.mkdir(parents=True, exist_ok=True)
83
+ build(out)
84
+ print(f"wrote {out} (Bindery Repair v{'.'.join(map(str, version))})")
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ sys.exit(main())
@@ -405,6 +405,52 @@ replacement contract is untouched. The scope refusal is mechanical: no `--ids`,
405
405
  no sweep, exit 2 (a library-wide sweep is a dedicated hours-long task, never a
406
406
  verb call). Books left partial or unreadable surface as `decisions_needed`.
407
407
 
408
+ ## The Calibre plugin (Bindery Repair)
409
+
410
+ Each release vendors the repair core into a Calibre plugin zip,
411
+ `BinderyRepair-v<VERSION>.zip`, generated from the tagged tree by
412
+ `scripts/build_plugin.py` and attached to the GitHub release. The zip carries
413
+ `transforms.py`, `epub.py`, `pagination.py`, `watermark.py`, and
414
+ `reserialize.py` byte-identical to `src/bindery/` (the zip root is a package,
415
+ so their relative imports resolve unchanged; a suite drift test pins the
416
+ equality) plus the plugin entry `plugin/__init__.py` with the version tuple
417
+ substituted from the single-source `VERSION`.
418
+
419
+ Identity and shape: the plugin is `Bindery Repair` (import name
420
+ `bindery_repair`, via the `plugin-import-name-` marker), a
421
+ `FileTypePlugin` with `on_import = True`, `supported_platforms = ['linux']`
422
+ (the loader rejects an empty platform list, which also makes other OSes an
423
+ enforced non-goal).
424
+
425
+ The active fix set is exactly the CLI's default pass: the five
426
+ well-formedness transforms plus the NCX pipeline. All structural repairs and
427
+ the three lossy strips stay CLI-only: their acceptance IS the epubcheck gate,
428
+ which cannot run inside Calibre. Opt-in flags are never enabled by the
429
+ plugin; nothing runs ungated.
430
+
431
+ Behavior contract:
432
+
433
+ - `run(path)` never raises. It returns the path of a repaired copy built with
434
+ the plugin's `temporary_file()` (Calibre imports that instead; the original
435
+ on disk is untouched, `metadata.db` is never written, there is no
436
+ `postimport` hook), or the original path on any trouble: non-`.epub`
437
+ suffix, over the size cap, zero fixes, a failed `testzip()` verification of
438
+ the rewritten archive, or an exception (each logged, never raised).
439
+ - Byte-idempotence: an already-clean book yields zero fixes and the original
440
+ path, because the format-add path (`db.add_format(..., run_hooks=True)`)
441
+ re-enters the plugin on repaired files.
442
+ - One log line per book (fixed / no fixes / refused / errored), appended
443
+ under a lock-free best-effort policy: logging failures never break an
444
+ import.
445
+ - Configuration is the `site_customization` string parsed as JSON: `log`
446
+ (default true), `log_path` (default `<config_dir>/bindery_repair.log`),
447
+ `max_size_mb` (default 150: refuse absurd files rather than stall an
448
+ import), and `epubcheck_path` (default null). `epubcheck_path` enables the
449
+ experimental on-PATH validation mode: the repaired copy is re-measured with
450
+ the user's epubcheck binary under the `no_worse` bar and refused on a
451
+ regression; an unanswered measurement never refuses. The mode is off by
452
+ default (the latency ruling stands; the default pass is safe ungated).
453
+
408
454
  ## Out of scope (non-goals)
409
455
 
410
456
  - Fixing RSC-005 schema/content-model violations *in bulk*: the scoped, opt-in
@@ -4,7 +4,7 @@ strip), epubcheck-gated.
4
4
  Single source of truth for the version. pyproject.toml must match VERSION below.
5
5
  """
6
6
 
7
- VERSION = "0.36.0"
7
+ VERSION = "0.37.0"
8
8
  __version__ = VERSION
9
9
 
10
10
  from .epub import RepairReport, repair_epub # noqa: E402
@@ -333,9 +333,16 @@ def fix_ncx_playorder(s: str) -> tuple[str, int]:
333
333
 
334
334
  def repl(m: re.Match) -> str:
335
335
  nonlocal count, playorder
336
- if m.group(2) != str(playorder):
336
+ # the captured value carries its quotes; strip them before comparing,
337
+ # or every already-correct navPoint counts as a fix on every pass
338
+ # (identical output bytes, phantom counts; found by the plugin work,
339
+ # 2026-09-12). A correct value is returned untouched, so a
340
+ # single-quoted correct attribute keeps its quote style.
341
+ if m.group(2).strip("\"'") == str(playorder):
342
+ res = m.group(0)
343
+ else:
337
344
  count += 1
338
- res = f'{m.group(1)} playOrder="{playorder}"'
345
+ res = f'{m.group(1)} playOrder="{playorder}"'
339
346
  playorder += 1
340
347
  return res
341
348
 
@@ -536,8 +536,8 @@ class TestApplyFailureIsolation(unittest.TestCase):
536
536
  def test_apply_oserror_is_recorded_and_run_continues(self):
537
537
  with tempfile.TemporaryDirectory() as td:
538
538
  root = Path(td)
539
- _phase1_book(root / "a.epub", broken=False)
540
- _phase1_book(root / "b.epub", broken=False)
539
+ _phase1_book(root / "a.epub", broken=False, real_fix=True)
540
+ _phase1_book(root / "b.epub", broken=False, real_fix=True)
541
541
  jout = root / "lib.json"
542
542
  out, err = io.StringIO(), io.StringIO()
543
543
  with (
@@ -902,10 +902,13 @@ class TestAuditJsonWiring(unittest.TestCase):
902
902
  run.assert_not_called()
903
903
 
904
904
 
905
- def _phase1_book(path: Path, broken: bool) -> None:
905
+ def _phase1_book(path: Path, broken: bool, real_fix: bool = False) -> None:
906
906
  """A realistic loose EPUB: proper container/OPF/NCX, >2000 chars of body
907
907
  text so the audit battery stays silent on it. `broken` leaves an unclosed
908
- <p> that only --reserialize can repair (epubcheck fatal, audit-clean)."""
908
+ <p> that only --reserialize can repair (epubcheck fatal, audit-clean).
909
+ `real_fix` writes a genuinely unordered NCX playOrder so the default pass
910
+ reports a real fix (the 2026-09-12 playOrder counter fix made a clean
911
+ book a true noop, so tests exercising the apply path need this)."""
909
912
  container = (
910
913
  '<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
911
914
  '<rootfiles><rootfile full-path="content.opf" '
@@ -921,11 +924,12 @@ def _phase1_book(path: Path, broken: bool) -> None:
921
924
  '<item id="ncx" href="t.ncx" media-type="application/x-dtbncx+xml"/>'
922
925
  '</manifest><spine toc="ncx"><itemref idref="c1"/></spine></package>'
923
926
  )
927
+ playorder_attr = 'playOrder="7"' if real_fix else 'playOrder="1"'
924
928
  ncx = (
925
929
  '<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">'
926
930
  '<head><meta name="dtb:uid" content="x"/></head>'
927
931
  "<docTitle><text>T</text></docTitle><navMap>"
928
- '<navPoint id="n1" playOrder="1"><navLabel><text>C1</text></navLabel>'
932
+ f'<navPoint id="n1" {playorder_attr}><navLabel><text>C1</text></navLabel>'
929
933
  '<content src="t.xhtml"/></navPoint></navMap></ncx>'
930
934
  )
931
935
  if broken:
@@ -0,0 +1,360 @@
1
+ """The Bindery Repair Calibre plugin: zip build drift, import shape, and
2
+ run() behavior under a stubbed calibre namespace.
3
+
4
+ The plugin vendors the repair core's five modules byte-identically; the
5
+ loader (calibre/customize/zipplugin.py) imports every zip-root .py as a
6
+ submodule of the plugin package, which is exactly the import shape these
7
+ tests install, so behavior tested here is behavior that ships."""
8
+
9
+ import importlib.util
10
+ import json
11
+ import pathlib
12
+ import sys
13
+ import tempfile
14
+ import types
15
+ import unittest
16
+ import zipfile
17
+ from collections import namedtuple
18
+
19
+ REPO = pathlib.Path(__file__).resolve().parents[1]
20
+ PLUGIN_DIR = REPO / "plugin"
21
+ _Counts = namedtuple("_Counts", "fatals errors warnings")
22
+
23
+ VENDOR_MODULES = (
24
+ "transforms.py",
25
+ "epub.py",
26
+ "pagination.py",
27
+ "watermark.py",
28
+ "reserialize.py",
29
+ )
30
+
31
+
32
+ def _repo_version() -> tuple[int, int, int]:
33
+ text = (REPO / "src" / "bindery" / "__init__.py").read_text(encoding="utf-8")
34
+ prefix = 'VERSION = "'
35
+ start = text.index(prefix) + len(prefix)
36
+ end = text.index('"', start)
37
+ return tuple(int(part) for part in text[start:end].split("."))
38
+
39
+
40
+ _PTF_DIR: tempfile.TemporaryDirectory | None = None
41
+
42
+
43
+ class _StubPersistentTemporaryFile:
44
+ """The slice of calibre.ptempfile.PersistentTemporaryFile the plugin
45
+ uses: a context manager handing out a real path that survives close()
46
+ and the with-block (run() returns the path for Calibre to import; only
47
+ interpreter shutdown cleans it)."""
48
+
49
+ def __init__(self, suffix):
50
+ fd = tempfile.NamedTemporaryFile(suffix=suffix, delete=False, dir=_ptf_dir())
51
+ self.name = fd.name
52
+ fd.close()
53
+
54
+ def __enter__(self):
55
+ return self
56
+
57
+ def __exit__(self, *args):
58
+ pass
59
+
60
+
61
+ def _ptf_dir() -> str:
62
+ return _PTF_DIR.name if _PTF_DIR is not None else tempfile.gettempdir()
63
+
64
+
65
+ def _install_calibre_stubs(config_dir: str) -> None:
66
+ cal = types.ModuleType("calibre")
67
+ customize = types.ModuleType("calibre.customize")
68
+ constants = types.ModuleType("calibre.constants")
69
+ ptemp = types.ModuleType("calibre.ptempfile")
70
+
71
+ class FileTypePlugin:
72
+ def __init__(self, plugin_path):
73
+ self.plugin_path = plugin_path
74
+ self.site_customization = None
75
+
76
+ def temporary_file(self, suffix):
77
+ return _StubPersistentTemporaryFile(suffix)
78
+
79
+ customize.FileTypePlugin = FileTypePlugin
80
+ constants.config_dir = config_dir
81
+ ptemp.PersistentTemporaryFile = _StubPersistentTemporaryFile
82
+ cal.customize = customize
83
+ cal.constants = constants
84
+ cal.ptempfile = ptemp
85
+ for name, mod in (
86
+ ("calibre", cal),
87
+ ("calibre.customize", customize),
88
+ ("calibre.constants", constants),
89
+ ("calibre.ptempfile", ptemp),
90
+ ):
91
+ sys.modules.setdefault(name, mod)
92
+
93
+
94
+ def _load_plugin_package():
95
+ """Import plugin/__init__.py exactly the way calibre's loader does: as
96
+ the package calibre_plugins.bindery_repair, with the vendored modules
97
+ resolvable as its submodules (plugin dir first, then src/bindery)."""
98
+ _install_calibre_stubs(tempfile.gettempdir())
99
+ pkg = types.ModuleType("calibre_plugins")
100
+ sys.modules.setdefault("calibre_plugins", pkg)
101
+ spec = importlib.util.spec_from_file_location(
102
+ "calibre_plugins.bindery_repair",
103
+ PLUGIN_DIR / "__init__.py",
104
+ submodule_search_locations=[str(PLUGIN_DIR), str(REPO / "src" / "bindery")],
105
+ )
106
+ module = importlib.util.module_from_spec(spec)
107
+ sys.modules["calibre_plugins.bindery_repair"] = module
108
+ spec.loader.exec_module(module)
109
+ return module
110
+
111
+
112
+ class TestPluginBuild(unittest.TestCase):
113
+ """The zip the release attaches is a pure function of the repo tree."""
114
+
115
+ @classmethod
116
+ def setUpClass(cls):
117
+ sys.path.insert(0, str(REPO / "scripts"))
118
+ import build_plugin
119
+
120
+ cls.build_plugin = build_plugin
121
+ cls.tmp = tempfile.TemporaryDirectory(prefix="bindery_plugin_")
122
+ cls.zip_path = pathlib.Path(cls.tmp.name) / "test-plugin.zip"
123
+ build_plugin.build(cls.zip_path)
124
+
125
+ @classmethod
126
+ def tearDownClass(cls):
127
+ cls.tmp.cleanup()
128
+ sys.path.remove(str(REPO / "scripts"))
129
+
130
+ def _entry(self, name: str) -> bytes:
131
+ with zipfile.ZipFile(self.zip_path) as z:
132
+ return z.read(name)
133
+
134
+ def test_vendor_modules_are_byte_identical(self):
135
+ for name in VENDOR_MODULES:
136
+ self.assertEqual(
137
+ self._entry(name),
138
+ (REPO / "src" / "bindery" / name).read_bytes(),
139
+ name,
140
+ )
141
+
142
+ def test_entry_is_the_plugin_source_with_the_repo_version(self):
143
+ raw = self._entry("__init__.py").decode("utf-8")
144
+ version = _repo_version()
145
+ self.assertIn(f"__PLUGIN_VERSION__ = {version!r}", raw)
146
+ committed = (PLUGIN_DIR / "__init__.py").read_text(encoding="utf-8")
147
+ # the only permitted difference is the substituted version line
148
+ self.assertEqual(
149
+ raw,
150
+ self.build_plugin._VERSION_LINE.sub(
151
+ f"__PLUGIN_VERSION__ = {version!r}", committed, count=1
152
+ ),
153
+ )
154
+
155
+ def test_import_name_marker_and_zip_layout(self):
156
+ self.assertEqual(
157
+ self._entry("plugin-import-name-bindery_repair.txt"),
158
+ b"bindery_repair\n",
159
+ )
160
+ with zipfile.ZipFile(self.zip_path) as z:
161
+ names = set(z.namelist())
162
+ self.assertIn("__init__.py", names)
163
+ self.assertTrue(set(VENDOR_MODULES) <= names)
164
+
165
+ def test_default_output_name_carries_the_version(self):
166
+ version = ".".join(str(part) for part in _repo_version())
167
+ self.assertEqual(self.build_plugin.repo_version(), _repo_version())
168
+ out = self.build_plugin.REPO / "dist" / f"BinderyRepair-v{version}.zip"
169
+ self.assertEqual(out.parent, self.build_plugin.REPO / "dist")
170
+ self.assertTrue(out.name.startswith("BinderyRepair-v"))
171
+
172
+
173
+ class TestPluginRun(unittest.TestCase):
174
+ """run() behavior against synthetic EPUBs, under the calibre stubs."""
175
+
176
+ CONTAINER = (
177
+ '<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
178
+ '<rootfiles><rootfile full-path="content.opf" '
179
+ 'media-type="application/oebps-package+xml"/></rootfiles></container>'
180
+ )
181
+ OPF = (
182
+ '<package xmlns="http://www.idpf.org/2007/opf">'
183
+ "<manifest>"
184
+ '<item id="c1" href="text.xhtml" media-type="application/xhtml+xml"/>'
185
+ "</manifest>"
186
+ '<spine><itemref idref="c1"/></spine></package>'
187
+ )
188
+
189
+ @classmethod
190
+ def setUpClass(cls):
191
+ global _PTF_DIR
192
+ _PTF_DIR = tempfile.TemporaryDirectory(prefix="bindery_plugin_tmp_")
193
+ cls._ptf_dir = _PTF_DIR
194
+ cls.plugin_module = _load_plugin_package()
195
+
196
+ @classmethod
197
+ def tearDownClass(cls):
198
+ cls._ptf_dir.cleanup()
199
+
200
+ def setUp(self):
201
+ self.tmp = tempfile.TemporaryDirectory(prefix="bindery_plugin_run_")
202
+ self.addCleanup(self.tmp.cleanup)
203
+ self.plugin = self.plugin_module.BinderyRepair("/tmp/not-a-real-plugin.zip")
204
+ self.plugin.site_customization = json.dumps(
205
+ {"log_path": str(pathlib.Path(self.tmp.name) / "plugin.log")}
206
+ )
207
+
208
+ def _epub(self, name="t.epub", doc=None, pad=0):
209
+ # doc is the FULL content-document text; None builds a clean doc
210
+ if doc is None:
211
+ doc = "<html><body><p>ordinary prose</p></body></html>"
212
+ p = pathlib.Path(self.tmp.name) / name
213
+ with zipfile.ZipFile(p, "w") as z:
214
+ z.writestr("mimetype", "application/epub+zip")
215
+ z.writestr("META-INF/container.xml", self.CONTAINER)
216
+ z.writestr("content.opf", self.OPF)
217
+ z.writestr("text.xhtml", doc)
218
+ if pad:
219
+ z.writestr("filler.bin", b"\0" * pad)
220
+ return p
221
+
222
+ def _log(self) -> str:
223
+ f = pathlib.Path(self.tmp.name) / "plugin.log"
224
+ return f.read_text() if f.exists() else ""
225
+
226
+ def test_damaged_book_is_repaired_and_original_untouched(self):
227
+ # a BOM before the first < is prolog junk: one default-pass fix
228
+ p = pathlib.Path(self.tmp.name) / "d.epub"
229
+ with zipfile.ZipFile(p, "w") as z:
230
+ z.writestr("mimetype", "application/epub+zip")
231
+ z.writestr("META-INF/container.xml", self.CONTAINER)
232
+ z.writestr("content.opf", self.OPF)
233
+ z.writestr(
234
+ "text.xhtml",
235
+ "\ufeff<html><body><p>prolog junk here</p></body></html>",
236
+ )
237
+ before = p.read_bytes()
238
+ out = self.plugin.run(str(p))
239
+ self.assertNotEqual(out, str(p))
240
+ self.assertTrue(pathlib.Path(out).exists())
241
+ self.assertEqual(p.read_bytes(), before) # the original is never touched
242
+ with zipfile.ZipFile(out) as z:
243
+ self.assertIsNone(z.testzip())
244
+ self.assertIn("prolog junk here", z.read("text.xhtml").decode("utf-8"))
245
+ self.assertIn("fixed:", self._log())
246
+
247
+ def test_clean_book_returns_original_byte_for_byte(self):
248
+ p = self._epub()
249
+ out = self.plugin.run(str(p))
250
+ self.assertEqual(out, str(p))
251
+ self.assertIn("no fixes:", self._log())
252
+
253
+ def test_idempotent_second_run_reports_no_fixes(self):
254
+ p = self._epub(doc="\ufeff<html><body><p>junk</p></body></html>")
255
+ first = self.plugin.run(str(p))
256
+ repaired = pathlib.Path(first).read_bytes()
257
+ second = self.plugin.run(first)
258
+ self.assertEqual(second, first)
259
+ self.assertIn("no fixes:", self._log())
260
+ # and the repaired file the first run produced is stable
261
+ self.assertEqual(pathlib.Path(first).read_bytes(), repaired)
262
+
263
+ def test_size_cap_refuses_and_returns_original(self):
264
+ self.plugin.site_customization = json.dumps(
265
+ {
266
+ "log_path": str(pathlib.Path(self.tmp.name) / "plugin.log"),
267
+ "max_size_mb": 0.01, # ~10KB: the padded book exceeds it
268
+ }
269
+ )
270
+ p = self._epub(pad=64 * 1024)
271
+ out = self.plugin.run(str(p))
272
+ self.assertEqual(out, str(p))
273
+ self.assertIn("refused:", self._log())
274
+ self.assertIn("cap", self._log())
275
+
276
+ def test_broken_json_config_falls_back_to_defaults(self):
277
+ self.plugin.site_customization = "{not json"
278
+ p = self._epub(doc="\ufeff<html><body><p>junk</p></body></html>")
279
+ out = self.plugin.run(str(p))
280
+ # defaults still repair (log goes to the calibre config dir, which
281
+ # the stub points into a read-safe scratch dir)
282
+ self.assertNotEqual(out, str(p))
283
+
284
+ def test_non_epub_suffix_passes_through(self):
285
+ p = pathlib.Path(self.tmp.name) / "cover.jpg"
286
+ p.write_bytes(b"not an epub at all")
287
+ out = self.plugin.run(str(p))
288
+ self.assertEqual(out, str(p))
289
+ self.assertEqual(self._log(), "")
290
+
291
+ def test_unreadable_zip_errors_and_returns_original(self):
292
+ p = pathlib.Path(self.tmp.name) / "broken.epub"
293
+ p.write_bytes(b"PK\x03\x04 this is not really a zip")
294
+ out = self.plugin.run(str(p))
295
+ self.assertEqual(out, str(p))
296
+ self.assertIn("errored:", self._log())
297
+
298
+ def test_experimental_epubcheck_mode_refuses_a_regression(self):
299
+ p = self._epub(doc="\ufeff<html><body><p>junk</p></body></html>")
300
+ self.plugin.site_customization = json.dumps(
301
+ {
302
+ "log_path": str(pathlib.Path(self.tmp.name) / "plugin.log"),
303
+ "epubcheck_path": "/usr/bin/false",
304
+ }
305
+ )
306
+
307
+ def fake_counts(plugin, path, cmd):
308
+ # the original measures clean, the repaired copy "regresses"
309
+ return _Counts(0, 0, 0) if path == p else _Counts(1, 0, 0)
310
+
311
+ self.plugin_module.BinderyRepair._epubcheck_counts = fake_counts
312
+ self.addCleanup(
313
+ delattr,
314
+ self.plugin_module.BinderyRepair,
315
+ "_epubcheck_counts",
316
+ )
317
+ out = self.plugin.run(str(p))
318
+ self.assertEqual(out, str(p))
319
+ self.assertIn("refused:", self._log())
320
+
321
+ def test_experimental_epubcheck_mode_accepts_no_worse(self):
322
+ p = self._epub(doc="\ufeff<html><body><p>junk</p></body></html>")
323
+ self.plugin.site_customization = json.dumps(
324
+ {
325
+ "log_path": str(pathlib.Path(self.tmp.name) / "plugin.log"),
326
+ "epubcheck_path": "/usr/bin/false",
327
+ }
328
+ )
329
+ self.plugin_module.BinderyRepair._epubcheck_counts = lambda plugin, path, cmd: (
330
+ _Counts(1, 0, 0)
331
+ )
332
+ self.addCleanup(
333
+ delattr,
334
+ self.plugin_module.BinderyRepair,
335
+ "_epubcheck_counts",
336
+ )
337
+ out = self.plugin.run(str(p))
338
+ self.assertNotEqual(out, str(p))
339
+ self.assertIn("fixed:", self._log())
340
+
341
+ def test_experimental_epubcheck_mode_unanswered_check_accepts(self):
342
+ p = self._epub(doc="\ufeff<html><body><p>junk</p></body></html>")
343
+ self.plugin.site_customization = json.dumps(
344
+ {
345
+ "log_path": str(pathlib.Path(self.tmp.name) / "plugin.log"),
346
+ "epubcheck_path": "/usr/bin/false",
347
+ }
348
+ )
349
+ self.plugin_module.BinderyRepair._epubcheck_counts = lambda plugin, path, cmd: (
350
+ None
351
+ )
352
+ self.addCleanup(
353
+ delattr,
354
+ self.plugin_module.BinderyRepair,
355
+ "_epubcheck_counts",
356
+ )
357
+ out = self.plugin.run(str(p))
358
+ # an unanswered measurement never refuses the (safe) default pass
359
+ self.assertNotEqual(out, str(p))
360
+ self.assertIn("fixed:", self._log())
@@ -462,6 +462,33 @@ class TestFixNcxPlayorder(unittest.TestCase):
462
462
  self.assertEqual(out.count('playOrder="1"'), 1)
463
463
  self.assertIn("Chapter playOrder=", out)
464
464
 
465
+ def test_already_sequential_playorder_is_not_counted_as_fixes(self):
466
+ # found by the plugin work (2026-09-12): the already-correct
467
+ # comparison read the attribute value WITH its quotes, so every
468
+ # sequential navPoint counted as a fix on every pass. The rewritten
469
+ # bytes were identical, which is why the deterministic-output tests
470
+ # never caught it; only the fix counts were phantom (59 on a real
471
+ # book, every run), and the plugin's zero-fixes contract broke.
472
+ ncx = "".join(f'<navPoint id="p{i}" playOrder="{i}"/>' for i in range(1, 5))
473
+ out, n = fix_ncx_playorder(ncx)
474
+ self.assertEqual(n, 0)
475
+ self.assertEqual(out, ncx)
476
+
477
+ def test_single_quoted_sequential_playorder_is_untouched(self):
478
+ # the same bug's second face: already-correct markup must stay
479
+ # untouched (single quotes preserved), not be rewritten to doubles
480
+ ncx = "".join(f"<navPoint id='p{i}' playOrder='{i}'/>" for i in range(1, 5))
481
+ out, n = fix_ncx_playorder(ncx)
482
+ self.assertEqual(n, 0)
483
+ self.assertEqual(out, ncx)
484
+
485
+ def test_resequence_count_still_counts_real_changes(self):
486
+ ncx = '<navPoint playOrder="2"/><navPoint playOrder="2"/>'
487
+ out, n = fix_ncx_playorder(ncx)
488
+ self.assertEqual(n, 1) # the first moves 2->1; the second is right
489
+ self.assertIn('playOrder="1"', out)
490
+ self.assertIn('playOrder="2"', out)
491
+
465
492
 
466
493
  class TestStripInvalidValue(unittest.TestCase):
467
494
  def test_misplaced_value_is_stripped(self):
@@ -4,7 +4,7 @@ requires-python = ">=3.14"
4
4
 
5
5
  [[package]]
6
6
  name = "bindery-cli"
7
- version = "0.35.0"
7
+ version = "0.37.0"
8
8
  source = { editable = "." }
9
9
  dependencies = [
10
10
  { name = "cquarry" },
File without changes
File without changes
File without changes
File without changes
File without changes