Some checks failed
CI / lint (pull_request) Successful in 45s
CI / notebooks-smoke (pull_request) Successful in 1m58s
CI / test (pull_request) Successful in 2m55s
CI / lint (push) Successful in 45s
CI / notebooks-smoke (push) Successful in 1m43s
CI / test (push) Successful in 2m38s
Infra CI / zotero (pull_request) Successful in 15s
Infra CI / notebooks (pull_request) Successful in 1m5s
Infra CI / api (pull_request) Successful in 1m12s
Infra CI / docs (pull_request) Successful in 2m1s
Infra CI / mc (pull_request) Failing after 44s
Infra CI / llm (pull_request) Successful in 1m5s
Infra CI / zotero (push) Successful in 22s
Infra CI / notebooks (push) Successful in 1m14s
Infra CI / llm (push) Successful in 19s
Infra CI / docs (push) Successful in 15s
Infra CI / api (push) Successful in 24s
Infra CI / mc (push) Failing after 55s
- Anna's 403 whose body is not a key/membership error disables Anna's for the run (annas_left=0) instead of raising AnnasAuthError. - run() isolates each item: any exception rolls the transaction back, unlinks scratch files, ledgers outcome=error with the exception text, and continues; AnnasAuthError still aborts. - attach_file writes rows before copying so a failed copy rolls back cleanly instead of orphaning a storage dir. - Notes (miss vs error) from resolve/deliver land in every ledger line; an item whose lookups/deliveries errored (transport, 5xx, throttle page) is 'error' and retried next run rather than a 30-day miss. - resolve tolerates non-dict file entries and non-numeric filesize. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
78 lines
2.8 KiB
Python
78 lines
2.8 KiB
Python
"""Put a delivered file into Zotero storage and register it as a child.
|
|
|
|
Generalises ``dev/scripts/add_ama_coding_to_zotero.py::_attach``: the
|
|
storage tree is owned by the zotero container's uid (100999), so the
|
|
default copier shells out to ``sudo``; tests inject a plain copier.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
from zot.db import Db, generate_key
|
|
|
|
FULL_TEXT_TYPES = {"application/pdf", "application/epub+zip"}
|
|
CONTENT_TYPES = {".pdf": "application/pdf", ".epub": "application/epub+zip"}
|
|
ZOTERO_UID_GID = "100999:100999"
|
|
|
|
Copier = Callable[[Path, Path], None]
|
|
|
|
|
|
def has_full_text(db: Db, item_id: int) -> bool:
|
|
"""True when *item_id* already has a PDF or EPUB child attachment."""
|
|
rows = db.con.execute(
|
|
"SELECT contentType FROM itemAttachments WHERE parentItemID = ?", (item_id,)
|
|
).fetchall()
|
|
return any((r[0] or "") in FULL_TEXT_TYPES for r in rows)
|
|
|
|
|
|
def sudo_copier(src: Path, dest_dir: Path) -> None:
|
|
"""Copy *src* into *dest_dir* as the zotero container's owner."""
|
|
subprocess.run(["sudo", "mkdir", "-p", str(dest_dir)], check=True)
|
|
subprocess.run(["sudo", "cp", str(src), str(dest_dir / src.name)], check=True)
|
|
subprocess.run(["sudo", "chown", "-R", ZOTERO_UID_GID, str(dest_dir)], check=True)
|
|
|
|
|
|
def append_extra(db: Db, item_id: int, line: str) -> None:
|
|
"""Append *line* to the item's ``extra`` field unless already present."""
|
|
existing = db.get_field(item_id, "extra") or ""
|
|
if line in existing.splitlines():
|
|
return
|
|
db.set_field(item_id, "extra", f"{existing.rstrip()}\n{line}".strip())
|
|
|
|
|
|
def attach_file(
|
|
db: Db,
|
|
parent_id: int,
|
|
path: Path,
|
|
*,
|
|
source: str,
|
|
storage_dir: Path,
|
|
md5: str = "",
|
|
copier: Copier = sudo_copier,
|
|
) -> int:
|
|
"""Attach *path* to *parent_id*; returns the attachment ``itemID``.
|
|
|
|
Provenance: tag ``source:<source>`` on the parent and, when an md5
|
|
is known, an ``md5: <md5>`` line in ``extra``. Nothing else on the
|
|
parent changes. The caller commits; on any exception it must roll
|
|
the transaction back.
|
|
"""
|
|
ctype = CONTENT_TYPES.get(path.suffix.lower())
|
|
if ctype is None:
|
|
raise ValueError(f"unsupported attachment type: {path.suffix!r} ({path.name})")
|
|
att_key = generate_key()
|
|
att_id = db.add_attachment(
|
|
parent_id, key=att_key, content_type=ctype, path=f"storage:{path.name}"
|
|
)
|
|
db.set_field(att_id, "title", path.name)
|
|
db.tag_item(parent_id, f"source:{source}")
|
|
if md5:
|
|
append_extra(db, parent_id, f"md5: {md5}")
|
|
# Rows first, file second: a failed copy leaves rows the caller rolls
|
|
# back; the only orphan case is a failed commit after a good copy.
|
|
copier(path, Path(storage_dir) / att_key)
|
|
return att_id
|