zop-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zop/commands/export.py ADDED
@@ -0,0 +1,71 @@
1
+ """Export CLI: export items in BibTeX, CSL-JSON, RIS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from zop.core.config import load_config
11
+ from zop.core.envelope import emit, emit_error
12
+ from zop.core.errors import ZopError
13
+ from zop.services.export import ExportService
14
+
15
+
16
+ def _service() -> ExportService:
17
+ cfg = load_config()
18
+ if not cfg.data_dir:
19
+ raise click.UsageError("data_dir not configured")
20
+ return ExportService(db_path=Path(cfg.data_dir) / "zotero.sqlite")
21
+
22
+
23
+ def _human() -> bool:
24
+ return sys.stdout.isatty()
25
+
26
+
27
+ @click.command(name="export")
28
+ @click.argument("item_keys", nargs=-1, required=True)
29
+ @click.option(
30
+ "--format",
31
+ "fmt",
32
+ type=click.Choice(["csl-json", "bibtex", "ris"]),
33
+ default="bibtex",
34
+ )
35
+ @click.option("--out", "-o", type=click.Path(), help="Write to file instead of stdout.")
36
+ def export_cmd(
37
+ item_keys: tuple[str, ...], fmt: str, out: str | None
38
+ ) -> None:
39
+ """Export items by KEY in the chosen format."""
40
+ try:
41
+ svc = _service()
42
+ items = [svc._reader.get_item(k) for k in item_keys]
43
+ if fmt == "csl-json":
44
+ payload: object = svc.to_csl_json(items)
45
+ elif fmt == "bibtex":
46
+ payload = svc.to_bibtex(items)
47
+ elif fmt == "ris":
48
+ payload = svc.to_ris(items)
49
+ else:
50
+ raise ZopError(f"Unknown format: {fmt}")
51
+ if out:
52
+ if fmt == "csl-json":
53
+ import json
54
+ Path(out).write_text(
55
+ json.dumps(payload, indent=2, ensure_ascii=False),
56
+ encoding="utf-8",
57
+ )
58
+ else:
59
+ Path(out).write_text(str(payload), encoding="utf-8")
60
+ emit({"written": out, "count": len(items)}, human=_human())
61
+ else:
62
+ if fmt == "csl-json":
63
+ import json
64
+ sys.stdout.write(json.dumps(payload, indent=2, ensure_ascii=False))
65
+ sys.stdout.write("\n")
66
+ else:
67
+ sys.stdout.write(str(payload))
68
+ sys.stdout.flush()
69
+ except ZopError as e:
70
+ emit_error(e, human=_human())
71
+ sys.exit(1)
zop/commands/item.py ADDED
@@ -0,0 +1,176 @@
1
+ """Item CLI subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from zop.adapters.zotero_api import ApiCreds
12
+ from zop.core.config import load_config
13
+ from zop.core.envelope import emit, emit_batch, emit_error
14
+ from zop.core.errors import ZopError
15
+ from zop.services.items import ItemsService
16
+
17
+
18
+ def _service() -> ItemsService:
19
+ cfg = load_config()
20
+ creds = (
21
+ ApiCreds(library_id=cfg.library_id, api_key=cfg.api_key)
22
+ if cfg.has_write_credentials
23
+ else None
24
+ )
25
+ if not cfg.data_dir:
26
+ raise click.UsageError("data_dir not configured")
27
+ return ItemsService(db_path=Path(cfg.data_dir) / "zotero.sqlite", creds=creds)
28
+
29
+
30
+ def _human() -> bool:
31
+ return sys.stdout.isatty()
32
+
33
+
34
+ @click.group(name="item")
35
+ def item() -> None:
36
+ """Manage Zotero items."""
37
+
38
+
39
+ @item.command("search")
40
+ @click.argument("query")
41
+ @click.option("--limit", default=50, type=int, help="Max results (default 50).")
42
+ def search_cmd(query: str, limit: int) -> None:
43
+ """Search items by title, abstract, or author (LIKE substring)."""
44
+ try:
45
+ svc = _service()
46
+ results = svc.search(query, limit=limit)
47
+ emit(
48
+ [it.model_dump() for it in results],
49
+ human=_human(),
50
+ count=len(results),
51
+ )
52
+ except ZopError as e:
53
+ emit_error(e, human=_human())
54
+ sys.exit(1)
55
+
56
+
57
+ @item.command("read")
58
+ @click.argument("key")
59
+ def read_cmd(key: str) -> None:
60
+ """Get full metadata for an item (by KEY)."""
61
+ try:
62
+ svc = _service()
63
+ data = svc.get(key)
64
+ emit(data.model_dump(), human=_human())
65
+ except ZopError as e:
66
+ emit_error(e, human=_human())
67
+ sys.exit(1)
68
+
69
+
70
+ @item.command("update")
71
+ @click.argument("key")
72
+ @click.option("--title", default=None)
73
+ @click.option("--date", default=None)
74
+ @click.option("--abstract", default=None)
75
+ @click.option("--doi", default=None)
76
+ @click.option("--url", default=None)
77
+ @click.option("--set", "extras", multiple=True, help="Set extra field KEY=VALUE (repeatable).")
78
+ def update_cmd(
79
+ key: str,
80
+ title: str | None,
81
+ date: str | None,
82
+ abstract: str | None,
83
+ doi: str | None,
84
+ url: str | None,
85
+ extras: tuple[str, ...],
86
+ ) -> None:
87
+ """Update an item's metadata. Only provided fields are changed."""
88
+ extra_dict: dict[str, str] = {}
89
+ for e in extras:
90
+ if "=" in e:
91
+ k, v = e.split("=", 1)
92
+ extra_dict[k.strip()] = v.strip()
93
+ try:
94
+ svc = _service()
95
+ result = asyncio.run(
96
+ svc.update(
97
+ key,
98
+ title=title,
99
+ date=date,
100
+ abstract=abstract,
101
+ doi=doi,
102
+ url=url,
103
+ extra=extra_dict or None,
104
+ )
105
+ )
106
+ emit(result.model_dump(), human=_human())
107
+ except ZopError as e:
108
+ emit_error(e, human=_human())
109
+ sys.exit(1)
110
+
111
+
112
+ @item.command("delete")
113
+ @click.argument("keys", nargs=-1, required=True)
114
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
115
+ def delete_cmd(keys: tuple[str, ...], yes: bool) -> None:
116
+ """Delete one or more items."""
117
+ if not yes:
118
+ click.confirm(f"Delete {len(keys)} item(s)?", abort=True)
119
+ try:
120
+ svc = _service()
121
+
122
+ async def _all() -> list[tuple[str, Exception | None]]:
123
+ results = await asyncio.gather(
124
+ *[svc.delete(k) for k in keys], return_exceptions=True
125
+ )
126
+ out: list[tuple[str, Exception | None]] = []
127
+ for k, r in zip(keys, results, strict=True):
128
+ if isinstance(r, Exception):
129
+ out.append((k, r))
130
+ else:
131
+ out.append((k, None))
132
+ return out
133
+
134
+ outcomes = asyncio.run(_all())
135
+ ok = [k for k, e in outcomes if e is None]
136
+ fail = [(k, e) for k, e in outcomes if e is not None]
137
+ emit_batch(
138
+ [{"key": k} for k in ok],
139
+ [(k, _wrap(e)) for k, e in fail],
140
+ human=_human(),
141
+ )
142
+ if fail:
143
+ sys.exit(2)
144
+ except ZopError as e:
145
+ emit_error(e, human=_human())
146
+ sys.exit(1)
147
+
148
+
149
+ @item.command("add")
150
+ @click.option("--doi", "dois", multiple=True, help="DOI(s) to add (repeatable).")
151
+ @click.option("--from-file", "dois_file", type=click.Path(exists=True), help="File with one DOI per line.")
152
+ def add_cmd(dois: tuple[str, ...], dois_file: str | None) -> None:
153
+ """Add item(s) by DOI."""
154
+ items: list[str] = list(dois)
155
+ if dois_file:
156
+ items.extend(Path(dois_file).read_text(encoding="utf-8").splitlines())
157
+ items = [d.strip() for d in items if d.strip() and not d.startswith("#")]
158
+ if not items:
159
+ raise click.UsageError("Provide --doi or --from-file")
160
+ try:
161
+ svc = _service()
162
+ created = asyncio.run(svc.add_many(items))
163
+ emit(
164
+ [it.model_dump() for it in created],
165
+ human=_human(),
166
+ count=len(created),
167
+ )
168
+ except ZopError as e:
169
+ emit_error(e, human=_human())
170
+ sys.exit(1)
171
+
172
+
173
+ def _wrap(e: BaseException) -> ZopError:
174
+ if isinstance(e, ZopError):
175
+ return e
176
+ return ZopError(str(e))
@@ -0,0 +1,63 @@
1
+ """Top-level library commands: stats, recent, duplicates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from zop.core.config import load_config
11
+ from zop.core.envelope import emit, emit_error
12
+ from zop.core.errors import ZopError
13
+ from zop.services.library import LibraryService
14
+
15
+
16
+ def _service() -> LibraryService:
17
+ cfg = load_config()
18
+ if not cfg.data_dir:
19
+ raise click.UsageError("data_dir not configured")
20
+ return LibraryService(db_path=Path(cfg.data_dir) / "zotero.sqlite")
21
+
22
+
23
+ def _human() -> bool:
24
+ return sys.stdout.isatty()
25
+
26
+
27
+ @click.command(name="stats")
28
+ def stats_cmd() -> None:
29
+ """Show library statistics (items, types, top tags, collections)."""
30
+ try:
31
+ svc = _service()
32
+ data = svc.stats()
33
+ emit(data, human=_human())
34
+ except ZopError as e:
35
+ emit_error(e, human=_human())
36
+ sys.exit(1)
37
+
38
+
39
+ @click.command(name="recent")
40
+ @click.option("--days", default=7, type=int, help="Look back N days (default 7).")
41
+ @click.option("--limit", default=50, type=int)
42
+ def recent_cmd(days: int, limit: int) -> None:
43
+ """List recently added items."""
44
+ try:
45
+ svc = _service()
46
+ data = svc.recent(days=days, limit=limit)
47
+ emit([it.model_dump() for it in data], human=_human(), count=len(data))
48
+ except ZopError as e:
49
+ emit_error(e, human=_human())
50
+ sys.exit(1)
51
+
52
+
53
+ @click.command(name="duplicates")
54
+ @click.option("--by", type=click.Choice(["doi", "title"]), default="doi")
55
+ def duplicates_cmd(by: str) -> None:
56
+ """Find potential duplicate items grouped by DOI (or title)."""
57
+ try:
58
+ svc = _service()
59
+ data = svc.duplicates(by=by)
60
+ emit(data, human=_human(), count=len(data))
61
+ except ZopError as e:
62
+ emit_error(e, human=_human())
63
+ sys.exit(1)
zop/commands/note.py ADDED
@@ -0,0 +1,68 @@
1
+ """Note CLI subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from zop.adapters.zotero_api import ApiCreds
12
+ from zop.core.config import load_config
13
+ from zop.core.envelope import emit, emit_error
14
+ from zop.core.errors import ZopError
15
+ from zop.services.notes import NotesService
16
+
17
+
18
+ def _service() -> NotesService:
19
+ cfg = load_config()
20
+ creds = (
21
+ ApiCreds(library_id=cfg.library_id, api_key=cfg.api_key)
22
+ if cfg.has_write_credentials
23
+ else None
24
+ )
25
+ if not cfg.data_dir:
26
+ raise click.UsageError("data_dir not configured")
27
+ return NotesService(db_path=Path(cfg.data_dir) / "zotero.sqlite", creds=creds)
28
+
29
+
30
+ def _human() -> bool:
31
+ return sys.stdout.isatty()
32
+
33
+
34
+ @click.group(name="note")
35
+ def note() -> None:
36
+ """Manage notes."""
37
+
38
+
39
+ @note.command("list")
40
+ @click.argument("item_key")
41
+ def list_cmd(item_key: str) -> None:
42
+ """List notes attached to an item (by KEY)."""
43
+ try:
44
+ svc = _service()
45
+ data = svc.list_for_item(item_key)
46
+ emit(data, human=_human(), count=len(data))
47
+ except ZopError as e:
48
+ emit_error(e, human=_human())
49
+ sys.exit(1)
50
+
51
+
52
+ @note.command("add")
53
+ @click.argument("item_key")
54
+ @click.option("--text", "-t", required=True, help="Note text (Markdown/HTML allowed).")
55
+ @click.option("--file", "file", type=click.Path(exists=True), help="Read note text from file.")
56
+ def add_cmd(item_key: str, text: str | None, file: str | None) -> None:
57
+ """Add a note to an item (parent KEY)."""
58
+ if file:
59
+ text = Path(file).read_text(encoding="utf-8")
60
+ if not text:
61
+ raise click.UsageError("Provide --text or --file")
62
+ try:
63
+ svc = _service()
64
+ new_key = asyncio.run(svc.add(item_key, text))
65
+ emit({"created": new_key}, human=_human())
66
+ except ZopError as e:
67
+ emit_error(e, human=_human())
68
+ sys.exit(1)
zop/commands/pdf.py ADDED
@@ -0,0 +1,71 @@
1
+ """PDF CLI subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from zop.core.config import load_config
11
+ from zop.core.envelope import emit, emit_error
12
+ from zop.core.errors import ZopError
13
+ from zop.services.pdf import PdfService
14
+
15
+
16
+ def _service() -> PdfService:
17
+ cfg = load_config()
18
+ if not cfg.data_dir:
19
+ raise click.UsageError("data_dir not configured")
20
+ return PdfService(db_path=Path(cfg.data_dir) / "zotero.sqlite")
21
+
22
+
23
+ def _human() -> bool:
24
+ return sys.stdout.isatty()
25
+
26
+
27
+ @click.group(name="pdf")
28
+ def pdf() -> None:
29
+ """Read local PDF attachments."""
30
+
31
+
32
+ @pdf.command("read")
33
+ @click.argument("item_key")
34
+ @click.option("--max-chars", default=200_000, type=int)
35
+ def read_cmd(item_key: str, max_chars: int) -> None:
36
+ """Extract full text from the PDF attached to an item."""
37
+ try:
38
+ svc = _service()
39
+ text = svc.read_text(item_key, max_chars=max_chars)
40
+ emit({"text": text, "length": len(text)}, human=_human())
41
+ except ZopError as e:
42
+ emit_error(e, human=_human())
43
+ sys.exit(1)
44
+
45
+
46
+ @pdf.command("outline")
47
+ @click.argument("item_key")
48
+ def outline_cmd(item_key: str) -> None:
49
+ """Show PDF outline (bookmarks)."""
50
+ try:
51
+ svc = _service()
52
+ outline = svc.get_outline(item_key)
53
+ emit(outline, human=_human(), count=len(outline))
54
+ except ZopError as e:
55
+ emit_error(e, human=_human())
56
+ sys.exit(1)
57
+
58
+
59
+ @pdf.command("section")
60
+ @click.argument("item_key")
61
+ @click.argument("section_number", type=int)
62
+ @click.option("--max-chars", default=100_000, type=int)
63
+ def section_cmd(item_key: str, section_number: int, max_chars: int) -> None:
64
+ """Read a specific outline section (1-indexed)."""
65
+ try:
66
+ svc = _service()
67
+ text = svc.read_section(item_key, section_number, max_chars=max_chars)
68
+ emit({"text": text, "length": len(text)}, human=_human())
69
+ except ZopError as e:
70
+ emit_error(e, human=_human())
71
+ sys.exit(1)
zop/commands/tag.py ADDED
@@ -0,0 +1,94 @@
1
+ """Tag CLI subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from zop.adapters.zotero_api import ApiCreds
12
+ from zop.core.config import load_config
13
+ from zop.core.envelope import emit, emit_batch, emit_error
14
+ from zop.core.errors import ZopError
15
+ from zop.services.tags import TagsService
16
+
17
+
18
+ def _service() -> TagsService:
19
+ cfg = load_config()
20
+ creds = (
21
+ ApiCreds(library_id=cfg.library_id, api_key=cfg.api_key)
22
+ if cfg.has_write_credentials
23
+ else None
24
+ )
25
+ if not cfg.data_dir:
26
+ raise click.UsageError("data_dir not configured")
27
+ return TagsService(db_path=Path(cfg.data_dir) / "zotero.sqlite", creds=creds)
28
+
29
+
30
+ def _human() -> bool:
31
+ return sys.stdout.isatty()
32
+
33
+
34
+ @click.group(name="tag")
35
+ def tag() -> None:
36
+ """Manage tags."""
37
+
38
+
39
+ @tag.command("list")
40
+ def list_cmd() -> None:
41
+ """List all tags with usage counts."""
42
+ try:
43
+ svc = _service()
44
+ data = svc.list_all()
45
+ emit(data, human=_human(), count=len(data))
46
+ except ZopError as e:
47
+ emit_error(e, human=_human())
48
+ sys.exit(1)
49
+
50
+
51
+ @tag.command("add")
52
+ @click.argument("item_keys", nargs=-1, required=True)
53
+ @click.option("--tags", "tags", required=True, help="Comma-separated tags to add.")
54
+ def add_cmd(item_keys: tuple[str, ...], tags: str) -> None:
55
+ """Add tags to items (preserves existing)."""
56
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()]
57
+ try:
58
+ svc = _service()
59
+ ok, fail = asyncio.run(svc.add(list(item_keys), tag_list))
60
+ emit_batch(
61
+ [{"key": k} for k in ok],
62
+ [(k, _wrap(e)) for k, e in fail],
63
+ human=_human(),
64
+ )
65
+ if fail:
66
+ sys.exit(2)
67
+ except ZopError as e:
68
+ emit_error(e, human=_human())
69
+ sys.exit(1)
70
+
71
+
72
+ @tag.command("remove")
73
+ @click.argument("item_keys", nargs=-1, required=True)
74
+ @click.option("--tags", "tags", required=True, help="Comma-separated tags to remove.")
75
+ def remove_cmd(item_keys: tuple[str, ...], tags: str) -> None:
76
+ """Remove tags from items."""
77
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()]
78
+ try:
79
+ svc = _service()
80
+ ok, fail = asyncio.run(svc.remove(list(item_keys), tag_list))
81
+ emit_batch(
82
+ [{"key": k} for k in ok],
83
+ [(k, _wrap(e)) for k, e in fail],
84
+ human=_human(),
85
+ )
86
+ if fail:
87
+ sys.exit(2)
88
+ except ZopError as e:
89
+ emit_error(e, human=_human())
90
+ sys.exit(1)
91
+
92
+
93
+ def _wrap(e: BaseException) -> ZopError:
94
+ return e if isinstance(e, ZopError) else ZopError(str(e))
zop/core/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Core utilities (config, sqlite reader, http client)."""
2
+
3
+ from zop.core.config import AppConfig, load_config
4
+
5
+ __all__ = ["AppConfig", "load_config"]
@@ -0,0 +1,38 @@
1
+ """Bounded concurrency helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Iterable, Sequence
7
+ from typing import TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ async def bounded_gather[T](
13
+ coros: Iterable[Awaitable[T]],
14
+ *,
15
+ concurrency: int = 8,
16
+ ) -> list[T]:
17
+ """Run async tasks with bounded concurrency.
18
+
19
+ Args:
20
+ coros: Iterable of awaitables (not yet started).
21
+ concurrency: Max number of tasks running at once.
22
+
23
+ Returns:
24
+ List of results in the same order as input.
25
+ """
26
+ sem = asyncio.Semaphore(concurrency)
27
+
28
+ async def _run(coro: Awaitable[T]) -> T:
29
+ async with sem:
30
+ return await coro
31
+
32
+ tasks = [asyncio.create_task(_run(c)) for c in coros]
33
+ return list(await asyncio.gather(*tasks, return_exceptions=False))
34
+
35
+
36
+ def chunked[T](seq: Sequence[T], size: int) -> list[Sequence[T]]:
37
+ """Split a sequence into chunks of `size`."""
38
+ return [seq[i : i + size] for i in range(0, len(seq), size)]
zop/core/config.py ADDED
@@ -0,0 +1,66 @@
1
+ """Configuration loader.
2
+
3
+ Reads ``~/.config/zop/config.toml`` (or an explicit path from the
4
+ ``ZOP_CONFIG`` env var). Supports a flat TOML schema only in v0.1 — a
5
+ profile-based schema is on the roadmap.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tomllib
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ import platformdirs
16
+
17
+ APP_NAME = "zop"
18
+ CONFIG_FILE = Path(platformdirs.user_config_dir(APP_NAME, appauthor=False)) / "config.toml"
19
+
20
+
21
+ @dataclass
22
+ class AppConfig:
23
+ """Resolved configuration."""
24
+
25
+ data_dir: str = ""
26
+ library_id: str = ""
27
+ api_key: str = ""
28
+ semantic_scholar_api_key: str = ""
29
+
30
+ @property
31
+ def has_write_credentials(self) -> bool:
32
+ return bool(self.library_id and self.api_key)
33
+
34
+
35
+ def _load_toml(path: Path) -> dict[str, object]:
36
+ if not path.exists():
37
+ return {}
38
+ with path.open("rb") as f:
39
+ return tomllib.load(f)
40
+
41
+
42
+ def load_config(path: Path | None = None) -> AppConfig:
43
+ """Load configuration from disk.
44
+
45
+ Lookup order:
46
+ 1. ``ZOP_CONFIG`` env var (explicit override path)
47
+ 2. ``<config_dir>/zop/config.toml`` (default location)
48
+ """
49
+ if path is None:
50
+ path = Path(os.environ["ZOP_CONFIG"]) if "ZOP_CONFIG" in os.environ else None
51
+
52
+ data = _load_toml(path) if path is not None else _load_toml(CONFIG_FILE)
53
+
54
+ # Flat schema: [zotero] section.
55
+ z = data.get("zotero", {}) if isinstance(data, dict) else {}
56
+ if not isinstance(z, dict):
57
+ z = {}
58
+ return AppConfig(
59
+ data_dir=str(z.get("data_dir", "")),
60
+ library_id=str(z.get("library_id", "")),
61
+ api_key=str(z.get("api_key", "")),
62
+ semantic_scholar_api_key=str(z.get("semantic_scholar_api_key", "")),
63
+ )
64
+
65
+
66
+ __all__ = ["CONFIG_FILE", "AppConfig", "load_config"]