The curator app (Python backend, tests, systemd units, config, scripts) now lives in this repo under scenarios/curator/backend, exported from the standalone checkout's tracked tree (.pi mirror, venv and caches excluded). 149 unit tests pass from the new location; _SHARED_LIB and eval GOLDEN_DIR resolve unchanged. History not preserved per decision. verify-no-secrets: the ASSIGN heuristic now requires the value to carry entropy (a digit or uppercase letter), so vendored Python kwargs like token=extraction_token no longer false-positive while real base64/hex/random secrets still trip it.
99 lines
4.0 KiB
Python
99 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
from curator.db import Database, normalize
|
|
from curator.epub import inspect_epub
|
|
from curator.library import safe_component, safe_filename
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Repair a Curator EPUB from its embedded metadata")
|
|
parser.add_argument("database", type=Path)
|
|
parser.add_argument("asset_id", type=int)
|
|
parser.add_argument("--apply", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
connection = sqlite3.connect(args.database)
|
|
connection.row_factory = sqlite3.Row
|
|
row = connection.execute(
|
|
"""SELECT a.*, e.id AS edition_id, e.work_id, e.variant
|
|
FROM assets a JOIN editions e ON e.id=a.edition_id WHERE a.id=?""",
|
|
(args.asset_id,),
|
|
).fetchone()
|
|
if not row or row["format"] != "epub":
|
|
raise SystemExit("asset must be an existing EPUB")
|
|
old_path = Path(row["path"])
|
|
info = inspect_epub(old_path)
|
|
filename = safe_filename(row["filename"], "book.epub")
|
|
root = old_path.parents[3]
|
|
destination = (
|
|
root / safe_component(info.author, "Unknown Author") / safe_component(info.title, "Untitled")
|
|
/ safe_component(f"{info.language}-{row['variant']}", "und-original") / filename
|
|
)
|
|
metadata = json.loads(row["metadata_json"] or "{}")
|
|
metadata.update({
|
|
"display_title": info.display_title,
|
|
"title_aliases": list(info.title_aliases),
|
|
"declared_language": info.declared_language,
|
|
"identifiers": list(info.identifiers),
|
|
"source_identifiers": list(info.source_identifiers),
|
|
})
|
|
plan = {"work_id": row["work_id"], "asset_id": row["id"], "title": info.title, "author": info.author,
|
|
"language": info.language, "isbn": info.isbn, "old": str(old_path), "new": str(destination)}
|
|
print(json.dumps(plan, ensure_ascii=False, indent=2))
|
|
if not args.apply:
|
|
return
|
|
|
|
backup = args.database.with_name(f"{args.database.stem}-before-repair-{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}.sqlite3")
|
|
with sqlite3.connect(backup) as backup_connection:
|
|
connection.backup(backup_connection)
|
|
moved = False
|
|
try:
|
|
connection.execute("PRAGMA foreign_keys=ON")
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
if destination.exists() and destination != old_path:
|
|
raise FileExistsError(destination)
|
|
if destination != old_path:
|
|
os.replace(old_path, destination)
|
|
moved = True
|
|
stamp = datetime.now(UTC).replace(microsecond=0).isoformat()
|
|
connection.execute(
|
|
"""UPDATE works SET title=?, author=?, normalized_title=?, normalized_author=?, updated_at=? WHERE id=?""",
|
|
(info.title, info.author, normalize(info.title), normalize(info.author), stamp, row["work_id"]),
|
|
)
|
|
connection.execute(
|
|
"UPDATE editions SET language=?, isbn=?, publisher=? WHERE id=?",
|
|
(info.language, info.isbn, info.publisher, row["edition_id"]),
|
|
)
|
|
connection.execute(
|
|
"UPDATE assets SET filename=?, path=?, metadata_json=? WHERE id=?",
|
|
(filename, str(destination), json.dumps(metadata, ensure_ascii=False, sort_keys=True), row["id"]),
|
|
)
|
|
connection.execute(
|
|
"UPDATE activity_jobs SET detail=?, updated_at=? WHERE kind='book-import' AND detail LIKE '%�%'",
|
|
(f"{info.title} · {info.author or '未知作者'} · 已校验并入库", stamp),
|
|
)
|
|
connection.commit()
|
|
except Exception:
|
|
connection.rollback()
|
|
if moved:
|
|
old_path.parent.mkdir(parents=True, exist_ok=True)
|
|
os.replace(destination, old_path)
|
|
raise
|
|
finally:
|
|
connection.close()
|
|
Database(args.database).reconcile_imported_book(info.title, info.author)
|
|
print(f"backup={backup}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|