scourgify 1.0.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.
- scourgify/__init__.py +3 -0
- scourgify/__main__.py +4 -0
- scourgify/_writer.py +45 -0
- scourgify/afm.swift +30 -0
- scourgify/classify.py +403 -0
- scourgify/cli.py +31 -0
- scourgify/common.py +118 -0
- scourgify/defaults/characters.csv +220 -0
- scourgify/defaults/classify_vocab.txt +120 -0
- scourgify/defaults/decompose.csv +7 -0
- scourgify/defaults/fandom_blocklist.txt +57 -0
- scourgify/defaults/fandoms.csv +277 -0
- scourgify/defaults/genres_allow.txt +64 -0
- scourgify/defaults/genres_canon.csv +12 -0
- scourgify/defaults/genres_split.csv +7 -0
- scourgify/defaults/junk.txt +280 -0
- scourgify/defaults/ratings.txt +19 -0
- scourgify/defaults/tropes.csv +859 -0
- scourgify/staleness.py +72 -0
- scourgify/ui.py +67 -0
- scourgify/wizard.py +245 -0
- scourgify/wrangle.py +460 -0
- scourgify-1.0.0.dist-info/METADATA +259 -0
- scourgify-1.0.0.dist-info/RECORD +27 -0
- scourgify-1.0.0.dist-info/WHEEL +4 -0
- scourgify-1.0.0.dist-info/entry_points.txt +2 -0
- scourgify-1.0.0.dist-info/licenses/LICENSE +21 -0
scourgify/__init__.py
ADDED
scourgify/__main__.py
ADDED
scourgify/_writer.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Internal Calibre write helper — invoked UNDER calibre-debug by wrangle.py / classify.py, never by hand.
|
|
3
|
+
|
|
4
|
+
The standalone (system-python) tools compute everything via read-only sqlite, then shell out to:
|
|
5
|
+
calibre-debug -e _writer.py -- <ops.json>
|
|
6
|
+
to perform the writes through Calibre's API — the only fast batched write path (calibredb's set_metadata is
|
|
7
|
+
one book per process). One generic op list keeps Calibre access in a single ~30-line stub.
|
|
8
|
+
|
|
9
|
+
ops.json = [ {op: ...}, ... ]:
|
|
10
|
+
{"op":"create_column","label":"wrangled","name":"Wrangled","datatype":"datetime","is_multiple":false}
|
|
11
|
+
{"op":"set_field","field":"tags","values":{"<book_id>": ["a","b"] | "scalar"}} # shape coerced to the column
|
|
12
|
+
{"op":"stamp_now","field":"#wrangled","books":[id,...] | null} # null = all books
|
|
13
|
+
{"op":"set_pref","key":"namespaced:...","value":{...}}
|
|
14
|
+
"""
|
|
15
|
+
import os, sys, json
|
|
16
|
+
from calibre.library import db as DB
|
|
17
|
+
|
|
18
|
+
LIB = os.path.expanduser(os.environ.get("CALIBRE_LIBRARY", ""))
|
|
19
|
+
if not LIB: raise SystemExit("_writer: CALIBRE_LIBRARY not set")
|
|
20
|
+
ops = json.load(open(sys.argv[-1]))
|
|
21
|
+
legacy = DB(LIB); api = legacy.new_api
|
|
22
|
+
|
|
23
|
+
for op in ops:
|
|
24
|
+
kind = op["op"]
|
|
25
|
+
if kind == "create_column":
|
|
26
|
+
if "#" + op["label"].lstrip("#") not in api.field_metadata.all_field_keys():
|
|
27
|
+
legacy.create_custom_column(op["label"].lstrip("#"), op["name"], op["datatype"], op.get("is_multiple", False))
|
|
28
|
+
legacy = DB(LIB); api = legacy.new_api # reopen so the new column is usable
|
|
29
|
+
print(f" created #{op['label'].lstrip('#')}")
|
|
30
|
+
elif kind == "set_field":
|
|
31
|
+
field = op["field"]
|
|
32
|
+
mult = bool(api.field_metadata.all_metadata().get(field, {}).get("is_multiple"))
|
|
33
|
+
vals = {}
|
|
34
|
+
for b, v in op["values"].items():
|
|
35
|
+
vals[int(b)] = (tuple(v) if isinstance(v, list) else (v,)) if mult else ((v[0] if isinstance(v, list) and v else v) or None)
|
|
36
|
+
api.set_field(field, vals)
|
|
37
|
+
print(f" set {field}: {len(vals)} books")
|
|
38
|
+
elif kind == "stamp_now":
|
|
39
|
+
from calibre.utils.date import now as cal_now
|
|
40
|
+
books = op["books"] if op.get("books") is not None else list(api.all_book_ids())
|
|
41
|
+
ts = cal_now(); api.set_field(op["field"], {int(b): ts for b in books})
|
|
42
|
+
print(f" stamped {op['field']}: {len(books)} books")
|
|
43
|
+
elif kind == "set_pref":
|
|
44
|
+
api.set_pref(op["key"], op["value"]); print(f" set pref {op['key']}")
|
|
45
|
+
print("WROTE.")
|
scourgify/afm.swift
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Apple Foundation Models bridge for calibre-wrangler (macOS 26+, Apple Intelligence).
|
|
2
|
+
// Persistent stdin loop: each input line is one prompt (literal newlines escaped as );
|
|
3
|
+
// prints one response line per prompt. Used by classify.py --engine apple.
|
|
4
|
+
// build: swiftc -O afm.swift -o afm (or run directly: swift afm.swift)
|
|
5
|
+
import Foundation
|
|
6
|
+
import FoundationModels
|
|
7
|
+
|
|
8
|
+
@available(macOS 26.0, *)
|
|
9
|
+
func loop() async {
|
|
10
|
+
guard case .available = SystemLanguageModel.default.availability else {
|
|
11
|
+
FileHandle.standardError.write(Data("AFM_UNAVAILABLE\n".utf8)); exit(2)
|
|
12
|
+
}
|
|
13
|
+
while let line = readLine(strippingNewline: true) {
|
|
14
|
+
let prompt = line.replacingOccurrences(of: "\u{01}", with: "\n")
|
|
15
|
+
var out = "ERR"
|
|
16
|
+
do {
|
|
17
|
+
let resp = try await LanguageModelSession().respond(to: prompt)
|
|
18
|
+
out = resp.content.replacingOccurrences(of: "\n", with: " ")
|
|
19
|
+
} catch { out = "ERR: \(error)".replacingOccurrences(of: "\n", with: " ") }
|
|
20
|
+
print(out); fflush(stdout)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if #available(macOS 26.0, *) {
|
|
25
|
+
let sem = DispatchSemaphore(value: 0)
|
|
26
|
+
Task { await loop(); sem.signal() }
|
|
27
|
+
sem.wait()
|
|
28
|
+
} else {
|
|
29
|
+
FileHandle.standardError.write(Data("needs macOS 26+\n".utf8)); exit(1)
|
|
30
|
+
}
|
scourgify/classify.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Content-based tagging from a controlled vocabulary, with TWO outputs per book:
|
|
3
|
+
1) added_tags — tags chosen from defaults/classify_vocab.txt (the consolidated set); these get APPLIED.
|
|
4
|
+
2) proposed_new — short reusable tags the model thinks apply but are NOT in the vocab yet; aggregated into
|
|
5
|
+
classify_newtags_ranked.csv for review, so the vocabulary grows cleanly (promote -> vocab).
|
|
6
|
+
|
|
7
|
+
scourgify classify [--engine apple|claude|openai|gemini] [--incremental] [--workers N] [--batch N] [--fresh]
|
|
8
|
+
scourgify classify --apply # apply 'added_tags' + stamp #wrangled (Calibre CLOSED; writes shell to calibre-debug)
|
|
9
|
+
|
|
10
|
+
Engines (--engine): apple = on-device Apple Foundation Models via ./afm (free; macOS 26+).
|
|
11
|
+
claude = Anthropic (ANTHROPIC_API_KEY) | openai = OpenAI (OPENAI_API_KEY) | gemini = Google (GEMINI_API_KEY).
|
|
12
|
+
--model overrides the per-engine default. Only books with < --min-tags tags AND a description are processed.
|
|
13
|
+
Runs are resumable (skip books already in the proposal; --fresh restarts). Dry-run until --apply.
|
|
14
|
+
--incremental = cheap maintenance after new downloads: (re)process only books whose #updated is newer than their own
|
|
15
|
+
#wrangled marker (or never wrangled), plus any still untagged. --apply auto-creates the #wrangled datetime
|
|
16
|
+
column and stamps each tagged book, so the state lives IN the library — no external file. (--since DATE
|
|
17
|
+
forces an explicit cutoff against #updated.) Avoids the ~full-library cost of a --fresh pass."""
|
|
18
|
+
import argparse, os, re, csv, json, subprocess, collections, time
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
20
|
+
from scourgify.common import HERE, DATA, ro_connect, read_custom_column, custom_column_id, run_writer, library
|
|
21
|
+
try: # rich is optional: live dashboard/tables in system python3
|
|
22
|
+
from rich.console import Console, Group
|
|
23
|
+
from rich.live import Live
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.progress import Progress, BarColumn, TextColumn, MofNCompleteColumn, TimeRemainingColumn
|
|
26
|
+
from rich.table import Table
|
|
27
|
+
from rich.text import Text
|
|
28
|
+
_con = Console(stderr=True); RICH = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
RICH = False
|
|
31
|
+
|
|
32
|
+
VOCAB = [l.strip() for l in open(f"{HERE}/defaults/classify_vocab.txt") if l.strip() and not l.startswith("#")]
|
|
33
|
+
VLOW = {v.lower(): v for v in VOCAB}
|
|
34
|
+
PROP = f"{DATA}/classify_proposal.csv"
|
|
35
|
+
RANK = f"{DATA}/classify_newtags_ranked.csv"
|
|
36
|
+
FAIL = f"{DATA}/classify_failures.csv"
|
|
37
|
+
SPEND_GATE = 200 # cloud runs above this many books require an explicit yes
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def prompt_for(desc, maxtags):
|
|
41
|
+
return ("You are tagging a fanfiction story. Return ONLY a JSON object with two arrays:\n"
|
|
42
|
+
f' "tags": tags from the CONTROLLED LIST below that clearly apply (exact spelling, at most {maxtags}; '
|
|
43
|
+
"be conservative; [] if vague; do NOT echo the whole list).\n"
|
|
44
|
+
' "new": up to 3 SHORT reusable trope/genre/theme tags (Title Case) that clearly apply but are NOT in the '
|
|
45
|
+
"list and would be worth adding to the vocabulary. No plot specifics, character names, or fandoms; [] if none.\n"
|
|
46
|
+
f"CONTROLLED LIST: {', '.join(VOCAB)}\n\nDESCRIPTION:\n{desc[:1500]}\n\nJSON:")
|
|
47
|
+
|
|
48
|
+
def parse_resp(text, maxtags=6):
|
|
49
|
+
m = re.search(r"\{.*\}", text, re.S)
|
|
50
|
+
if not m: return [], []
|
|
51
|
+
try: obj = json.loads(m.group(0))
|
|
52
|
+
except Exception: return [], []
|
|
53
|
+
vt = [VLOW[str(t).strip().lower()] for t in obj.get("tags", []) if str(t).strip().lower() in VLOW]
|
|
54
|
+
if len(vt) > maxtags * 2: vt = [] # model echoed the list, not selecting
|
|
55
|
+
nt, seen = [], set()
|
|
56
|
+
for t in obj.get("new", []):
|
|
57
|
+
t = str(t).strip()
|
|
58
|
+
# first char must be alphanumeric: also blocks =/+/-/@ spreadsheet-formula injection in the review CSVs
|
|
59
|
+
if t and t[0].isalnum() and 1 < len(t) <= 40 and t.lower() not in VLOW and t.lower() not in seen:
|
|
60
|
+
seen.add(t.lower()); nt.append(t)
|
|
61
|
+
return vt[:maxtags], nt[:3]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---- engines ----
|
|
65
|
+
class Apple:
|
|
66
|
+
def __init__(self, model, timeout):
|
|
67
|
+
exe = f"{HERE}/afm" if os.path.exists(f"{HERE}/afm") else None
|
|
68
|
+
cmd = [exe] if exe else ["swift", f"{HERE}/afm.swift"]
|
|
69
|
+
self.p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1)
|
|
70
|
+
def ask(self, prompt):
|
|
71
|
+
self.p.stdin.write(prompt.replace("\n", "") + "\n"); self.p.stdin.flush()
|
|
72
|
+
return self.p.stdout.readline()
|
|
73
|
+
|
|
74
|
+
class Claude:
|
|
75
|
+
def __init__(self, model, timeout):
|
|
76
|
+
self.key = os.environ.get("ANTHROPIC_API_KEY")
|
|
77
|
+
if not self.key: raise SystemExit("claude engine needs ANTHROPIC_API_KEY (or use --engine apple).")
|
|
78
|
+
self.model = model or "claude-haiku-4-5-20251001"; self.timeout = timeout
|
|
79
|
+
def ask(self, prompt):
|
|
80
|
+
import urllib.request
|
|
81
|
+
body = json.dumps({"model": self.model, "max_tokens": 300,
|
|
82
|
+
"messages": [{"role": "user", "content": prompt}]}).encode()
|
|
83
|
+
req = urllib.request.Request("https://api.anthropic.com/v1/messages", data=body,
|
|
84
|
+
headers={"x-api-key": self.key, "anthropic-version": "2023-06-01", "content-type": "application/json"})
|
|
85
|
+
return json.load(urllib.request.urlopen(req, timeout=self.timeout))["content"][0]["text"]
|
|
86
|
+
|
|
87
|
+
class OpenAI:
|
|
88
|
+
def __init__(self, model, timeout):
|
|
89
|
+
self.key = os.environ.get("OPENAI_API_KEY")
|
|
90
|
+
if not self.key: raise SystemExit("openai engine needs OPENAI_API_KEY.")
|
|
91
|
+
self.model = model or "gpt-4o-mini"; self.timeout = timeout
|
|
92
|
+
def ask(self, prompt):
|
|
93
|
+
import urllib.request
|
|
94
|
+
body = json.dumps({"model": self.model, "max_tokens": 300,
|
|
95
|
+
"messages": [{"role": "user", "content": prompt}]}).encode()
|
|
96
|
+
req = urllib.request.Request("https://api.openai.com/v1/chat/completions", data=body,
|
|
97
|
+
headers={"Authorization": f"Bearer {self.key}", "content-type": "application/json"})
|
|
98
|
+
return json.load(urllib.request.urlopen(req, timeout=self.timeout))["choices"][0]["message"]["content"]
|
|
99
|
+
|
|
100
|
+
class Gemini:
|
|
101
|
+
# personal fanfic library: don't let safety filters drop mature/dark stories (the tag list itself lists such terms)
|
|
102
|
+
SAFE = [{"category": c, "threshold": "BLOCK_NONE"} for c in
|
|
103
|
+
("HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH",
|
|
104
|
+
"HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT")]
|
|
105
|
+
def __init__(self, model, timeout):
|
|
106
|
+
self.key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
|
107
|
+
if not self.key: raise SystemExit("gemini engine needs GEMINI_API_KEY (or GOOGLE_API_KEY).")
|
|
108
|
+
self.model = model or "gemini-2.5-flash"; self.timeout = timeout
|
|
109
|
+
def ask(self, prompt):
|
|
110
|
+
import urllib.request
|
|
111
|
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:generateContent"
|
|
112
|
+
body = json.dumps({"contents": [{"parts": [{"text": prompt}]}], "safetySettings": self.SAFE,
|
|
113
|
+
"generationConfig": {"maxOutputTokens": 2048}}).encode() # bound cost; roomy for thinking models
|
|
114
|
+
req = urllib.request.Request(url, data=body,
|
|
115
|
+
headers={"content-type": "application/json", "x-goog-api-key": self.key}) # key in header, never in the URL
|
|
116
|
+
r = json.load(urllib.request.urlopen(req, timeout=self.timeout))
|
|
117
|
+
cands = r.get("candidates")
|
|
118
|
+
if not cands: raise RuntimeError("blocked:" + str(r.get("promptFeedback", {}).get("blockReason")))
|
|
119
|
+
parts = cands[0].get("content", {}).get("parts")
|
|
120
|
+
if not parts: raise RuntimeError("nocontent:" + str(cands[0].get("finishReason")))
|
|
121
|
+
return parts[0]["text"]
|
|
122
|
+
|
|
123
|
+
ENGINES = {"apple": Apple, "claude": Claude, "openai": OpenAI, "gemini": Gemini}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---- live run display ----
|
|
127
|
+
def sparkline(vals, width=28):
|
|
128
|
+
"""Unicode sparkline of a numeric series (last `width` points), scaled to its max."""
|
|
129
|
+
vals = [v for v in vals][-width:]
|
|
130
|
+
if not vals: return ""
|
|
131
|
+
blocks = "▁▂▃▄▅▆▇█"
|
|
132
|
+
hi = max(vals)
|
|
133
|
+
if hi <= 0: return blocks[0] * len(vals)
|
|
134
|
+
return "".join(blocks[min(7, int(v * 8 / hi))] for v in vals)
|
|
135
|
+
|
|
136
|
+
class _Dashboard:
|
|
137
|
+
"""Live display for a classify run: progress bar, running numbers (tagged / failed /
|
|
138
|
+
no-match / rate), a throughput sparkline, and the rising new-tag candidates.
|
|
139
|
+
rich renders it live; without rich it degrades to a checkpoint line every 25 books."""
|
|
140
|
+
BUCKET = 5.0 # seconds per throughput bucket
|
|
141
|
+
|
|
142
|
+
def __init__(self, todo_n, done_before, targets_n):
|
|
143
|
+
self.total, self.done_before, self.targets = todo_n, done_before, targets_n
|
|
144
|
+
self.n = self.tagged = self.fails = 0
|
|
145
|
+
self.newtags = collections.Counter()
|
|
146
|
+
self.t0 = time.monotonic(); self.hist = [0]
|
|
147
|
+
self.live = self.prog = self.task = None
|
|
148
|
+
|
|
149
|
+
def __enter__(self):
|
|
150
|
+
if RICH and self.total:
|
|
151
|
+
self.prog = Progress(TextColumn("[cyan]classifying"), BarColumn(bar_width=None),
|
|
152
|
+
MofNCompleteColumn(), TimeRemainingColumn(), console=_con)
|
|
153
|
+
self.task = self.prog.add_task("", total=self.total)
|
|
154
|
+
self.live = Live(self._render(), console=_con, refresh_per_second=4)
|
|
155
|
+
self.live.__enter__()
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
def __exit__(self, *exc):
|
|
159
|
+
if self.live: self.live.__exit__(*exc)
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
def update(self, vt, nt, err):
|
|
163
|
+
self.n += 1
|
|
164
|
+
if err: self.fails += 1
|
|
165
|
+
elif vt: self.tagged += 1
|
|
166
|
+
self.newtags.update(nt)
|
|
167
|
+
b = int((time.monotonic() - self.t0) // self.BUCKET)
|
|
168
|
+
while len(self.hist) <= b: self.hist.append(0)
|
|
169
|
+
self.hist[b] += 1
|
|
170
|
+
if self.live:
|
|
171
|
+
self.prog.update(self.task, advance=1)
|
|
172
|
+
self.live.update(self._render())
|
|
173
|
+
elif self.n % 25 == 0:
|
|
174
|
+
el = time.monotonic() - self.t0
|
|
175
|
+
print(f" +{self.n}/{self.total} … {self.tagged} tagged, {self.fails} failed, {self.n / el:.1f}/s")
|
|
176
|
+
|
|
177
|
+
def _render(self):
|
|
178
|
+
el = time.monotonic() - self.t0
|
|
179
|
+
rate = self.n / el if el > 1 else 0.0
|
|
180
|
+
g = Table.grid(padding=(0, 2))
|
|
181
|
+
g.add_row("[bold]this run[/]", f"{self.n}/{self.total}",
|
|
182
|
+
"[green]tagged[/]", str(self.tagged),
|
|
183
|
+
"[red]failed[/]", str(self.fails),
|
|
184
|
+
"[dim]no match[/]", str(max(0, self.n - self.tagged - self.fails)),
|
|
185
|
+
"[bold]rate[/]", f"{rate:.1f}/s")
|
|
186
|
+
parts = [self.prog, g]
|
|
187
|
+
spark = sparkline(self.hist)
|
|
188
|
+
if spark: parts.append(Text.assemble(("throughput ", "bold"), (spark, "cyan")))
|
|
189
|
+
if self.newtags:
|
|
190
|
+
top = " · ".join(f"{t} ×{c}" for t, c in self.newtags.most_common(5))
|
|
191
|
+
parts.append(Text.assemble(("rising candidates ", "bold"), (top, "magenta")))
|
|
192
|
+
return Panel(Group(*parts), border_style="cyan", padding=(0, 1),
|
|
193
|
+
title=f"classify — {self.done_before + self.n}/{self.targets} total")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---- apply: 'added_tags' + stamp #wrangled — standalone, no LLM calls ----
|
|
197
|
+
def apply_proposal():
|
|
198
|
+
if not os.path.exists(PROP):
|
|
199
|
+
raise SystemExit(f"no proposal to apply ({os.path.basename(PROP)} not found — run a classify pass first).")
|
|
200
|
+
con = ro_connect()
|
|
201
|
+
cur = collections.defaultdict(list)
|
|
202
|
+
for b, t in con.execute("SELECT l.book, t.name FROM books_tags_link l JOIN tags t ON t.id=l.tag"): cur[b].append(t)
|
|
203
|
+
have_wrangled = custom_column_id(con, "wrangled") is not None
|
|
204
|
+
chg = {}
|
|
205
|
+
for r in csv.DictReader(open(PROP)):
|
|
206
|
+
b = int(r["book_id"]); tags = [t for t in r.get("added_tags", "").split("; ") if t.strip()]
|
|
207
|
+
if tags: chg[str(b)] = sorted(set(cur.get(b, [])) | set(tags)) # union with current tags
|
|
208
|
+
ops = []
|
|
209
|
+
if not have_wrangled: # first run: create + backfill whole library as wrangled-now
|
|
210
|
+
ops.append({"op": "create_column", "label": "wrangled", "name": "Wrangled", "datatype": "datetime", "is_multiple": False})
|
|
211
|
+
ops.append({"op": "stamp_now", "field": "#wrangled", "books": None})
|
|
212
|
+
ops.append({"op": "set_field", "field": "tags", "values": chg})
|
|
213
|
+
ops.append({"op": "stamp_now", "field": "#wrangled", "books": [int(b) for b in chg]})
|
|
214
|
+
run_writer(ops)
|
|
215
|
+
# archive so a later --apply can't re-add tags you've since hand-removed (stale rows never re-apply)
|
|
216
|
+
arch = PROP.replace(".csv", f"_applied_{time.strftime('%Y%m%d-%H%M%S')}.csv")
|
|
217
|
+
os.rename(PROP, arch)
|
|
218
|
+
print(f"applied tags to {len(chg)} books + stamped #wrangled; proposal archived -> {os.path.basename(arch)}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---- gather books (read-only) ----
|
|
222
|
+
def strip_html(s): return re.sub(r"<[^>]+>", " ", s or "").strip()
|
|
223
|
+
|
|
224
|
+
def book_text(path, limit=6000):
|
|
225
|
+
if not path or not os.path.exists(path): return ""
|
|
226
|
+
if path.lower().endswith(".epub"): # fast path: epub is a zip of XHTML
|
|
227
|
+
import zipfile
|
|
228
|
+
try:
|
|
229
|
+
z = zipfile.ZipFile(path); out = []
|
|
230
|
+
for n in z.namelist():
|
|
231
|
+
if not n.lower().endswith((".xhtml", ".html", ".htm")): continue
|
|
232
|
+
if z.getinfo(n).file_size > 2_000_000: continue # untrusted download: skip zip-bomb members
|
|
233
|
+
t = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", z.read(n).decode("utf-8", "ignore"))).strip()
|
|
234
|
+
if len(t) > 200: out.append(t) # skip nav/title pages
|
|
235
|
+
if sum(len(x) for x in out) > limit: break
|
|
236
|
+
return " ".join(out)[:limit]
|
|
237
|
+
except Exception: return ""
|
|
238
|
+
import tempfile # other formats (MOBI/PDF/DOCX/…): let calibre extract
|
|
239
|
+
try:
|
|
240
|
+
with tempfile.TemporaryDirectory() as td:
|
|
241
|
+
o = os.path.join(td, "o.txt")
|
|
242
|
+
subprocess.run(["ebook-convert", path, o], capture_output=True, timeout=180)
|
|
243
|
+
return re.sub(r"\s+", " ", open(o, errors="ignore").read()).strip()[:limit] if os.path.exists(o) else ""
|
|
244
|
+
except Exception: return ""
|
|
245
|
+
|
|
246
|
+
def gather(a):
|
|
247
|
+
"""-> (targets [(book, text)], titles, needs) for books under --min-tags or changed since last wrangle."""
|
|
248
|
+
con = ro_connect(); c = con.cursor()
|
|
249
|
+
tagn = {b: 0 for (b,) in c.execute("SELECT id FROM books")}
|
|
250
|
+
for (b,) in c.execute("SELECT book FROM books_tags_link"): tagn[b] = tagn.get(b, 0) + 1
|
|
251
|
+
desc = {b: t for b, t in c.execute("SELECT book, text FROM comments")}
|
|
252
|
+
updated = wrangled = {}
|
|
253
|
+
if a.since or a.incremental:
|
|
254
|
+
updated = {b: str(v)[:10] for b, v in (read_custom_column(con, "#updated") or {}).items()}
|
|
255
|
+
if a.incremental: wrangled = {b: str(v)[:10] for b, v in (read_custom_column(con, "#wrangled") or {}).items()}
|
|
256
|
+
def needs(b): # changed since we last tagged it, or after an explicit --since date
|
|
257
|
+
if a.incremental and (b not in wrangled or updated.get(b, "") > wrangled.get(b, "")): return True
|
|
258
|
+
if a.since and updated.get(b, "") >= a.since: return True
|
|
259
|
+
return False
|
|
260
|
+
bookfile = {}
|
|
261
|
+
if a.text_fallback: # when the description is thin, sample the book's own text
|
|
262
|
+
bp = {b: p for b, p in c.execute("SELECT id, path FROM books")}
|
|
263
|
+
byb = {}
|
|
264
|
+
for b, fmt, name in c.execute("SELECT book, format, name FROM data"):
|
|
265
|
+
byb.setdefault(b, {})[fmt.upper()] = os.path.join(library(), bp[b], name + "." + fmt.lower())
|
|
266
|
+
for b, fm in byb.items():
|
|
267
|
+
bookfile[b] = fm.get("EPUB") or next(iter(fm.values())) # prefer EPUB, else any available format
|
|
268
|
+
def text_for(b):
|
|
269
|
+
d = strip_html(desc.get(b, ""))
|
|
270
|
+
if len(d) >= 80 or not a.text_fallback: return d
|
|
271
|
+
et = book_text(bookfile.get(b, ""))
|
|
272
|
+
return (d + " " + et).strip() if et else d
|
|
273
|
+
targets = [(b, text_for(b)) for b in tagn if tagn[b] < a.min_tags or needs(b)]
|
|
274
|
+
if a.incremental or a.since: print(f" incremental: {sum(1 for b in tagn if needs(b))} books changed since last wrangle")
|
|
275
|
+
targets = [(b, t) for b, t in targets if t and len(t) >= 40]
|
|
276
|
+
titles = {b: t for b, t in c.execute("SELECT id, title FROM books")}
|
|
277
|
+
if a.limit: targets = targets[:a.limit]
|
|
278
|
+
return targets, titles, needs
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def classify_run(a):
|
|
282
|
+
targets, titles, needs = gather(a)
|
|
283
|
+
print(f"engine={a.engine} books to process (< {a.min_tags} tags, has description): {len(targets)}")
|
|
284
|
+
|
|
285
|
+
proposal, done = {}, set() # book -> (vocab_tags, proposed_new_tags)
|
|
286
|
+
if os.path.exists(PROP) and not a.fresh: # resume: skip books already in proposal
|
|
287
|
+
for r in csv.DictReader(open(PROP)):
|
|
288
|
+
bid = int(r["book_id"]); at = [t for t in r.get("added_tags", "").split("; ") if t.strip()]
|
|
289
|
+
proposal[bid] = (at, [t for t in r.get("proposed_new", "").split("; ") if t.strip()])
|
|
290
|
+
if (at or not a.text_fallback) and not needs(bid): done.add(bid) # re-process books changed since last wrangle
|
|
291
|
+
if done: print(f" resuming: {len(done)} already in proposal (pass --fresh to restart)")
|
|
292
|
+
def dump():
|
|
293
|
+
with open(PROP, "w", newline="") as f:
|
|
294
|
+
w = csv.writer(f); w.writerow(["book_id", "title", "added_tags", "proposed_new"])
|
|
295
|
+
for b, (vt, nt) in proposal.items(): w.writerow([b, titles.get(b, ""), "; ".join(vt), "; ".join(nt)])
|
|
296
|
+
|
|
297
|
+
todo = [(b, d) for b, d in targets if b not in done]
|
|
298
|
+
if a.batch: todo = todo[:a.batch]
|
|
299
|
+
if a.engine != "apple" and len(todo) > SPEND_GATE and not a.yes: # spend gate: cloud runs cost real money
|
|
300
|
+
import sys
|
|
301
|
+
msg = f"about to send {len(todo)} books to the {a.engine} API (costs money; --incremental/--batch shrink it)."
|
|
302
|
+
if sys.stdin.isatty() and input(f" {msg} proceed? [y/N] ").strip().lower() not in ("y", "yes"):
|
|
303
|
+
raise SystemExit("aborted (nothing sent).")
|
|
304
|
+
elif not sys.stdin.isatty():
|
|
305
|
+
raise SystemExit(f" {msg}\n non-interactive: re-run with --yes to confirm.")
|
|
306
|
+
|
|
307
|
+
eng = ENGINES[a.engine](a.model, a.timeout)
|
|
308
|
+
def ask_retry(prompt, tries=4):
|
|
309
|
+
err = ""
|
|
310
|
+
for k in range(tries):
|
|
311
|
+
try: return eng.ask(prompt), ""
|
|
312
|
+
except RuntimeError as e: # deterministic content block (no candidates) — retrying is futile
|
|
313
|
+
return "", str(e)[:140]
|
|
314
|
+
except Exception as e: # transient (HTTP 429/503, network) — back off and retry
|
|
315
|
+
err = f"{type(e).__name__}: {e}"[:140]
|
|
316
|
+
if k == tries - 1: return "", err
|
|
317
|
+
time.sleep(2 ** k)
|
|
318
|
+
def work(b, d):
|
|
319
|
+
out, err = ask_retry(prompt_for(d, a.max_tags)); vt, nt = parse_resp(out, a.max_tags); return b, err, vt, nt
|
|
320
|
+
|
|
321
|
+
failures = []
|
|
322
|
+
print(f" {len(todo)} to do this run, {a.workers} concurrent")
|
|
323
|
+
ex = ThreadPoolExecutor(max_workers=a.workers)
|
|
324
|
+
interrupted = False
|
|
325
|
+
try:
|
|
326
|
+
with _Dashboard(len(todo), len(done), len(targets)) as dash:
|
|
327
|
+
futs = [ex.submit(work, b, d) for b, d in todo]
|
|
328
|
+
for fut in as_completed(futs):
|
|
329
|
+
b, err, vt, nt = fut.result()
|
|
330
|
+
if err: failures.append((b, err))
|
|
331
|
+
if vt or nt: proposal[b] = (vt, nt)
|
|
332
|
+
dash.update(vt, nt, err)
|
|
333
|
+
if dash.n % 50 == 0: dump() # checkpoint regardless of UI
|
|
334
|
+
except KeyboardInterrupt:
|
|
335
|
+
# Ctrl+C: never start queued work, don't wait for in-flight requests (they're
|
|
336
|
+
# abandoned; runs are resumable so nothing is lost beyond the requests in the air)
|
|
337
|
+
interrupted = True
|
|
338
|
+
ex.shutdown(wait=False, cancel_futures=True)
|
|
339
|
+
else:
|
|
340
|
+
ex.shutdown()
|
|
341
|
+
dump()
|
|
342
|
+
if interrupted:
|
|
343
|
+
print(f"\n interrupted — {len(proposal)} results saved to the proposal; re-run to resume where you left off.")
|
|
344
|
+
if failures:
|
|
345
|
+
with open(FAIL, "w", newline="") as f:
|
|
346
|
+
w = csv.writer(f); w.writerow(["book_id", "title", "reason"])
|
|
347
|
+
for b, e in failures: w.writerow([b, titles.get(b, ""), e])
|
|
348
|
+
bytype = collections.Counter(e.split(":")[0].split(" ")[0] for _, e in failures)
|
|
349
|
+
print(f"failures: {len(failures)} -> {os.path.basename(FAIL)} by type: {dict(bytype)}")
|
|
350
|
+
print(" (recover blocked books with a no-policy engine: scourgify classify --engine apple)")
|
|
351
|
+
|
|
352
|
+
ranked = collections.Counter()
|
|
353
|
+
for vt, nt in proposal.values():
|
|
354
|
+
for t in nt: ranked[t] += 1
|
|
355
|
+
with open(RANK, "w", newline="") as f:
|
|
356
|
+
w = csv.writer(f); w.writerow(["proposed_tag", "count"])
|
|
357
|
+
for t, cnt in ranked.most_common(): w.writerow([t, cnt])
|
|
358
|
+
print(f"\nOutput 1 (apply): {sum(1 for v in proposal.values() if v[0])} books with vocab tags -> {os.path.basename(PROP)} (col 'added_tags')")
|
|
359
|
+
print(f"Output 2 (grow): {len(ranked)} distinct new-tag candidates -> {os.path.basename(RANK)} (review -> promote into defaults/classify_vocab.txt)")
|
|
360
|
+
if RICH and ranked:
|
|
361
|
+
tbl = Table(title="top new-tag candidates (review → promote into the vocab)")
|
|
362
|
+
tbl.add_column("count", justify="right", style="cyan"); tbl.add_column("proposed tag")
|
|
363
|
+
for tag, cnt in ranked.most_common(25): tbl.add_row(str(cnt), tag)
|
|
364
|
+
_con.print(tbl)
|
|
365
|
+
else:
|
|
366
|
+
print("top new-tag candidates:")
|
|
367
|
+
for t, cnt in ranked.most_common(25): print(f" {cnt:4} {t}")
|
|
368
|
+
print("\nApply vocab tags with: scourgify classify --apply (Calibre closed)")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def build_parser():
|
|
372
|
+
p = argparse.ArgumentParser(description="Content-based tagging from a controlled vocabulary (LLM engines; dry-run until --apply).")
|
|
373
|
+
p.add_argument("--engine", default="apple", choices=sorted(ENGINES), help="apple = on-device, free (default)")
|
|
374
|
+
p.add_argument("--apply", action="store_true", help="apply 'added_tags' from the proposal + stamp #wrangled (Calibre closed)")
|
|
375
|
+
p.add_argument("--incremental", action="store_true", help="only books whose #updated is newer than their #wrangled marker")
|
|
376
|
+
p.add_argument("--since", default="", metavar="DATE", help="(re)process books with #updated >= this ISO date")
|
|
377
|
+
p.add_argument("--fresh", action="store_true", help="ignore the existing proposal and restart (a full cloud pass costs real money)")
|
|
378
|
+
p.add_argument("--batch", type=int, default=0, metavar="N", help="process only N new books this run (re-run resumes)")
|
|
379
|
+
p.add_argument("--limit", type=int, default=0, metavar="N", help="hard cap on candidate books")
|
|
380
|
+
p.add_argument("--workers", type=int, default=8, metavar="N", help="concurrent API requests (cloud engines are I/O-bound)")
|
|
381
|
+
p.add_argument("--min-tags", type=int, default=2, metavar="N", help="process books with fewer than N tags")
|
|
382
|
+
p.add_argument("--max-tags", type=int, default=6, metavar="N", help="max vocab tags per book")
|
|
383
|
+
p.add_argument("--model", default="", help="override the per-engine default model")
|
|
384
|
+
p.add_argument("--timeout", type=int, default=60, metavar="S", help="per-request HTTP timeout")
|
|
385
|
+
p.add_argument("--text-fallback", action="store_true", help="sample the book's own prose when the description is thin")
|
|
386
|
+
p.add_argument("--yes", "-y", action="store_true", help="skip the large-cloud-run confirmation")
|
|
387
|
+
return p
|
|
388
|
+
|
|
389
|
+
def normalize(a):
|
|
390
|
+
"""Post-parse invariants shared by the CLI and the wizard."""
|
|
391
|
+
if a.engine == "apple": a.workers = 1 # apple = one subprocess pipe, not thread-safe
|
|
392
|
+
library() # fail fast with a clear message
|
|
393
|
+
os.makedirs(DATA, exist_ok=True)
|
|
394
|
+
return a
|
|
395
|
+
|
|
396
|
+
def main():
|
|
397
|
+
a = normalize(build_parser().parse_args())
|
|
398
|
+
if a.apply: apply_proposal()
|
|
399
|
+
else: classify_run(a)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
if __name__ == "__main__":
|
|
403
|
+
main()
|
scourgify/cli.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Single `scourgify` command.
|
|
2
|
+
|
|
3
|
+
Dispatch: bare -> wizard (via wrangle); setup/audit/apply -> wrangle;
|
|
4
|
+
classify -> classify; staleness -> staleness. Each tool keeps its own argparse,
|
|
5
|
+
so we just hand off argv. Imports are lazy so `scourgify --version` stays cheap.
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from scourgify import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
argv = sys.argv[1:]
|
|
14
|
+
if argv and argv[0] in ("-V", "--version"):
|
|
15
|
+
print(f"scourgify {__version__}")
|
|
16
|
+
return
|
|
17
|
+
if argv and argv[0] == "classify":
|
|
18
|
+
from scourgify import classify
|
|
19
|
+
sys.argv = ["scourgify classify", *argv[1:]]
|
|
20
|
+
return classify.main()
|
|
21
|
+
if argv and argv[0] == "staleness":
|
|
22
|
+
from scourgify import staleness
|
|
23
|
+
sys.argv = ["scourgify staleness", *argv[1:]]
|
|
24
|
+
return staleness.main()
|
|
25
|
+
# setup / audit / apply / (none -> wizard) all live in wrangle's main()
|
|
26
|
+
from scourgify import wrangle
|
|
27
|
+
return wrangle.main()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
main()
|
scourgify/common.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared core for the standalone tools (wrangle / classify / staleness).
|
|
3
|
+
|
|
4
|
+
Everything here runs under plain system python3. It owns the four things the tools
|
|
5
|
+
used to each carry a private copy of:
|
|
6
|
+
- library resolution (CALIBRE_LIBRARY) — checked lazily, never at import time
|
|
7
|
+
- read-only sqlite access + link-table-aware custom-column reading
|
|
8
|
+
- normalization helpers (norm, ascii_fold) and the minimal TOML config reader
|
|
9
|
+
- the single write funnel: run_writer() -> calibre-debug -e _writer.py
|
|
10
|
+
(backs up metadata.db to /tmp before every write; refuses to run while Calibre is open)
|
|
11
|
+
"""
|
|
12
|
+
import os, re, sys, sqlite3, collections, unicodedata
|
|
13
|
+
|
|
14
|
+
HERE = os.path.dirname(os.path.abspath(__file__)) # the installed package dir (read-only)
|
|
15
|
+
DEFAULTS = os.path.join(HERE, "defaults") # bundled generic maps — ship inside the package
|
|
16
|
+
# Per-run + per-user files live in the working directory, not site-packages: `data/`
|
|
17
|
+
# (proposals/intermediates), config.toml, and overrides/ are all resolved against CWD.
|
|
18
|
+
# When run from the repo via `uv run`, CWD == repo root, so dev layout is unchanged.
|
|
19
|
+
DATA = os.path.join(os.getcwd(), "data") # personal review maps, proposals, intermediates (gitignored)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------- library resolution (lazy — importing this module never exits) ----------------
|
|
23
|
+
def library():
|
|
24
|
+
lib = os.path.expanduser(os.environ.get("CALIBRE_LIBRARY", ""))
|
|
25
|
+
if not lib:
|
|
26
|
+
raise SystemExit("Set CALIBRE_LIBRARY to your Calibre library folder (the one containing metadata.db).")
|
|
27
|
+
return lib
|
|
28
|
+
|
|
29
|
+
def db_path():
|
|
30
|
+
return os.path.join(library(), "metadata.db")
|
|
31
|
+
|
|
32
|
+
def ro_connect():
|
|
33
|
+
return sqlite3.connect(f"file:{db_path()}?mode=ro", uri=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------- normalization ----------------
|
|
37
|
+
def norm(s):
|
|
38
|
+
s = str(s).strip().lower(); s = re.sub(r"[\[\]\(\)]", "", s); s = s.replace("&", "and")
|
|
39
|
+
s = re.sub(r"[\s_\-/]+", " ", s); s = re.sub(r"[^\w ]", "", s, flags=re.UNICODE); return s.strip()
|
|
40
|
+
|
|
41
|
+
def ascii_fold(s):
|
|
42
|
+
s = s.replace("’", "'").replace("‘", "'").replace("“", '"').replace("”", '"').replace("…", "...").replace("–", "-").replace("—", "-")
|
|
43
|
+
return unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------- custom columns (single- and multi-value; link-table aware) ----------------
|
|
47
|
+
def custom_column_id(con, label):
|
|
48
|
+
r = con.execute("SELECT id FROM custom_columns WHERE label=?", (label.lstrip("#"),)).fetchone()
|
|
49
|
+
return r[0] if r else None
|
|
50
|
+
|
|
51
|
+
def read_custom_column(con, label, multi=False):
|
|
52
|
+
"""{book: value} (or {book: [values]} with multi=True) for a custom column; None if it doesn't exist.
|
|
53
|
+
Handles both storage shapes: a books_custom_column_N_link table, or an inline `book` column."""
|
|
54
|
+
i = custom_column_id(con, label)
|
|
55
|
+
if i is None: return None
|
|
56
|
+
link = f"books_custom_column_{i}_link"
|
|
57
|
+
has_link = con.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (link,)).fetchone()
|
|
58
|
+
q = (f"SELECT l.book, v.value FROM {link} l JOIN custom_column_{i} v ON v.id=l.value" if has_link
|
|
59
|
+
else f"SELECT book, value FROM custom_column_{i}")
|
|
60
|
+
out = collections.defaultdict(list) if multi else {}
|
|
61
|
+
for b, v in con.execute(q):
|
|
62
|
+
if multi: out[b].append(v)
|
|
63
|
+
else: out[b] = v
|
|
64
|
+
return dict(out)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------- config (minimal TOML reader; no tomllib dependency) ----------------
|
|
68
|
+
def load_config(path=None):
|
|
69
|
+
cfg = {"columns": {"fandoms": "#fandoms", "characters": "#characters", "relationships": "#relationships",
|
|
70
|
+
"genres": "#genres", "status": "#status", "tags": "tags"},
|
|
71
|
+
"behavior": {"fold_characters": True, "ascii_only_tags": True, "au_as": "genre", "crossover_as": "genre",
|
|
72
|
+
"reincarnation_as": "genre", "time_travel_as": "genre", "fold_ratings": False,
|
|
73
|
+
"keep_categories": True, "tropes_as": "tag"},
|
|
74
|
+
"overrides": {"dir": "overrides"}}
|
|
75
|
+
p = path or os.path.join(os.getcwd(), "config.toml") # user config: CWD, not the package
|
|
76
|
+
if os.path.exists(p):
|
|
77
|
+
sec = None
|
|
78
|
+
for raw in open(p):
|
|
79
|
+
ln = raw.strip()
|
|
80
|
+
if not ln or ln.startswith("#"): continue
|
|
81
|
+
if ln.startswith("["): sec = ln[1:ln.index("]")].strip(); cfg.setdefault(sec, {}); continue
|
|
82
|
+
if "=" in ln and sec:
|
|
83
|
+
k, v = ln.split("=", 1); k = k.strip(); v = v.strip()
|
|
84
|
+
if v[:1] in ("\"", "'"): # quoted string -> value between the quotes (# allowed inside)
|
|
85
|
+
v = v[1:].split(v[0], 1)[0]
|
|
86
|
+
else: # bool/number -> strip any trailing inline comment
|
|
87
|
+
v = v.split("#", 1)[0].strip()
|
|
88
|
+
if v.lower() in ("true", "false"): v = v.lower() == "true"
|
|
89
|
+
cfg[sec][k] = v
|
|
90
|
+
return cfg
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------- the write funnel ----------------
|
|
94
|
+
def calibre_open():
|
|
95
|
+
import subprocess, shutil
|
|
96
|
+
if not shutil.which("pgrep"): return False
|
|
97
|
+
out = subprocess.run(["pgrep", "-fl", "calibre"], capture_output=True, text=True).stdout
|
|
98
|
+
return any("calibre" in l.lower() and not any(x in l for x in ("calibre-debug", "pgrep", "wrangle", "_writer", "classify"))
|
|
99
|
+
for l in out.splitlines())
|
|
100
|
+
|
|
101
|
+
def run_writer(ops):
|
|
102
|
+
"""Apply a list of write-ops through Calibre by shelling out to `calibre-debug -e _writer.py`.
|
|
103
|
+
Automatically snapshots metadata.db to /tmp first — every write path gets a rollback point for free."""
|
|
104
|
+
import json, time, tempfile, subprocess, shutil
|
|
105
|
+
ops = [o for o in ops if o.get("op") != "set_field" or o.get("values")]
|
|
106
|
+
if not ops: print(" (nothing to write)"); return
|
|
107
|
+
if calibre_open(): raise SystemExit("Calibre is running — close it first (it locks metadata.db), then re-run.")
|
|
108
|
+
cb = shutil.which("calibre-debug") or "/Applications/calibre.app/Contents/MacOS/calibre-debug"
|
|
109
|
+
if not (shutil.which("calibre-debug") or os.path.exists(cb)): raise SystemExit("calibre-debug not found (install Calibre's CLI tools).")
|
|
110
|
+
bak = f"/tmp/ff_{int(time.time())}.db"
|
|
111
|
+
shutil.copy2(db_path(), bak)
|
|
112
|
+
print(f" backup: {bak}")
|
|
113
|
+
f = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False); json.dump(ops, f); f.close()
|
|
114
|
+
print(" → writing via calibre-debug …")
|
|
115
|
+
rc = subprocess.run([cb, "-e", os.path.join(HERE, "_writer.py"), "--", f.name],
|
|
116
|
+
env={**os.environ, "CALIBRE_LIBRARY": library()}).returncode
|
|
117
|
+
os.unlink(f.name)
|
|
118
|
+
if rc != 0: raise SystemExit(f"writer failed (exit {rc}) — library backup at {bak}")
|