bindery-cli 0.30.0__tar.gz → 0.31.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 (74) hide show
  1. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/CLAUDE.md +1 -1
  2. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/PKG-INFO +2 -2
  3. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/patchnotes.md +13 -0
  4. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/pyproject.toml +2 -2
  5. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/roadmap.md +310 -2
  6. bindery_cli-0.31.0/scripts/FastSweepExtract.java +44 -0
  7. bindery_cli-0.31.0/scripts/sweep.sh +34 -0
  8. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/src/bindery/__init__.py +1 -1
  9. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/src/bindery/library.py +9 -8
  10. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/tests/test_library.py +5 -4
  11. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/uv.lock +5 -5
  12. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/.github/workflows/ci.yml +0 -0
  13. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/.github/workflows/publish.yml +0 -0
  14. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/.gitignore +0 -0
  15. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/AGENTS.md +0 -0
  16. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/LICENSE +0 -0
  17. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/README.md +0 -0
  18. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/docs/screenshots/library-sweep.png +0 -0
  19. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/logo.svg +0 -0
  20. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/run_tests.sh +0 -0
  21. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/scripts/FastSweep.java +0 -0
  22. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/scripts/fast_sweep.py +0 -0
  23. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/scripts/find_block_in_inline.py +0 -0
  24. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/scripts/find_css_illegal_tags.py +0 -0
  25. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/scripts/find_duplicate_playorder.py +0 -0
  26. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/scripts/find_empty_body.py +0 -0
  27. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/scripts/find_illegal_tags.py +0 -0
  28. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/scripts/find_invalid_ids.py +0 -0
  29. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/scripts/find_invalid_st_tag.py +0 -0
  30. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/scripts/find_invalid_value_attr.py +0 -0
  31. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/scripts/find_missing_images.py +0 -0
  32. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/scripts/find_missing_title.py +0 -0
  33. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/spec.md +0 -0
  34. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/src/bindery/__main__.py +0 -0
  35. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/src/bindery/audit.py +0 -0
  36. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/src/bindery/cli.py +0 -0
  37. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/src/bindery/epub.py +0 -0
  38. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/src/bindery/pagination.py +0 -0
  39. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/src/bindery/reserialize.py +0 -0
  40. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/src/bindery/transforms.py +0 -0
  41. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/src/bindery/validate.py +0 -0
  42. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/src/bindery/watermark.py +0 -0
  43. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/agda.epub +0 -0
  44. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/agda_fixed.epub +0 -0
  45. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/last_man_out.epub +0 -0
  46. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/last_man_out_fixed.epub +0 -0
  47. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/leaves_of_grass.epub +0 -0
  48. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/leaves_of_grass_fixed.epub +0 -0
  49. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/leaves_of_grass_st_fixed.epub +0 -0
  50. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/leaves_of_grass_tags_fixed.epub +0 -0
  51. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/muse_of_nightmares.epub +0 -0
  52. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/muse_of_nightmares_fixed.epub +0 -0
  53. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/poor_mans_fight.epub +0 -0
  54. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/poor_mans_fight_fixed.epub +0 -0
  55. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/test_block_in_inline.py +0 -0
  56. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/test_duplicate_playorder.py +0 -0
  57. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/test_empty_body.py +0 -0
  58. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/test_illegal_tags.py +0 -0
  59. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/test_invalid_ids.py +0 -0
  60. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/test_invalid_value.py +0 -0
  61. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/test_missing_title.py +0 -0
  62. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/unworthy_gods.epub +0 -0
  63. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/test_facility/unworthy_gods_fixed.epub +0 -0
  64. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/testing_facility/books_to_fix/.gitkeep +0 -0
  65. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/testing_facility/top500candidates/REPORT.md +0 -0
  66. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/tests/test_audit.py +0 -0
  67. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/tests/test_cli.py +0 -0
  68. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/tests/test_epub.py +0 -0
  69. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/tests/test_pagination.py +0 -0
  70. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/tests/test_reserialize.py +0 -0
  71. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/tests/test_transforms.py +0 -0
  72. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/tests/test_validate.py +0 -0
  73. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/tests/test_version.py +0 -0
  74. {bindery_cli-0.30.0 → bindery_cli-0.31.0}/tests/test_watermark.py +0 -0
@@ -66,7 +66,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
66
66
  - `src/bindery/epub.py`: archive rewrite, NCX uid sync, RepairReport, mismatch detection, and the opt-in structural-repair plumbing (including the CSS precondition scan).
67
67
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
68
68
  - `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` (remove+add in one `batch()`; `add_format` refuses duplicates); the external `calibredb` CLI is gone. Never reintroduce directory-name guessing as the primary source: renamed/mismatched directories would replace the wrong book.
69
- - `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).
69
+ - `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).
70
70
  - `tests/`: transforms, end-to-end repair, atomic replace, pagination, watermarks, the audit analyzers (content/pagenumbers/emptytext/ocr/monolithic, archive integrity, spine classification), the library sweep and `CalibreIdResolver`, validate, reserialize, CLI wiring, and the version pin.
71
71
 
72
72
  ## Conventions
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.30.0
3
+ Version: 0.31.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
@@ -13,7 +13,7 @@ Classifier: License :: OSI Approved :: MIT License
13
13
  Classifier: Programming Language :: Python :: 3.14
14
14
  Classifier: Topic :: Utilities
15
15
  Requires-Python: >=3.14
16
- Requires-Dist: cquarry>=1.9.0
16
+ Requires-Dist: cquarry>=1.17.0
17
17
  Requires-Dist: tqdm
18
18
  Requires-Dist: vir-tui>=2.2.0
19
19
  Provides-Extra: reserialize
@@ -1,4 +1,17 @@
1
1
  # bindery-cli Patch Notes
2
+ ## v0.31.0 (2026-09-09)
3
+
4
+ ### Cascade: install through cquarry 1.17's set_format
5
+
6
+ - **`--install-to-calibre` replaces the format row through
7
+ `WritableCalibreDB.set_format`** (cquarry 1.17's sanctioned remove+add
8
+ in one transaction) instead of composing `remove_format` + `add_format`
9
+ in a `batch()` by hand. Same contract as before: same `data.name`,
10
+ truthful size, OPF-resync queue, and a database failure still degrades
11
+ to the in-place save with a warning. The cquarry floor moves from
12
+ >=1.9.0 to **>=1.17.0** (`uv.lock` re-resolved), and the failure-mode
13
+ test now injects through `set_format`.
14
+
2
15
  ## v0.30.0 (2026-09-06)
3
16
 
4
17
  ### Phase 8 stretch: concurrent sweep workers
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.30.0"
7
+ version = "0.31.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 = [
@@ -27,7 +27,7 @@ authors = [
27
27
  dependencies = [
28
28
  "tqdm",
29
29
  "vir-tui>=2.2.0",
30
- "cquarry>=1.9.0",
30
+ "cquarry>=1.17.0",
31
31
  ]
32
32
 
33
33
  [project.optional-dependencies]
@@ -609,6 +609,58 @@ hand-rolled zipfile sweep.
609
609
  *(Fixed in v0.23.1; RETIRED entirely by Phase 11 in v0.24.0 — the calibredb
610
610
  subprocess is gone, so the crash class no longer exists.)*
611
611
 
612
+ ### watermark anchored pass can delete whole paragraphs of real prose (2026-09-08 sweep)
613
+ **Bug in `--strip-watermarks`:** the anchored stamp regex
614
+ `<a\b[^>]*?oceanofpdf[^>]*?>.*?</a>` is DOTALL with no tag budget, so an
615
+ unclosed stamp anchor (exactly the kind of broken book this tool repairs)
616
+ swallows everything up to the next unrelated `</a>`, and when no pure wrapper
617
+ exists the fallback deletes the entire match. Demonstrated: two paragraphs of
618
+ real content vanish with count 1; the output is well-formed with equal or
619
+ fewer fatals, so the `no_worse` bar accepts it. `watermark.py:207-213`.
620
+ **Fix:** require the anchored match to be tag-free (or stamp-length visible
621
+ text) before falling back to whole-match deletion; anything larger goes to
622
+ `decisions_needed` for manual repair. *(Unfixed as of the 2026-09-08 sweep.)*
623
+
624
+ ### roman-numeral detector reads ordinary English words as page numbers (2026-09-08 sweep)
625
+ **Bug in `--strip-pagination`:** `_ROMAN_RE = [ivxlcdm]{2,7}\Z` (IGNORECASE)
626
+ matches words like `mid`, `dim`, `mix`, `lid`, `civil` (`number_value("mid")
627
+ = 1499`), so a standalone `<p>mid</p>` followed by a lowercase-starting
628
+ paragraph is a confident page-number hit and is deleted and merged away.
629
+ epubcheck is blind to deleted text, so `no_worse` passes. `pagination.py:44`,
630
+ `70-78`, `180-184`.
631
+ **Fix:** require uppercase romans, or validate with a strict roman grammar
632
+ (`M?(D?C{0,3}|C[MD])(X?L{0,3}|L?X{0,3})(I?V{0,3}|V?I{0,3})`). *(Unfixed as of
633
+ the 2026-09-08 sweep.)*
634
+
635
+ ### install_format can write a wrong size into metadata.db for stray files (2026-09-08 sweep)
636
+ **Bug in `library --apply --install-to-calibre` without `--id`:**
637
+ `iter_epubs` yields uncatalogued stray `.epub` files inside book dirs; the
638
+ resolver returns None, the `(id)` directory-name guess fires, and the
639
+ remove+add batch re-registers the catalogued format name with the stray
640
+ file's size. Demonstrated on a synthetic library: the catalogued 999-byte
641
+ file untouched on disk while `data` recorded 8123 bytes. Silent,
642
+ order-dependent catalog corruption; a stale `(N)` directory for a deleted
643
+ book instead raises an uncaught `TypeError` at the `book["path"]` access.
644
+ `library.py:169-231`.
645
+ **Fix:** when the id came from the directory guess, verify the target
646
+ matches the DB's book path and format name before the batch; otherwise save
647
+ in place and warn without touching the row, and catch the `TypeError` path.
648
+ *(Unfixed as of the 2026-09-08 sweep.)*
649
+
650
+ ### EPUB2-targeted structural fixes fire on EPUB3 books under --all (2026-09-08 sweep)
651
+ **Bug in `--strip-epub3-attrs` / `--downgrade-epub3-tags` via the run verbs
652
+ and `--all`:** neither fix checks `package@version`, so every EPUB3 book in
653
+ the sweep gets `epub:type`/`aria-label`/`page-progression-direction` stripped
654
+ and EPUB3 tags downgraded. Two demonstrated outcomes: (a) a repairable EPUB3
655
+ book gains a net-new error from stripping `epub:type="toc"` off the nav and
656
+ the whole repair is rejected as a regression; (b) a legal count-neutral
657
+ `epub:type="chapter"` is silently removed because the gate only sees
658
+ epubcheck counts. phase1 and phase3 always pass `--all`.
659
+ `epub.py:251-260`, `903-937`, `957-967`; `cli.py:830`, `976-987`.
660
+ **Fix:** gate both fixes on the package version carried in `opf_text`, or
661
+ make them finding-driven (only strip what the before-pass epubcheck flagged).
662
+ *(Unfixed as of the 2026-09-08 sweep.)*
663
+
612
664
  ## Phase 11: Migrate install-to-calibre to native cquarry API (proposed 2026-08-31)
613
665
 
614
666
  *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.*
@@ -715,7 +767,7 @@ classes, no behavior change to existing modes.*
715
767
  Exit codes 0/1/2 per the `library` contract. No DRM scan, no duplicate
716
768
  screen, no manifest assembly (CalibreQuarry's by the frontend split).
717
769
  *(Shipped in v0.29.0. Two composed stages: the audit battery through its
718
- own --json payload, then ONE fused repair sweep with the --all set — the
770
+ own --json payload, then ONE fused repair sweep with the --all set: the
719
771
  skill ran watermark detection and repairability as two sweeps, the verb
720
772
  pays epubcheck once and reads watermark hits out of the per-book fix
721
773
  summary. Read-only until --apply-lossy; --backup passes through (keep
@@ -732,7 +784,7 @@ classes, no behavior change to existing modes.*
732
784
  the real parser, so its flags cannot drift from the subcommand it wraps.
733
785
  The pre/post summary sums before/after epubcheck counts over the swept
734
786
  books; books left partial or unreadable surface as decisions_needed.
735
- The verb is the apply step by design — the batch was vetted in phase 1,
787
+ The verb is the apply step by design: the batch was vetted in phase 1,
736
788
  and the gate plus the atomic-replacement contract still govern every
737
789
  write.)*
738
790
  - [x] **Non-interactive contract**: every `run` verb takes
@@ -757,3 +809,259 @@ Non-goals: no manifest format ownership (CalibreQuarry owns the
757
809
  `acquisition-manifest` schema and the orchestration; this repo emits reports
758
810
  and consumes id lists); no TUI beyond the existing audit rendering; no PDF/DJVU
759
811
  work (never this repo's charter).
812
+
813
+ ## Phase 14: hardening backlog from the 2026-09-08 audit sweep (proposed 2026-09-08, digging only)
814
+
815
+ *Context: a five-agent adversarial sweep of the whole repo (repair pipeline,
816
+ audit, CLI/library/validate, tests and scripts, docs). No code was changed;
817
+ every finding below was demonstrated against the shipped code, most with a
818
+ live run. The four sharpest bugs are written up under Bug Reports above; this
819
+ phase is the rest of the backlog. Nothing here is committed work: it is the
820
+ map of what the sweep found, for when work resumes.*
821
+
822
+ *Verification postscript (2026-09-08, an independent second batch re-derived
823
+ the five sharpest claims; all five CONFIRMED, with scoping notes): the
824
+ watermark loss is invisible to `no_worse` and can even clear a fatal, so the
825
+ book ships as a success; the window is the standalone `--strip-watermarks`
826
+ flag (the anchorless pass never fallback-deletes, and under `--all`
827
+ `--reserialize` closes the anchor first). The roman deletion needs a
828
+ 120+ char prose neighbor (`PROSE_MIN`) and a lowercase-starting continuation
829
+ paragraph, and no test acknowledges word-shaped romans. The `install_format`
830
+ exposure is exactly directory-mode `--apply --install-to-calibre`; phase3
831
+ passes `--id` and is not exposed. The `fix_id_colons` dangling NCX ref
832
+ survives the gate whenever colon fixes outnumber NCX references (the minimal
833
+ 1:1 case is refused as a noop), and the phantom count additionally triggers
834
+ the re-encode that the untouched-file guard exists to prevent. The EPUB3
835
+ hard reject needs a clean book; in already-error books the attribute strip
836
+ can be net-neutral and ships silently.*
837
+
838
+ ### Repair-pipeline correctness
839
+
840
+ - [ ] **Anchor attribute-name regexes to real start tags.** Three findings
841
+ share one root cause: bare attribute-name regexes that are not
842
+ quote-aware and not tag-anchored. `strip_invalid_value` matches the
843
+ `value` in `data-value="42"` (the `\b` matches between `-` and `v`)
844
+ and rewrites it to a malformed `<span data->`
845
+ (`transforms.py:375`); `_EPUB3_ATTR_RE` is not anchored to tags at all,
846
+ so `strip_epub3_attributes` deletes visible prose like
847
+ `Use epub:type="chapter" here` down to `Use here` (`epub.py:245-248`),
848
+ contradicting that fix's own docstring. Reuse the quote-aware
849
+ start-tag matcher + protected-span machinery the module already has,
850
+ and a `(?<![\w:.-])value` lookbehind.
851
+ - [ ] **Make `fix_id_colons` consistent and honest.** It rewrites fragments
852
+ of external URLs (`http://example.com/page#sec:1` becomes `#sec_1`,
853
+ breaking the link; `transforms.py:524`, `536`, docstring at 509 claims
854
+ otherwise) and rewrites colon-bearing `data-id`/`xml:id`; it never
855
+ translates the NCX's `content src` fragments, so renaming
856
+ manufactures dangling NCX refs end to end
857
+ (`epub.py:976-979` vs the NCX branch at `873-901`); and it counts
858
+ every matched `id` attribute even when no colon was replaced, so
859
+ byte-identical reruns report phantom changes (`transforms.py:513-516`).
860
+ - [ ] **Run anchor stripping last and protect CDATA/comments everywhere.**
861
+ `strip_broken_anchors`' id snapshot predates `unwrap_block_in_inline`
862
+ and `unwrap_illegal_tags`, which can delete ids the snapshot thinks
863
+ exist (`epub.py:980-997`); `strip_broken_tags` and
864
+ `unwrap_illegal_tags` are not wrapped in the module's own
865
+ protected-span machinery, so they edit inside CDATA (rendered text)
866
+ and comments (`transforms.py:444-450`, `619-623`).
867
+ - [ ] **Stop re-encoding non-UTF-8 documents.** Any fix that fires on a
868
+ windows-1252 or UTF-16 document decodes with `replace` and re-encodes
869
+ UTF-8, materializing mojibake under an XML declaration that still
870
+ names the old encoding; usually well-formed, so the gate cannot see
871
+ it (`epub.py:874`, `902`, `939`, `1027`). Detect and skip with a
872
+ report entry, per the project's manual-repair philosophy.
873
+ - [ ] **Scope `--reserialize` to HTML roots.** A broken non-HTML `.xml`
874
+ sidecar gets html5lib's HTML algorithm and comes back
875
+ html/body-wrapped with `ns0:` prefixes, structurally rewritten while
876
+ staying well-formed (`epub.py:938`, `reserialize.py:39-42`). Require
877
+ an `html` root (or manifest media-type) before rebuilding.
878
+ - [ ] **Smaller repair papercuts:** `fix_ncx_playorder` can rewrite
879
+ `playOrder="N"`-shaped text inside nav labels (unanchored pattern,
880
+ `transforms.py:326-328`); mimetype fixes land in `report.fixes`
881
+ without incrementing `files_changed` (`epub.py:837-840`);
882
+ `fix_manifest_ids` is a silent count-0 no-op on single-quoted OPFs
883
+ (`epub.py:59`); `css_protected_tags` misses namespaced
884
+ (`svg|st`) and functional (`:is(st, w)`) selectors, and doesn't scan
885
+ `.xpgt` templates (`transforms.py:419-421`, `epub.py:790-794`); the
886
+ watermark `_norm` doesn't unescape entities so `OceanofPDF.com&nbsp;`
887
+ stamps are missed; drop_duplicate_xmlns/strip_prolog_junk edit inside
888
+ comments; the archive comment field is dropped by the rewrite.
889
+
890
+ ### audit.py correctness
891
+
892
+ - [ ] **Read nav/NCX inside the open zip and resolve NCX srcs against the
893
+ NCX's own directory.** The post-close `_read` of a manifest-declared
894
+ but absent nav/NCX poisons `Book.corrupt`, so an otherwise healthy
895
+ book with a leftover `toc.ncx` manifest entry is branded CORRUPT
896
+ "re-source" (`audit.py:252-272`, `_read` at `173-177`). And `full()`
897
+ resolves NCX `content src` against the OPF's directory, not the NCX's,
898
+ so a spec-compliant nested NCX counts every target absent and can
899
+ reach the FRAGMENT "quarantine" alarm (`audit.py:187`, `205-213`,
900
+ `249`, `263-272`). Also `html.unescape` captured ToC hrefs, which are
901
+ currently compared raw against archive names (`audit.py:258`, `268-272`).
902
+ - [ ] **Make archive/spine first-class in library mode.** `run_library`'s
903
+ report loop iterates the content-analyzer tuple only, so the
904
+ `archive`/`spine` branches and their section builders are dead code:
905
+ a CRC-corrupt book prints `emptytext CLEAN` and exits 0 in library
906
+ mode while directory mode says CORRUPT and exits 1, contradicting the
907
+ v0.22.0 "its own verdict in every mode" promise (`audit.py:1565`,
908
+ `1783-1803`, `1520-1558`).
909
+ - [ ] **Harden the edges of the record pipeline:** a failed `--json` write
910
+ raises after the scan and loses the whole run's summary and exit code
911
+ (`audit.py:1616`, `2023-2043`); `--tag` tags books whose content hits
912
+ the run itself marked expected-foreign (`audit.py:1830-1832` vs the
913
+ rc filter); a decoded-href vs stored-name spine miss silently reads
914
+ as EMPTY on a book full of prose, with no diagnostic
915
+ (`audit.py:219`); DRM-encrypted entries report as CORRUPT
916
+ "re-source" instead of their own status (`audit.py:173-177`); the
917
+ broken-span heuristic treats duplicate trailing numbers
918
+ (`part1a/part1b/part2`) and duplicate spine itemrefs as FRAGMENT
919
+ (`audit.py:1066-1079`).
920
+ - [ ] **Delete the dead surfaces:** `audit.main()` + its argparse block
921
+ duplicate `cli.run_audit_cmd` (`audit.py:2212-2315`);
922
+ `analyze_brokentags` is unreachable (`audit.py:1260-1274`); duplicate
923
+ imports at the top of the file; and standalone `audit <dir>` silently
924
+ skips uppercase `.EPUB` files (`audit.py:1937`).
925
+ - [ ] **Smaller audit papercuts:** `_Blocks.handle_endtag` pops the stack
926
+ top regardless of which tag closed, so mis-nested blockquotes lose
927
+ their in_quote guard (`audit.py:739-743`); nav selection matches
928
+ `data-nav` by substring (`audit.py:196`); tagging failure can surface
929
+ an undocumented exit 3 (`audit.py:2203-2208`); the JSON `analyzers`
930
+ list omits the always-on archive/spine verdicts and the console
931
+ problem counter can disagree with `summary.problems`
932
+ (`audit.py:1603`); `--min-chars`/`--thin-chars` are not validated
933
+ against each other; duplicate zip entries silently resolve last-wins
934
+ with no note in the record.
935
+
936
+ ### Apply-path and oracle safety
937
+
938
+ - [ ] **Move the write-back inside the per-book exception isolation.**
939
+ `make_backup`/`atomic_replace`/`install_format` sit bare in the
940
+ sweep loop, so an ENOSPC or EACCES partway through a multi-hour run
941
+ aborts raw with no summary, no JSON, and no record of what was
942
+ already applied (`cli.py:498-508` vs `544-557`). Wrap the block,
943
+ record an error Outcome, and still emit the report; a run journal
944
+ (one line per applied book, resumable) is the stronger version.
945
+ - [ ] **Drive the exit code from partial books.** The documented contract
946
+ says exit 2 means "ran fine but some books are in trouble", but
947
+ partial books land in `still_fatal` and return 0, in both `library`
948
+ and phase3, while phase1 maps partial to problem/exit 2; the layers
949
+ disagree and scripts can miss trouble (`cli.py:260`, `526-534`, `634`,
950
+ `1030-1032`).
951
+ - [ ] **Give backups overwrite protection and keep them out of the
952
+ candidate set.** `make_backup` clobbers an existing backup, so a
953
+ second `--apply` destroys the only copy of the author original
954
+ (`library.py:37-41`); a `--backup` dir inside the library root gets
955
+ its `.epub`-named copies swept as candidates on the next run
956
+ (`library.py:29-34`). Refuse overwrite (or rotate) and reject an
957
+ in-tree backup path.
958
+ - [ ] **Make `--workers` parallelism real and bound the daemon.** The
959
+ epubcheck daemon holds its lock across the whole blocking
960
+ round-trip, so every worker serializes behind it and `--workers N`
961
+ degrades to serial exactly when epubcheck is present
962
+ (`validate.py:155-169`, `cli.py:213-255`); the daemon path ignores
963
+ `timeout` entirely, so a JVM hang hangs the sweep forever
964
+ (`validate.py:196`); a dead daemon never resets `_proc`, so every
965
+ later book reads as `error` for the life of the process
966
+ (`validate.py:160-170`); a failed daemon start leaks its
967
+ `/tmp/bindery-daemon-*` workdir and retries the javac compile per
968
+ book (`validate.py:130-153`); and `--only ncx` crashes with a raw
969
+ RuntimeError on encrypted archives because `_select` probes outside
970
+ the exception net (`epub.py:688`, `cli.py:184`).
971
+ - [ ] **Smaller apply papercuts:** `--limit -1` and a missing
972
+ `--audit` file raise tracebacks instead of usage errors
973
+ (`cli.py:434`, `373`); the fresh-format branch of `install_format`
974
+ registers the format under the name `repaired`
975
+ (`library.py:223-230`); phase1 ignores `--backup` without
976
+ `--apply-lossy` silently; the lossy override can upgrade a noop to
977
+ partial in the still-fatal listing (conservative direction only).
978
+
979
+ ### Tests
980
+
981
+ - [ ] **Add the missing direct tests for the safety contract:** `gate()` and
982
+ `no_worse()` have zero direct tests (the subtle fatal-fixing
983
+ error-unmasking branch is asserted nowhere); `_EpubcheckDaemon` is
984
+ completely untested (and hides the no-reset liveness bug above);
985
+ `--strip-broken-tags` has zero tests anywhere despite being one of
986
+ the three named lossy strips; `--strip-watermarks`' gated apply path
987
+ has no analogue of the pagination gate test; `fix_ncx_playorder`
988
+ (core, always-on) is untested; `atomic_replace`'s failure path
989
+ (temp cleaned, target untouched, re-raise) has no failure-injection
990
+ test; `--install-to-calibre` CLI wiring and library-mode `--tag`
991
+ end-to-end are untested.
992
+ - [ ] **Clean the weak 2%:** delete the three mid-file
993
+ `if __name__ == "__main__": unittest.main()` blocks
994
+ (`test_audit.py:806`, `:943`; `test_cli.py:475`) that make direct
995
+ file runs silently skip ~40 tests; drop the constant-assertion
996
+ `test_all_tuple_has_ocr`; consolidate the verbatim-duplicated
997
+ roman/number implementations and their twin test classes (audit copy
998
+ lacks pagination's year exclusion, which is its own question);
999
+ remove the dead `sys.argv` patches in three CLI tests; strengthen or
1000
+ delete `test_non_interactive_flag_wiring` (asserts argparse, not the
1001
+ runner); de-alias the `pagenum`/`emptytext`/`ocr` module aliases.
1002
+ - [ ] **Add a ruff step to run_tests.sh** so the local loop matches CI
1003
+ (`uvx ruff check . && uvx ruff format --check .`), and note the
1004
+ html5lib-dependent tests silently skip without uv.
1005
+
1006
+ ### Scripts and repo hygiene
1007
+
1008
+ - [ ] **Retire the subsumed find_* diagnostics.** All ten `scripts/find_*.py`
1009
+ detection wedges became shipped flags with tests
1010
+ (the css one says so in its own docstring port note); keeping both
1011
+ copies means future behavior changes land in one only. Delete (git
1012
+ preserves) or move to an attic note; also delete `sweep.sh` and
1013
+ `FastSweepExtract.java` (superseded by `fast_sweep.py --mode=extract`
1014
+ and `FastSweep.java`), keeping `fast_sweep.py` + `FastSweep.java`
1015
+ (a live, different tool: one JVM saturated across cores vs the
1016
+ library sweep's N subprocesses). The untracked `.class` files are
1017
+ local build leftovers; already gitignored.
1018
+ - [ ] **Decide what test_facility/ is.** It carries 10 tracked commercial
1019
+ EPUBs (muse-of-nightmares, last-man-out, etc.; only leaves-of-grass is
1020
+ public domain), a second never-run "suite" of bare functions that
1021
+ `unittest discover` reports as 0 tests, and it mutates the tracked
1022
+ books in place. `.gitignore`'s own comment says books are ignored to
1023
+ prevent IP leaks, but that rule only covers `testing_facility/`;
1024
+ `test_facility/` predates it. At minimum stop tracking the EPUBs and
1025
+ delete or properly convert the dead suite; stripping them from
1026
+ history is the thorough option (repo is public).
1027
+ - [ ] **Mention validate.py's Java epubcheck daemon in the docs.** It is
1028
+ the first oracle tried at runtime, it compiles `_DAEMON_JAVA`
1029
+ (`validate.py:73-105`) into a tempdir, and nothing in README/spec
1030
+ admits it exists; it is also the scariest untested code in the repo.
1031
+
1032
+ ### Documentation (drift is real but narrow)
1033
+
1034
+ - [ ] **Reconcile spec.md with the shipped opt-ins.** The contract's
1035
+ non-goals forbid exactly what `--reserialize` (v0.3.0, the lone
1036
+ html5lib dependency) and `--strip-bad-attrs` (v0.4.0) do, and neither
1037
+ has a spec section; the OPF half of `--fix-ids` is missing;
1038
+ "the OPF is left untouched" is true only of the default pass; the
1039
+ NCX sentence at spec.md:52-53 is garbled (a dangling "to the OPF
1040
+ unique identifier" tail). A contributor reading spec.md first would
1041
+ conclude two shipped flags violate the charter.
1042
+ - [ ] **Fix the exit-code contract.** README and spec document usage
1043
+ errors as exit 1, but argparse-level misuse exits 2 (the same code
1044
+ as "book in trouble"); either a custom parser exit or updated docs.
1045
+ - [ ] **README completeness:** document `--min-chars`, `--thin-chars`,
1046
+ `--max-doc-chars`, and `--limit` in the flag reference; fix the
1047
+ `--only fatals` bullet to "needs `--audit` or `--sweep`" (it
1048
+ currently contradicts the `--sweep` bullet one line over).
1049
+ - [ ] **CLAUDE.md's exception taxonomy** lists twelve structural repairs
1050
+ and three lossy strips but omits the four safe opt-ins
1051
+ (`--fix-ids`, `--add-img-alt`, `--strip-bad-attrs`,
1052
+ `--escape-unknown-entities`), which reads as a complete inventory.
1053
+
1054
+ ### Completeness verdict from the sweep
1055
+
1056
+ *The engine is close to complete for its charter: the five-fixture core is
1057
+ solid under adversarial attack (zip machinery, splice engine, entity
1058
+ handling, and the gate all held), the suite is fast, deterministic, and about
1059
+ 95% load-bearing, and the docs are truthful everywhere except spec.md's
1060
+ never-absorbed v0.3/v0.4 era. The gap between "almost as complete as it can
1061
+ be" and "done" is one coherent theme, not scattered work: several opt-in
1062
+ repairs trust their regexes where they should trust the parse (the
1063
+ anchored-tag cluster), the audit's newest machinery (archive/spine) is not
1064
+ wired into every mode it promises, and the apply path's failure modes (crash
1065
+ mid-run, backup clobber, daemon stall) are unpadded. Fix the four Bug
1066
+ Reports entries and the anchored-regex cluster before any further features;
1067
+ everything else is backlog, not danger.*
@@ -0,0 +1,44 @@
1
+ import java.io.File;
2
+ import java.io.PrintWriter;
3
+ import java.io.StringWriter;
4
+ import java.util.Scanner;
5
+ import java.util.ArrayList;
6
+ import java.util.List;
7
+ import com.adobe.epubcheck.api.EpubCheck;
8
+ import com.adobe.epubcheck.reporting.CheckingReport;
9
+
10
+ public class FastSweepExtract {
11
+ public static void main(String[] args) throws Exception {
12
+ Scanner scanner = new Scanner(System.in);
13
+ List<String> paths = new ArrayList<>();
14
+ while (scanner.hasNextLine()) {
15
+ String path = scanner.nextLine();
16
+ if (!path.trim().isEmpty()) paths.add(path);
17
+ }
18
+
19
+ paths.parallelStream().forEach(path -> {
20
+ File epub = new File(path);
21
+ if (!epub.exists()) return;
22
+ try {
23
+ StringWriter sw = new StringWriter();
24
+ PrintWriter out = new PrintWriter(sw);
25
+ CheckingReport report = new CheckingReport(out, epub.getName());
26
+ EpubCheck check = new EpubCheck(epub, report);
27
+ check.doValidate();
28
+ out.flush();
29
+ String text = sw.toString();
30
+ if (report.getErrorCount() > 0 || report.getFatalErrorCount() > 0 || report.getWarningCount() > 0) {
31
+ // Extract error codes (e.g. RSC-005, CSS-008)
32
+ java.util.regex.Matcher m = java.util.regex.Pattern.compile("([A-Z]{3}-\\d{3})").matcher(text);
33
+ List<String> codes = new ArrayList<>();
34
+ while (m.find()) {
35
+ codes.add(m.group(1));
36
+ }
37
+ synchronized(System.out) {
38
+ System.out.println(epub.getCanonicalPath() + " ||| " + String.join(",", codes));
39
+ }
40
+ }
41
+ } catch (Exception e) {}
42
+ });
43
+ }
44
+ }
@@ -0,0 +1,34 @@
1
+ #!/bin/bash
2
+ javac --release 25 -cp "/home/bdkl/.local/opt/epubcheck-5.3.0/epubcheck.jar:/home/bdkl/.local/opt/epubcheck-5.3.0/lib/*" FastSweepExtract.java
3
+ find "$HOME/docs/Calibre Library" -type f -name "*.epub" > all_epubs.txt
4
+ java -cp ".:/home/bdkl/.local/opt/epubcheck-5.3.0/epubcheck.jar:/home/bdkl/.local/opt/epubcheck-5.3.0/lib/*" FastSweepExtract < all_epubs.txt > raw_errors.txt
5
+ python3 -c '
6
+ import sys
7
+ from collections import Counter, defaultdict
8
+
9
+ error_counts = Counter()
10
+ file_errors = defaultdict(set)
11
+
12
+ with open("raw_errors.txt") as f:
13
+ for line in f:
14
+ line = line.strip()
15
+ if " ||| " not in line: continue
16
+ path, codes_str = line.split(" ||| ", 1)
17
+ if not codes_str: continue
18
+ codes = codes_str.split(",")
19
+ for c in codes:
20
+ error_counts[c] += 1
21
+ file_errors[c].add(path)
22
+
23
+ print("TOP ERRORS:")
24
+ top = error_counts.most_common(500)
25
+ for c, count in top:
26
+ print(f"{c}: {count}")
27
+
28
+ with open("top_errors.txt", "w") as out:
29
+ for c, count in top:
30
+ out.write(f"{c}: {count}\n")
31
+ # Save a few candidate files for each error
32
+ for f in list(file_errors[c])[:5]:
33
+ out.write(f" {f}\n")
34
+ ' > sweep_summary.txt
@@ -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.30.0"
7
+ VERSION = "0.31.0"
8
8
  __version__ = VERSION
9
9
 
10
10
  from .epub import RepairReport, repair_epub # noqa: E402
@@ -148,9 +148,9 @@ def install_format(
148
148
  With a book id, the repaired file is placed in the book's directory —
149
149
  an atomic replace over the catalogued file when one exists (same path,
150
150
  same ``data.name``; Calibre's layout never changes) — and the ``data``
151
- row follows through ``WritableCalibreDB``: ``remove_format`` +
152
- ``add_format`` in one ``batch()`` when the format exists (``add_format``
153
- refuses duplicates by design), a fresh ``add_format`` otherwise. The
151
+ row follows through ``WritableCalibreDB``: ``set_format`` (cquarry
152
+ 1.17's sanctioned remove+add in one transaction) when the format
153
+ exists, a fresh ``add_format`` otherwise. The
154
154
  row's size stays truthful and the book lands in ``metadata_dirtied``, so
155
155
  Calibre regenerates its sidecar .opf. Files are the caller's
156
156
  responsibility in cquarry; they are placed here, atomically, before the
@@ -210,11 +210,12 @@ def install_format(
210
210
  if row is not None:
211
211
  atomic_replace(target, new_file)
212
212
  placed = True
213
- with wdb.batch():
214
- wdb.remove_format(int(calibre_id), "EPUB")
215
- wdb.add_format(
216
- int(calibre_id), "EPUB", row["name"], new_file.stat().st_size
217
- )
213
+ # cquarry 1.17's set_format is the sanctioned remove+add;
214
+ # the explicit two-call batch this used to compose is
215
+ # retired with it.
216
+ wdb.set_format(
217
+ int(calibre_id), "EPUB", row["name"], new_file.stat().st_size
218
+ )
218
219
  else:
219
220
  book = wdb.conn.execute(
220
221
  "SELECT path FROM books WHERE id = ?", (int(calibre_id),)
@@ -203,17 +203,18 @@ class TestInstallFormat(unittest.TestCase):
203
203
 
204
204
  def test_replace_survives_a_database_error(self):
205
205
  # A database failure must never lose the repair: the file is saved in
206
- # place and the warning says the row is stale. add_format fails after
207
- # the batch's remove_format, so the whole batch rolls back.
206
+ # place and the warning says the row is stale. set_format (cquarry
207
+ # 1.17's sanctioned remove+add) fails and rolls its own transaction
208
+ # back, so the old row survives untouched.
208
209
  import contextlib
209
210
  import io
210
211
 
211
- with mock.patch("cquarry.write.WritableCalibreDB.add_format") as m:
212
+ with mock.patch("cquarry.write.WritableCalibreDB.set_format") as m:
212
213
  m.side_effect = sqlite3.OperationalError("database is locked")
213
214
  with contextlib.redirect_stderr(io.StringIO()):
214
215
  install_format(self.epub, self.new, CalibreIdResolver(self.root))
215
216
  self.assertEqual(self.epub.read_bytes(), b"REPAIRED")
216
- # the batch rolled back: the old row survives untouched
217
+ # set_format rolled back: the old row survives untouched
217
218
  self.assertEqual(self._rows(), [("EPUB", "Title - Author", 10)])
218
219
 
219
220
  def test_guess_fallback_uses_calibre_dbpath(self):
@@ -4,7 +4,7 @@ requires-python = ">=3.14"
4
4
 
5
5
  [[package]]
6
6
  name = "bindery-cli"
7
- version = "0.30.0"
7
+ version = "0.31.0"
8
8
  source = { editable = "." }
9
9
  dependencies = [
10
10
  { name = "cquarry" },
@@ -19,7 +19,7 @@ reserialize = [
19
19
 
20
20
  [package.metadata]
21
21
  requires-dist = [
22
- { name = "cquarry", specifier = ">=1.9.0" },
22
+ { name = "cquarry", specifier = ">=1.17.0" },
23
23
  { name = "html5lib", marker = "extra == 'reserialize'", specifier = ">=1.1" },
24
24
  { name = "tqdm" },
25
25
  { name = "vir-tui", specifier = ">=2.2.0" },
@@ -37,11 +37,11 @@ wheels = [
37
37
 
38
38
  [[package]]
39
39
  name = "cquarry"
40
- version = "1.9.0"
40
+ version = "1.17.0"
41
41
  source = { registry = "https://pypi.org/simple" }
42
- sdist = { url = "https://files.pythonhosted.org/packages/37/50/ce20ce828d0836e229735c4297bb3761aa69c2d98949c7199a0f8dd1995b/cquarry-1.9.0.tar.gz", hash = "sha256:9d10644cdef161be466e4bdfa3a6c7a4a863ffd902fdc1148ca7a8dc68458871", size = 134894, upload-time = "2026-09-02T21:45:12.036Z" }
42
+ sdist = { url = "https://files.pythonhosted.org/packages/4d/6b/e2c80511bf62e3b008c52d47a05b29e43e2782614cf211aadc8c965a2be1/cquarry-1.17.0.tar.gz", hash = "sha256:07ec6c43ebc11f8921645a963373537ef4808985c88ad885de5dfc05ba08d737", size = 197288, upload-time = "2026-09-09T21:48:07.208Z" }
43
43
  wheels = [
44
- { url = "https://files.pythonhosted.org/packages/c9/8a/f10ca4563be8e16034374c3f01fc2c61386defeee10c7f3bee08f678a0fd/cquarry-1.9.0-py3-none-any.whl", hash = "sha256:ffc200eeab39a21a8952c3944ad3739e9c80f03bc8bdd6a3ecf8b2487d3c38a3", size = 53911, upload-time = "2026-09-02T21:45:10.649Z" },
44
+ { url = "https://files.pythonhosted.org/packages/4d/5f/90f6fe1c24a3ce8616cb377cbedee3dfb82c4eca84cbb1ec8b02a7034cb1/cquarry-1.17.0-py3-none-any.whl", hash = "sha256:f01c5b4ecb0863c78379e9fc899fd0da7d2518e815ed2a9000acec09cbea054b", size = 70998, upload-time = "2026-09-09T21:48:05.798Z" },
45
45
  ]
46
46
 
47
47
  [[package]]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes