linxiv_cli

Headless CLI for linXiv — search, fetch, list, tag, and manage projects without the GUI.

   1"""Headless CLI for linXiv — search, fetch, list, tag, and manage projects without the GUI."""
   2
   3from __future__ import annotations
   4
   5from dotenv import load_dotenv
   6from config import ENV_PATH, init_data_dir
   7load_dotenv(ENV_PATH)
   8
   9import argparse
  10import dataclasses
  11import datetime
  12from importlib.metadata import version, PackageNotFoundError
  13import json
  14from pathlib import Path
  15import re
  16import sys
  17from typing import Any
  18
  19try:
  20    __version__ = version("linxiv")
  21except PackageNotFoundError:
  22    __version__ = "unknown"
  23
  24from formats.bibtex import BibTeXFormat
  25from formats.markdown import ObsidianFormat
  26from sources.arxiv_source import ArxivSource
  27from sources.base import PaperMetadata, PaperSource
  28from sources.crossref_source import CrossRefSource
  29from sources.doi_resolve import resolve_doi
  30from sources.openalex_source import OpenAlexSource
  31import user_settings
  32
  33import service.author as svc_author
  34import service.paper as svc_paper
  35import service.tag as svc_tag
  36import service.project as svc_project
  37import service.note as svc_note
  38import service.files as svc_files
  39import service.export_import as svc_ei
  40from service.author import Author
  41from service.paper import Paper, Papers
  42from service.tag import Tag, TagIn
  43from service.project import Project, Projects, ProjectIn, Status, UNSET
  44from service.note import Note, Notes, NoteIn, NoteUpdateIn
  45
  46_FORMATS_DIR = Path(__file__).parent / "formats"
  47
  48_ARXIV_ID_RE = re.compile(r"^\d{4}\.\d{4,5}(v\d+)?$|^[a-z\-]+(\.[A-Z]{2})?/\d{7}(v\d+)?$")
  49
  50
  51def _validate_arxiv_id(source_id: str) -> str:
  52    if not _ARXIV_ID_RE.match(source_id):
  53        print(json.dumps({"error": f"Invalid arXiv ID format: {source_id!r}"}), file=sys.stderr)
  54        sys.exit(1)
  55    return source_id
  56
  57
  58def _as_source_id(raw: str, source: str = "arxiv") -> str:
  59    """Prefix a bare paper ID with its namespace; already-prefixed IDs are returned unchanged."""
  60    return raw if ":" in raw else f"{source}:{raw}"
  61
  62
  63def _render_paper(meta: PaperMetadata) -> str | None:
  64    template_path = _FORMATS_DIR / f"{meta.source}_paper.md"
  65    if not template_path.exists():
  66        return None
  67    template = template_path.read_text(encoding="utf-8")
  68    data = meta.model_dump(mode="json")
  69    data["authors_inline"] = ", ".join(meta.authors)
  70    return template.format_map(data)
  71
  72
  73_SOURCES: dict[str, type[PaperSource]] = {
  74    "arxiv":    ArxivSource,
  75    "openalex": OpenAlexSource,
  76    "crossref": CrossRefSource,
  77}
  78
  79
  80def _source_for(name: str) -> PaperSource:
  81    cls = _SOURCES.get(name)
  82    if cls is None:
  83        raise ValueError(f"Unknown source {name!r}. Available: {list(_SOURCES)}")
  84    return cls()
  85
  86
  87def _output(data: Any) -> None:
  88    json.dump(data, sys.stdout, indent=2, default=str)
  89    sys.stdout.write("\n")
  90
  91
  92def _details_to_dict(obj: Any) -> dict[str, Any]:
  93    return dataclasses.asdict(obj)
  94
  95
  96def _resolve_paper_or_exit(source_id: str) -> Any:
  97    details = svc_paper.get(Paper(source_id=source_id))
  98    if details is None:
  99        print(json.dumps({"error": f"Paper {source_id!r} not found in DB"}), file=sys.stderr)
 100        sys.exit(1)
 101    return details
 102
 103
 104def _resolve_project_or_exit(project_id: int) -> Any:
 105    details = svc_project.get(Project(project_fk=project_id))
 106    if details is None:
 107        print(json.dumps({"error": f"Project {project_id} not found"}), file=sys.stderr)
 108        sys.exit(1)
 109    return details
 110
 111
 112# ---------------------------------------------------------------------------
 113# Commands — search / fetch / list
 114# ---------------------------------------------------------------------------
 115
 116def cmd_search(args: argparse.Namespace) -> None:
 117    source = _source_for(args.source)
 118    try:
 119        results = source.search(args.query, max_results=args.max)
 120    except Exception as e:
 121        print(f"[search] {e}", file=sys.stderr)
 122        print(json.dumps({"error": str(e)}), file=sys.stderr)
 123        sys.exit(1)
 124    _output([m.model_dump(mode="json") for m in results])
 125
 126
 127def cmd_fetch(args: argparse.Namespace) -> None:
 128    if args.source == "arxiv":
 129        _validate_arxiv_id(args.source_id)
 130    source = _source_for(args.source)
 131    try:
 132        meta = source.fetch_by_id(args.source_id)
 133    except Exception as e:
 134        print(f"[fetch] {e}", file=sys.stderr)
 135        print(json.dumps({"error": str(e)}), file=sys.stderr)
 136        sys.exit(1)
 137    svc_paper.save_paper_metadata(meta, None)
 138    rendered = _render_paper(meta)
 139    if rendered:
 140        sys.stdout.write(rendered + "\n")
 141    else:
 142        _output(meta.model_dump(mode="json"))
 143
 144
 145def cmd_list(args: argparse.Namespace) -> None:
 146    rows = svc_paper.list_papers(limit=args.limit, offset=args.offset, category=args.category)
 147    papers = [{k: row[k] for k in row.keys()} for row in rows]
 148    _output(papers)
 149
 150
 151# ---------------------------------------------------------------------------
 152# Commands — paper subgroup
 153# ---------------------------------------------------------------------------
 154
 155def cmd_paper_get(args: argparse.Namespace) -> None:
 156    source_id = _as_source_id(args.source_id)
 157    details = _resolve_paper_or_exit(source_id)
 158    _output(_details_to_dict(details))
 159
 160
 161def cmd_paper_delete(args: argparse.Namespace) -> None:
 162    source_id = _as_source_id(args.source_id)
 163    _resolve_paper_or_exit(source_id)
 164    svc_paper.delete(svc_paper.Paper(source_id=source_id))
 165    _output({"deleted": source_id})
 166
 167
 168def cmd_paper_versions(args: argparse.Namespace) -> None:
 169    source_id = _as_source_id(args.source_id)
 170    all_versions = svc_paper.get_all(Paper(source_id=source_id))
 171    if all_versions is None:
 172        print(json.dumps({"error": f"Paper {source_id!r} not found in DB"}), file=sys.stderr)
 173        sys.exit(1)
 174    _output(_details_to_dict(all_versions))
 175
 176
 177def cmd_paper_repair(args: argparse.Namespace) -> None:
 178    source_id = _as_source_id(args.source_id)
 179    root = svc_paper.get_paper_root(source_id)
 180    if root is None:
 181        print(json.dumps({"error": f"Paper {source_id!r} not found"}), file=sys.stderr)
 182        sys.exit(1)
 183    source_fk = int(root["SOURCE_FK"])
 184    existing = svc_paper.get(Paper(source_id=source_id))
 185    version = existing.version if existing is not None else 1
 186    try:
 187        published = datetime.date.fromisoformat(args.published)
 188    except ValueError:
 189        print(json.dumps({"error": f"Invalid date {args.published!r}; use YYYY-MM-DD"}), file=sys.stderr)
 190        sys.exit(1)
 191    meta = PaperMetadata(
 192        source_id=source_id,
 193        version=version,
 194        title=args.title,
 195        authors=args.authors,
 196        published=published,
 197        summary=args.summary or "",
 198        category=args.category,
 199        doi=args.doi,
 200        url=args.url,
 201        tags=args.tags or None,
 202        source=None,
 203    )
 204    svc_paper.repair_paper(source_fk, meta)
 205    _output({"repaired": source_id})
 206
 207
 208def _do_paper_restore(source_id: str) -> dict:
 209    if not svc_paper.is_paper_deleted(source_id):
 210        print(json.dumps({"error": f"Paper {source_id!r} not found in trash"}), file=sys.stderr)
 211        sys.exit(1)
 212    pdf_path, project_fks = svc_paper.restore(Paper(source_id=source_id))
 213    return {"restored": source_id, "pdf_path": pdf_path, "project_fks": project_fks}
 214
 215
 216def _do_paper_hard_delete(source_id: str) -> dict:
 217    root = svc_paper.get_paper_root(source_id)
 218    if root is None:
 219        print(json.dumps({"error": f"Paper {source_id!r} not found"}), file=sys.stderr)
 220        sys.exit(1)
 221    svc_paper.hard_delete(Paper(source_id=source_id))
 222    return {"hard_deleted": source_id}
 223
 224
 225def cmd_paper_restore(args: argparse.Namespace) -> None:
 226    source_id = _as_source_id(args.source_id)
 227    _output(_do_paper_restore(source_id))
 228
 229
 230def cmd_paper_hard_delete(args: argparse.Namespace) -> None:
 231    source_id = _as_source_id(args.source_id)
 232    _output(_do_paper_hard_delete(source_id))
 233
 234
 235def cmd_paper_search(args: argparse.Namespace) -> None:
 236    results = svc_paper.search_papers(args.query, limit=args.limit)
 237    _output([_details_to_dict(r) for r in results])
 238
 239
 240def cmd_paper_remove_from_all(args: argparse.Namespace) -> None:
 241    source_id = _as_source_id(args.source_id)
 242    removed = svc_project.remove_paper_from_all_projects_by_id(source_id)
 243    if removed is None:
 244        print(json.dumps({"error": f"Paper {source_id!r} not found"}), file=sys.stderr)
 245        sys.exit(1)
 246    _output({"source_id": source_id, "removed_from_projects": removed})
 247
 248
 249# ---------------------------------------------------------------------------
 250# Commands — trash subgroup
 251# ---------------------------------------------------------------------------
 252
 253def cmd_trash_list(args: argparse.Namespace) -> None:
 254    papers = svc_paper.list_deleted()
 255    projects = svc_project.list_deleted()
 256    _output({
 257        "papers": [_details_to_dict(p) for p in papers],
 258        "projects": [p.to_dict() for p in projects],
 259    })
 260
 261
 262def cmd_trash_restore(args: argparse.Namespace) -> None:
 263    source_id = _as_source_id(args.source_id)
 264    _output(_do_paper_restore(source_id))
 265
 266
 267def cmd_trash_hard_delete(args: argparse.Namespace) -> None:
 268    source_id = _as_source_id(args.source_id)
 269    if not svc_paper.is_paper_deleted(source_id):
 270        print(json.dumps({"error": f"Paper {source_id!r} not found in trash"}), file=sys.stderr)
 271        sys.exit(1)
 272    _output(_do_paper_hard_delete(source_id))
 273
 274
 275def cmd_trash_restore_project(args: argparse.Namespace) -> None:
 276    details = _resolve_project_or_exit(args.project_id)
 277    if details.status != Status.DELETED:
 278        print(json.dumps({"error": f"Project {args.project_id} is not in trash"}), file=sys.stderr)
 279        sys.exit(1)
 280    svc_project.restore(Project(project_fk=args.project_id))
 281    _output({"restored_project_id": args.project_id})
 282
 283
 284def cmd_trash_hard_delete_project(args: argparse.Namespace) -> None:
 285    details = _resolve_project_or_exit(args.project_id)
 286    if details.status != Status.DELETED:
 287        print(json.dumps({"error": f"Project {args.project_id} is not in trash"}), file=sys.stderr)
 288        sys.exit(1)
 289    svc_project.hard_delete(Project(project_fk=args.project_id))
 290    _output({"hard_deleted_project_id": args.project_id})
 291
 292
 293# ---------------------------------------------------------------------------
 294# Commands — doi subgroup
 295# ---------------------------------------------------------------------------
 296
 297def cmd_doi_resolve(args: argparse.Namespace) -> None:
 298    try:
 299        meta = resolve_doi(args.doi)
 300    except Exception as e:
 301        print(f"[doi] {e}", file=sys.stderr)
 302        print(json.dumps({"error": str(e)}), file=sys.stderr)
 303        sys.exit(1)
 304    _output(meta.model_dump(mode="json"))
 305
 306
 307def cmd_doi_save(args: argparse.Namespace) -> None:
 308    try:
 309        meta = resolve_doi(args.doi)
 310    except Exception as e:
 311        print(f"[doi] {e}", file=sys.stderr)
 312        print(json.dumps({"error": str(e)}), file=sys.stderr)
 313        sys.exit(1)
 314    source_id, ver = svc_paper.save_paper_metadata(meta)
 315    _output({"source_id": source_id, "version": ver, "title": meta.title})
 316
 317
 318# ---------------------------------------------------------------------------
 319# Commands — author subgroup
 320# ---------------------------------------------------------------------------
 321
 322def cmd_author_list(args: argparse.Namespace) -> None:
 323    authors = svc_author.list_with_paper_count()
 324    _output([a.to_dict() for a in authors])
 325
 326
 327def cmd_author_get(args: argparse.Namespace) -> None:
 328    author = svc_author.get(Author(author_id=args.author_id))
 329    if author is None:
 330        print(json.dumps({"error": f"Author {args.author_id} not found"}), file=sys.stderr)
 331        sys.exit(1)
 332    previews = svc_author.get_paper_previews(args.author_id)
 333    result = author.to_dict()
 334    result["papers"] = [p.to_dict() for p in previews]
 335    _output(result)
 336
 337
 338def cmd_author_update(args: argparse.Namespace) -> None:
 339    if svc_author.get(Author(author_id=args.author_id)) is None:
 340        print(json.dumps({"error": f"Author {args.author_id} not found"}), file=sys.stderr)
 341        sys.exit(1)
 342    if args.full_name is None and args.first_name is None and args.last_name is None and args.orcid is None:
 343        print(json.dumps({"error": "at least one of --full-name, --first-name, --last-name, or --orcid must be provided"}), file=sys.stderr)
 344        sys.exit(1)
 345    svc_author.update_fields(
 346        author_id=args.author_id,
 347        full_name=args.full_name,
 348        first_name=args.first_name,
 349        last_name=args.last_name,
 350        orcid=args.orcid,
 351    )
 352    _output({"updated_author_id": args.author_id})
 353
 354
 355def cmd_author_delete(args: argparse.Namespace) -> None:
 356    link_count = svc_author.count_paper_links(args.author_id)
 357    if link_count > 0:
 358        print(
 359            json.dumps({"error": f"Author {args.author_id} is linked to {link_count} paper(s); unlink first"}),
 360            file=sys.stderr,
 361        )
 362        sys.exit(1)
 363    svc_author.delete_author(args.author_id)
 364    _output({"deleted_author_id": args.author_id})
 365
 366
 367# ---------------------------------------------------------------------------
 368# Commands — tag subgroup
 369# ---------------------------------------------------------------------------
 370
 371def cmd_tag_add(args: argparse.Namespace) -> None:
 372    source_id = _as_source_id(args.source_id)
 373    try:
 374        updated = svc_tag.add_paper_tags(source_id, args.tags)
 375    except KeyError:
 376        print(json.dumps({"error": f"Paper {source_id} not found in DB"}), file=sys.stderr)
 377        sys.exit(1)
 378    _output({"source_id": source_id, "tags": updated})
 379
 380
 381def cmd_tag_remove(args: argparse.Namespace) -> None:
 382    source_id = _as_source_id(args.source_id)
 383    try:
 384        updated = svc_tag.remove_paper_tags(source_id, args.tags)
 385    except KeyError:
 386        print(json.dumps({"error": f"Paper {source_id} not found in DB"}), file=sys.stderr)
 387        sys.exit(1)
 388    _output({"source_id": source_id, "tags": updated})
 389
 390
 391def cmd_tag_list(args: argparse.Namespace) -> None:
 392    source_id = _as_source_id(args.source_id)
 393    tags = svc_tag.get_paper_tags(source_id)
 394    _output({"source_id": source_id, "tags": tags})
 395
 396
 397def cmd_tag_list_all(args: argparse.Namespace) -> None:
 398    _output(svc_tag.list_all_tags())
 399
 400
 401def cmd_tag_create(args: argparse.Namespace) -> None:
 402    tag_id = svc_tag.upsert(TagIn(label=args.label))
 403    _output({"tag_id": tag_id, "label": args.label})
 404
 405
 406def cmd_tag_delete(args: argparse.Namespace) -> None:
 407    svc_tag.delete(Tag(tag_id=args.tag_id))
 408    _output({"deleted_tag_id": args.tag_id})
 409
 410
 411def cmd_tag_add_project(args: argparse.Namespace) -> None:
 412    _resolve_project_or_exit(args.project_id)
 413    updated = svc_tag.add_project_tags(args.project_id, args.tags)
 414    _output({"project_id": args.project_id, "tags": updated})
 415
 416
 417def cmd_tag_remove_project(args: argparse.Namespace) -> None:
 418    _resolve_project_or_exit(args.project_id)
 419    updated = svc_tag.remove_project_tags(args.project_id, args.tags)
 420    _output({"project_id": args.project_id, "tags": updated})
 421
 422
 423def cmd_tag_list_project(args: argparse.Namespace) -> None:
 424    details = _resolve_project_or_exit(args.project_id)
 425    _output({"project_id": args.project_id, "tags": details.project_tags})
 426
 427
 428# ---------------------------------------------------------------------------
 429# Commands — project subgroup
 430# ---------------------------------------------------------------------------
 431
 432def cmd_project_list(args: argparse.Namespace) -> None:
 433    status = Status(args.status) if args.status else None
 434    projects = svc_project.get_many(Projects(status=status))
 435    if status is None:
 436        projects = [p for p in projects if p.status != Status.DELETED]
 437    _output([{
 438        "id": p.id,
 439        "name": p.name,
 440        "description": p.description,
 441        "status": p.status.value,
 442        "paper_count": len(p.source_fks),
 443        "color": p.color,
 444        "project_tags": p.project_tags,
 445    } for p in projects])
 446
 447
 448def cmd_project_get(args: argparse.Namespace) -> None:
 449    details = _resolve_project_or_exit(args.project_id)
 450    _output(details.to_dict())
 451
 452
 453def cmd_project_create(args: argparse.Namespace) -> None:
 454    color = svc_project.color_from_hex(args.color) if args.color else None
 455    tags = args.tags or []
 456    fk = svc_project.upsert(ProjectIn(
 457        name=args.name,
 458        description=args.description or "",
 459        color=color,
 460        tags=tags,
 461    ))
 462    _output({"id": fk, "name": args.name, "status": "active"})
 463
 464
 465def cmd_project_update(args: argparse.Namespace) -> None:
 466    _resolve_project_or_exit(args.project_id)
 467    try:
 468        color: Any = UNSET
 469        if args.color is not None:
 470            color = svc_project.color_from_hex(args.color)
 471        status = Status(args.status) if args.status else None
 472        svc_project.update(
 473            project_fk=args.project_id,
 474            name=args.name,
 475            description=args.description,
 476            color=color,
 477            project_tags=args.tags,
 478            status=status,
 479        )
 480    except (LookupError, ValueError) as e:
 481        print(f"[project] {e}", file=sys.stderr)
 482        print(json.dumps({"error": str(e)}), file=sys.stderr)
 483        sys.exit(1)
 484    updated = _resolve_project_or_exit(args.project_id)
 485    _output(updated.to_dict())
 486
 487
 488def cmd_project_delete(args: argparse.Namespace) -> None:
 489    _resolve_project_or_exit(args.project_id)
 490    svc_project.delete(Project(project_fk=args.project_id))
 491    _output({"deleted_project_id": args.project_id})
 492
 493
 494def cmd_project_archive(args: argparse.Namespace) -> None:
 495    _resolve_project_or_exit(args.project_id)
 496    svc_project.archive(Project(project_fk=args.project_id))
 497    _output({"archived_project_id": args.project_id})
 498
 499
 500def cmd_project_restore(args: argparse.Namespace) -> None:
 501    _resolve_project_or_exit(args.project_id)
 502    svc_project.restore(Project(project_fk=args.project_id))
 503    _output({"restored_project_id": args.project_id})
 504
 505
 506def cmd_project_hard_delete(args: argparse.Namespace) -> None:
 507    _resolve_project_or_exit(args.project_id)
 508    svc_project.hard_delete(Project(project_fk=args.project_id))
 509    _output({"hard_deleted_project_id": args.project_id})
 510
 511
 512def cmd_project_add_paper(args: argparse.Namespace) -> None:
 513    source_id = _as_source_id(args.source_id)
 514    try:
 515        failed = svc_project.add_papers(args.project_id, [source_id])
 516    except (svc_project.ProjectNotFoundError, svc_project.ProjectDeletedError) as e:
 517        print(json.dumps({"error": str(e)}), file=sys.stderr)
 518        sys.exit(1)
 519    if failed:
 520        print(json.dumps({"error": f"Paper {source_id} not found in database"}), file=sys.stderr)
 521        sys.exit(1)
 522    _output({"project_id": args.project_id, "source_id": source_id})
 523
 524
 525def cmd_project_remove_paper(args: argparse.Namespace) -> None:
 526    source_id = _as_source_id(args.source_id)
 527    try:
 528        failed = svc_project.remove_papers(args.project_id, [source_id])
 529    except (svc_project.ProjectNotFoundError, svc_project.ProjectDeletedError) as e:
 530        print(json.dumps({"error": str(e)}), file=sys.stderr)
 531        sys.exit(1)
 532    if failed:
 533        print(json.dumps({"error": f"Paper {source_id} not found in database"}), file=sys.stderr)
 534        sys.exit(1)
 535    _output({"project_id": args.project_id, "source_id": source_id, "removed": True})
 536
 537
 538# ---------------------------------------------------------------------------
 539# Commands — project export / import
 540# ---------------------------------------------------------------------------
 541
 542def cmd_project_export(args: argparse.Namespace) -> None:
 543    try:
 544        out = svc_ei.export_project(args.project_id, Path(args.dest), include_pdfs=args.pdfs)
 545    except Exception as e:
 546        print(f"[export] {e}", file=sys.stderr)
 547        print(json.dumps({"error": str(e)}), file=sys.stderr)
 548        sys.exit(1)
 549    _output({"path": str(out), "project_id": args.project_id})
 550
 551
 552def cmd_project_import(args: argparse.Namespace) -> None:
 553    zip_path = Path(args.zip_path)
 554    if args.preview:
 555        try:
 556            preview = svc_ei.preview_import(zip_path)
 557        except Exception as e:
 558            print(f"[import] {e}", file=sys.stderr)
 559            print(json.dumps({"error": str(e)}), file=sys.stderr)
 560            sys.exit(1)
 561        _output(_details_to_dict(preview))
 562    else:
 563        try:
 564            fk = svc_ei.commit_import(zip_path, on_conflict=args.on_conflict)
 565        except Exception as e:
 566            print(f"[import] {e}", file=sys.stderr)
 567            print(json.dumps({"error": str(e)}), file=sys.stderr)
 568            sys.exit(1)
 569        _output({"project_id": fk})
 570
 571
 572def cmd_project_export_bibtex(args: argparse.Namespace) -> None:
 573    details = _resolve_project_or_exit(args.project_id)
 574    papers = svc_paper.get_many(Papers(source_fks=details.source_fks)) if details.source_fks else []
 575    bibtex_str = BibTeXFormat().export_papers([_details_to_dict(p) for p in papers])
 576    dest = Path(args.dest)
 577    if not dest.suffix:
 578        dest = dest.with_suffix(".bib")
 579    dest.write_text(bibtex_str, encoding="utf-8")
 580    _output({"path": str(dest), "project_id": args.project_id})
 581
 582
 583def cmd_project_export_obsidian(args: argparse.Namespace) -> None:
 584    details = _resolve_project_or_exit(args.project_id)
 585    papers = svc_paper.get_many(Papers(source_fks=details.source_fks)) if details.source_fks else []
 586    md_str = ObsidianFormat().export_papers([_details_to_dict(p) for p in papers])
 587    dest = Path(args.dest)
 588    if not dest.suffix:
 589        dest = dest.with_suffix(".md")
 590    dest.write_text(md_str, encoding="utf-8")
 591    _output({"path": str(dest), "project_id": args.project_id})
 592
 593
 594# ---------------------------------------------------------------------------
 595# Commands — note subgroup
 596# ---------------------------------------------------------------------------
 597
 598def cmd_note_create(args: argparse.Namespace) -> None:
 599    if args.project_id is not None:
 600        _resolve_project_or_exit(args.project_id)
 601    source_id = _as_source_id(args.source_id)
 602    root = svc_paper.get_paper_root(source_id)
 603    if root is None:
 604        print(json.dumps({"error": f"Paper {source_id} not found in DB"}), file=sys.stderr)
 605        sys.exit(1)
 606    source_fk = int(root["SOURCE_FK"])
 607    note_id = svc_note.create(NoteIn(
 608        source_fk=source_fk,
 609        title=args.title or "",
 610        content=args.content,
 611        project_fk=args.project_id,
 612    ))
 613    _output({"id": note_id, "source_fk": source_fk, "project_id": args.project_id, "title": args.title or ""})
 614
 615
 616def cmd_note_get(args: argparse.Namespace) -> None:
 617    details = svc_note.get(Note(note_id=args.note_id))
 618    if details is None:
 619        print(json.dumps({"error": f"Note {args.note_id} not found"}), file=sys.stderr)
 620        sys.exit(1)
 621    _output(details.to_dict())
 622
 623
 624def cmd_note_list(args: argparse.Namespace) -> None:
 625    source_fk = None
 626    if args.source_id:
 627        sid = _as_source_id(args.source_id)
 628        root = svc_paper.get_paper_root(sid)
 629        if root is None:
 630            print(json.dumps({"error": f"Paper {sid!r} not found in DB"}), file=sys.stderr)
 631            sys.exit(1)
 632        source_fk = int(root["SOURCE_FK"])
 633    if source_fk is None and args.project_id is None:
 634        notes = svc_note.list_all()
 635    else:
 636        notes = svc_note.get_many(Notes(source_fk=source_fk, project_fk=args.project_id))
 637    _output([n.to_dict() for n in notes])
 638
 639
 640def cmd_note_update(args: argparse.Namespace) -> None:
 641    if svc_note.get(Note(note_id=args.note_id)) is None:
 642        print(json.dumps({"error": f"Note {args.note_id} not found"}), file=sys.stderr)
 643        sys.exit(1)
 644    if args.title is None and args.content is None:
 645        print(json.dumps({"error": "at least one of --title or --content must be provided"}), file=sys.stderr)
 646        sys.exit(1)
 647    svc_note.update(NoteUpdateIn(note_id=args.note_id, title=args.title, content=args.content))
 648    _output({"id": args.note_id, "updated": True})
 649
 650
 651def cmd_note_delete(args: argparse.Namespace) -> None:
 652    details = svc_note.get(Note(note_id=args.note_id))
 653    if details is None:
 654        print(json.dumps({"error": f"Note {args.note_id} not found"}), file=sys.stderr)
 655        sys.exit(1)
 656    svc_note.delete(Note(note_id=args.note_id))
 657    _output({"deleted_note_id": args.note_id})
 658
 659
 660# ---------------------------------------------------------------------------
 661# Commands — pdf subgroup
 662# ---------------------------------------------------------------------------
 663
 664def cmd_pdf_path(args: argparse.Namespace) -> None:
 665    paper = _resolve_paper_or_exit(_as_source_id(args.source_id))
 666    version = args.version if args.version else paper.version
 667    path = svc_files.pdf_path(paper.source_id, version, paper.pdf_path)
 668    _output({"source_id": paper.source_id, "version": version, "path": path})
 669
 670
 671def cmd_pdf_download(args: argparse.Namespace) -> None:
 672    paper = _resolve_paper_or_exit(_as_source_id(args.source_id))
 673    version = args.version if args.version else paper.version
 674    try:
 675        path = svc_files.download_pdf(paper.source_id, version, args.url)
 676    except Exception as e:
 677        print(f"[pdf] {e}", file=sys.stderr)
 678        print(json.dumps({"error": str(e)}), file=sys.stderr)
 679        sys.exit(1)
 680    if path is None:
 681        print(json.dumps({"error": "Download failed"}), file=sys.stderr)
 682        sys.exit(1)
 683    _output({"source_id": paper.source_id, "version": version, "path": path})
 684
 685
 686def cmd_pdf_storage(args: argparse.Namespace) -> None:
 687    mb = svc_files.pdf_storage_mb()
 688    _output({"storage_mb": round(mb, 3), "pdf_dir": svc_files.managed_pdf_dir()})
 689
 690
 691def cmd_pdf_import(args: argparse.Namespace) -> None:
 692    # import_pdf applies the membership guards itself (before any import
 693    # work); the except below turns them into the JSON error exit.
 694    pdf_path = Path(args.file)
 695    try:
 696        content = pdf_path.read_bytes()
 697        result = svc_paper.import_pdf(content, args.project_id)
 698    except Exception as e:
 699        print(f"[pdf-import] {e}", file=sys.stderr)
 700        print(json.dumps({"error": str(e)}), file=sys.stderr)
 701        sys.exit(1)
 702    _output({"source_id": result.source_id, "title": result.title})
 703
 704
 705# ---------------------------------------------------------------------------
 706# Commands — bibtex subgroup
 707# ---------------------------------------------------------------------------
 708
 709def cmd_bibtex_import(args: argparse.Namespace) -> None:
 710    bib_path = Path(args.file)
 711    if args.project_id is not None:
 712        # Guard before parsing/saving so a missing or deleted project fails
 713        # the command before the library is mutated.
 714        try:
 715            svc_project.ensure_membership_writable(args.project_id)
 716        except (svc_project.ProjectNotFoundError, svc_project.ProjectDeletedError) as e:
 717            print(json.dumps({"error": str(e)}), file=sys.stderr)
 718            sys.exit(1)
 719    try:
 720        metas = BibTeXFormat().import_file(str(bib_path))
 721    except Exception as e:
 722        print(f"[bibtex-import] {e}", file=sys.stderr)
 723        print(json.dumps({"error": str(e)}), file=sys.stderr)
 724        sys.exit(1)
 725    results = svc_paper.save_papers_metadata(metas)
 726    if args.project_id is not None and results:
 727        try:
 728            svc_project.link_imported(args.project_id, [s for s, _ in results])
 729        except (svc_project.ProjectNotFoundError, svc_project.ProjectDeletedError) as e:
 730            # Project went away between the pre-parse guard and the link; the
 731            # message says the papers stayed imported.
 732            print(
 733                json.dumps({"error": f"{len(results)} paper(s) were imported but could not be linked: {e}"}),
 734                file=sys.stderr,
 735            )
 736            sys.exit(1)
 737    _output({"imported": len(results), "papers": [{"source_id": s, "version": v} for s, v in results]})
 738
 739
 740# ---------------------------------------------------------------------------
 741# Commands — stats / categories / settings
 742# ---------------------------------------------------------------------------
 743
 744def cmd_stats(args: argparse.Namespace) -> None:
 745    papers = svc_paper.list_paper_details(latest_only=True)
 746    categories = svc_paper.get_categories()
 747    all_tags = svc_tag.list_all_tags()
 748    pdf_count = sum(1 for p in papers if p.has_pdf)
 749    _output({
 750        "paper_count": len(papers),
 751        "tag_count": len(all_tags),
 752        "category_count": len(categories),
 753        "pdf_count": pdf_count,
 754    })
 755
 756
 757def cmd_categories(args: argparse.Namespace) -> None:
 758    _output(svc_paper.get_categories())
 759
 760
 761def cmd_settings_get(args: argparse.Namespace) -> None:
 762    _output(user_settings.all_settings())
 763
 764
 765def cmd_settings_update(args: argparse.Namespace) -> None:
 766    try:
 767        value: Any = json.loads(args.value)
 768    except json.JSONDecodeError:
 769        value = args.value
 770    user_settings.set(args.key, value)
 771    _output({args.key: value})
 772
 773
 774# ---------------------------------------------------------------------------
 775# Argument parsing
 776# ---------------------------------------------------------------------------
 777
 778def build_parser() -> argparse.ArgumentParser:
 779    parser = argparse.ArgumentParser(prog="linxiv", description="linXiv headless CLI")
 780    parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
 781    sub = parser.add_subparsers(dest="command", required=True)
 782
 783    _source_choices = list(_SOURCES)
 784
 785    # ── search ──────────────────────────────────────────────────────────────
 786    p_search = sub.add_parser("search", help="Search for papers")
 787    p_search.add_argument("query", help="Search query string")
 788    p_search.add_argument("--source", choices=_source_choices, default="arxiv")
 789    p_search.add_argument("--max", type=int, default=10, help="Max results")
 790    p_search.set_defaults(func=cmd_search)
 791
 792    # ── fetch ────────────────────────────────────────────────────────────────
 793    p_fetch = sub.add_parser("fetch", help="Fetch and save a paper by ID")
 794    p_fetch.add_argument("source_id", help="Paper ID (e.g. 2204.12985 or W3123456789)")
 795    p_fetch.add_argument("--source", choices=_source_choices, default="arxiv")
 796    p_fetch.set_defaults(func=cmd_fetch)
 797
 798    # ── list ─────────────────────────────────────────────────────────────────
 799    p_list = sub.add_parser("list", help="List papers in the database")
 800    p_list.add_argument("--limit", type=int, default=None, help="Max papers to return")
 801    p_list.add_argument("--offset", type=int, default=0, help="Offset for pagination")
 802    p_list.add_argument("--category", type=str, default=None, help="Filter by category")
 803    p_list.set_defaults(func=cmd_list)
 804
 805    # ── paper ────────────────────────────────────────────────────────────────
 806    p_paper = sub.add_parser("paper", help="Manage individual papers")
 807    paper_sub = p_paper.add_subparsers(dest="paper_command", required=True)
 808
 809    p_paper_get = paper_sub.add_parser("get", help="Get full details for a paper")
 810    p_paper_get.add_argument("source_id", help="Paper source ID")
 811    p_paper_get.set_defaults(func=cmd_paper_get)
 812
 813    p_paper_del = paper_sub.add_parser("delete", help="Soft-delete a paper")
 814    p_paper_del.add_argument("source_id", help="Paper source ID")
 815    p_paper_del.set_defaults(func=cmd_paper_delete)
 816
 817    p_paper_ver = paper_sub.add_parser("versions", help="List all stored versions of a paper")
 818    p_paper_ver.add_argument("source_id", help="Paper source ID")
 819    p_paper_ver.set_defaults(func=cmd_paper_versions)
 820
 821    p_paper_repair = paper_sub.add_parser("repair", help="Overwrite paper metadata in-place")
 822    p_paper_repair.add_argument("source_id", help="Paper source ID")
 823    p_paper_repair.add_argument("--title", required=True, help="New title")
 824    p_paper_repair.add_argument("--authors", nargs="+", required=True, help="Author names")
 825    p_paper_repair.add_argument("--published", required=True, help="Publication date (YYYY-MM-DD)")
 826    p_paper_repair.add_argument("--summary", default="", help="Abstract / summary")
 827    p_paper_repair.add_argument("--category", default=None, help="Category")
 828    p_paper_repair.add_argument("--doi", default=None, help="DOI")
 829    p_paper_repair.add_argument("--url", default=None, help="URL")
 830    p_paper_repair.add_argument("--tags", nargs="*", default=None, help="Tags")
 831    p_paper_repair.set_defaults(func=cmd_paper_repair)
 832
 833    p_paper_restore = paper_sub.add_parser("restore", help="Restore a soft-deleted paper")
 834    p_paper_restore.add_argument("source_id", help="Paper source ID")
 835    p_paper_restore.set_defaults(func=cmd_paper_restore)
 836
 837    p_paper_hd = paper_sub.add_parser("hard-delete", help="Permanently delete a paper")
 838    p_paper_hd.add_argument("source_id", help="Paper source ID")
 839    p_paper_hd.set_defaults(func=cmd_paper_hard_delete)
 840
 841    p_paper_search = paper_sub.add_parser("search", help="Full-text search within local library")
 842    p_paper_search.add_argument("query", help="Search query")
 843    p_paper_search.add_argument("--limit", type=int, default=50, help="Max results")
 844    p_paper_search.set_defaults(func=cmd_paper_search)
 845
 846    p_paper_rmall = paper_sub.add_parser(
 847        "remove-from-all-projects", help="Remove a paper from every project"
 848    )
 849    p_paper_rmall.add_argument("source_id", help="Paper source ID")
 850    p_paper_rmall.set_defaults(func=cmd_paper_remove_from_all)
 851
 852    # ── tag ──────────────────────────────────────────────────────────────────
 853    p_tag = sub.add_parser("tag", help="Manage tags")
 854    tag_sub = p_tag.add_subparsers(dest="tag_command", required=True)
 855
 856    p_tag_add = tag_sub.add_parser("add", help="Add tags to a paper")
 857    p_tag_add.add_argument("source_id", help="Paper source ID")
 858    p_tag_add.add_argument("tags", nargs="+", help="Tags to add")
 859    p_tag_add.set_defaults(func=cmd_tag_add)
 860
 861    p_tag_remove = tag_sub.add_parser("remove", help="Remove tags from a paper")
 862    p_tag_remove.add_argument("source_id", help="Paper source ID")
 863    p_tag_remove.add_argument("tags", nargs="+", help="Tags to remove")
 864    p_tag_remove.set_defaults(func=cmd_tag_remove)
 865
 866    p_tag_list = tag_sub.add_parser("list", help="List tags on a paper")
 867    p_tag_list.add_argument("source_id", help="Paper source ID")
 868    p_tag_list.set_defaults(func=cmd_tag_list)
 869
 870    p_tag_list_all = tag_sub.add_parser("list-all", help="List all tags in the database")
 871    p_tag_list_all.set_defaults(func=cmd_tag_list_all)
 872
 873    p_tag_create = tag_sub.add_parser("create", help="Create a tag")
 874    p_tag_create.add_argument("label", help="Tag label")
 875    p_tag_create.set_defaults(func=cmd_tag_create)
 876
 877    p_tag_delete = tag_sub.add_parser("delete", help="Delete a tag by ID")
 878    p_tag_delete.add_argument("tag_id", type=int, help="Tag ID")
 879    p_tag_delete.set_defaults(func=cmd_tag_delete)
 880
 881    p_tag_add_proj = tag_sub.add_parser("add-project", help="Add tags to a project")
 882    p_tag_add_proj.add_argument("project_id", type=int, help="Project ID")
 883    p_tag_add_proj.add_argument("tags", nargs="+", help="Tags to add")
 884    p_tag_add_proj.set_defaults(func=cmd_tag_add_project)
 885
 886    p_tag_remove_proj = tag_sub.add_parser("remove-project", help="Remove tags from a project")
 887    p_tag_remove_proj.add_argument("project_id", type=int, help="Project ID")
 888    p_tag_remove_proj.add_argument("tags", nargs="+", help="Tags to remove")
 889    p_tag_remove_proj.set_defaults(func=cmd_tag_remove_project)
 890
 891    p_tag_list_proj = tag_sub.add_parser("list-project", help="List tags on a project")
 892    p_tag_list_proj.add_argument("project_id", type=int, help="Project ID")
 893    p_tag_list_proj.set_defaults(func=cmd_tag_list_project)
 894
 895    # ── project ───────────────────────────────────────────────────────────────
 896    p_proj = sub.add_parser("project", help="Manage projects")
 897    proj_sub = p_proj.add_subparsers(dest="project_command", required=True)
 898
 899    p_proj_list = proj_sub.add_parser("list", help="List projects")
 900    p_proj_list.add_argument("--status", choices=["active", "archived", "deleted"], default=None)
 901    p_proj_list.set_defaults(func=cmd_project_list)
 902
 903    p_proj_get = proj_sub.add_parser("get", help="Get project details")
 904    p_proj_get.add_argument("project_id", type=int, help="Project ID")
 905    p_proj_get.set_defaults(func=cmd_project_get)
 906
 907    p_proj_create = proj_sub.add_parser("create", help="Create a project")
 908    p_proj_create.add_argument("name", help="Project name")
 909    p_proj_create.add_argument("--description", default="", help="Project description")
 910    p_proj_create.add_argument("--color", default=None, help="Hex color (e.g. #4f86f7)")
 911    p_proj_create.add_argument("--tags", nargs="*", default=None, help="Project tags")
 912    p_proj_create.set_defaults(func=cmd_project_create)
 913
 914    p_proj_update = proj_sub.add_parser("update", help="Update project fields")
 915    p_proj_update.add_argument("project_id", type=int, help="Project ID")
 916    p_proj_update.add_argument("--name", default=None, help="New name")
 917    p_proj_update.add_argument("--description", default=None, help="New description")
 918    p_proj_update.add_argument("--color", default=None, help="Hex color (e.g. #4f86f7)")
 919    p_proj_update.add_argument("--tags", nargs="*", default=None,
 920                               help="Project tags (replaces existing; pass no values to clear)")
 921    p_proj_update.add_argument("--status", choices=["active", "archived", "deleted"], default=None)
 922    p_proj_update.set_defaults(func=cmd_project_update)
 923
 924    p_proj_delete = proj_sub.add_parser("delete", help="Soft-delete a project")
 925    p_proj_delete.add_argument("project_id", type=int, help="Project ID")
 926    p_proj_delete.set_defaults(func=cmd_project_delete)
 927
 928    p_proj_archive = proj_sub.add_parser("archive", help="Archive an active project")
 929    p_proj_archive.add_argument("project_id", type=int, help="Project ID")
 930    p_proj_archive.set_defaults(func=cmd_project_archive)
 931
 932    p_proj_restore = proj_sub.add_parser("restore", help="Restore an archived or deleted project")
 933    p_proj_restore.add_argument("project_id", type=int, help="Project ID")
 934    p_proj_restore.set_defaults(func=cmd_project_restore)
 935
 936    p_proj_hd = proj_sub.add_parser("hard-delete", help="Permanently delete a project")
 937    p_proj_hd.add_argument("project_id", type=int, help="Project ID")
 938    p_proj_hd.set_defaults(func=cmd_project_hard_delete)
 939
 940    p_proj_add = proj_sub.add_parser("add-paper", help="Add a paper to a project")
 941    p_proj_add.add_argument("project_id", type=int, help="Project ID")
 942    p_proj_add.add_argument("source_id", help="Paper source ID")
 943    p_proj_add.set_defaults(func=cmd_project_add_paper)
 944
 945    p_proj_rem = proj_sub.add_parser("remove-paper", help="Remove a paper from a project")
 946    p_proj_rem.add_argument("project_id", type=int, help="Project ID")
 947    p_proj_rem.add_argument("source_id", help="Paper source ID")
 948    p_proj_rem.set_defaults(func=cmd_project_remove_paper)
 949
 950    p_proj_export = proj_sub.add_parser("export", help="Export a project to a .lxproj archive")
 951    p_proj_export.add_argument("project_id", type=int, help="Project ID")
 952    p_proj_export.add_argument("dest", help="Destination path (.lxproj extension added automatically)")
 953    p_proj_export.add_argument("--pdfs", action="store_true", default=False,
 954                               help="Include bundled PDFs in the archive")
 955    p_proj_export.set_defaults(func=cmd_project_export)
 956
 957    p_proj_import = proj_sub.add_parser("import", help="Import a project from a .lxproj archive")
 958    p_proj_import.add_argument("zip_path", help="Path to .lxproj archive")
 959    p_proj_import.add_argument("--preview", action="store_true", default=False,
 960                               help="Show archive summary without modifying the database")
 961    p_proj_import.add_argument("--on-conflict", choices=["merge", "overwrite"], default="merge",
 962                               dest="on_conflict",
 963                               help="How to handle papers that already exist (default: merge)")
 964    p_proj_import.set_defaults(func=cmd_project_import)
 965
 966    p_proj_export_bib = proj_sub.add_parser("export-bibtex", help="Export project papers as BibTeX")
 967    p_proj_export_bib.add_argument("project_id", type=int, help="Project ID")
 968    p_proj_export_bib.add_argument("dest", help="Output file path (.bib added if no extension)")
 969    p_proj_export_bib.set_defaults(func=cmd_project_export_bibtex)
 970
 971    p_proj_export_obs = proj_sub.add_parser("export-obsidian",
 972                                             help="Export project papers as Obsidian markdown")
 973    p_proj_export_obs.add_argument("project_id", type=int, help="Project ID")
 974    p_proj_export_obs.add_argument("dest", help="Output file path (.md added if no extension)")
 975    p_proj_export_obs.set_defaults(func=cmd_project_export_obsidian)
 976
 977    # ── note ─────────────────────────────────────────────────────────────────
 978    p_note = sub.add_parser("note", help="Manage notes")
 979    note_sub = p_note.add_subparsers(dest="note_command", required=True)
 980
 981    p_note_create = note_sub.add_parser("create", help="Create a note on a paper")
 982    p_note_create.add_argument("source_id", help="Paper source ID")
 983    p_note_create.add_argument("content", help="Note body text")
 984    p_note_create.add_argument("--title", default="", help="Note title")
 985    p_note_create.add_argument("--project-id", type=int, dest="project_id", default=None,
 986                               help="Associate note with a project")
 987    p_note_create.set_defaults(func=cmd_note_create)
 988
 989    p_note_get = note_sub.add_parser("get", help="Get a note by ID")
 990    p_note_get.add_argument("note_id", type=int, help="Note ID")
 991    p_note_get.set_defaults(func=cmd_note_get)
 992
 993    p_note_list = note_sub.add_parser("list", help="List notes")
 994    p_note_list.add_argument("--paper-id", dest="source_id", default=None,
 995                             help="Filter by paper source ID")
 996    p_note_list.add_argument("--project-id", type=int, dest="project_id", default=None,
 997                             help="Filter by project ID")
 998    p_note_list.set_defaults(func=cmd_note_list)
 999
1000    p_note_update = note_sub.add_parser("update", help="Update note title or content")
1001    p_note_update.add_argument("note_id", type=int, help="Note ID")
1002    p_note_update.add_argument("--title", default=None, help="New title")
1003    p_note_update.add_argument("--content", default=None, help="New content")
1004    p_note_update.set_defaults(func=cmd_note_update)
1005
1006    p_note_del = note_sub.add_parser("delete", help="Delete a note by ID")
1007    p_note_del.add_argument("note_id", type=int, help="Note ID")
1008    p_note_del.set_defaults(func=cmd_note_delete)
1009
1010    # ── pdf ──────────────────────────────────────────────────────────────────
1011    p_pdf = sub.add_parser("pdf", help="Manage PDFs")
1012    pdf_sub = p_pdf.add_subparsers(dest="pdf_command", required=True)
1013
1014    p_pdf_path = pdf_sub.add_parser("path", help="Show local PDF path for a paper")
1015    p_pdf_path.add_argument("source_id", help="Paper source ID")
1016    p_pdf_path.add_argument("--version", type=int, default=None,
1017                            help="Paper version (defaults to latest)")
1018    p_pdf_path.set_defaults(func=cmd_pdf_path)
1019
1020    p_pdf_dl = pdf_sub.add_parser("download", help="Download PDF for a paper")
1021    p_pdf_dl.add_argument("source_id", help="Paper source ID")
1022    p_pdf_dl.add_argument("url", help="PDF download URL")
1023    p_pdf_dl.add_argument("--version", type=int, default=None,
1024                          help="Paper version (defaults to latest)")
1025    p_pdf_dl.set_defaults(func=cmd_pdf_download)
1026
1027    p_pdf_storage = pdf_sub.add_parser("storage", help="Report total PDF storage usage")
1028    p_pdf_storage.set_defaults(func=cmd_pdf_storage)
1029
1030    p_pdf_import = pdf_sub.add_parser("import", help="Import a local PDF (extract metadata)")
1031    p_pdf_import.add_argument("file", help="Path to PDF file")
1032    p_pdf_import.add_argument("--project-id", type=int, dest="project_id", default=None,
1033                              help="Link imported paper to a project")
1034    p_pdf_import.set_defaults(func=cmd_pdf_import)
1035
1036    # ── trash ─────────────────────────────────────────────────────────────────
1037    p_trash = sub.add_parser("trash", help="Manage soft-deleted items")
1038    trash_sub = p_trash.add_subparsers(dest="trash_command", required=True)
1039
1040    p_trash_list = trash_sub.add_parser("list", help="List soft-deleted papers and projects")
1041    p_trash_list.set_defaults(func=cmd_trash_list)
1042
1043    p_trash_restore = trash_sub.add_parser("restore", help="Restore a soft-deleted paper")
1044    p_trash_restore.add_argument("source_id", help="Paper source ID")
1045    p_trash_restore.set_defaults(func=cmd_trash_restore)
1046
1047    p_trash_hd = trash_sub.add_parser("hard-delete", help="Permanently delete a paper")
1048    p_trash_hd.add_argument("source_id", help="Paper source ID")
1049    p_trash_hd.set_defaults(func=cmd_trash_hard_delete)
1050
1051    p_trash_rp = trash_sub.add_parser("restore-project", help="Restore a soft-deleted project")
1052    p_trash_rp.add_argument("project_id", type=int, help="Project ID")
1053    p_trash_rp.set_defaults(func=cmd_trash_restore_project)
1054
1055    p_trash_hdp = trash_sub.add_parser("hard-delete-project", help="Permanently delete a project")
1056    p_trash_hdp.add_argument("project_id", type=int, help="Project ID")
1057    p_trash_hdp.set_defaults(func=cmd_trash_hard_delete_project)
1058
1059    # ── doi ───────────────────────────────────────────────────────────────────
1060    p_doi = sub.add_parser("doi", help="Resolve and save papers by DOI")
1061    doi_sub = p_doi.add_subparsers(dest="doi_command", required=True)
1062
1063    p_doi_resolve = doi_sub.add_parser("resolve", help="Resolve DOI to metadata (no save)")
1064    p_doi_resolve.add_argument("doi", help="DOI string")
1065    p_doi_resolve.set_defaults(func=cmd_doi_resolve)
1066
1067    p_doi_save = doi_sub.add_parser("save", help="Resolve DOI and save paper to library")
1068    p_doi_save.add_argument("doi", help="DOI string")
1069    p_doi_save.set_defaults(func=cmd_doi_save)
1070
1071    # ── author ────────────────────────────────────────────────────────────────
1072    p_author = sub.add_parser("author", help="Manage authors")
1073    author_sub = p_author.add_subparsers(dest="author_command", required=True)
1074
1075    p_author_list = author_sub.add_parser("list", help="List all authors with paper counts")
1076    p_author_list.set_defaults(func=cmd_author_list)
1077
1078    p_author_get = author_sub.add_parser("get", help="Get author details and paper list")
1079    p_author_get.add_argument("author_id", type=int, help="Author ID")
1080    p_author_get.set_defaults(func=cmd_author_get)
1081
1082    p_author_update = author_sub.add_parser("update", help="Update author fields")
1083    p_author_update.add_argument("author_id", type=int, help="Author ID")
1084    p_author_update.add_argument("--full-name", dest="full_name", default=None)
1085    p_author_update.add_argument("--first-name", dest="first_name", default=None)
1086    p_author_update.add_argument("--last-name", dest="last_name", default=None)
1087    p_author_update.add_argument("--orcid", default=None)
1088    p_author_update.set_defaults(func=cmd_author_update)
1089
1090    p_author_delete = author_sub.add_parser(
1091        "delete", help="Delete an author (blocked if linked to papers)"
1092    )
1093    p_author_delete.add_argument("author_id", type=int, help="Author ID")
1094    p_author_delete.set_defaults(func=cmd_author_delete)
1095
1096    # ── bibtex ────────────────────────────────────────────────────────────────
1097    p_bibtex = sub.add_parser("bibtex", help="BibTeX import")
1098    bibtex_sub = p_bibtex.add_subparsers(dest="bibtex_command", required=True)
1099
1100    p_bibtex_import = bibtex_sub.add_parser("import", help="Import papers from a .bib file")
1101    p_bibtex_import.add_argument("file", help="Path to .bib file")
1102    p_bibtex_import.add_argument("--project-id", type=int, dest="project_id", default=None,
1103                                  help="Link imported papers to a project")
1104    p_bibtex_import.set_defaults(func=cmd_bibtex_import)
1105
1106    # ── stats ─────────────────────────────────────────────────────────────────
1107    p_stats = sub.add_parser("stats", help="Library statistics")
1108    p_stats.set_defaults(func=cmd_stats)
1109
1110    # ── categories ────────────────────────────────────────────────────────────
1111    p_cats = sub.add_parser("categories", help="List all paper categories in the library")
1112    p_cats.set_defaults(func=cmd_categories)
1113
1114    # ── settings ──────────────────────────────────────────────────────────────
1115    p_settings = sub.add_parser("settings", help="View and update user settings")
1116    settings_sub = p_settings.add_subparsers(dest="settings_command", required=True)
1117
1118    p_settings_get = settings_sub.add_parser("get", help="Show all current settings")
1119    p_settings_get.set_defaults(func=cmd_settings_get)
1120
1121    p_settings_update = settings_sub.add_parser("update", help="Set a setting value")
1122    p_settings_update.add_argument("key", help="Setting key")
1123    p_settings_update.add_argument("value", help="New value (JSON-parsed if valid JSON, else string)")
1124    p_settings_update.set_defaults(func=cmd_settings_update)
1125
1126    return parser
1127
1128
1129def main(argv: list[str] | None = None) -> None:
1130    init_data_dir()
1131    svc_paper.init_db()
1132    svc_project.ensure_projects_db()
1133    svc_note.ensure_notes_db()
1134    parser = build_parser()
1135    args = parser.parse_args(argv)
1136    args.func(args)
1137
1138
1139if __name__ == "__main__":
1140    main()
def cmd_fetch(args: argparse.Namespace) -> None:
128def cmd_fetch(args: argparse.Namespace) -> None:
129    if args.source == "arxiv":
130        _validate_arxiv_id(args.source_id)
131    source = _source_for(args.source)
132    try:
133        meta = source.fetch_by_id(args.source_id)
134    except Exception as e:
135        print(f"[fetch] {e}", file=sys.stderr)
136        print(json.dumps({"error": str(e)}), file=sys.stderr)
137        sys.exit(1)
138    svc_paper.save_paper_metadata(meta, None)
139    rendered = _render_paper(meta)
140    if rendered:
141        sys.stdout.write(rendered + "\n")
142    else:
143        _output(meta.model_dump(mode="json"))
def cmd_list(args: argparse.Namespace) -> None:
146def cmd_list(args: argparse.Namespace) -> None:
147    rows = svc_paper.list_papers(limit=args.limit, offset=args.offset, category=args.category)
148    papers = [{k: row[k] for k in row.keys()} for row in rows]
149    _output(papers)
def cmd_paper_get(args: argparse.Namespace) -> None:
156def cmd_paper_get(args: argparse.Namespace) -> None:
157    source_id = _as_source_id(args.source_id)
158    details = _resolve_paper_or_exit(source_id)
159    _output(_details_to_dict(details))
def cmd_paper_delete(args: argparse.Namespace) -> None:
162def cmd_paper_delete(args: argparse.Namespace) -> None:
163    source_id = _as_source_id(args.source_id)
164    _resolve_paper_or_exit(source_id)
165    svc_paper.delete(svc_paper.Paper(source_id=source_id))
166    _output({"deleted": source_id})
def cmd_paper_versions(args: argparse.Namespace) -> None:
169def cmd_paper_versions(args: argparse.Namespace) -> None:
170    source_id = _as_source_id(args.source_id)
171    all_versions = svc_paper.get_all(Paper(source_id=source_id))
172    if all_versions is None:
173        print(json.dumps({"error": f"Paper {source_id!r} not found in DB"}), file=sys.stderr)
174        sys.exit(1)
175    _output(_details_to_dict(all_versions))
def cmd_paper_repair(args: argparse.Namespace) -> None:
178def cmd_paper_repair(args: argparse.Namespace) -> None:
179    source_id = _as_source_id(args.source_id)
180    root = svc_paper.get_paper_root(source_id)
181    if root is None:
182        print(json.dumps({"error": f"Paper {source_id!r} not found"}), file=sys.stderr)
183        sys.exit(1)
184    source_fk = int(root["SOURCE_FK"])
185    existing = svc_paper.get(Paper(source_id=source_id))
186    version = existing.version if existing is not None else 1
187    try:
188        published = datetime.date.fromisoformat(args.published)
189    except ValueError:
190        print(json.dumps({"error": f"Invalid date {args.published!r}; use YYYY-MM-DD"}), file=sys.stderr)
191        sys.exit(1)
192    meta = PaperMetadata(
193        source_id=source_id,
194        version=version,
195        title=args.title,
196        authors=args.authors,
197        published=published,
198        summary=args.summary or "",
199        category=args.category,
200        doi=args.doi,
201        url=args.url,
202        tags=args.tags or None,
203        source=None,
204    )
205    svc_paper.repair_paper(source_fk, meta)
206    _output({"repaired": source_id})
def cmd_paper_restore(args: argparse.Namespace) -> None:
226def cmd_paper_restore(args: argparse.Namespace) -> None:
227    source_id = _as_source_id(args.source_id)
228    _output(_do_paper_restore(source_id))
def cmd_paper_hard_delete(args: argparse.Namespace) -> None:
231def cmd_paper_hard_delete(args: argparse.Namespace) -> None:
232    source_id = _as_source_id(args.source_id)
233    _output(_do_paper_hard_delete(source_id))
def cmd_paper_remove_from_all(args: argparse.Namespace) -> None:
241def cmd_paper_remove_from_all(args: argparse.Namespace) -> None:
242    source_id = _as_source_id(args.source_id)
243    removed = svc_project.remove_paper_from_all_projects_by_id(source_id)
244    if removed is None:
245        print(json.dumps({"error": f"Paper {source_id!r} not found"}), file=sys.stderr)
246        sys.exit(1)
247    _output({"source_id": source_id, "removed_from_projects": removed})
def cmd_trash_list(args: argparse.Namespace) -> None:
254def cmd_trash_list(args: argparse.Namespace) -> None:
255    papers = svc_paper.list_deleted()
256    projects = svc_project.list_deleted()
257    _output({
258        "papers": [_details_to_dict(p) for p in papers],
259        "projects": [p.to_dict() for p in projects],
260    })
def cmd_trash_restore(args: argparse.Namespace) -> None:
263def cmd_trash_restore(args: argparse.Namespace) -> None:
264    source_id = _as_source_id(args.source_id)
265    _output(_do_paper_restore(source_id))
def cmd_trash_hard_delete(args: argparse.Namespace) -> None:
268def cmd_trash_hard_delete(args: argparse.Namespace) -> None:
269    source_id = _as_source_id(args.source_id)
270    if not svc_paper.is_paper_deleted(source_id):
271        print(json.dumps({"error": f"Paper {source_id!r} not found in trash"}), file=sys.stderr)
272        sys.exit(1)
273    _output(_do_paper_hard_delete(source_id))
def cmd_trash_restore_project(args: argparse.Namespace) -> None:
276def cmd_trash_restore_project(args: argparse.Namespace) -> None:
277    details = _resolve_project_or_exit(args.project_id)
278    if details.status != Status.DELETED:
279        print(json.dumps({"error": f"Project {args.project_id} is not in trash"}), file=sys.stderr)
280        sys.exit(1)
281    svc_project.restore(Project(project_fk=args.project_id))
282    _output({"restored_project_id": args.project_id})
def cmd_trash_hard_delete_project(args: argparse.Namespace) -> None:
285def cmd_trash_hard_delete_project(args: argparse.Namespace) -> None:
286    details = _resolve_project_or_exit(args.project_id)
287    if details.status != Status.DELETED:
288        print(json.dumps({"error": f"Project {args.project_id} is not in trash"}), file=sys.stderr)
289        sys.exit(1)
290    svc_project.hard_delete(Project(project_fk=args.project_id))
291    _output({"hard_deleted_project_id": args.project_id})
def cmd_doi_resolve(args: argparse.Namespace) -> None:
298def cmd_doi_resolve(args: argparse.Namespace) -> None:
299    try:
300        meta = resolve_doi(args.doi)
301    except Exception as e:
302        print(f"[doi] {e}", file=sys.stderr)
303        print(json.dumps({"error": str(e)}), file=sys.stderr)
304        sys.exit(1)
305    _output(meta.model_dump(mode="json"))
def cmd_doi_save(args: argparse.Namespace) -> None:
308def cmd_doi_save(args: argparse.Namespace) -> None:
309    try:
310        meta = resolve_doi(args.doi)
311    except Exception as e:
312        print(f"[doi] {e}", file=sys.stderr)
313        print(json.dumps({"error": str(e)}), file=sys.stderr)
314        sys.exit(1)
315    source_id, ver = svc_paper.save_paper_metadata(meta)
316    _output({"source_id": source_id, "version": ver, "title": meta.title})
def cmd_author_list(args: argparse.Namespace) -> None:
323def cmd_author_list(args: argparse.Namespace) -> None:
324    authors = svc_author.list_with_paper_count()
325    _output([a.to_dict() for a in authors])
def cmd_author_get(args: argparse.Namespace) -> None:
328def cmd_author_get(args: argparse.Namespace) -> None:
329    author = svc_author.get(Author(author_id=args.author_id))
330    if author is None:
331        print(json.dumps({"error": f"Author {args.author_id} not found"}), file=sys.stderr)
332        sys.exit(1)
333    previews = svc_author.get_paper_previews(args.author_id)
334    result = author.to_dict()
335    result["papers"] = [p.to_dict() for p in previews]
336    _output(result)
def cmd_author_update(args: argparse.Namespace) -> None:
339def cmd_author_update(args: argparse.Namespace) -> None:
340    if svc_author.get(Author(author_id=args.author_id)) is None:
341        print(json.dumps({"error": f"Author {args.author_id} not found"}), file=sys.stderr)
342        sys.exit(1)
343    if args.full_name is None and args.first_name is None and args.last_name is None and args.orcid is None:
344        print(json.dumps({"error": "at least one of --full-name, --first-name, --last-name, or --orcid must be provided"}), file=sys.stderr)
345        sys.exit(1)
346    svc_author.update_fields(
347        author_id=args.author_id,
348        full_name=args.full_name,
349        first_name=args.first_name,
350        last_name=args.last_name,
351        orcid=args.orcid,
352    )
353    _output({"updated_author_id": args.author_id})
def cmd_author_delete(args: argparse.Namespace) -> None:
356def cmd_author_delete(args: argparse.Namespace) -> None:
357    link_count = svc_author.count_paper_links(args.author_id)
358    if link_count > 0:
359        print(
360            json.dumps({"error": f"Author {args.author_id} is linked to {link_count} paper(s); unlink first"}),
361            file=sys.stderr,
362        )
363        sys.exit(1)
364    svc_author.delete_author(args.author_id)
365    _output({"deleted_author_id": args.author_id})
def cmd_tag_add(args: argparse.Namespace) -> None:
372def cmd_tag_add(args: argparse.Namespace) -> None:
373    source_id = _as_source_id(args.source_id)
374    try:
375        updated = svc_tag.add_paper_tags(source_id, args.tags)
376    except KeyError:
377        print(json.dumps({"error": f"Paper {source_id} not found in DB"}), file=sys.stderr)
378        sys.exit(1)
379    _output({"source_id": source_id, "tags": updated})
def cmd_tag_remove(args: argparse.Namespace) -> None:
382def cmd_tag_remove(args: argparse.Namespace) -> None:
383    source_id = _as_source_id(args.source_id)
384    try:
385        updated = svc_tag.remove_paper_tags(source_id, args.tags)
386    except KeyError:
387        print(json.dumps({"error": f"Paper {source_id} not found in DB"}), file=sys.stderr)
388        sys.exit(1)
389    _output({"source_id": source_id, "tags": updated})
def cmd_tag_list(args: argparse.Namespace) -> None:
392def cmd_tag_list(args: argparse.Namespace) -> None:
393    source_id = _as_source_id(args.source_id)
394    tags = svc_tag.get_paper_tags(source_id)
395    _output({"source_id": source_id, "tags": tags})
def cmd_tag_list_all(args: argparse.Namespace) -> None:
398def cmd_tag_list_all(args: argparse.Namespace) -> None:
399    _output(svc_tag.list_all_tags())
def cmd_tag_create(args: argparse.Namespace) -> None:
402def cmd_tag_create(args: argparse.Namespace) -> None:
403    tag_id = svc_tag.upsert(TagIn(label=args.label))
404    _output({"tag_id": tag_id, "label": args.label})
def cmd_tag_delete(args: argparse.Namespace) -> None:
407def cmd_tag_delete(args: argparse.Namespace) -> None:
408    svc_tag.delete(Tag(tag_id=args.tag_id))
409    _output({"deleted_tag_id": args.tag_id})
def cmd_tag_add_project(args: argparse.Namespace) -> None:
412def cmd_tag_add_project(args: argparse.Namespace) -> None:
413    _resolve_project_or_exit(args.project_id)
414    updated = svc_tag.add_project_tags(args.project_id, args.tags)
415    _output({"project_id": args.project_id, "tags": updated})
def cmd_tag_remove_project(args: argparse.Namespace) -> None:
418def cmd_tag_remove_project(args: argparse.Namespace) -> None:
419    _resolve_project_or_exit(args.project_id)
420    updated = svc_tag.remove_project_tags(args.project_id, args.tags)
421    _output({"project_id": args.project_id, "tags": updated})
def cmd_tag_list_project(args: argparse.Namespace) -> None:
424def cmd_tag_list_project(args: argparse.Namespace) -> None:
425    details = _resolve_project_or_exit(args.project_id)
426    _output({"project_id": args.project_id, "tags": details.project_tags})
def cmd_project_list(args: argparse.Namespace) -> None:
433def cmd_project_list(args: argparse.Namespace) -> None:
434    status = Status(args.status) if args.status else None
435    projects = svc_project.get_many(Projects(status=status))
436    if status is None:
437        projects = [p for p in projects if p.status != Status.DELETED]
438    _output([{
439        "id": p.id,
440        "name": p.name,
441        "description": p.description,
442        "status": p.status.value,
443        "paper_count": len(p.source_fks),
444        "color": p.color,
445        "project_tags": p.project_tags,
446    } for p in projects])
def cmd_project_get(args: argparse.Namespace) -> None:
449def cmd_project_get(args: argparse.Namespace) -> None:
450    details = _resolve_project_or_exit(args.project_id)
451    _output(details.to_dict())
def cmd_project_create(args: argparse.Namespace) -> None:
454def cmd_project_create(args: argparse.Namespace) -> None:
455    color = svc_project.color_from_hex(args.color) if args.color else None
456    tags = args.tags or []
457    fk = svc_project.upsert(ProjectIn(
458        name=args.name,
459        description=args.description or "",
460        color=color,
461        tags=tags,
462    ))
463    _output({"id": fk, "name": args.name, "status": "active"})
def cmd_project_update(args: argparse.Namespace) -> None:
466def cmd_project_update(args: argparse.Namespace) -> None:
467    _resolve_project_or_exit(args.project_id)
468    try:
469        color: Any = UNSET
470        if args.color is not None:
471            color = svc_project.color_from_hex(args.color)
472        status = Status(args.status) if args.status else None
473        svc_project.update(
474            project_fk=args.project_id,
475            name=args.name,
476            description=args.description,
477            color=color,
478            project_tags=args.tags,
479            status=status,
480        )
481    except (LookupError, ValueError) as e:
482        print(f"[project] {e}", file=sys.stderr)
483        print(json.dumps({"error": str(e)}), file=sys.stderr)
484        sys.exit(1)
485    updated = _resolve_project_or_exit(args.project_id)
486    _output(updated.to_dict())
def cmd_project_delete(args: argparse.Namespace) -> None:
489def cmd_project_delete(args: argparse.Namespace) -> None:
490    _resolve_project_or_exit(args.project_id)
491    svc_project.delete(Project(project_fk=args.project_id))
492    _output({"deleted_project_id": args.project_id})
def cmd_project_archive(args: argparse.Namespace) -> None:
495def cmd_project_archive(args: argparse.Namespace) -> None:
496    _resolve_project_or_exit(args.project_id)
497    svc_project.archive(Project(project_fk=args.project_id))
498    _output({"archived_project_id": args.project_id})
def cmd_project_restore(args: argparse.Namespace) -> None:
501def cmd_project_restore(args: argparse.Namespace) -> None:
502    _resolve_project_or_exit(args.project_id)
503    svc_project.restore(Project(project_fk=args.project_id))
504    _output({"restored_project_id": args.project_id})
def cmd_project_hard_delete(args: argparse.Namespace) -> None:
507def cmd_project_hard_delete(args: argparse.Namespace) -> None:
508    _resolve_project_or_exit(args.project_id)
509    svc_project.hard_delete(Project(project_fk=args.project_id))
510    _output({"hard_deleted_project_id": args.project_id})
def cmd_project_add_paper(args: argparse.Namespace) -> None:
513def cmd_project_add_paper(args: argparse.Namespace) -> None:
514    source_id = _as_source_id(args.source_id)
515    try:
516        failed = svc_project.add_papers(args.project_id, [source_id])
517    except (svc_project.ProjectNotFoundError, svc_project.ProjectDeletedError) as e:
518        print(json.dumps({"error": str(e)}), file=sys.stderr)
519        sys.exit(1)
520    if failed:
521        print(json.dumps({"error": f"Paper {source_id} not found in database"}), file=sys.stderr)
522        sys.exit(1)
523    _output({"project_id": args.project_id, "source_id": source_id})
def cmd_project_remove_paper(args: argparse.Namespace) -> None:
526def cmd_project_remove_paper(args: argparse.Namespace) -> None:
527    source_id = _as_source_id(args.source_id)
528    try:
529        failed = svc_project.remove_papers(args.project_id, [source_id])
530    except (svc_project.ProjectNotFoundError, svc_project.ProjectDeletedError) as e:
531        print(json.dumps({"error": str(e)}), file=sys.stderr)
532        sys.exit(1)
533    if failed:
534        print(json.dumps({"error": f"Paper {source_id} not found in database"}), file=sys.stderr)
535        sys.exit(1)
536    _output({"project_id": args.project_id, "source_id": source_id, "removed": True})
def cmd_project_export(args: argparse.Namespace) -> None:
543def cmd_project_export(args: argparse.Namespace) -> None:
544    try:
545        out = svc_ei.export_project(args.project_id, Path(args.dest), include_pdfs=args.pdfs)
546    except Exception as e:
547        print(f"[export] {e}", file=sys.stderr)
548        print(json.dumps({"error": str(e)}), file=sys.stderr)
549        sys.exit(1)
550    _output({"path": str(out), "project_id": args.project_id})
def cmd_project_import(args: argparse.Namespace) -> None:
553def cmd_project_import(args: argparse.Namespace) -> None:
554    zip_path = Path(args.zip_path)
555    if args.preview:
556        try:
557            preview = svc_ei.preview_import(zip_path)
558        except Exception as e:
559            print(f"[import] {e}", file=sys.stderr)
560            print(json.dumps({"error": str(e)}), file=sys.stderr)
561            sys.exit(1)
562        _output(_details_to_dict(preview))
563    else:
564        try:
565            fk = svc_ei.commit_import(zip_path, on_conflict=args.on_conflict)
566        except Exception as e:
567            print(f"[import] {e}", file=sys.stderr)
568            print(json.dumps({"error": str(e)}), file=sys.stderr)
569            sys.exit(1)
570        _output({"project_id": fk})
def cmd_project_export_bibtex(args: argparse.Namespace) -> None:
573def cmd_project_export_bibtex(args: argparse.Namespace) -> None:
574    details = _resolve_project_or_exit(args.project_id)
575    papers = svc_paper.get_many(Papers(source_fks=details.source_fks)) if details.source_fks else []
576    bibtex_str = BibTeXFormat().export_papers([_details_to_dict(p) for p in papers])
577    dest = Path(args.dest)
578    if not dest.suffix:
579        dest = dest.with_suffix(".bib")
580    dest.write_text(bibtex_str, encoding="utf-8")
581    _output({"path": str(dest), "project_id": args.project_id})
def cmd_project_export_obsidian(args: argparse.Namespace) -> None:
584def cmd_project_export_obsidian(args: argparse.Namespace) -> None:
585    details = _resolve_project_or_exit(args.project_id)
586    papers = svc_paper.get_many(Papers(source_fks=details.source_fks)) if details.source_fks else []
587    md_str = ObsidianFormat().export_papers([_details_to_dict(p) for p in papers])
588    dest = Path(args.dest)
589    if not dest.suffix:
590        dest = dest.with_suffix(".md")
591    dest.write_text(md_str, encoding="utf-8")
592    _output({"path": str(dest), "project_id": args.project_id})
def cmd_note_create(args: argparse.Namespace) -> None:
599def cmd_note_create(args: argparse.Namespace) -> None:
600    if args.project_id is not None:
601        _resolve_project_or_exit(args.project_id)
602    source_id = _as_source_id(args.source_id)
603    root = svc_paper.get_paper_root(source_id)
604    if root is None:
605        print(json.dumps({"error": f"Paper {source_id} not found in DB"}), file=sys.stderr)
606        sys.exit(1)
607    source_fk = int(root["SOURCE_FK"])
608    note_id = svc_note.create(NoteIn(
609        source_fk=source_fk,
610        title=args.title or "",
611        content=args.content,
612        project_fk=args.project_id,
613    ))
614    _output({"id": note_id, "source_fk": source_fk, "project_id": args.project_id, "title": args.title or ""})
def cmd_note_get(args: argparse.Namespace) -> None:
617def cmd_note_get(args: argparse.Namespace) -> None:
618    details = svc_note.get(Note(note_id=args.note_id))
619    if details is None:
620        print(json.dumps({"error": f"Note {args.note_id} not found"}), file=sys.stderr)
621        sys.exit(1)
622    _output(details.to_dict())
def cmd_note_list(args: argparse.Namespace) -> None:
625def cmd_note_list(args: argparse.Namespace) -> None:
626    source_fk = None
627    if args.source_id:
628        sid = _as_source_id(args.source_id)
629        root = svc_paper.get_paper_root(sid)
630        if root is None:
631            print(json.dumps({"error": f"Paper {sid!r} not found in DB"}), file=sys.stderr)
632            sys.exit(1)
633        source_fk = int(root["SOURCE_FK"])
634    if source_fk is None and args.project_id is None:
635        notes = svc_note.list_all()
636    else:
637        notes = svc_note.get_many(Notes(source_fk=source_fk, project_fk=args.project_id))
638    _output([n.to_dict() for n in notes])
def cmd_note_update(args: argparse.Namespace) -> None:
641def cmd_note_update(args: argparse.Namespace) -> None:
642    if svc_note.get(Note(note_id=args.note_id)) is None:
643        print(json.dumps({"error": f"Note {args.note_id} not found"}), file=sys.stderr)
644        sys.exit(1)
645    if args.title is None and args.content is None:
646        print(json.dumps({"error": "at least one of --title or --content must be provided"}), file=sys.stderr)
647        sys.exit(1)
648    svc_note.update(NoteUpdateIn(note_id=args.note_id, title=args.title, content=args.content))
649    _output({"id": args.note_id, "updated": True})
def cmd_note_delete(args: argparse.Namespace) -> None:
652def cmd_note_delete(args: argparse.Namespace) -> None:
653    details = svc_note.get(Note(note_id=args.note_id))
654    if details is None:
655        print(json.dumps({"error": f"Note {args.note_id} not found"}), file=sys.stderr)
656        sys.exit(1)
657    svc_note.delete(Note(note_id=args.note_id))
658    _output({"deleted_note_id": args.note_id})
def cmd_pdf_path(args: argparse.Namespace) -> None:
665def cmd_pdf_path(args: argparse.Namespace) -> None:
666    paper = _resolve_paper_or_exit(_as_source_id(args.source_id))
667    version = args.version if args.version else paper.version
668    path = svc_files.pdf_path(paper.source_id, version, paper.pdf_path)
669    _output({"source_id": paper.source_id, "version": version, "path": path})
def cmd_pdf_download(args: argparse.Namespace) -> None:
672def cmd_pdf_download(args: argparse.Namespace) -> None:
673    paper = _resolve_paper_or_exit(_as_source_id(args.source_id))
674    version = args.version if args.version else paper.version
675    try:
676        path = svc_files.download_pdf(paper.source_id, version, args.url)
677    except Exception as e:
678        print(f"[pdf] {e}", file=sys.stderr)
679        print(json.dumps({"error": str(e)}), file=sys.stderr)
680        sys.exit(1)
681    if path is None:
682        print(json.dumps({"error": "Download failed"}), file=sys.stderr)
683        sys.exit(1)
684    _output({"source_id": paper.source_id, "version": version, "path": path})
def cmd_pdf_storage(args: argparse.Namespace) -> None:
687def cmd_pdf_storage(args: argparse.Namespace) -> None:
688    mb = svc_files.pdf_storage_mb()
689    _output({"storage_mb": round(mb, 3), "pdf_dir": svc_files.managed_pdf_dir()})
def cmd_pdf_import(args: argparse.Namespace) -> None:
692def cmd_pdf_import(args: argparse.Namespace) -> None:
693    # import_pdf applies the membership guards itself (before any import
694    # work); the except below turns them into the JSON error exit.
695    pdf_path = Path(args.file)
696    try:
697        content = pdf_path.read_bytes()
698        result = svc_paper.import_pdf(content, args.project_id)
699    except Exception as e:
700        print(f"[pdf-import] {e}", file=sys.stderr)
701        print(json.dumps({"error": str(e)}), file=sys.stderr)
702        sys.exit(1)
703    _output({"source_id": result.source_id, "title": result.title})
def cmd_bibtex_import(args: argparse.Namespace) -> None:
710def cmd_bibtex_import(args: argparse.Namespace) -> None:
711    bib_path = Path(args.file)
712    if args.project_id is not None:
713        # Guard before parsing/saving so a missing or deleted project fails
714        # the command before the library is mutated.
715        try:
716            svc_project.ensure_membership_writable(args.project_id)
717        except (svc_project.ProjectNotFoundError, svc_project.ProjectDeletedError) as e:
718            print(json.dumps({"error": str(e)}), file=sys.stderr)
719            sys.exit(1)
720    try:
721        metas = BibTeXFormat().import_file(str(bib_path))
722    except Exception as e:
723        print(f"[bibtex-import] {e}", file=sys.stderr)
724        print(json.dumps({"error": str(e)}), file=sys.stderr)
725        sys.exit(1)
726    results = svc_paper.save_papers_metadata(metas)
727    if args.project_id is not None and results:
728        try:
729            svc_project.link_imported(args.project_id, [s for s, _ in results])
730        except (svc_project.ProjectNotFoundError, svc_project.ProjectDeletedError) as e:
731            # Project went away between the pre-parse guard and the link; the
732            # message says the papers stayed imported.
733            print(
734                json.dumps({"error": f"{len(results)} paper(s) were imported but could not be linked: {e}"}),
735                file=sys.stderr,
736            )
737            sys.exit(1)
738    _output({"imported": len(results), "papers": [{"source_id": s, "version": v} for s, v in results]})
def cmd_stats(args: argparse.Namespace) -> None:
745def cmd_stats(args: argparse.Namespace) -> None:
746    papers = svc_paper.list_paper_details(latest_only=True)
747    categories = svc_paper.get_categories()
748    all_tags = svc_tag.list_all_tags()
749    pdf_count = sum(1 for p in papers if p.has_pdf)
750    _output({
751        "paper_count": len(papers),
752        "tag_count": len(all_tags),
753        "category_count": len(categories),
754        "pdf_count": pdf_count,
755    })
def cmd_categories(args: argparse.Namespace) -> None:
758def cmd_categories(args: argparse.Namespace) -> None:
759    _output(svc_paper.get_categories())
def cmd_settings_get(args: argparse.Namespace) -> None:
762def cmd_settings_get(args: argparse.Namespace) -> None:
763    _output(user_settings.all_settings())
def cmd_settings_update(args: argparse.Namespace) -> None:
766def cmd_settings_update(args: argparse.Namespace) -> None:
767    try:
768        value: Any = json.loads(args.value)
769    except json.JSONDecodeError:
770        value = args.value
771    user_settings.set(args.key, value)
772    _output({args.key: value})
def build_parser() -> argparse.ArgumentParser:
 779def build_parser() -> argparse.ArgumentParser:
 780    parser = argparse.ArgumentParser(prog="linxiv", description="linXiv headless CLI")
 781    parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
 782    sub = parser.add_subparsers(dest="command", required=True)
 783
 784    _source_choices = list(_SOURCES)
 785
 786    # ── search ──────────────────────────────────────────────────────────────
 787    p_search = sub.add_parser("search", help="Search for papers")
 788    p_search.add_argument("query", help="Search query string")
 789    p_search.add_argument("--source", choices=_source_choices, default="arxiv")
 790    p_search.add_argument("--max", type=int, default=10, help="Max results")
 791    p_search.set_defaults(func=cmd_search)
 792
 793    # ── fetch ────────────────────────────────────────────────────────────────
 794    p_fetch = sub.add_parser("fetch", help="Fetch and save a paper by ID")
 795    p_fetch.add_argument("source_id", help="Paper ID (e.g. 2204.12985 or W3123456789)")
 796    p_fetch.add_argument("--source", choices=_source_choices, default="arxiv")
 797    p_fetch.set_defaults(func=cmd_fetch)
 798
 799    # ── list ─────────────────────────────────────────────────────────────────
 800    p_list = sub.add_parser("list", help="List papers in the database")
 801    p_list.add_argument("--limit", type=int, default=None, help="Max papers to return")
 802    p_list.add_argument("--offset", type=int, default=0, help="Offset for pagination")
 803    p_list.add_argument("--category", type=str, default=None, help="Filter by category")
 804    p_list.set_defaults(func=cmd_list)
 805
 806    # ── paper ────────────────────────────────────────────────────────────────
 807    p_paper = sub.add_parser("paper", help="Manage individual papers")
 808    paper_sub = p_paper.add_subparsers(dest="paper_command", required=True)
 809
 810    p_paper_get = paper_sub.add_parser("get", help="Get full details for a paper")
 811    p_paper_get.add_argument("source_id", help="Paper source ID")
 812    p_paper_get.set_defaults(func=cmd_paper_get)
 813
 814    p_paper_del = paper_sub.add_parser("delete", help="Soft-delete a paper")
 815    p_paper_del.add_argument("source_id", help="Paper source ID")
 816    p_paper_del.set_defaults(func=cmd_paper_delete)
 817
 818    p_paper_ver = paper_sub.add_parser("versions", help="List all stored versions of a paper")
 819    p_paper_ver.add_argument("source_id", help="Paper source ID")
 820    p_paper_ver.set_defaults(func=cmd_paper_versions)
 821
 822    p_paper_repair = paper_sub.add_parser("repair", help="Overwrite paper metadata in-place")
 823    p_paper_repair.add_argument("source_id", help="Paper source ID")
 824    p_paper_repair.add_argument("--title", required=True, help="New title")
 825    p_paper_repair.add_argument("--authors", nargs="+", required=True, help="Author names")
 826    p_paper_repair.add_argument("--published", required=True, help="Publication date (YYYY-MM-DD)")
 827    p_paper_repair.add_argument("--summary", default="", help="Abstract / summary")
 828    p_paper_repair.add_argument("--category", default=None, help="Category")
 829    p_paper_repair.add_argument("--doi", default=None, help="DOI")
 830    p_paper_repair.add_argument("--url", default=None, help="URL")
 831    p_paper_repair.add_argument("--tags", nargs="*", default=None, help="Tags")
 832    p_paper_repair.set_defaults(func=cmd_paper_repair)
 833
 834    p_paper_restore = paper_sub.add_parser("restore", help="Restore a soft-deleted paper")
 835    p_paper_restore.add_argument("source_id", help="Paper source ID")
 836    p_paper_restore.set_defaults(func=cmd_paper_restore)
 837
 838    p_paper_hd = paper_sub.add_parser("hard-delete", help="Permanently delete a paper")
 839    p_paper_hd.add_argument("source_id", help="Paper source ID")
 840    p_paper_hd.set_defaults(func=cmd_paper_hard_delete)
 841
 842    p_paper_search = paper_sub.add_parser("search", help="Full-text search within local library")
 843    p_paper_search.add_argument("query", help="Search query")
 844    p_paper_search.add_argument("--limit", type=int, default=50, help="Max results")
 845    p_paper_search.set_defaults(func=cmd_paper_search)
 846
 847    p_paper_rmall = paper_sub.add_parser(
 848        "remove-from-all-projects", help="Remove a paper from every project"
 849    )
 850    p_paper_rmall.add_argument("source_id", help="Paper source ID")
 851    p_paper_rmall.set_defaults(func=cmd_paper_remove_from_all)
 852
 853    # ── tag ──────────────────────────────────────────────────────────────────
 854    p_tag = sub.add_parser("tag", help="Manage tags")
 855    tag_sub = p_tag.add_subparsers(dest="tag_command", required=True)
 856
 857    p_tag_add = tag_sub.add_parser("add", help="Add tags to a paper")
 858    p_tag_add.add_argument("source_id", help="Paper source ID")
 859    p_tag_add.add_argument("tags", nargs="+", help="Tags to add")
 860    p_tag_add.set_defaults(func=cmd_tag_add)
 861
 862    p_tag_remove = tag_sub.add_parser("remove", help="Remove tags from a paper")
 863    p_tag_remove.add_argument("source_id", help="Paper source ID")
 864    p_tag_remove.add_argument("tags", nargs="+", help="Tags to remove")
 865    p_tag_remove.set_defaults(func=cmd_tag_remove)
 866
 867    p_tag_list = tag_sub.add_parser("list", help="List tags on a paper")
 868    p_tag_list.add_argument("source_id", help="Paper source ID")
 869    p_tag_list.set_defaults(func=cmd_tag_list)
 870
 871    p_tag_list_all = tag_sub.add_parser("list-all", help="List all tags in the database")
 872    p_tag_list_all.set_defaults(func=cmd_tag_list_all)
 873
 874    p_tag_create = tag_sub.add_parser("create", help="Create a tag")
 875    p_tag_create.add_argument("label", help="Tag label")
 876    p_tag_create.set_defaults(func=cmd_tag_create)
 877
 878    p_tag_delete = tag_sub.add_parser("delete", help="Delete a tag by ID")
 879    p_tag_delete.add_argument("tag_id", type=int, help="Tag ID")
 880    p_tag_delete.set_defaults(func=cmd_tag_delete)
 881
 882    p_tag_add_proj = tag_sub.add_parser("add-project", help="Add tags to a project")
 883    p_tag_add_proj.add_argument("project_id", type=int, help="Project ID")
 884    p_tag_add_proj.add_argument("tags", nargs="+", help="Tags to add")
 885    p_tag_add_proj.set_defaults(func=cmd_tag_add_project)
 886
 887    p_tag_remove_proj = tag_sub.add_parser("remove-project", help="Remove tags from a project")
 888    p_tag_remove_proj.add_argument("project_id", type=int, help="Project ID")
 889    p_tag_remove_proj.add_argument("tags", nargs="+", help="Tags to remove")
 890    p_tag_remove_proj.set_defaults(func=cmd_tag_remove_project)
 891
 892    p_tag_list_proj = tag_sub.add_parser("list-project", help="List tags on a project")
 893    p_tag_list_proj.add_argument("project_id", type=int, help="Project ID")
 894    p_tag_list_proj.set_defaults(func=cmd_tag_list_project)
 895
 896    # ── project ───────────────────────────────────────────────────────────────
 897    p_proj = sub.add_parser("project", help="Manage projects")
 898    proj_sub = p_proj.add_subparsers(dest="project_command", required=True)
 899
 900    p_proj_list = proj_sub.add_parser("list", help="List projects")
 901    p_proj_list.add_argument("--status", choices=["active", "archived", "deleted"], default=None)
 902    p_proj_list.set_defaults(func=cmd_project_list)
 903
 904    p_proj_get = proj_sub.add_parser("get", help="Get project details")
 905    p_proj_get.add_argument("project_id", type=int, help="Project ID")
 906    p_proj_get.set_defaults(func=cmd_project_get)
 907
 908    p_proj_create = proj_sub.add_parser("create", help="Create a project")
 909    p_proj_create.add_argument("name", help="Project name")
 910    p_proj_create.add_argument("--description", default="", help="Project description")
 911    p_proj_create.add_argument("--color", default=None, help="Hex color (e.g. #4f86f7)")
 912    p_proj_create.add_argument("--tags", nargs="*", default=None, help="Project tags")
 913    p_proj_create.set_defaults(func=cmd_project_create)
 914
 915    p_proj_update = proj_sub.add_parser("update", help="Update project fields")
 916    p_proj_update.add_argument("project_id", type=int, help="Project ID")
 917    p_proj_update.add_argument("--name", default=None, help="New name")
 918    p_proj_update.add_argument("--description", default=None, help="New description")
 919    p_proj_update.add_argument("--color", default=None, help="Hex color (e.g. #4f86f7)")
 920    p_proj_update.add_argument("--tags", nargs="*", default=None,
 921                               help="Project tags (replaces existing; pass no values to clear)")
 922    p_proj_update.add_argument("--status", choices=["active", "archived", "deleted"], default=None)
 923    p_proj_update.set_defaults(func=cmd_project_update)
 924
 925    p_proj_delete = proj_sub.add_parser("delete", help="Soft-delete a project")
 926    p_proj_delete.add_argument("project_id", type=int, help="Project ID")
 927    p_proj_delete.set_defaults(func=cmd_project_delete)
 928
 929    p_proj_archive = proj_sub.add_parser("archive", help="Archive an active project")
 930    p_proj_archive.add_argument("project_id", type=int, help="Project ID")
 931    p_proj_archive.set_defaults(func=cmd_project_archive)
 932
 933    p_proj_restore = proj_sub.add_parser("restore", help="Restore an archived or deleted project")
 934    p_proj_restore.add_argument("project_id", type=int, help="Project ID")
 935    p_proj_restore.set_defaults(func=cmd_project_restore)
 936
 937    p_proj_hd = proj_sub.add_parser("hard-delete", help="Permanently delete a project")
 938    p_proj_hd.add_argument("project_id", type=int, help="Project ID")
 939    p_proj_hd.set_defaults(func=cmd_project_hard_delete)
 940
 941    p_proj_add = proj_sub.add_parser("add-paper", help="Add a paper to a project")
 942    p_proj_add.add_argument("project_id", type=int, help="Project ID")
 943    p_proj_add.add_argument("source_id", help="Paper source ID")
 944    p_proj_add.set_defaults(func=cmd_project_add_paper)
 945
 946    p_proj_rem = proj_sub.add_parser("remove-paper", help="Remove a paper from a project")
 947    p_proj_rem.add_argument("project_id", type=int, help="Project ID")
 948    p_proj_rem.add_argument("source_id", help="Paper source ID")
 949    p_proj_rem.set_defaults(func=cmd_project_remove_paper)
 950
 951    p_proj_export = proj_sub.add_parser("export", help="Export a project to a .lxproj archive")
 952    p_proj_export.add_argument("project_id", type=int, help="Project ID")
 953    p_proj_export.add_argument("dest", help="Destination path (.lxproj extension added automatically)")
 954    p_proj_export.add_argument("--pdfs", action="store_true", default=False,
 955                               help="Include bundled PDFs in the archive")
 956    p_proj_export.set_defaults(func=cmd_project_export)
 957
 958    p_proj_import = proj_sub.add_parser("import", help="Import a project from a .lxproj archive")
 959    p_proj_import.add_argument("zip_path", help="Path to .lxproj archive")
 960    p_proj_import.add_argument("--preview", action="store_true", default=False,
 961                               help="Show archive summary without modifying the database")
 962    p_proj_import.add_argument("--on-conflict", choices=["merge", "overwrite"], default="merge",
 963                               dest="on_conflict",
 964                               help="How to handle papers that already exist (default: merge)")
 965    p_proj_import.set_defaults(func=cmd_project_import)
 966
 967    p_proj_export_bib = proj_sub.add_parser("export-bibtex", help="Export project papers as BibTeX")
 968    p_proj_export_bib.add_argument("project_id", type=int, help="Project ID")
 969    p_proj_export_bib.add_argument("dest", help="Output file path (.bib added if no extension)")
 970    p_proj_export_bib.set_defaults(func=cmd_project_export_bibtex)
 971
 972    p_proj_export_obs = proj_sub.add_parser("export-obsidian",
 973                                             help="Export project papers as Obsidian markdown")
 974    p_proj_export_obs.add_argument("project_id", type=int, help="Project ID")
 975    p_proj_export_obs.add_argument("dest", help="Output file path (.md added if no extension)")
 976    p_proj_export_obs.set_defaults(func=cmd_project_export_obsidian)
 977
 978    # ── note ─────────────────────────────────────────────────────────────────
 979    p_note = sub.add_parser("note", help="Manage notes")
 980    note_sub = p_note.add_subparsers(dest="note_command", required=True)
 981
 982    p_note_create = note_sub.add_parser("create", help="Create a note on a paper")
 983    p_note_create.add_argument("source_id", help="Paper source ID")
 984    p_note_create.add_argument("content", help="Note body text")
 985    p_note_create.add_argument("--title", default="", help="Note title")
 986    p_note_create.add_argument("--project-id", type=int, dest="project_id", default=None,
 987                               help="Associate note with a project")
 988    p_note_create.set_defaults(func=cmd_note_create)
 989
 990    p_note_get = note_sub.add_parser("get", help="Get a note by ID")
 991    p_note_get.add_argument("note_id", type=int, help="Note ID")
 992    p_note_get.set_defaults(func=cmd_note_get)
 993
 994    p_note_list = note_sub.add_parser("list", help="List notes")
 995    p_note_list.add_argument("--paper-id", dest="source_id", default=None,
 996                             help="Filter by paper source ID")
 997    p_note_list.add_argument("--project-id", type=int, dest="project_id", default=None,
 998                             help="Filter by project ID")
 999    p_note_list.set_defaults(func=cmd_note_list)
1000
1001    p_note_update = note_sub.add_parser("update", help="Update note title or content")
1002    p_note_update.add_argument("note_id", type=int, help="Note ID")
1003    p_note_update.add_argument("--title", default=None, help="New title")
1004    p_note_update.add_argument("--content", default=None, help="New content")
1005    p_note_update.set_defaults(func=cmd_note_update)
1006
1007    p_note_del = note_sub.add_parser("delete", help="Delete a note by ID")
1008    p_note_del.add_argument("note_id", type=int, help="Note ID")
1009    p_note_del.set_defaults(func=cmd_note_delete)
1010
1011    # ── pdf ──────────────────────────────────────────────────────────────────
1012    p_pdf = sub.add_parser("pdf", help="Manage PDFs")
1013    pdf_sub = p_pdf.add_subparsers(dest="pdf_command", required=True)
1014
1015    p_pdf_path = pdf_sub.add_parser("path", help="Show local PDF path for a paper")
1016    p_pdf_path.add_argument("source_id", help="Paper source ID")
1017    p_pdf_path.add_argument("--version", type=int, default=None,
1018                            help="Paper version (defaults to latest)")
1019    p_pdf_path.set_defaults(func=cmd_pdf_path)
1020
1021    p_pdf_dl = pdf_sub.add_parser("download", help="Download PDF for a paper")
1022    p_pdf_dl.add_argument("source_id", help="Paper source ID")
1023    p_pdf_dl.add_argument("url", help="PDF download URL")
1024    p_pdf_dl.add_argument("--version", type=int, default=None,
1025                          help="Paper version (defaults to latest)")
1026    p_pdf_dl.set_defaults(func=cmd_pdf_download)
1027
1028    p_pdf_storage = pdf_sub.add_parser("storage", help="Report total PDF storage usage")
1029    p_pdf_storage.set_defaults(func=cmd_pdf_storage)
1030
1031    p_pdf_import = pdf_sub.add_parser("import", help="Import a local PDF (extract metadata)")
1032    p_pdf_import.add_argument("file", help="Path to PDF file")
1033    p_pdf_import.add_argument("--project-id", type=int, dest="project_id", default=None,
1034                              help="Link imported paper to a project")
1035    p_pdf_import.set_defaults(func=cmd_pdf_import)
1036
1037    # ── trash ─────────────────────────────────────────────────────────────────
1038    p_trash = sub.add_parser("trash", help="Manage soft-deleted items")
1039    trash_sub = p_trash.add_subparsers(dest="trash_command", required=True)
1040
1041    p_trash_list = trash_sub.add_parser("list", help="List soft-deleted papers and projects")
1042    p_trash_list.set_defaults(func=cmd_trash_list)
1043
1044    p_trash_restore = trash_sub.add_parser("restore", help="Restore a soft-deleted paper")
1045    p_trash_restore.add_argument("source_id", help="Paper source ID")
1046    p_trash_restore.set_defaults(func=cmd_trash_restore)
1047
1048    p_trash_hd = trash_sub.add_parser("hard-delete", help="Permanently delete a paper")
1049    p_trash_hd.add_argument("source_id", help="Paper source ID")
1050    p_trash_hd.set_defaults(func=cmd_trash_hard_delete)
1051
1052    p_trash_rp = trash_sub.add_parser("restore-project", help="Restore a soft-deleted project")
1053    p_trash_rp.add_argument("project_id", type=int, help="Project ID")
1054    p_trash_rp.set_defaults(func=cmd_trash_restore_project)
1055
1056    p_trash_hdp = trash_sub.add_parser("hard-delete-project", help="Permanently delete a project")
1057    p_trash_hdp.add_argument("project_id", type=int, help="Project ID")
1058    p_trash_hdp.set_defaults(func=cmd_trash_hard_delete_project)
1059
1060    # ── doi ───────────────────────────────────────────────────────────────────
1061    p_doi = sub.add_parser("doi", help="Resolve and save papers by DOI")
1062    doi_sub = p_doi.add_subparsers(dest="doi_command", required=True)
1063
1064    p_doi_resolve = doi_sub.add_parser("resolve", help="Resolve DOI to metadata (no save)")
1065    p_doi_resolve.add_argument("doi", help="DOI string")
1066    p_doi_resolve.set_defaults(func=cmd_doi_resolve)
1067
1068    p_doi_save = doi_sub.add_parser("save", help="Resolve DOI and save paper to library")
1069    p_doi_save.add_argument("doi", help="DOI string")
1070    p_doi_save.set_defaults(func=cmd_doi_save)
1071
1072    # ── author ────────────────────────────────────────────────────────────────
1073    p_author = sub.add_parser("author", help="Manage authors")
1074    author_sub = p_author.add_subparsers(dest="author_command", required=True)
1075
1076    p_author_list = author_sub.add_parser("list", help="List all authors with paper counts")
1077    p_author_list.set_defaults(func=cmd_author_list)
1078
1079    p_author_get = author_sub.add_parser("get", help="Get author details and paper list")
1080    p_author_get.add_argument("author_id", type=int, help="Author ID")
1081    p_author_get.set_defaults(func=cmd_author_get)
1082
1083    p_author_update = author_sub.add_parser("update", help="Update author fields")
1084    p_author_update.add_argument("author_id", type=int, help="Author ID")
1085    p_author_update.add_argument("--full-name", dest="full_name", default=None)
1086    p_author_update.add_argument("--first-name", dest="first_name", default=None)
1087    p_author_update.add_argument("--last-name", dest="last_name", default=None)
1088    p_author_update.add_argument("--orcid", default=None)
1089    p_author_update.set_defaults(func=cmd_author_update)
1090
1091    p_author_delete = author_sub.add_parser(
1092        "delete", help="Delete an author (blocked if linked to papers)"
1093    )
1094    p_author_delete.add_argument("author_id", type=int, help="Author ID")
1095    p_author_delete.set_defaults(func=cmd_author_delete)
1096
1097    # ── bibtex ────────────────────────────────────────────────────────────────
1098    p_bibtex = sub.add_parser("bibtex", help="BibTeX import")
1099    bibtex_sub = p_bibtex.add_subparsers(dest="bibtex_command", required=True)
1100
1101    p_bibtex_import = bibtex_sub.add_parser("import", help="Import papers from a .bib file")
1102    p_bibtex_import.add_argument("file", help="Path to .bib file")
1103    p_bibtex_import.add_argument("--project-id", type=int, dest="project_id", default=None,
1104                                  help="Link imported papers to a project")
1105    p_bibtex_import.set_defaults(func=cmd_bibtex_import)
1106
1107    # ── stats ─────────────────────────────────────────────────────────────────
1108    p_stats = sub.add_parser("stats", help="Library statistics")
1109    p_stats.set_defaults(func=cmd_stats)
1110
1111    # ── categories ────────────────────────────────────────────────────────────
1112    p_cats = sub.add_parser("categories", help="List all paper categories in the library")
1113    p_cats.set_defaults(func=cmd_categories)
1114
1115    # ── settings ──────────────────────────────────────────────────────────────
1116    p_settings = sub.add_parser("settings", help="View and update user settings")
1117    settings_sub = p_settings.add_subparsers(dest="settings_command", required=True)
1118
1119    p_settings_get = settings_sub.add_parser("get", help="Show all current settings")
1120    p_settings_get.set_defaults(func=cmd_settings_get)
1121
1122    p_settings_update = settings_sub.add_parser("update", help="Set a setting value")
1123    p_settings_update.add_argument("key", help="Setting key")
1124    p_settings_update.add_argument("value", help="New value (JSON-parsed if valid JSON, else string)")
1125    p_settings_update.set_defaults(func=cmd_settings_update)
1126
1127    return parser
def main(argv: list[str] | None = None) -> None:
1130def main(argv: list[str] | None = None) -> None:
1131    init_data_dir()
1132    svc_paper.init_db()
1133    svc_project.ensure_projects_db()
1134    svc_note.ensure_notes_db()
1135    parser = build_parser()
1136    args = parser.parse_args(argv)
1137    args.func(args)