bindery-cli 0.27.0__py3-none-any.whl
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.
- bindery/__init__.py +12 -0
- bindery/__main__.py +4 -0
- bindery/audit.py +2137 -0
- bindery/cli.py +961 -0
- bindery/epub.py +618 -0
- bindery/library.py +244 -0
- bindery/pagination.py +359 -0
- bindery/reserialize.py +42 -0
- bindery/transforms.py +579 -0
- bindery/validate.py +254 -0
- bindery/watermark.py +244 -0
- bindery_cli-0.27.0.dist-info/METADATA +218 -0
- bindery_cli-0.27.0.dist-info/RECORD +16 -0
- bindery_cli-0.27.0.dist-info/WHEEL +4 -0
- bindery_cli-0.27.0.dist-info/entry_points.txt +2 -0
- bindery_cli-0.27.0.dist-info/licenses/LICENSE +21 -0
bindery/cli.py
ADDED
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
"""Command-line interface for Bindery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import zipfile
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from itertools import islice
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from tqdm import tqdm
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .audit import (
|
|
20
|
+
ALL,
|
|
21
|
+
DEFAULT_MAX_DOC_CHARS,
|
|
22
|
+
DEFAULT_MIN_CHARS,
|
|
23
|
+
DEFAULT_THIN_CHARS,
|
|
24
|
+
run_directory,
|
|
25
|
+
run_single,
|
|
26
|
+
)
|
|
27
|
+
from .audit import (
|
|
28
|
+
run_library as run_audit_library,
|
|
29
|
+
)
|
|
30
|
+
from .epub import ncx_uid_mismatch, repair_epub
|
|
31
|
+
from .library import (
|
|
32
|
+
CalibreIdResolver,
|
|
33
|
+
atomic_replace,
|
|
34
|
+
install_format,
|
|
35
|
+
iter_epubs,
|
|
36
|
+
make_backup,
|
|
37
|
+
)
|
|
38
|
+
from .validate import (
|
|
39
|
+
CheckResult,
|
|
40
|
+
epubcheck_available,
|
|
41
|
+
gate,
|
|
42
|
+
no_worse,
|
|
43
|
+
run_epubcheck,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Outcome:
|
|
49
|
+
epub: Path
|
|
50
|
+
# accept | partial | reject | nochange | equal | unvalidated | error | unreadable
|
|
51
|
+
status: str
|
|
52
|
+
before: CheckResult | None
|
|
53
|
+
after: CheckResult | None
|
|
54
|
+
summary: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def process_book(
|
|
58
|
+
epub: Path,
|
|
59
|
+
workdir: Path,
|
|
60
|
+
validate: bool,
|
|
61
|
+
fix_ids: bool = False,
|
|
62
|
+
reserialize: bool = False,
|
|
63
|
+
strip_attrs: bool = False,
|
|
64
|
+
strip_pagination: bool = False,
|
|
65
|
+
strip_brokentags: bool = False,
|
|
66
|
+
strip_watermarks: bool = False,
|
|
67
|
+
escape_entities: bool = False,
|
|
68
|
+
img_alt: bool = False,
|
|
69
|
+
empty_body: bool = False,
|
|
70
|
+
missing_title: bool = False,
|
|
71
|
+
id_colons: bool = False,
|
|
72
|
+
block_in_inline: bool = False,
|
|
73
|
+
invalid_value: bool = False,
|
|
74
|
+
illegal_tags: bool = False,
|
|
75
|
+
page_map: bool = False,
|
|
76
|
+
strip_epub3_attrs: bool = False,
|
|
77
|
+
downgrade_epub3: bool = False,
|
|
78
|
+
before: CheckResult | None = None,
|
|
79
|
+
) -> Outcome:
|
|
80
|
+
"""Repair `epub` into a temp file and decide whether the result is acceptable.
|
|
81
|
+
|
|
82
|
+
`before` is a pre-measured epubcheck result for `epub` (from a --sweep pass),
|
|
83
|
+
saving a second multi-second run; when None it is measured here."""
|
|
84
|
+
repaired = workdir / "repaired.epub"
|
|
85
|
+
report = repair_epub(
|
|
86
|
+
epub,
|
|
87
|
+
repaired,
|
|
88
|
+
fix_ids=fix_ids,
|
|
89
|
+
reserialize=reserialize,
|
|
90
|
+
strip_attrs=strip_attrs,
|
|
91
|
+
strip_pagination=strip_pagination,
|
|
92
|
+
strip_brokentags=strip_brokentags,
|
|
93
|
+
strip_watermarks=strip_watermarks,
|
|
94
|
+
escape_entities=escape_entities,
|
|
95
|
+
img_alt=img_alt,
|
|
96
|
+
empty_body=empty_body,
|
|
97
|
+
missing_title=missing_title,
|
|
98
|
+
id_colons=id_colons,
|
|
99
|
+
block_in_inline=block_in_inline,
|
|
100
|
+
invalid_value=invalid_value,
|
|
101
|
+
illegal_tags=illegal_tags,
|
|
102
|
+
page_map=page_map,
|
|
103
|
+
strip_epub3_attrs=strip_epub3_attrs,
|
|
104
|
+
downgrade_epub3=downgrade_epub3,
|
|
105
|
+
)
|
|
106
|
+
if not report:
|
|
107
|
+
return Outcome(epub, "nochange", None, None, "no applicable fixes")
|
|
108
|
+
|
|
109
|
+
summary = ", ".join(f"{k}:{v}" for k, v in report.fixes.items())
|
|
110
|
+
if report.ncx_uid_synced:
|
|
111
|
+
summary = (summary + ", " if summary else "") + "ncx_uid_synced"
|
|
112
|
+
|
|
113
|
+
if not validate:
|
|
114
|
+
return Outcome(epub, "unvalidated", None, None, summary)
|
|
115
|
+
|
|
116
|
+
if before is None:
|
|
117
|
+
before = run_epubcheck(epub)
|
|
118
|
+
after = run_epubcheck(repaired)
|
|
119
|
+
if before is None or after is None:
|
|
120
|
+
# Validation was requested but the oracle failed (crash, timeout, unparsable
|
|
121
|
+
# output). This is "error", not "unvalidated": the gate did not accept the
|
|
122
|
+
# repair, so it must never be applied. Only --no-validate skips the gate.
|
|
123
|
+
return Outcome(epub, "error", before, after, summary + " (epubcheck failed)")
|
|
124
|
+
verdict = gate(before, after)
|
|
125
|
+
if (
|
|
126
|
+
report.fixes.get("stripped_pagination")
|
|
127
|
+
or report.fixes.get("stripped_broken_tags")
|
|
128
|
+
or report.fixes.get("stripped_watermarks")
|
|
129
|
+
or report.fixes.get("dropped_marker")
|
|
130
|
+
):
|
|
131
|
+
# The strip's gain (in-body page numbers removed) is invisible to epubcheck, so
|
|
132
|
+
# 'no measurable gain' is expected; accept as long as nothing regressed. But a
|
|
133
|
+
# book that still has fatals will not open: no_worse must never promote it past
|
|
134
|
+
# the gate's 'partial' (still-fatal books are never auto-applied).
|
|
135
|
+
if not no_worse(before, after):
|
|
136
|
+
verdict = "reject"
|
|
137
|
+
elif after.fatals > 0:
|
|
138
|
+
verdict = "partial"
|
|
139
|
+
else:
|
|
140
|
+
verdict = "accept"
|
|
141
|
+
if verdict == "reject":
|
|
142
|
+
summary += " (REGRESSION)"
|
|
143
|
+
elif verdict == "noop":
|
|
144
|
+
summary += " (no measurable gain)"
|
|
145
|
+
status = "equal" if verdict == "noop" else verdict
|
|
146
|
+
return Outcome(epub, status, before, after, summary)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _load_audit(path: Path) -> dict[str, tuple[int, int, int]]:
|
|
150
|
+
out: dict[str, tuple[int, int, int]] = {}
|
|
151
|
+
with path.open() as fh:
|
|
152
|
+
for row in csv.reader(fh):
|
|
153
|
+
if len(row) != 4:
|
|
154
|
+
continue
|
|
155
|
+
f, e, w, p = row
|
|
156
|
+
try:
|
|
157
|
+
# Resolved, so a CSV written with one path shape still matches a scan
|
|
158
|
+
# run with another (relative vs. absolute, symlinked mounts).
|
|
159
|
+
out[str(Path(p).expanduser().resolve())] = (int(f), int(e), int(w))
|
|
160
|
+
except ValueError: # the header row, if present
|
|
161
|
+
continue
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _select(epubs, only: str, audit: dict | None, audit_hits: list | None = None):
|
|
166
|
+
"""Filter the candidate list by --only and an optional audit CSV."""
|
|
167
|
+
for epub in epubs:
|
|
168
|
+
counts = None
|
|
169
|
+
if audit is not None:
|
|
170
|
+
counts = audit.get(str(epub.resolve()))
|
|
171
|
+
if counts is not None and audit_hits is not None:
|
|
172
|
+
audit_hits.append(epub)
|
|
173
|
+
if only == "fatals":
|
|
174
|
+
if audit is not None and (counts is None or counts[0] == 0):
|
|
175
|
+
continue
|
|
176
|
+
elif only == "ncx":
|
|
177
|
+
if not ncx_uid_mismatch(epub):
|
|
178
|
+
continue
|
|
179
|
+
else: # all
|
|
180
|
+
if audit is not None and counts == (0, 0, 0):
|
|
181
|
+
continue
|
|
182
|
+
yield epub
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _sweep_select(epubs, only: str, root: Path, checks: dict, *, quiet: bool):
|
|
186
|
+
"""Candidate selection driven by a live epubcheck sweep instead of an audit CSV.
|
|
187
|
+
|
|
188
|
+
Each result is cached in `checks` so process_book reuses it as the book's
|
|
189
|
+
`before` measurement instead of running epubcheck twice. A book the oracle
|
|
190
|
+
cannot read stays a candidate (it cannot be proven clean); process_book will
|
|
191
|
+
classify it as an error."""
|
|
192
|
+
for epub in epubs:
|
|
193
|
+
if not quiet:
|
|
194
|
+
tqdm.write(f"[sweep] {epub.relative_to(root)}", file=sys.stderr)
|
|
195
|
+
counts = run_epubcheck(epub)
|
|
196
|
+
if counts is not None:
|
|
197
|
+
checks[epub] = counts
|
|
198
|
+
if only == "fatals":
|
|
199
|
+
if counts is not None and counts.fatals == 0:
|
|
200
|
+
continue
|
|
201
|
+
elif counts == CheckResult(0, 0, 0): # only == "all": skip clean books
|
|
202
|
+
continue
|
|
203
|
+
yield epub
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# Everything a run did not (or could not) auto-repair; the --manual-list export.
|
|
207
|
+
_MANUAL_STATUSES = frozenset(
|
|
208
|
+
{"nochange", "equal", "partial", "reject", "error", "unreadable"}
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _counts_dict(r: CheckResult | None) -> dict | None:
|
|
213
|
+
return (
|
|
214
|
+
None
|
|
215
|
+
if r is None
|
|
216
|
+
else {"fatals": r.fatals, "errors": r.errors, "warnings": r.warnings}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _unreadable_reason(e: Exception) -> str:
|
|
221
|
+
"""Split the sweep's `unreadable` bucket by disease.
|
|
222
|
+
|
|
223
|
+
The bucket used to lump corruption together with not-a-zip/truncated/
|
|
224
|
+
encrypted; the sub-reason names the disease so a CRC-damaged download
|
|
225
|
+
(re-source) is distinguishable from a DRM'd or truncated one without
|
|
226
|
+
leaving the sweep. zipfile names the broken entry in its CRC message.
|
|
227
|
+
"""
|
|
228
|
+
msg = str(e)
|
|
229
|
+
if isinstance(e, RuntimeError) and "encrypted" in msg:
|
|
230
|
+
return "encrypted"
|
|
231
|
+
if "CRC" in msg:
|
|
232
|
+
return "corrupt_entry"
|
|
233
|
+
if isinstance(e, EOFError) or "truncated" in msg.lower():
|
|
234
|
+
return "truncated"
|
|
235
|
+
if isinstance(e, zipfile.BadZipFile) and "not a zip file" in msg:
|
|
236
|
+
return "not_a_zip"
|
|
237
|
+
return "unreadable"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _epubs_for_ids(root: Path, id_csv: str) -> list[Path] | None:
|
|
241
|
+
"""Resolve comma-separated Calibre book ids to EPUB paths via cquarry's
|
|
242
|
+
get_format_path (the audit --id contract, sweep-shaped). Unresolvable ids
|
|
243
|
+
warn and skip: one wrong id must not sink the batch."""
|
|
244
|
+
from cquarry.db import CalibreDB
|
|
245
|
+
|
|
246
|
+
db_path = root / "metadata.db"
|
|
247
|
+
if not db_path.is_file():
|
|
248
|
+
print("error: --id needs metadata.db in the library root", file=sys.stderr)
|
|
249
|
+
return None
|
|
250
|
+
try:
|
|
251
|
+
db = CalibreDB(str(db_path))
|
|
252
|
+
except Exception as e:
|
|
253
|
+
print(f"error: cannot open {db_path}: {e}", file=sys.stderr)
|
|
254
|
+
return None
|
|
255
|
+
epubs: list[Path] = []
|
|
256
|
+
seen: set[int] = set()
|
|
257
|
+
try:
|
|
258
|
+
for raw in id_csv.split(","):
|
|
259
|
+
raw = raw.strip()
|
|
260
|
+
if not raw:
|
|
261
|
+
continue
|
|
262
|
+
try:
|
|
263
|
+
bid = int(raw)
|
|
264
|
+
except ValueError:
|
|
265
|
+
print(
|
|
266
|
+
f"warning: --id {raw!r} is not a number; skipped", file=sys.stderr
|
|
267
|
+
)
|
|
268
|
+
continue
|
|
269
|
+
if bid in seen:
|
|
270
|
+
continue
|
|
271
|
+
seen.add(bid)
|
|
272
|
+
try:
|
|
273
|
+
epubs.append(Path(db.get_format_path(bid, "EPUB", verify=False)))
|
|
274
|
+
except (ValueError, FileNotFoundError) as e:
|
|
275
|
+
print(f"warning: book #{bid}: {e}; skipped", file=sys.stderr)
|
|
276
|
+
finally:
|
|
277
|
+
db.close()
|
|
278
|
+
return epubs
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def run_library(args) -> int:
|
|
282
|
+
root = Path(args.path).expanduser()
|
|
283
|
+
# cquarry-backed id resolution for --install-to-calibre: one lazy map
|
|
284
|
+
# build per run, read-only against metadata.db.
|
|
285
|
+
id_resolver = CalibreIdResolver(root)
|
|
286
|
+
if not root.is_dir():
|
|
287
|
+
print(f"error: not a directory: {root}", file=sys.stderr)
|
|
288
|
+
return 1
|
|
289
|
+
if args.only == "fatals" and not (args.audit or args.sweep):
|
|
290
|
+
# Without fatal-count data (a CSV or a live sweep), silently scanning every
|
|
291
|
+
# book is not what --only fatals promised.
|
|
292
|
+
print("error: --only fatals needs --audit CSV or --sweep", file=sys.stderr)
|
|
293
|
+
return 1
|
|
294
|
+
if args.sweep and args.audit:
|
|
295
|
+
print("error: --sweep and --audit are mutually exclusive", file=sys.stderr)
|
|
296
|
+
return 1
|
|
297
|
+
if getattr(args, "id", "") and getattr(args, "audit", None):
|
|
298
|
+
print("error: --id and --audit are mutually exclusive", file=sys.stderr)
|
|
299
|
+
return 1
|
|
300
|
+
if args.sweep and args.no_validate:
|
|
301
|
+
print(
|
|
302
|
+
"error: --sweep is an epubcheck sweep; drop --no-validate", file=sys.stderr
|
|
303
|
+
)
|
|
304
|
+
return 1
|
|
305
|
+
if args.sweep and args.only == "ncx":
|
|
306
|
+
print(
|
|
307
|
+
"error: --sweep does not apply to --only ncx (NCX-001 detection "
|
|
308
|
+
"needs no epubcheck data)",
|
|
309
|
+
file=sys.stderr,
|
|
310
|
+
)
|
|
311
|
+
return 1
|
|
312
|
+
|
|
313
|
+
validate = not args.no_validate
|
|
314
|
+
if validate and not epubcheck_available():
|
|
315
|
+
print(
|
|
316
|
+
"error: epubcheck not found. install it or pass --no-validate.",
|
|
317
|
+
file=sys.stderr,
|
|
318
|
+
)
|
|
319
|
+
return 1
|
|
320
|
+
|
|
321
|
+
audit = _load_audit(Path(args.audit).expanduser()) if args.audit else None
|
|
322
|
+
backup_dir = Path(args.backup).expanduser() if args.backup else None
|
|
323
|
+
wants_backup = backup_dir is not None or args.backup_inplace
|
|
324
|
+
if wants_backup and not args.apply:
|
|
325
|
+
print(
|
|
326
|
+
"note: dry run -- --backup/--backup-inplace do nothing without --apply",
|
|
327
|
+
file=sys.stderr,
|
|
328
|
+
)
|
|
329
|
+
if (
|
|
330
|
+
args.apply
|
|
331
|
+
and not wants_backup
|
|
332
|
+
and (args.strip_pagination or args.strip_broken_tags or args.strip_watermarks)
|
|
333
|
+
):
|
|
334
|
+
print(
|
|
335
|
+
"WARNING: the --strip-* modes are lossy; strongly consider --backup DIR "
|
|
336
|
+
"or --backup-inplace when applying them.",
|
|
337
|
+
file=sys.stderr,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if args.id:
|
|
341
|
+
scoped = _epubs_for_ids(root, args.id)
|
|
342
|
+
if scoped is None:
|
|
343
|
+
return 1
|
|
344
|
+
all_epubs = scoped
|
|
345
|
+
else:
|
|
346
|
+
all_epubs = list(iter_epubs(root))
|
|
347
|
+
audit_hits: list[Path] = []
|
|
348
|
+
checks: dict[Path, CheckResult] = {}
|
|
349
|
+
if args.sweep:
|
|
350
|
+
iterator = (
|
|
351
|
+
all_epubs if args.quiet else tqdm(all_epubs, desc="Sweeping", unit="book")
|
|
352
|
+
)
|
|
353
|
+
selected = _sweep_select(iterator, args.only, root, checks, quiet=args.quiet)
|
|
354
|
+
else:
|
|
355
|
+
selected = _select(all_epubs, args.only, audit, audit_hits)
|
|
356
|
+
if args.limit is not None:
|
|
357
|
+
# islice keeps the scan lazy: draining it pulls at most `limit` candidates, so
|
|
358
|
+
# --only ncx --limit 20 still stops opening archives after the 20th instead of
|
|
359
|
+
# probing every book in the tree. Draining it here (rather than iterating it in
|
|
360
|
+
# the loop) is what lets the progress line show the real denominator: a tree
|
|
361
|
+
# with 3 candidates under --limit 20 used to count "[1/20]".
|
|
362
|
+
candidates = list(islice(selected, args.limit))
|
|
363
|
+
header = f"limit={args.limit}"
|
|
364
|
+
else:
|
|
365
|
+
candidates = list(selected)
|
|
366
|
+
header = f"{len(candidates)} candidate book(s)"
|
|
367
|
+
|
|
368
|
+
mode = "APPLY" if args.apply else "DRY-RUN"
|
|
369
|
+
print(f"Bindery {mode}: {header}, only={args.only}, validate={validate}\n")
|
|
370
|
+
|
|
371
|
+
accepted = applied = rejected = equal = nochange = unvalidated = partials = 0
|
|
372
|
+
errors = unreadable = processed = 0
|
|
373
|
+
reasons: dict[str, int] = {}
|
|
374
|
+
still_fatal = []
|
|
375
|
+
records: list[Outcome] = [] # every processed book, for --json / --manual-list
|
|
376
|
+
applied_paths: set[Path] = set()
|
|
377
|
+
|
|
378
|
+
with tempfile.TemporaryDirectory() as td:
|
|
379
|
+
work = Path(td)
|
|
380
|
+
repair_iterator = (
|
|
381
|
+
candidates
|
|
382
|
+
if args.quiet
|
|
383
|
+
else tqdm(candidates, desc="Repairing", unit="book")
|
|
384
|
+
)
|
|
385
|
+
for epub in repair_iterator:
|
|
386
|
+
processed += 1
|
|
387
|
+
rel = epub.relative_to(root)
|
|
388
|
+
try:
|
|
389
|
+
o = process_book(
|
|
390
|
+
epub,
|
|
391
|
+
work,
|
|
392
|
+
validate,
|
|
393
|
+
fix_ids=args.fix_ids or getattr(args, "all", False),
|
|
394
|
+
reserialize=args.reserialize or getattr(args, "all", False),
|
|
395
|
+
strip_attrs=args.strip_bad_attrs or getattr(args, "all", False),
|
|
396
|
+
strip_pagination=args.strip_pagination
|
|
397
|
+
or getattr(args, "all", False),
|
|
398
|
+
strip_brokentags=args.strip_broken_tags
|
|
399
|
+
or getattr(args, "all", False),
|
|
400
|
+
strip_watermarks=args.strip_watermarks
|
|
401
|
+
or getattr(args, "all", False),
|
|
402
|
+
escape_entities=args.escape_unknown_entities
|
|
403
|
+
or getattr(args, "all", False),
|
|
404
|
+
img_alt=args.add_img_alt or getattr(args, "all", False),
|
|
405
|
+
empty_body=args.fix_empty_body or getattr(args, "all", False),
|
|
406
|
+
missing_title=args.fix_missing_title or getattr(args, "all", False),
|
|
407
|
+
id_colons=args.fix_id_colons or getattr(args, "all", False),
|
|
408
|
+
block_in_inline=args.unwrap_block_in_inline
|
|
409
|
+
or getattr(args, "all", False),
|
|
410
|
+
invalid_value=args.strip_invalid_value
|
|
411
|
+
or getattr(args, "all", False),
|
|
412
|
+
illegal_tags=args.unwrap_illegal_tags
|
|
413
|
+
or getattr(args, "all", False),
|
|
414
|
+
page_map=args.fix_page_map or getattr(args, "all", False),
|
|
415
|
+
strip_epub3_attrs=args.strip_epub3_attrs
|
|
416
|
+
or getattr(args, "all", False),
|
|
417
|
+
downgrade_epub3=args.downgrade_epub3_tags
|
|
418
|
+
or getattr(args, "all", False),
|
|
419
|
+
before=checks.get(epub),
|
|
420
|
+
)
|
|
421
|
+
except (zipfile.BadZipFile, OSError, RuntimeError) as e:
|
|
422
|
+
# One corrupt (non-zip, truncated, encrypted) book must not abort a
|
|
423
|
+
# multi-hour sweep; report it under its sub-reason and keep going.
|
|
424
|
+
reason = _unreadable_reason(e)
|
|
425
|
+
unreadable += 1
|
|
426
|
+
reasons[reason] = reasons.get(reason, 0) + 1
|
|
427
|
+
records.append(
|
|
428
|
+
Outcome(epub, "unreadable", None, None, f"{reason}: {e}")
|
|
429
|
+
)
|
|
430
|
+
tqdm.write(f" ERROR {rel}\n unreadable ({reason}): {e}")
|
|
431
|
+
continue
|
|
432
|
+
records.append(o)
|
|
433
|
+
if o.status == "nochange":
|
|
434
|
+
nochange += 1
|
|
435
|
+
continue
|
|
436
|
+
if o.status == "reject":
|
|
437
|
+
rejected += 1
|
|
438
|
+
tqdm.write(
|
|
439
|
+
f" REJECT {rel}\n {o.before} -> {o.after} {o.summary}"
|
|
440
|
+
)
|
|
441
|
+
continue
|
|
442
|
+
if o.status == "equal":
|
|
443
|
+
equal += 1
|
|
444
|
+
continue
|
|
445
|
+
if o.status == "error":
|
|
446
|
+
errors += 1
|
|
447
|
+
tqdm.write(f" ERROR {rel}\n {o.summary}; not applied")
|
|
448
|
+
continue
|
|
449
|
+
if o.status == "partial":
|
|
450
|
+
# Fewer fatals but not zero: a real improvement, but the book still will
|
|
451
|
+
# not open, so it needs manual work. Never auto-applied.
|
|
452
|
+
partials += 1
|
|
453
|
+
still_fatal.append((rel, o.after))
|
|
454
|
+
tqdm.write(
|
|
455
|
+
f" PARTIAL {rel}\n {o.before} -> {o.after} {o.summary}"
|
|
456
|
+
)
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
# accept or unvalidated
|
|
460
|
+
if o.status == "unvalidated":
|
|
461
|
+
unvalidated += 1
|
|
462
|
+
ba = ""
|
|
463
|
+
else:
|
|
464
|
+
accepted += 1
|
|
465
|
+
ba = f"{o.before} -> {o.after} "
|
|
466
|
+
|
|
467
|
+
tag = "ACCEPT"
|
|
468
|
+
if args.apply:
|
|
469
|
+
if backup_dir is not None or args.backup_inplace:
|
|
470
|
+
make_backup(epub, backup_dir)
|
|
471
|
+
if args.install_to_calibre:
|
|
472
|
+
# The id comes from cquarry's metadata.db view — accurate
|
|
473
|
+
# even when the (id) directory was renamed.
|
|
474
|
+
install_format(epub, work / "repaired.epub", id_resolver)
|
|
475
|
+
else:
|
|
476
|
+
atomic_replace(epub, work / "repaired.epub")
|
|
477
|
+
applied += 1
|
|
478
|
+
applied_paths.add(epub)
|
|
479
|
+
tag = "APPLIED"
|
|
480
|
+
tqdm.write(f" {tag} {rel}\n {ba}{o.summary}")
|
|
481
|
+
|
|
482
|
+
if audit is not None and not audit_hits:
|
|
483
|
+
print(
|
|
484
|
+
"\nWARNING: no scanned book matched any path in the audit CSV. The CSV "
|
|
485
|
+
"was probably generated against a different path (absolute vs. relative, "
|
|
486
|
+
"another mount point), so candidate selection saw no fatal counts.",
|
|
487
|
+
file=sys.stderr,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
print("\n========== SUMMARY ==========")
|
|
491
|
+
print(f"candidates: {processed}")
|
|
492
|
+
print(
|
|
493
|
+
f"accepted: {accepted}"
|
|
494
|
+
+ (f" (applied: {applied})" if args.apply else "")
|
|
495
|
+
)
|
|
496
|
+
print(f"partial (manual):{partials}")
|
|
497
|
+
print(f"no change: {nochange}")
|
|
498
|
+
print(f"equal (skipped): {equal}")
|
|
499
|
+
print(f"unvalidated: {unvalidated}")
|
|
500
|
+
print(f"epubcheck errors:{errors}")
|
|
501
|
+
print(f"unreadable: {unreadable}")
|
|
502
|
+
for reason in sorted(reasons):
|
|
503
|
+
print(f" {reason}: {reasons[reason]}")
|
|
504
|
+
print(f"REJECTED: {rejected}")
|
|
505
|
+
if still_fatal:
|
|
506
|
+
print(f"\nimproved but STILL FATAL ({len(still_fatal)}) -- manual follow-up:")
|
|
507
|
+
for rel, after in still_fatal:
|
|
508
|
+
print(f" {after} {rel}")
|
|
509
|
+
if not args.apply:
|
|
510
|
+
print(
|
|
511
|
+
"\n(dry run -- no files written. re-run with --apply to replace in place.)"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
if args.manual_list:
|
|
515
|
+
manual = [o for o in records if o.status in _MANUAL_STATUSES]
|
|
516
|
+
Path(args.manual_list).expanduser().write_text(
|
|
517
|
+
"".join(f"{o.epub}\n" for o in manual)
|
|
518
|
+
)
|
|
519
|
+
print(
|
|
520
|
+
f"manual list: {len(manual)} book(s) -> {args.manual_list}",
|
|
521
|
+
file=sys.stderr,
|
|
522
|
+
)
|
|
523
|
+
if args.json:
|
|
524
|
+
payload = {
|
|
525
|
+
"mode": "apply" if args.apply else "dry-run",
|
|
526
|
+
"root": str(root),
|
|
527
|
+
"only": args.only,
|
|
528
|
+
"validate": validate,
|
|
529
|
+
"candidates": processed,
|
|
530
|
+
"summary": {
|
|
531
|
+
"accepted": accepted,
|
|
532
|
+
"applied": applied,
|
|
533
|
+
"partial": partials,
|
|
534
|
+
"nochange": nochange,
|
|
535
|
+
"equal": equal,
|
|
536
|
+
"unvalidated": unvalidated,
|
|
537
|
+
"errors": errors,
|
|
538
|
+
"unreadable": unreadable,
|
|
539
|
+
"rejected": rejected,
|
|
540
|
+
},
|
|
541
|
+
"books": [
|
|
542
|
+
{
|
|
543
|
+
"path": str(o.epub),
|
|
544
|
+
"status": o.status,
|
|
545
|
+
"before": _counts_dict(o.before),
|
|
546
|
+
"after": _counts_dict(o.after),
|
|
547
|
+
"summary": o.summary,
|
|
548
|
+
"applied": o.epub in applied_paths,
|
|
549
|
+
}
|
|
550
|
+
for o in records
|
|
551
|
+
],
|
|
552
|
+
}
|
|
553
|
+
Path(args.json).expanduser().write_text(json.dumps(payload, indent=2) + "\n")
|
|
554
|
+
|
|
555
|
+
# 2 lets scripts and cron distinguish "ran fine but some books are in trouble"
|
|
556
|
+
# from a clean sweep (0) and a usage error (1).
|
|
557
|
+
return 2 if (rejected + errors + unreadable) > 0 else 0
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def run_repair(args) -> int:
|
|
561
|
+
src = Path(args.path).expanduser()
|
|
562
|
+
if not src.is_file():
|
|
563
|
+
print(f"error: no such file: {src}", file=sys.stderr)
|
|
564
|
+
return 1
|
|
565
|
+
dst = (
|
|
566
|
+
Path(args.output).expanduser()
|
|
567
|
+
if args.output
|
|
568
|
+
else src.with_name(f"{src.stem} (repaired).epub")
|
|
569
|
+
)
|
|
570
|
+
if dst.resolve() == src.resolve():
|
|
571
|
+
print("error: refusing to overwrite the input in place", file=sys.stderr)
|
|
572
|
+
return 1
|
|
573
|
+
if dst.exists() and not args.force:
|
|
574
|
+
print(
|
|
575
|
+
f"error: output exists: {dst} (pass --force to overwrite)",
|
|
576
|
+
file=sys.stderr,
|
|
577
|
+
)
|
|
578
|
+
return 1
|
|
579
|
+
|
|
580
|
+
with tempfile.TemporaryDirectory() as td:
|
|
581
|
+
work = Path(td)
|
|
582
|
+
try:
|
|
583
|
+
o = process_book(
|
|
584
|
+
src,
|
|
585
|
+
work,
|
|
586
|
+
validate=not args.no_validate,
|
|
587
|
+
fix_ids=args.fix_ids or getattr(args, "all", False),
|
|
588
|
+
reserialize=args.reserialize or getattr(args, "all", False),
|
|
589
|
+
strip_attrs=args.strip_bad_attrs or getattr(args, "all", False),
|
|
590
|
+
strip_pagination=args.strip_pagination or getattr(args, "all", False),
|
|
591
|
+
strip_brokentags=args.strip_broken_tags or getattr(args, "all", False),
|
|
592
|
+
strip_watermarks=args.strip_watermarks or getattr(args, "all", False),
|
|
593
|
+
escape_entities=args.escape_unknown_entities
|
|
594
|
+
or getattr(args, "all", False),
|
|
595
|
+
img_alt=args.add_img_alt or getattr(args, "all", False),
|
|
596
|
+
empty_body=args.fix_empty_body or getattr(args, "all", False),
|
|
597
|
+
missing_title=args.fix_missing_title or getattr(args, "all", False),
|
|
598
|
+
id_colons=args.fix_id_colons or getattr(args, "all", False),
|
|
599
|
+
block_in_inline=args.unwrap_block_in_inline
|
|
600
|
+
or getattr(args, "all", False),
|
|
601
|
+
invalid_value=args.strip_invalid_value or getattr(args, "all", False),
|
|
602
|
+
illegal_tags=args.unwrap_illegal_tags or getattr(args, "all", False),
|
|
603
|
+
page_map=args.fix_page_map or getattr(args, "all", False),
|
|
604
|
+
strip_epub3_attrs=args.strip_epub3_attrs or getattr(args, "all", False),
|
|
605
|
+
downgrade_epub3=args.downgrade_epub3_tags
|
|
606
|
+
or getattr(args, "all", False),
|
|
607
|
+
)
|
|
608
|
+
except (zipfile.BadZipFile, OSError, RuntimeError) as e:
|
|
609
|
+
print(f"error: cannot read {src}: {e}", file=sys.stderr)
|
|
610
|
+
return 1
|
|
611
|
+
if o.status == "nochange":
|
|
612
|
+
print("no applicable fixes; nothing written.")
|
|
613
|
+
return 0
|
|
614
|
+
if o.status == "reject":
|
|
615
|
+
print(
|
|
616
|
+
f"repair REJECTED (regression): {o.before} -> {o.after}; nothing written."
|
|
617
|
+
)
|
|
618
|
+
return 1
|
|
619
|
+
if o.status == "error":
|
|
620
|
+
print(
|
|
621
|
+
"epubcheck failed; nothing written (pass --no-validate to skip the gate).",
|
|
622
|
+
file=sys.stderr,
|
|
623
|
+
)
|
|
624
|
+
return 1
|
|
625
|
+
# Copy the exact bytes the gate accepted. Re-repairing src here would silently
|
|
626
|
+
# drop the opt-in flags (--fix-ids, --reserialize, --strip-bad-attrs) and write
|
|
627
|
+
# a file that differs from the one epubcheck validated.
|
|
628
|
+
shutil.copyfile(work / "repaired.epub", dst)
|
|
629
|
+
ba = f"{o.before} -> {o.after} " if o.before else ""
|
|
630
|
+
if o.status == "partial":
|
|
631
|
+
# The file is a real improvement and worth writing, but calling it
|
|
632
|
+
# "repaired" would read as fixed; it still will not open.
|
|
633
|
+
print(
|
|
634
|
+
f"PARTIAL (still has fatals; needs manual work): {ba}{o.summary}\n"
|
|
635
|
+
f"wrote {dst}"
|
|
636
|
+
)
|
|
637
|
+
else:
|
|
638
|
+
print(f"repaired: {ba}{o.summary}\nwrote {dst}")
|
|
639
|
+
return 0
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _add_repair_flags(p: argparse.ArgumentParser) -> None:
|
|
643
|
+
"""The fix-selection and gate flags shared by both subcommands."""
|
|
644
|
+
p.add_argument(
|
|
645
|
+
"--fix-ids",
|
|
646
|
+
action="store_true",
|
|
647
|
+
help="also rewrite invalid ids in the OPF manifest and the NCX (RSC-005)",
|
|
648
|
+
)
|
|
649
|
+
p.add_argument(
|
|
650
|
+
"--add-img-alt",
|
|
651
|
+
action="store_true",
|
|
652
|
+
help='add alt="" to <img> elements missing the required attribute '
|
|
653
|
+
"(renders identically; asserts 'decorative' to screen readers)",
|
|
654
|
+
)
|
|
655
|
+
p.add_argument(
|
|
656
|
+
"--reserialize",
|
|
657
|
+
action="store_true",
|
|
658
|
+
help="rebuild still-malformed documents via html5lib (closes unclosed elements)",
|
|
659
|
+
)
|
|
660
|
+
p.add_argument(
|
|
661
|
+
"--strip-bad-attrs",
|
|
662
|
+
action="store_true",
|
|
663
|
+
help="drop invalid attributes (digit-led names, unbound namespace prefixes)",
|
|
664
|
+
)
|
|
665
|
+
p.add_argument(
|
|
666
|
+
"--escape-unknown-entities",
|
|
667
|
+
action="store_true",
|
|
668
|
+
help="escape entity names outside the HTML5 table (&foo; -> &foo;), "
|
|
669
|
+
"rendering as browsers already render them; documents with a DOCTYPE "
|
|
670
|
+
"internal subset (which can declare custom entities) are skipped",
|
|
671
|
+
)
|
|
672
|
+
p.add_argument(
|
|
673
|
+
"--fix-empty-body",
|
|
674
|
+
action="store_true",
|
|
675
|
+
help="append to a strictly empty <body></body> (adds visible "
|
|
676
|
+
"content; hence opt-in)",
|
|
677
|
+
)
|
|
678
|
+
p.add_argument(
|
|
679
|
+
"--fix-missing-title",
|
|
680
|
+
action="store_true",
|
|
681
|
+
help="inject a <title>Unknown</title> fallback when the head has none",
|
|
682
|
+
)
|
|
683
|
+
p.add_argument(
|
|
684
|
+
"--fix-id-colons",
|
|
685
|
+
action="store_true",
|
|
686
|
+
help='translate illegal colons in id="X:Y" and their matching #X:Y '
|
|
687
|
+
"fragment references to underscores",
|
|
688
|
+
)
|
|
689
|
+
p.add_argument(
|
|
690
|
+
"--unwrap-block-in-inline",
|
|
691
|
+
action="store_true",
|
|
692
|
+
help="unwrap a <span> that illegally wraps a block element (<div>/<p>/"
|
|
693
|
+
"<blockquote>), keeping the block and its text",
|
|
694
|
+
)
|
|
695
|
+
p.add_argument(
|
|
696
|
+
"--strip-invalid-value",
|
|
697
|
+
action="store_true",
|
|
698
|
+
help='strip misplaced value="..." attributes from non-form elements',
|
|
699
|
+
)
|
|
700
|
+
p.add_argument(
|
|
701
|
+
"--unwrap-illegal-tags",
|
|
702
|
+
action="store_true",
|
|
703
|
+
help="delete illegal/deprecated tags (<st>, <sentence>, <o>, <w>, "
|
|
704
|
+
"<pagebreak>) keeping inner text; any tag a stylesheet styles as an "
|
|
705
|
+
"element selector is protected book-wide",
|
|
706
|
+
)
|
|
707
|
+
p.add_argument(
|
|
708
|
+
"--fix-page-map",
|
|
709
|
+
dest="fix_page_map",
|
|
710
|
+
action="store_true",
|
|
711
|
+
help="normalize legacy page-map markup: drop the non-standard page-map "
|
|
712
|
+
'attribute from the OPF spine and add class="pages" to classless NCX '
|
|
713
|
+
"<pageList> elements (epubcheck rejects both)",
|
|
714
|
+
)
|
|
715
|
+
p.add_argument(
|
|
716
|
+
"--strip-epub3-attrs",
|
|
717
|
+
dest="strip_epub3_attrs",
|
|
718
|
+
action="store_true",
|
|
719
|
+
help="scrub the EPUB3-only attributes epubcheck rejects on an EPUB2 "
|
|
720
|
+
"package (page-progression-direction, epub:type, aria-label; fixed set)",
|
|
721
|
+
)
|
|
722
|
+
p.add_argument(
|
|
723
|
+
"--downgrade-epub3-tags",
|
|
724
|
+
dest="downgrade_epub3_tags",
|
|
725
|
+
action="store_true",
|
|
726
|
+
help="downgrade EPUB3/HTML5 semantic elements to EPUB2 equivalents "
|
|
727
|
+
"(figure/section to div, figcaption to p; semantic name kept as a "
|
|
728
|
+
"class; names a stylesheet styles as an element selector are "
|
|
729
|
+
"protected book-wide)",
|
|
730
|
+
)
|
|
731
|
+
p.add_argument(
|
|
732
|
+
"--strip-pagination",
|
|
733
|
+
action="store_true",
|
|
734
|
+
help="LOSSY: remove print page numbers/running headers baked into the body "
|
|
735
|
+
"text by a bad conversion, rejoining sentences they split (epubcheck-gated, "
|
|
736
|
+
"accepted when no worse)",
|
|
737
|
+
)
|
|
738
|
+
p.add_argument(
|
|
739
|
+
"--strip-broken-tags",
|
|
740
|
+
action="store_true",
|
|
741
|
+
help="LOSSY: remove leaked HTML closing tags missing their open bracket (e.g. </p> rendered as text) (epubcheck-gated)",
|
|
742
|
+
)
|
|
743
|
+
p.add_argument(
|
|
744
|
+
"--strip-watermarks",
|
|
745
|
+
action="store_true",
|
|
746
|
+
help="LOSSY: remove producer/distributor watermarks (e.g. OceanofPDF) (epubcheck-gated)",
|
|
747
|
+
)
|
|
748
|
+
p.add_argument(
|
|
749
|
+
"--all",
|
|
750
|
+
action="store_true",
|
|
751
|
+
help="enable every opt-in fix flag (the safe structural repairs above "
|
|
752
|
+
"and all lossy strips)",
|
|
753
|
+
)
|
|
754
|
+
p.add_argument("--no-validate", action="store_true", help="skip the epubcheck gate")
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def run_audit_cmd(args: argparse.Namespace) -> int:
|
|
758
|
+
selected = list(ALL) if args.mode == "all" else [args.mode]
|
|
759
|
+
max_doc = args.max_doc_chars
|
|
760
|
+
if args.id is not None:
|
|
761
|
+
if args.path:
|
|
762
|
+
print("ERROR: --id audits a library book; drop the directory argument.")
|
|
763
|
+
return 2
|
|
764
|
+
rc = 0
|
|
765
|
+
for raw in str(args.id).split(","):
|
|
766
|
+
raw = raw.strip()
|
|
767
|
+
if not raw:
|
|
768
|
+
continue
|
|
769
|
+
try:
|
|
770
|
+
bid = int(raw)
|
|
771
|
+
except ValueError:
|
|
772
|
+
print(f"ERROR: --id {raw!r} is not a book id.", file=sys.stderr)
|
|
773
|
+
rc |= 2
|
|
774
|
+
continue
|
|
775
|
+
rc |= run_single(
|
|
776
|
+
bid,
|
|
777
|
+
selected,
|
|
778
|
+
args.min_chars,
|
|
779
|
+
args.thin_chars,
|
|
780
|
+
tag=args.tag,
|
|
781
|
+
max_doc_chars=max_doc,
|
|
782
|
+
)
|
|
783
|
+
return rc
|
|
784
|
+
if args.path:
|
|
785
|
+
return run_directory(
|
|
786
|
+
Path(args.path).expanduser(),
|
|
787
|
+
selected,
|
|
788
|
+
args.min_chars,
|
|
789
|
+
args.thin_chars,
|
|
790
|
+
max_doc_chars=max_doc,
|
|
791
|
+
)
|
|
792
|
+
return run_audit_library(
|
|
793
|
+
selected, args.min_chars, args.thin_chars, tag=args.tag, max_doc_chars=max_doc
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
798
|
+
# Generate an attractive, perfectly aligned help block for the shared flags
|
|
799
|
+
dummy = argparse.ArgumentParser(add_help=False, usage=argparse.SUPPRESS)
|
|
800
|
+
group = dummy.add_argument_group(
|
|
801
|
+
"shared fix flags (can be passed to either repair or library)"
|
|
802
|
+
)
|
|
803
|
+
_add_repair_flags(group)
|
|
804
|
+
lib_group = dummy.add_argument_group("library-specific integration")
|
|
805
|
+
lib_group.add_argument(
|
|
806
|
+
"--install-to-calibre",
|
|
807
|
+
action="store_true",
|
|
808
|
+
help="with --apply, use calibredb to natively replace the format in the Calibre database instead of filesystem replace",
|
|
809
|
+
)
|
|
810
|
+
shared_help = dummy.format_help().strip()
|
|
811
|
+
|
|
812
|
+
ap = argparse.ArgumentParser(
|
|
813
|
+
prog="bindery",
|
|
814
|
+
description="Repair EPUBs, epubcheck-gated.",
|
|
815
|
+
epilog=shared_help,
|
|
816
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
817
|
+
)
|
|
818
|
+
ap.add_argument("--version", action="version", version=f"bindery {__version__}")
|
|
819
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
820
|
+
|
|
821
|
+
r = sub.add_parser("repair", help="repair a single EPUB to a new file")
|
|
822
|
+
r.add_argument("path")
|
|
823
|
+
r.add_argument("output", nargs="?")
|
|
824
|
+
r.add_argument(
|
|
825
|
+
"--force",
|
|
826
|
+
action="store_true",
|
|
827
|
+
help="overwrite the output file if it already exists",
|
|
828
|
+
)
|
|
829
|
+
_add_repair_flags(r)
|
|
830
|
+
r.set_defaults(func=run_repair)
|
|
831
|
+
|
|
832
|
+
audit = sub.add_parser(
|
|
833
|
+
"audit",
|
|
834
|
+
help="audit EPUB body text to detect non-schema content flaws (OCR damage, hardcoded page numbers, empty books, non-English text)",
|
|
835
|
+
)
|
|
836
|
+
audit.add_argument(
|
|
837
|
+
"mode",
|
|
838
|
+
choices=("content", "pagenumbers", "emptytext", "ocr", "monolithic", "all"),
|
|
839
|
+
help="which audit to run",
|
|
840
|
+
)
|
|
841
|
+
audit.add_argument(
|
|
842
|
+
"path",
|
|
843
|
+
nargs="?",
|
|
844
|
+
help="vet loose .epub files under this directory instead of the library",
|
|
845
|
+
)
|
|
846
|
+
audit.add_argument(
|
|
847
|
+
"--min-chars",
|
|
848
|
+
type=int,
|
|
849
|
+
default=DEFAULT_MIN_CHARS,
|
|
850
|
+
help="emptytext EMPTY threshold",
|
|
851
|
+
)
|
|
852
|
+
audit.add_argument(
|
|
853
|
+
"--thin-chars",
|
|
854
|
+
type=int,
|
|
855
|
+
default=DEFAULT_THIN_CHARS,
|
|
856
|
+
help="emptytext THIN advisory threshold",
|
|
857
|
+
)
|
|
858
|
+
audit.add_argument(
|
|
859
|
+
"--max-doc-chars",
|
|
860
|
+
type=int,
|
|
861
|
+
default=DEFAULT_MAX_DOC_CHARS,
|
|
862
|
+
help="monolithic FLAG threshold (chars in ONE content document)",
|
|
863
|
+
)
|
|
864
|
+
audit.add_argument(
|
|
865
|
+
"--tag",
|
|
866
|
+
default=None,
|
|
867
|
+
metavar="TAG",
|
|
868
|
+
help="after a library-mode audit, tag every flagged book in metadata.db "
|
|
869
|
+
"via cquarry's write module (Calibre must be closed; books are queued "
|
|
870
|
+
"for OPF regeneration automatically)",
|
|
871
|
+
)
|
|
872
|
+
audit.add_argument(
|
|
873
|
+
"--id",
|
|
874
|
+
metavar="BOOK_IDS",
|
|
875
|
+
default=None,
|
|
876
|
+
help="audit library book(s) by Calibre id — one id or a comma-separated "
|
|
877
|
+
"list (fetched via cquarry's single-entity get_book; cannot be "
|
|
878
|
+
"combined with a directory)",
|
|
879
|
+
)
|
|
880
|
+
audit.set_defaults(func=run_audit_cmd)
|
|
881
|
+
|
|
882
|
+
lib = sub.add_parser("library", help="scan/repair a Calibre library tree")
|
|
883
|
+
lib.add_argument("path")
|
|
884
|
+
lib.add_argument(
|
|
885
|
+
"--apply",
|
|
886
|
+
action="store_true",
|
|
887
|
+
help="atomically replace accepted books in place (default: dry run)",
|
|
888
|
+
)
|
|
889
|
+
lib.add_argument(
|
|
890
|
+
"--only",
|
|
891
|
+
choices=("fatals", "ncx", "all"),
|
|
892
|
+
default="all",
|
|
893
|
+
help="restrict to books with fatals, NCX-001 mismatch, or all (default)",
|
|
894
|
+
)
|
|
895
|
+
lib.add_argument(
|
|
896
|
+
"--audit", help="audit CSV (fatals,errors,warnings,path) to filter candidates"
|
|
897
|
+
)
|
|
898
|
+
lib.add_argument(
|
|
899
|
+
"--sweep",
|
|
900
|
+
action="store_true",
|
|
901
|
+
help="select candidates via a live epubcheck sweep instead of an --audit CSV "
|
|
902
|
+
"(each sweep result doubles as that book's 'before' measurement)",
|
|
903
|
+
)
|
|
904
|
+
lib.add_argument(
|
|
905
|
+
"--json",
|
|
906
|
+
metavar="FILE",
|
|
907
|
+
help="write a machine-readable JSON report of the run to FILE",
|
|
908
|
+
)
|
|
909
|
+
lib.add_argument(
|
|
910
|
+
"--manual-list",
|
|
911
|
+
metavar="FILE",
|
|
912
|
+
help="write the paths of books that were not auto-repaired "
|
|
913
|
+
"(nochange/equal/partial/reject/error/unreadable), one per line",
|
|
914
|
+
)
|
|
915
|
+
lib.add_argument(
|
|
916
|
+
"--install-to-calibre",
|
|
917
|
+
action="store_true",
|
|
918
|
+
help="with --apply, use calibredb to natively replace the format in the Calibre database instead of filesystem replace",
|
|
919
|
+
)
|
|
920
|
+
lib.add_argument(
|
|
921
|
+
"--id",
|
|
922
|
+
default="",
|
|
923
|
+
metavar="IDS",
|
|
924
|
+
help="comma-separated Calibre book ids to scope the sweep to "
|
|
925
|
+
"(resolved via cquarry's get_format_path; mutually exclusive with --audit)",
|
|
926
|
+
)
|
|
927
|
+
lib.add_argument("--backup", help="directory to mirror backups into before --apply")
|
|
928
|
+
lib.add_argument(
|
|
929
|
+
"--backup-inplace",
|
|
930
|
+
action="store_true",
|
|
931
|
+
help="with --apply, write a .epub.bak beside each replaced file",
|
|
932
|
+
)
|
|
933
|
+
lib.add_argument(
|
|
934
|
+
"--limit", type=int, help="process at most N candidates (for sampling)"
|
|
935
|
+
)
|
|
936
|
+
lib.add_argument(
|
|
937
|
+
"--quiet",
|
|
938
|
+
action="store_true",
|
|
939
|
+
help="suppress the per-book progress line on stderr",
|
|
940
|
+
)
|
|
941
|
+
_add_repair_flags(lib)
|
|
942
|
+
lib.set_defaults(func=run_library)
|
|
943
|
+
return ap
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def main(argv: list[str] | None = None) -> int:
|
|
947
|
+
# Line-buffer stdout so per-book progress is visible live even when redirected to a
|
|
948
|
+
# file or pipe (otherwise a long library run shows nothing until the buffer fills).
|
|
949
|
+
try:
|
|
950
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
951
|
+
except AttributeError:
|
|
952
|
+
pass
|
|
953
|
+
args = build_parser().parse_args(argv)
|
|
954
|
+
try:
|
|
955
|
+
return args.func(args)
|
|
956
|
+
except KeyboardInterrupt:
|
|
957
|
+
# A library run can take a long time; end a Ctrl-C cleanly instead of with a
|
|
958
|
+
# traceback. In-flight work is safe: the original is only ever touched by the
|
|
959
|
+
# atomic os.replace.
|
|
960
|
+
print("\ninterrupted", file=sys.stderr)
|
|
961
|
+
return 130
|