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_search(args: argparse.Namespace) -> None:
117def cmd_search(args: argparse.Namespace) -> None: 118 source = _source_for(args.source) 119 try: 120 results = source.search(args.query, max_results=args.max) 121 except Exception as e: 122 print(f"[search] {e}", file=sys.stderr) 123 print(json.dumps({"error": str(e)}), file=sys.stderr) 124 sys.exit(1) 125 _output([m.model_dump(mode="json") for m in results])
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:
def
cmd_paper_get(args: argparse.Namespace) -> None:
def
cmd_paper_delete(args: argparse.Namespace) -> None:
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:
def
cmd_paper_hard_delete(args: argparse.Namespace) -> None:
def
cmd_paper_search(args: argparse.Namespace) -> None:
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:
def
cmd_trash_restore(args: argparse.Namespace) -> None:
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:
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_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:
def
cmd_tag_list_all(args: argparse.Namespace) -> None:
def
cmd_tag_create(args: argparse.Namespace) -> None:
def
cmd_tag_delete(args: argparse.Namespace) -> None:
def
cmd_tag_add_project(args: argparse.Namespace) -> None:
def
cmd_tag_remove_project(args: argparse.Namespace) -> None:
def
cmd_tag_list_project(args: argparse.Namespace) -> None:
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:
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:
def
cmd_project_archive(args: argparse.Namespace) -> None:
def
cmd_project_restore(args: argparse.Namespace) -> None:
def
cmd_project_hard_delete(args: argparse.Namespace) -> None:
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:
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:
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:
def
cmd_settings_get(args: argparse.Namespace) -> None:
def
cmd_settings_update(args: argparse.Namespace) -> None:
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: