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 ADDED
@@ -0,0 +1,3 @@
1
+ """zotcli — A read-only CLI for browsing and exporting a Zotero library."""
2
+
3
+ __version__ = "0.1.0"
zotcli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from zotcli.cli.main import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
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