#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import shutil import sqlite3 from datetime import UTC, datetime from pathlib import Path from curator.epub import inspect_epub from curator.library import safe_component, safe_filename def main() -> None: parser = argparse.ArgumentParser(description="Merge one Curator book work into another") parser.add_argument("database", type=Path) parser.add_argument("target_work_id", type=int) parser.add_argument("source_work_id", type=int) parser.add_argument("--apply", action="store_true") args = parser.parse_args() connection = sqlite3.connect(args.database) connection.row_factory = sqlite3.Row connection.execute("PRAGMA foreign_keys=ON") target = connection.execute("SELECT * FROM works WHERE id=?", (args.target_work_id,)).fetchone() source = connection.execute("SELECT * FROM works WHERE id=?", (args.source_work_id,)).fetchone() if not target or not source or target["media_type"] != "book" or source["media_type"] != "book": raise SystemExit("target and source must both be existing book works") rows = connection.execute( """SELECT a.*, e.id AS source_edition_id, e.language, e.variant, e.isbn FROM assets a JOIN editions e ON e.id=a.edition_id WHERE e.work_id=?""", (args.source_work_id,), ).fetchall() if not rows: raise SystemExit("source work has no assets") changes: list[dict[str, object]] = [] for row in rows: old_path = Path(row["path"]) info = inspect_epub(old_path) if row["format"] == "epub" else None language = info.language if info else row["language"] edition_dir = safe_component(f"{language}-{row['variant']}", "und-original") destination_dir = old_path.parents[3] / safe_component(target["author"], "Unknown Author") / safe_component(target["title"], "Untitled") / edition_dir filename = safe_filename(row["filename"], f"book.{row['format']}") destination = destination_dir / filename metadata = json.loads(row["metadata_json"] or "{}") if info: 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), }) changes.append({ "asset_id": row["id"], "edition_id": row["source_edition_id"], "old": old_path, "new": destination, "filename": filename, "language": language, "isbn": info.isbn if info else row["isbn"], "metadata": metadata, }) print(json.dumps([{**change, "old": str(change["old"]), "new": str(change["new"])} for change in changes], ensure_ascii=False, indent=2, default=str)) if not args.apply: return backup = args.database.with_name(f"{args.database.stem}-before-merge-{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}.sqlite3") with sqlite3.connect(backup) as backup_connection: connection.backup(backup_connection) moved: list[tuple[Path, Path]] = [] try: connection.execute("BEGIN IMMEDIATE") for change in changes: old_path = change["old"] destination = change["new"] assert isinstance(old_path, Path) and isinstance(destination, Path) destination.parent.mkdir(parents=True, exist_ok=True) if destination.exists() and destination != old_path: raise FileExistsError(destination) os.replace(old_path, destination) moved.append((destination, old_path)) connection.execute( "UPDATE editions SET work_id=?, language=?, isbn=? WHERE id=?", (args.target_work_id, change["language"], change["isbn"], change["edition_id"]), ) connection.execute( "UPDATE assets SET filename=?, path=?, metadata_json=? WHERE id=?", (change["filename"], str(destination), json.dumps(change["metadata"], ensure_ascii=False, sort_keys=True), change["asset_id"]), ) connection.execute("DELETE FROM works WHERE id=?", (args.source_work_id,)) connection.execute( "UPDATE works SET updated_at=? WHERE id=?", (datetime.now(UTC).replace(microsecond=0).isoformat(), args.target_work_id), ) connection.commit() except Exception: connection.rollback() for current, original in reversed(moved): original.parent.mkdir(parents=True, exist_ok=True) os.replace(current, original) raise finally: connection.close() print(f"backup={backup}") if __name__ == "__main__": main()