zotcli 0.1.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.
- zotcli/__init__.py +3 -0
- zotcli/__main__.py +4 -0
- zotcli/cli/__init__.py +0 -0
- zotcli/cli/attachments.py +125 -0
- zotcli/cli/collections.py +100 -0
- zotcli/cli/export.py +111 -0
- zotcli/cli/items.py +111 -0
- zotcli/cli/main.py +71 -0
- zotcli/cli/render.py +93 -0
- zotcli/cli/search.py +82 -0
- zotcli/cli/stats.py +156 -0
- zotcli/config.py +78 -0
- zotcli/db.py +127 -0
- zotcli/export/__init__.py +0 -0
- zotcli/export/bibtex.py +136 -0
- zotcli/export/csv_.py +55 -0
- zotcli/export/json_.py +16 -0
- zotcli/export/markdown.py +51 -0
- zotcli/models.py +145 -0
- zotcli/queries/__init__.py +0 -0
- zotcli/queries/attachments.py +115 -0
- zotcli/queries/collections.py +137 -0
- zotcli/queries/items.py +309 -0
- zotcli/queries/search.py +133 -0
- zotcli/queries/tags.py +41 -0
- zotcli-0.1.0.dist-info/METADATA +538 -0
- zotcli-0.1.0.dist-info/RECORD +29 -0
- zotcli-0.1.0.dist-info/WHEEL +4 -0
- zotcli-0.1.0.dist-info/entry_points.txt +2 -0
zotcli/__init__.py
ADDED
zotcli/__main__.py
ADDED
zotcli/cli/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""CLI — `zot attachments` subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from zotcli.cli.main import pass_ctx, Context
|
|
12
|
+
from zotcli.cli.render import make_console
|
|
13
|
+
from zotcli.models import Attachment
|
|
14
|
+
from zotcli.queries.attachments import enrich_attachment_paths
|
|
15
|
+
from zotcli.queries.items import get_item
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group()
|
|
19
|
+
def attachments():
|
|
20
|
+
"""Browse and open Zotero attachments."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@attachments.command("list")
|
|
24
|
+
@click.option("--missing", is_flag=True, help="Only show missing files")
|
|
25
|
+
@click.option("--type", "content_type", default=None, help="Filter by content type (e.g. pdf)")
|
|
26
|
+
@pass_ctx
|
|
27
|
+
def list_attachments(ctx: Context, missing: bool, content_type: str | None):
|
|
28
|
+
"""List all attachments."""
|
|
29
|
+
console = make_console(ctx.color)
|
|
30
|
+
data_dir = ctx.db.path.parent
|
|
31
|
+
|
|
32
|
+
rows = ctx.db.fetchall(
|
|
33
|
+
"""
|
|
34
|
+
SELECT i.itemID, i.key, ia.parentItemID, ia.linkMode,
|
|
35
|
+
ia.contentType, ia.path
|
|
36
|
+
FROM itemAttachments ia
|
|
37
|
+
JOIN items i ON ia.itemID = i.itemID
|
|
38
|
+
WHERE ia.parentItemID IS NOT NULL
|
|
39
|
+
ORDER BY ia.contentType
|
|
40
|
+
"""
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
atts = [
|
|
44
|
+
Attachment(
|
|
45
|
+
item_id=r["itemID"],
|
|
46
|
+
key=r["key"],
|
|
47
|
+
parent_item_id=r["parentItemID"],
|
|
48
|
+
link_mode=r["linkMode"] if r["linkMode"] is not None else 0,
|
|
49
|
+
content_type=r["contentType"] or "",
|
|
50
|
+
path=r["path"],
|
|
51
|
+
)
|
|
52
|
+
for r in rows
|
|
53
|
+
]
|
|
54
|
+
enrich_attachment_paths(atts, data_dir)
|
|
55
|
+
|
|
56
|
+
if content_type:
|
|
57
|
+
atts = [a for a in atts if content_type.lower() in a.content_type.lower()]
|
|
58
|
+
if missing:
|
|
59
|
+
atts = [a for a in atts if not a.file_exists]
|
|
60
|
+
|
|
61
|
+
if not atts:
|
|
62
|
+
console.print("[dim]No attachments found.[/dim]")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
t = Table(title=f"{len(atts)} attachments")
|
|
66
|
+
t.add_column("Key", style="cyan", width=10)
|
|
67
|
+
t.add_column("Type", width=20)
|
|
68
|
+
t.add_column("Mode", width=14)
|
|
69
|
+
t.add_column("Exists", width=7)
|
|
70
|
+
t.add_column("Path")
|
|
71
|
+
|
|
72
|
+
for att in atts[:500]:
|
|
73
|
+
exists = "[green]✓[/green]" if att.file_exists else "[red]✗[/red]"
|
|
74
|
+
path_str = str(att.absolute_path) if att.absolute_path else att.path or ""
|
|
75
|
+
t.add_row(att.key, att.content_type, att.link_mode_name, exists, path_str[:80])
|
|
76
|
+
console.print(t)
|
|
77
|
+
if len(atts) > 500:
|
|
78
|
+
console.print(f"[dim]... and {len(atts) - 500} more[/dim]")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@attachments.command("path")
|
|
82
|
+
@click.argument("id_or_key")
|
|
83
|
+
@pass_ctx
|
|
84
|
+
def attachment_path(ctx: Context, id_or_key: str):
|
|
85
|
+
"""Print the resolved absolute path(s) for an item's attachments."""
|
|
86
|
+
item_id_val = int(id_or_key) if id_or_key.isdigit() else id_or_key
|
|
87
|
+
item = get_item(ctx.db, item_id_val)
|
|
88
|
+
if item is None:
|
|
89
|
+
raise click.ClickException(f"Item not found: {id_or_key!r}")
|
|
90
|
+
|
|
91
|
+
for att in item.attachments:
|
|
92
|
+
if att.absolute_path:
|
|
93
|
+
click.echo(str(att.absolute_path))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@attachments.command("open")
|
|
97
|
+
@click.argument("id_or_key")
|
|
98
|
+
@pass_ctx
|
|
99
|
+
def open_attachment(ctx: Context, id_or_key: str):
|
|
100
|
+
"""Open the first attachment with the system default application."""
|
|
101
|
+
item_id_val = int(id_or_key) if id_or_key.isdigit() else id_or_key
|
|
102
|
+
item = get_item(ctx.db, item_id_val)
|
|
103
|
+
if item is None:
|
|
104
|
+
raise click.ClickException(f"Item not found: {id_or_key!r}")
|
|
105
|
+
|
|
106
|
+
existing = [a for a in item.attachments if a.file_exists and a.absolute_path]
|
|
107
|
+
if not existing:
|
|
108
|
+
raise click.ClickException("No local files found for this item.")
|
|
109
|
+
|
|
110
|
+
path = existing[0].absolute_path
|
|
111
|
+
click.echo(f"Opening: {path}")
|
|
112
|
+
|
|
113
|
+
if sys.platform == "win32":
|
|
114
|
+
import os
|
|
115
|
+
os.startfile(str(path))
|
|
116
|
+
elif sys.platform == "darwin":
|
|
117
|
+
subprocess.run(["open", str(path)])
|
|
118
|
+
else:
|
|
119
|
+
opener = "wslview" if _which("wslview") else "xdg-open"
|
|
120
|
+
subprocess.run([opener, str(path)])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _which(cmd: str) -> bool:
|
|
124
|
+
import shutil
|
|
125
|
+
return shutil.which(cmd) is not None
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""CLI — `zot collections` subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from zotcli.cli.main import pass_ctx, Context
|
|
9
|
+
from zotcli.cli.render import make_console, collection_tree, items_table
|
|
10
|
+
from zotcli.queries.collections import (
|
|
11
|
+
get_all_collections,
|
|
12
|
+
get_collection_tree,
|
|
13
|
+
get_collection_by_id,
|
|
14
|
+
get_collection_by_name,
|
|
15
|
+
get_items_in_collection,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
def collections():
|
|
21
|
+
"""Browse Zotero collections."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@collections.command("list")
|
|
25
|
+
@click.option("--flat", is_flag=True, help="Flat list with IDs instead of tree view")
|
|
26
|
+
@pass_ctx
|
|
27
|
+
def list_collections(ctx: Context, flat: bool):
|
|
28
|
+
"""Print the collection tree."""
|
|
29
|
+
console = make_console(ctx.color)
|
|
30
|
+
if flat:
|
|
31
|
+
cols = get_all_collections(ctx.db, ctx.library_id)
|
|
32
|
+
t = Table(show_header=True)
|
|
33
|
+
t.add_column("ID", style="dim", width=8)
|
|
34
|
+
t.add_column("Key", style="cyan", width=10)
|
|
35
|
+
t.add_column("Name")
|
|
36
|
+
t.add_column("Parent", width=8)
|
|
37
|
+
t.add_column("Items", width=7)
|
|
38
|
+
for c in cols:
|
|
39
|
+
t.add_row(
|
|
40
|
+
str(c.collection_id),
|
|
41
|
+
c.key,
|
|
42
|
+
c.name,
|
|
43
|
+
str(c.parent_collection_id or ""),
|
|
44
|
+
str(c.item_count),
|
|
45
|
+
)
|
|
46
|
+
console.print(t)
|
|
47
|
+
else:
|
|
48
|
+
roots = get_collection_tree(ctx.db, ctx.library_id)
|
|
49
|
+
tree = collection_tree(roots)
|
|
50
|
+
console.print(tree)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@collections.command("show")
|
|
54
|
+
@click.argument("id_or_name")
|
|
55
|
+
@pass_ctx
|
|
56
|
+
def show_collection(ctx: Context, id_or_name: str):
|
|
57
|
+
"""Show details for a collection."""
|
|
58
|
+
console = make_console(ctx.color)
|
|
59
|
+
col = _resolve_collection(ctx, id_or_name)
|
|
60
|
+
if col is None:
|
|
61
|
+
raise click.ClickException(f"Collection not found: {id_or_name!r}")
|
|
62
|
+
console.print(f"[bold]{col.name}[/bold] ID={col.collection_id} Key={col.key}")
|
|
63
|
+
console.print(f"Parent: {col.parent_collection_id or '(root)'}")
|
|
64
|
+
console.print(f"Items: {col.item_count}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@collections.command("items")
|
|
68
|
+
@click.argument("id_or_name")
|
|
69
|
+
@click.option("--recursive", "-r", is_flag=True, help="Include sub-collections")
|
|
70
|
+
@click.option("--type", "item_type", default=None, help="Filter to item type (e.g. journalArticle)")
|
|
71
|
+
@pass_ctx
|
|
72
|
+
def collection_items(ctx: Context, id_or_name: str, recursive: bool, item_type: str | None):
|
|
73
|
+
"""List items in a collection."""
|
|
74
|
+
console = make_console(ctx.color)
|
|
75
|
+
col = _resolve_collection(ctx, id_or_name)
|
|
76
|
+
if col is None:
|
|
77
|
+
raise click.ClickException(f"Collection not found: {id_or_name!r}")
|
|
78
|
+
|
|
79
|
+
items = get_items_in_collection(ctx.db, col.collection_id, recursive=recursive)
|
|
80
|
+
if item_type:
|
|
81
|
+
items = [i for i in items if i.item_type.lower() == item_type.lower()]
|
|
82
|
+
|
|
83
|
+
if not items:
|
|
84
|
+
console.print(f"[dim]No items in collection {col.name!r}[/dim]")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
t = items_table(items, title=f"{col.name} ({len(items)} items)")
|
|
88
|
+
console.print(t)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolve_collection(ctx: Context, id_or_name: str):
|
|
92
|
+
"""Resolve a collection ID (int) or name (str)."""
|
|
93
|
+
from zotcli.queries.collections import get_collection_by_id, get_collection_by_name
|
|
94
|
+
|
|
95
|
+
if id_or_name.isdigit():
|
|
96
|
+
return get_collection_by_id(ctx.db, int(id_or_name))
|
|
97
|
+
matches = get_collection_by_name(ctx.db, id_or_name, fuzzy=False)
|
|
98
|
+
if not matches:
|
|
99
|
+
matches = get_collection_by_name(ctx.db, id_or_name, fuzzy=True)
|
|
100
|
+
return matches[0] if matches else None
|
zotcli/cli/export.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""CLI — `zot export` subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
9
|
+
|
|
10
|
+
from zotcli.cli.main import pass_ctx, Context
|
|
11
|
+
from zotcli.cli.render import make_console
|
|
12
|
+
from zotcli.queries.items import get_items
|
|
13
|
+
from zotcli.queries.collections import get_collection_by_name, get_collection_by_id, get_items_in_collection
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_items(ctx: Context, collection: str | None, all_items: bool) -> tuple[list, object | None]:
|
|
17
|
+
"""Return (items, collection_obj|None)."""
|
|
18
|
+
if collection:
|
|
19
|
+
if collection.isdigit():
|
|
20
|
+
col = get_collection_by_id(ctx.db, int(collection))
|
|
21
|
+
else:
|
|
22
|
+
matches = get_collection_by_name(ctx.db, collection, fuzzy=True)
|
|
23
|
+
col = matches[0] if matches else None
|
|
24
|
+
if col is None:
|
|
25
|
+
raise click.ClickException(f"Collection not found: {collection!r}")
|
|
26
|
+
return get_items_in_collection(ctx.db, col.collection_id), col
|
|
27
|
+
if all_items:
|
|
28
|
+
return get_items(ctx.db, library_id=ctx.library_id), None
|
|
29
|
+
raise click.UsageError("Provide --collection NAME or --all.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.group("export")
|
|
33
|
+
def export():
|
|
34
|
+
"""Export items to various formats."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@export.command("json")
|
|
38
|
+
@click.option("--collection", "-c", default=None, help="Export a specific collection")
|
|
39
|
+
@click.option("--all", "all_items", is_flag=True, help="Export entire library")
|
|
40
|
+
@click.option("--output", "-o", default=None, help="Output file (default: stdout)")
|
|
41
|
+
@pass_ctx
|
|
42
|
+
def export_json(ctx: Context, collection: str | None, all_items: bool, output: str | None):
|
|
43
|
+
"""Export items as JSON."""
|
|
44
|
+
from zotcli.export.json_ import items_to_json
|
|
45
|
+
items, _ = _get_items(ctx, collection, all_items)
|
|
46
|
+
fp = open(output, "w", encoding="utf-8") if output else sys.stdout
|
|
47
|
+
try:
|
|
48
|
+
items_to_json(items, fp)
|
|
49
|
+
if output:
|
|
50
|
+
make_console(ctx.color).print(f"[green]Exported {len(items)} items to {output}[/green]")
|
|
51
|
+
finally:
|
|
52
|
+
if output:
|
|
53
|
+
fp.close()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@export.command("csv")
|
|
57
|
+
@click.option("--collection", "-c", default=None)
|
|
58
|
+
@click.option("--all", "all_items", is_flag=True)
|
|
59
|
+
@click.option("--output", "-o", default=None)
|
|
60
|
+
@pass_ctx
|
|
61
|
+
def export_csv(ctx: Context, collection: str | None, all_items: bool, output: str | None):
|
|
62
|
+
"""Export items as CSV."""
|
|
63
|
+
from zotcli.export.csv_ import items_to_csv
|
|
64
|
+
items, _ = _get_items(ctx, collection, all_items)
|
|
65
|
+
fp = open(output, "w", newline="", encoding="utf-8") if output else sys.stdout
|
|
66
|
+
try:
|
|
67
|
+
items_to_csv(items, fp)
|
|
68
|
+
if output:
|
|
69
|
+
make_console(ctx.color).print(f"[green]Exported {len(items)} items to {output}[/green]")
|
|
70
|
+
finally:
|
|
71
|
+
if output:
|
|
72
|
+
fp.close()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@export.command("bib")
|
|
76
|
+
@click.option("--collection", "-c", default=None)
|
|
77
|
+
@click.option("--all", "all_items", is_flag=True)
|
|
78
|
+
@click.option("--output", "-o", default=None)
|
|
79
|
+
@pass_ctx
|
|
80
|
+
def export_bib(ctx: Context, collection: str | None, all_items: bool, output: str | None):
|
|
81
|
+
"""Export items as BibTeX."""
|
|
82
|
+
from zotcli.export.bibtex import items_to_bibtex
|
|
83
|
+
items, _ = _get_items(ctx, collection, all_items)
|
|
84
|
+
fp = open(output, "w", encoding="utf-8") if output else sys.stdout
|
|
85
|
+
try:
|
|
86
|
+
items_to_bibtex(items, fp)
|
|
87
|
+
if output:
|
|
88
|
+
make_console(ctx.color).print(f"[green]Exported {len(items)} items to {output}[/green]")
|
|
89
|
+
finally:
|
|
90
|
+
if output:
|
|
91
|
+
fp.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@export.command("markdown")
|
|
95
|
+
@click.option("--collection", "-c", default=None)
|
|
96
|
+
@click.option("--all", "all_items", is_flag=True)
|
|
97
|
+
@click.option("--output", "-o", default=None)
|
|
98
|
+
@click.option("--notes", is_flag=True, help="Include notes sections")
|
|
99
|
+
@pass_ctx
|
|
100
|
+
def export_markdown(ctx: Context, collection: str | None, all_items: bool, output: str | None, notes: bool):
|
|
101
|
+
"""Export items as a Markdown report."""
|
|
102
|
+
from zotcli.export.markdown import items_to_markdown
|
|
103
|
+
items, col = _get_items(ctx, collection, all_items)
|
|
104
|
+
fp = open(output, "w", encoding="utf-8") if output else sys.stdout
|
|
105
|
+
try:
|
|
106
|
+
items_to_markdown(items, collection=col, include_notes=notes, fp=fp)
|
|
107
|
+
if output:
|
|
108
|
+
make_console(ctx.color).print(f"[green]Exported {len(items)} items to {output}[/green]")
|
|
109
|
+
finally:
|
|
110
|
+
if output:
|
|
111
|
+
fp.close()
|
zotcli/cli/items.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""CLI — `zot items` subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from zotcli.cli.main import pass_ctx, Context
|
|
10
|
+
from zotcli.cli.render import make_console, item_panel, items_table
|
|
11
|
+
from zotcli.queries.items import get_items, get_item
|
|
12
|
+
from zotcli.queries.collections import get_collection_by_name, get_items_in_collection
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def items():
|
|
17
|
+
"""Browse Zotero items."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@items.command("list")
|
|
21
|
+
@click.option("--type", "item_type", default=None, help="Filter by item type")
|
|
22
|
+
@click.option("--collection", "col_name", default=None, help="Filter by collection name/ID")
|
|
23
|
+
@click.option("--limit", default=50, show_default=True, help="Max items to show")
|
|
24
|
+
@pass_ctx
|
|
25
|
+
def list_items(ctx: Context, item_type: str | None, col_name: str | None, limit: int):
|
|
26
|
+
"""List items (paginated)."""
|
|
27
|
+
console = make_console(ctx.color)
|
|
28
|
+
|
|
29
|
+
if col_name:
|
|
30
|
+
if col_name.isdigit():
|
|
31
|
+
from zotcli.queries.collections import get_collection_by_id
|
|
32
|
+
col = get_collection_by_id(ctx.db, int(col_name))
|
|
33
|
+
else:
|
|
34
|
+
matches = get_collection_by_name(ctx.db, col_name, fuzzy=True)
|
|
35
|
+
col = matches[0] if matches else None
|
|
36
|
+
if col is None:
|
|
37
|
+
raise click.ClickException(f"Collection not found: {col_name!r}")
|
|
38
|
+
result = get_items_in_collection(ctx.db, col.collection_id)
|
|
39
|
+
if item_type:
|
|
40
|
+
result = [i for i in result if i.item_type.lower() == item_type.lower()]
|
|
41
|
+
result = result[:limit]
|
|
42
|
+
title = f"{col.name} — {len(result)} items"
|
|
43
|
+
else:
|
|
44
|
+
result = get_items(ctx.db, library_id=ctx.library_id, item_type=item_type, limit=limit)
|
|
45
|
+
title = f"{len(result)} items"
|
|
46
|
+
|
|
47
|
+
if not result:
|
|
48
|
+
console.print("[dim]No items found.[/dim]")
|
|
49
|
+
return
|
|
50
|
+
console.print(items_table(result, title=title))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@items.command("show")
|
|
54
|
+
@click.argument("id_or_key")
|
|
55
|
+
@pass_ctx
|
|
56
|
+
def show_item(ctx: Context, id_or_key: str):
|
|
57
|
+
"""Show full details for an item."""
|
|
58
|
+
console = make_console(ctx.color)
|
|
59
|
+
item_id = int(id_or_key) if id_or_key.isdigit() else id_or_key
|
|
60
|
+
item = get_item(ctx.db, item_id)
|
|
61
|
+
if item is None:
|
|
62
|
+
raise click.ClickException(f"Item not found: {id_or_key!r}")
|
|
63
|
+
# attachments and notes already populated by _build_items
|
|
64
|
+
console.print(item_panel(item))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@items.command("attachments")
|
|
68
|
+
@click.argument("id_or_key")
|
|
69
|
+
@pass_ctx
|
|
70
|
+
def item_attachments(ctx: Context, id_or_key: str):
|
|
71
|
+
"""List attachments for an item."""
|
|
72
|
+
console = make_console(ctx.color)
|
|
73
|
+
item_id_val = int(id_or_key) if id_or_key.isdigit() else id_or_key
|
|
74
|
+
item = get_item(ctx.db, item_id_val)
|
|
75
|
+
if item is None:
|
|
76
|
+
raise click.ClickException(f"Item not found: {id_or_key!r}")
|
|
77
|
+
|
|
78
|
+
if not item.attachments:
|
|
79
|
+
console.print("[dim]No attachments.[/dim]")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
t = Table(title=f"Attachments for {item.key}")
|
|
83
|
+
t.add_column("Key", style="cyan")
|
|
84
|
+
t.add_column("Type")
|
|
85
|
+
t.add_column("Mode")
|
|
86
|
+
t.add_column("Exists")
|
|
87
|
+
t.add_column("Path")
|
|
88
|
+
for att in item.attachments:
|
|
89
|
+
exists = "[green]✓[/green]" if att.file_exists else "[red]✗[/red]"
|
|
90
|
+
path_str = str(att.absolute_path) if att.absolute_path else att.path or ""
|
|
91
|
+
t.add_row(att.key, att.content_type, att.link_mode_name, exists, path_str[:80])
|
|
92
|
+
console.print(t)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@items.command("notes")
|
|
96
|
+
@click.argument("id_or_key")
|
|
97
|
+
@pass_ctx
|
|
98
|
+
def item_notes(ctx: Context, id_or_key: str):
|
|
99
|
+
"""Show notes attached to an item."""
|
|
100
|
+
console = make_console(ctx.color)
|
|
101
|
+
item_id_val = int(id_or_key) if id_or_key.isdigit() else id_or_key
|
|
102
|
+
item = get_item(ctx.db, item_id_val)
|
|
103
|
+
if item is None:
|
|
104
|
+
raise click.ClickException(f"Item not found: {id_or_key!r}")
|
|
105
|
+
|
|
106
|
+
if not item.notes:
|
|
107
|
+
console.print("[dim]No notes.[/dim]")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
for note in item.notes:
|
|
111
|
+
console.print(Panel(note.plain_text[:2000], title=note.title or "Note"))
|
zotcli/cli/main.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Root Click group and shared context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from zotcli.db import ZoteroDatabase, discover_db
|
|
11
|
+
from zotcli.config import get_db_path, get_library_id
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Context:
|
|
15
|
+
def __init__(self, db: ZoteroDatabase, library_id: int, fmt: str, color: bool):
|
|
16
|
+
self.db = db
|
|
17
|
+
self.library_id = library_id
|
|
18
|
+
self.fmt = fmt
|
|
19
|
+
self.color = color
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
pass_ctx = click.make_pass_decorator(Context)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
@click.option("--db", "db_path", default=None, help="Path to zotero.sqlite")
|
|
27
|
+
@click.option("--library", "library_id", default=None, type=int, help="Library ID (default: 1)")
|
|
28
|
+
@click.option(
|
|
29
|
+
"--format",
|
|
30
|
+
"fmt",
|
|
31
|
+
default="table",
|
|
32
|
+
type=click.Choice(["table", "json", "csv"]),
|
|
33
|
+
help="Output format",
|
|
34
|
+
)
|
|
35
|
+
@click.option("--no-color", is_flag=True, default=False, help="Disable colour output")
|
|
36
|
+
@click.pass_context
|
|
37
|
+
def cli(ctx: click.Context, db_path: str | None, library_id: int | None, fmt: str, no_color: bool):
|
|
38
|
+
"""zot — read-only CLI for your Zotero library."""
|
|
39
|
+
resolved_path = get_db_path(db_path)
|
|
40
|
+
if resolved_path is None:
|
|
41
|
+
try:
|
|
42
|
+
resolved_path = discover_db()
|
|
43
|
+
except FileNotFoundError as e:
|
|
44
|
+
raise click.ClickException(str(e))
|
|
45
|
+
|
|
46
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
47
|
+
warnings.simplefilter("always")
|
|
48
|
+
db = ZoteroDatabase(resolved_path, warn_if_open=True)
|
|
49
|
+
|
|
50
|
+
for w in caught:
|
|
51
|
+
click.echo(f"[warning] {w.message}", err=True)
|
|
52
|
+
|
|
53
|
+
lib_id = get_library_id(library_id)
|
|
54
|
+
ctx.obj = Context(db=db, library_id=lib_id, fmt=fmt, color=not no_color)
|
|
55
|
+
ctx.call_on_close(db.close)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Import and register subcommand groups
|
|
59
|
+
from zotcli.cli import collections as _col_mod # noqa: E402
|
|
60
|
+
from zotcli.cli import items as _items_mod # noqa: E402
|
|
61
|
+
from zotcli.cli import attachments as _att_mod # noqa: E402
|
|
62
|
+
from zotcli.cli import search as _search_mod # noqa: E402
|
|
63
|
+
from zotcli.cli import stats as _stats_mod # noqa: E402
|
|
64
|
+
from zotcli.cli import export as _export_mod # noqa: E402
|
|
65
|
+
|
|
66
|
+
cli.add_command(_col_mod.collections)
|
|
67
|
+
cli.add_command(_items_mod.items)
|
|
68
|
+
cli.add_command(_att_mod.attachments)
|
|
69
|
+
cli.add_command(_search_mod.search)
|
|
70
|
+
cli.add_command(_stats_mod.stats)
|
|
71
|
+
cli.add_command(_export_mod.export)
|
zotcli/cli/render.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Shared Rich rendering helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from rich.tree import Tree
|
|
10
|
+
|
|
11
|
+
from zotcli.models import Collection, Item
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_console(color: bool = True, width: int = 160) -> Console:
|
|
15
|
+
return (
|
|
16
|
+
Console(highlight=False, markup=True, width=width)
|
|
17
|
+
if color
|
|
18
|
+
else Console(highlight=False, no_color=True, width=width)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def items_table(items: list[Item], title: str = "Items") -> Table:
|
|
23
|
+
t = Table(title=title, show_lines=False, highlight=False)
|
|
24
|
+
t.add_column("#", style="dim", no_wrap=True, width=6)
|
|
25
|
+
t.add_column("Key", style="cyan", no_wrap=True, width=9)
|
|
26
|
+
t.add_column("Type", style="magenta", no_wrap=True, width=18)
|
|
27
|
+
t.add_column("Title")
|
|
28
|
+
t.add_column("Authors", width=24)
|
|
29
|
+
t.add_column("Year", width=6)
|
|
30
|
+
|
|
31
|
+
for i, item in enumerate(items, start=1):
|
|
32
|
+
t.add_row(
|
|
33
|
+
str(i),
|
|
34
|
+
item.key,
|
|
35
|
+
item.item_type,
|
|
36
|
+
item.title[:80],
|
|
37
|
+
item.authors_short,
|
|
38
|
+
item.year or "",
|
|
39
|
+
)
|
|
40
|
+
return t
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def item_panel(item: Item) -> Panel:
|
|
44
|
+
lines: list[str] = []
|
|
45
|
+
lines.append(f"[bold]Title[/bold] {item.title}")
|
|
46
|
+
if item.authors:
|
|
47
|
+
lines.append(f"[bold]Authors[/bold] {'; '.join(item.authors[:5])}")
|
|
48
|
+
if item.year:
|
|
49
|
+
lines.append(f"[bold]Year[/bold] {item.year}")
|
|
50
|
+
if "publicationTitle" in item.fields:
|
|
51
|
+
lines.append(f"[bold]Journal[/bold] {item.fields['publicationTitle']}")
|
|
52
|
+
if item.doi:
|
|
53
|
+
lines.append(f"[bold]DOI[/bold] {item.doi}")
|
|
54
|
+
if item.tags:
|
|
55
|
+
tag_str = " ".join(f"[{t}]" for t in item.tags[:10])
|
|
56
|
+
lines.append(f"[bold]Tags[/bold] {tag_str}")
|
|
57
|
+
|
|
58
|
+
for field, value in sorted(item.fields.items()):
|
|
59
|
+
if field in ("title", "DOI", "publicationTitle", "date", "citationKey"):
|
|
60
|
+
continue
|
|
61
|
+
if value:
|
|
62
|
+
lines.append(f"[dim]{field:12}[/dim] {value[:80]}")
|
|
63
|
+
|
|
64
|
+
if item.attachments:
|
|
65
|
+
lines.append("\n[bold]Attachments[/bold]")
|
|
66
|
+
for att in item.attachments:
|
|
67
|
+
icon = "[green]✓[/green]" if att.file_exists else "[red]✗[/red]"
|
|
68
|
+
name = att.filename or att.content_type or "?"
|
|
69
|
+
lines.append(f" {icon} {name} [dim]({att.link_mode_name})[/dim]")
|
|
70
|
+
|
|
71
|
+
if item.notes:
|
|
72
|
+
lines.append(f"\n[bold]Notes[/bold] ({len(item.notes)})")
|
|
73
|
+
for note in item.notes[:3]:
|
|
74
|
+
snippet = (note.plain_text or "")[:120].replace("\n", " ")
|
|
75
|
+
lines.append(f" • {snippet}")
|
|
76
|
+
|
|
77
|
+
body = "\n".join(lines)
|
|
78
|
+
return Panel(
|
|
79
|
+
body,
|
|
80
|
+
title=f"[bold cyan]{item.item_type}[/bold cyan] [dim]#{item.item_id}[/dim] [bold]{item.key}[/bold]",
|
|
81
|
+
expand=False,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def collection_tree(roots: list[Collection], tree: Tree | None = None) -> Tree:
|
|
86
|
+
if tree is None:
|
|
87
|
+
tree = Tree("[bold blue]Collections[/bold blue]")
|
|
88
|
+
for col in roots:
|
|
89
|
+
label = f"[green]{col.name}[/green] [dim]({col.item_count})[/dim]"
|
|
90
|
+
branch = tree.add(label)
|
|
91
|
+
if col.children:
|
|
92
|
+
collection_tree(col.children, branch)
|
|
93
|
+
return tree
|