zotlib 0.4.3__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.
zotlib/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Zotlib - Extract and format bibliographic data from Zotero databases."""
2
+
3
+ __version__ = "0.4.3"
4
+
5
+ from zotlib.database import ZoteroDatabase
6
+ from zotlib.extractors import (
7
+ extract_collections,
8
+ extract_creators,
9
+ extract_cv_items,
10
+ extract_items,
11
+ extract_libraries,
12
+ )
13
+ from zotlib.formatters.apa import format_apa_reference, format_cv_as_apa
14
+
15
+ __all__ = [
16
+ "ZoteroDatabase",
17
+ "extract_items",
18
+ "extract_creators",
19
+ "extract_collections",
20
+ "extract_libraries",
21
+ "extract_cv_items",
22
+ "format_apa_reference",
23
+ "format_cv_as_apa",
24
+ ]
zotlib/backup.py ADDED
@@ -0,0 +1,98 @@
1
+ """Backup Zotero data directory as a compressed archive."""
2
+
3
+ import os
4
+ import tarfile
5
+ from datetime import date
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+ from rich.progress import (
10
+ BarColumn,
11
+ MofNCompleteColumn,
12
+ Progress,
13
+ SpinnerColumn,
14
+ TextColumn,
15
+ )
16
+
17
+
18
+ def get_directory_stats(source_dir: Path) -> tuple[int, int]:
19
+ """Walk a directory and return (file_count, total_bytes)."""
20
+ file_count = 0
21
+ total_bytes = 0
22
+ for dirpath, _dirnames, filenames in os.walk(source_dir):
23
+ for filename in filenames:
24
+ filepath = Path(dirpath) / filename
25
+ try:
26
+ total_bytes += filepath.stat().st_size
27
+ file_count += 1
28
+ except OSError as e:
29
+ print(f"Warning: skipped {filepath}: {e}")
30
+ continue
31
+ return file_count, total_bytes
32
+
33
+
34
+ def default_backup_path() -> Path:
35
+ """Generate default backup path in data/backups/."""
36
+ today = date.today().isoformat()
37
+ return Path("data") / "backups" / f"zotero-{today}.tar.bz2"
38
+
39
+
40
+ def create_backup(
41
+ source_dir: Path,
42
+ output_path: Path,
43
+ console: Console,
44
+ ) -> Path:
45
+ """Archive a Zotero data directory as .tar.bz2.
46
+
47
+ Args:
48
+ source_dir: The Zotero data directory to archive.
49
+ output_path: Path for the output .tar.bz2 file.
50
+ console: Rich console for progress output.
51
+
52
+ Returns:
53
+ Path to the created archive.
54
+
55
+ Raises:
56
+ FileNotFoundError: If source_dir does not exist.
57
+ FileExistsError: If output_path already exists.
58
+ """
59
+ if not source_dir.is_dir():
60
+ raise FileNotFoundError(f"Zotero directory not found: {source_dir}")
61
+
62
+ if output_path.exists():
63
+ raise FileExistsError(f"Output file already exists: {output_path}")
64
+
65
+ file_count, total_bytes = get_directory_stats(source_dir)
66
+ size_mb = total_bytes / (1024 * 1024)
67
+ console.print(f"Source: {source_dir}")
68
+ console.print(f"Files: {file_count:,} ({size_mb:,.1f} MB)")
69
+
70
+ archived = 0
71
+ with Progress(
72
+ SpinnerColumn(),
73
+ TextColumn("[progress.description]{task.description}"),
74
+ BarColumn(),
75
+ MofNCompleteColumn(),
76
+ console=console,
77
+ ) as progress:
78
+ task = progress.add_task("Archiving", total=file_count)
79
+
80
+ with tarfile.open(output_path, "w:bz2") as tar:
81
+ for dirpath, _dirnames, filenames in os.walk(source_dir):
82
+ rel_dir = Path(dirpath).relative_to(source_dir.parent)
83
+ tar.add(dirpath, arcname=str(rel_dir), recursive=False)
84
+
85
+ for filename in filenames:
86
+ filepath = Path(dirpath) / filename
87
+ arcname = str(rel_dir / filename)
88
+ try:
89
+ tar.add(filepath, arcname=arcname)
90
+ archived += 1
91
+ progress.update(task, completed=archived)
92
+ except OSError as e:
93
+ progress.console.print(f"[yellow]Warning: skipped {filepath}: {e}[/yellow]")
94
+ continue
95
+
96
+ archive_mb = output_path.stat().st_size / (1024 * 1024)
97
+ console.print(f"Saved: {output_path} ({archive_mb:,.1f} MB)")
98
+ return output_path
zotlib/cli.py ADDED
@@ -0,0 +1,448 @@
1
+ """Command-line interface for zotlib."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import polars as pl
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from zotlib.backup import create_backup, default_backup_path
12
+ from zotlib.config import (
13
+ CONFIG_FILE,
14
+ discover_pdfs_dir,
15
+ discover_zotero_database,
16
+ get_database_path,
17
+ get_pdfs_dir,
18
+ load_config,
19
+ write_config,
20
+ )
21
+ from zotlib.covers import generate_covers, generate_thumbnails
22
+ from zotlib.database import ZoteroDatabase
23
+ from zotlib.exporters import export_collection
24
+ from zotlib.extractors import (
25
+ extract_collections,
26
+ extract_creators,
27
+ extract_cv_items,
28
+ extract_items,
29
+ extract_libraries,
30
+ )
31
+ from zotlib.formatters.apa import format_cv_as_apa
32
+ from zotlib.tables import ALL_SCHEMAS, CORE_SCHEMAS, SCHEMA_MAP
33
+
34
+ app = typer.Typer(
35
+ name="zotlib",
36
+ help="Extract and format bibliographic data from Zotero databases.",
37
+ )
38
+ console = Console()
39
+
40
+
41
+ # --- Init ---
42
+
43
+
44
+ @app.command()
45
+ def init():
46
+ """Discover Zotero paths and save to zotlib.toml.
47
+
48
+ Auto-discovers the Zotero database and linked PDFs directory,
49
+ then writes them to a config file for future use.
50
+
51
+ Examples:
52
+ zotlib init
53
+ """
54
+ # Check for existing config
55
+ existing = load_config()
56
+ if existing:
57
+ console.print(f"[yellow]Existing {CONFIG_FILE}:[/yellow]")
58
+ for key, value in existing.items():
59
+ console.print(f" {key} = {value}")
60
+ overwrite = typer.confirm("Overwrite?", default=False)
61
+ if not overwrite:
62
+ raise typer.Abort()
63
+
64
+ # Discover database
65
+ db_path = discover_zotero_database()
66
+ if db_path:
67
+ console.print(f"[green]database:[/green] {db_path}")
68
+ else:
69
+ console.print("[red]Could not find Zotero database[/red]")
70
+ raise typer.Exit(1)
71
+
72
+ # Discover PDFs directory
73
+ pdfs_dir = discover_pdfs_dir(check_exists=False)
74
+ if pdfs_dir:
75
+ console.print(f"[green]pdfs_dir:[/green] {pdfs_dir}")
76
+ if not pdfs_dir.exists():
77
+ console.print("[yellow] Path not currently accessible[/yellow]")
78
+ else:
79
+ console.print("[yellow]Could not find linked PDFs directory[/yellow]")
80
+
81
+ # Write config
82
+ config_path = write_config(db_path, pdfs_dir)
83
+ console.print(f"\nSaved to {config_path}")
84
+
85
+
86
+ # --- Explore ---
87
+
88
+
89
+ @app.command("show-tables")
90
+ def show_tables(
91
+ table_name: Annotated[
92
+ str | None,
93
+ typer.Argument(help="Table name to show schema for (optional)"),
94
+ ] = None,
95
+ database: Annotated[
96
+ Path | None,
97
+ typer.Option("--database", "-d", help="Path to zotero.sqlite"),
98
+ ] = None,
99
+ all: Annotated[
100
+ bool,
101
+ typer.Option("--all", "-a", help="List all tables in the database"),
102
+ ] = False,
103
+ ):
104
+ """Show Zotero database schema for tables used by zotlib.
105
+
106
+ Examples:
107
+ zotlib show-tables
108
+ zotlib show-tables items
109
+ zotlib show-tables itemAnnotations
110
+ zotlib show-tables --all
111
+ """
112
+ wide = Console(width=250, force_terminal=True)
113
+ if all:
114
+ db_path = get_database_path(database)
115
+ db = ZoteroDatabase(db_path)
116
+ table_names = db.get_table_names()
117
+ table = Table(title=f"All Tables in {db_path.name}")
118
+ table.add_column("Table", style="cyan", no_wrap=True)
119
+ table.add_column("Description", no_wrap=True)
120
+ table.add_column("Columns")
121
+ for name in sorted(table_names):
122
+ schema = SCHEMA_MAP.get(name)
123
+ desc = schema.description if schema else ""
124
+ cols = ",".join(schema.columns.keys()) if schema else ""
125
+ if len(cols) > 132:
126
+ cols = cols[:132].rsplit(",", 1)[0] + ",..."
127
+ table.add_row(name, desc, cols)
128
+ wide.print(table)
129
+ elif table_name:
130
+ # Show specific table
131
+ for s in ALL_SCHEMAS:
132
+ if s.name == table_name:
133
+ table = Table(show_header=True)
134
+ table.add_column("Table", style="cyan bold")
135
+ table.add_column("Column", style="green")
136
+ table.add_column("Type")
137
+ table.add_column("Description")
138
+ for i, (col, col_def) in enumerate(s.columns.items()):
139
+ table.add_row(
140
+ s.name if i == 0 else "",
141
+ col,
142
+ col_def.type,
143
+ col_def.description,
144
+ )
145
+ wide.print(table)
146
+ return
147
+ wide.print(f"[red]Unknown table: {table_name}[/red]")
148
+ wide.print(f"Available: {', '.join(s.name for s in ALL_SCHEMAS)}")
149
+ else:
150
+ # Show core tables used by zotlib
151
+ table = Table(title="Zotero Database Schema (used by zotlib)")
152
+ table.add_column("Table", style="cyan", no_wrap=True)
153
+ table.add_column("Description", no_wrap=True)
154
+ table.add_column("Columns")
155
+ for s in CORE_SCHEMAS:
156
+ cols = ",".join(s.columns.keys())
157
+ if len(cols) > 132:
158
+ cols = cols[:132].rsplit(",", 1)[0] + ",..."
159
+ table.add_row(s.name, s.description, cols)
160
+ wide.print(table)
161
+
162
+
163
+ @app.command("show-collections")
164
+ def show_collections(
165
+ database: Annotated[
166
+ Path | None,
167
+ typer.Option("--database", "-d", help="Path to zotero.sqlite"),
168
+ ] = None,
169
+ ):
170
+ """List all collections in the Zotero library."""
171
+ db_path = get_database_path(database)
172
+ db = ZoteroDatabase(db_path)
173
+
174
+ colls = extract_collections(db)
175
+ unique_collections = colls.select("collectionID", "collectionName").unique()
176
+ counts = colls.group_by("collectionName").agg(pl.len().alias("items"))
177
+ unique_collections = unique_collections.join(counts, on="collectionName")
178
+
179
+ table = Table(title="Zotero Collections")
180
+ table.add_column("ID", style="dim")
181
+ table.add_column("Name", style="bold")
182
+ table.add_column("Items", justify="right")
183
+
184
+ for row in unique_collections.iter_rows(named=True):
185
+ table.add_row(
186
+ str(row["collectionID"]),
187
+ row["collectionName"],
188
+ str(row["items"]),
189
+ )
190
+
191
+ console.print(table)
192
+
193
+
194
+ # --- Export ---
195
+
196
+
197
+ @app.command("export-annotations")
198
+ def export_annotations(
199
+ collection: Annotated[
200
+ str,
201
+ typer.Option("--collection", "-c", help="Collection name"),
202
+ ],
203
+ database: Annotated[
204
+ Path | None,
205
+ typer.Option("--database", "-d", help="Path to zotero.sqlite"),
206
+ ] = None,
207
+ output_dir: Annotated[
208
+ Path,
209
+ typer.Option("--output", "-o", help="Output directory"),
210
+ ] = Path("output/export-annotations"),
211
+ pdfs_dir: Annotated[
212
+ Path | None,
213
+ typer.Option("--pdfs-dir", "-p", help="Directory for linked PDF attachments"),
214
+ ] = None,
215
+ ):
216
+ """Export collection with baked annotations and markdown notes.
217
+
218
+ For each item in the collection, exports a subdirectory containing
219
+ the PDF (with annotations baked in) and a markdown file with YAML
220
+ frontmatter and annotation text.
221
+
222
+ Examples:
223
+ zotlib export-annotations -c mycollection
224
+ zotlib export-annotations -c mycollection -p "/mnt/i/My Drive/zotero-pdfs/"
225
+ zotlib export-annotations -c mycollection -o custom/output/path
226
+ """
227
+ db_path = get_database_path(database)
228
+ console.print(f"Using database: {db_path}")
229
+
230
+ db = ZoteroDatabase(db_path)
231
+ if pdfs_dir is None:
232
+ pdfs_dir = get_pdfs_dir()
233
+ if pdfs_dir:
234
+ console.print(f"Using linked PDFs directory: {pdfs_dir}")
235
+ collection_dir = output_dir / collection
236
+
237
+ exported, skipped, warnings = export_collection(
238
+ db, collection, collection_dir, pdfs_dir=pdfs_dir, console=console
239
+ )
240
+
241
+ console.print(f"\nExported {exported} items to {collection_dir}")
242
+ if skipped:
243
+ console.print(f"[yellow]Skipped {skipped} items (no PDF or annotations)[/yellow]")
244
+ if warnings:
245
+ console.print("[yellow]Warnings:[/yellow]")
246
+ for w in warnings:
247
+ console.print(f" - {w}")
248
+
249
+
250
+ @app.command("export-apa")
251
+ def export_apa(
252
+ collection: Annotated[
253
+ str,
254
+ typer.Option("--collection", "-c", help="Collection name"),
255
+ ],
256
+ database: Annotated[
257
+ Path | None,
258
+ typer.Option("--database", "-d", help="Path to zotero.sqlite"),
259
+ ] = None,
260
+ output_dir: Annotated[
261
+ Path,
262
+ typer.Option("--output", "-o", help="Output directory"),
263
+ ] = Path("output/export-apa"),
264
+ group_by: Annotated[
265
+ str,
266
+ typer.Option("--group-by", "-g", help="Column to group references by"),
267
+ ] = "typeName",
268
+ ):
269
+ """Format collection items as APA references.
270
+
271
+ Examples:
272
+ zotlib export-apa -c publications
273
+ zotlib export-apa -c publications -g year
274
+ """
275
+ db_path = get_database_path(database)
276
+ console.print(f"Using database: {db_path}")
277
+
278
+ db = ZoteroDatabase(db_path)
279
+ output_dir.mkdir(parents=True, exist_ok=True)
280
+
281
+ items = extract_cv_items(db, collection)
282
+ apa_path = output_dir / f"{collection}.md"
283
+ format_cv_as_apa(items, apa_path, group_by=group_by)
284
+ console.print(f"Saved: {apa_path} ({len(items)} items)")
285
+
286
+
287
+ @app.command("export-covers")
288
+ def export_covers(
289
+ collection: Annotated[
290
+ str,
291
+ typer.Option("--collection", "-c", help="Collection name"),
292
+ ],
293
+ database: Annotated[
294
+ Path | None,
295
+ typer.Option("--database", "-d", help="Path to zotero.sqlite"),
296
+ ] = None,
297
+ output_dir: Annotated[
298
+ Path,
299
+ typer.Option("--output", "-o", help="Output directory"),
300
+ ] = Path("output/export-covers"),
301
+ pdfs_dir: Annotated[
302
+ Path | None,
303
+ typer.Option("--pdfs-dir", "-p", help="Directory for linked PDF attachments"),
304
+ ] = None,
305
+ dpi: Annotated[
306
+ int,
307
+ typer.Option("--dpi", help="Image resolution in DPI"),
308
+ ] = 300,
309
+ thumbnail_width: Annotated[
310
+ int,
311
+ typer.Option("--thumb-width", "-w", help="Thumbnail width in pixels"),
312
+ ] = 400,
313
+ no_thumbnails: Annotated[
314
+ bool,
315
+ typer.Option("--no-thumbnails", help="Skip thumbnail generation"),
316
+ ] = False,
317
+ ):
318
+ """Generate first-page cover images and thumbnails for PDFs in a collection.
319
+
320
+ Examples:
321
+ zotlib export-covers -c publications
322
+ zotlib export-covers -c publications -p "/mnt/i/My Drive/zotero-pdfs/"
323
+ zotlib export-covers -c publications --no-thumbnails
324
+ """
325
+ db_path = get_database_path(database)
326
+ console.print(f"Using database: {db_path}")
327
+
328
+ db = ZoteroDatabase(db_path)
329
+ if pdfs_dir is None:
330
+ pdfs_dir = get_pdfs_dir()
331
+ if pdfs_dir:
332
+ console.print(f"Using linked PDFs directory: {pdfs_dir}")
333
+ fullsize_dir = output_dir / collection / "fullsize"
334
+
335
+ cover_paths, skipped = generate_covers(db, collection, fullsize_dir, dpi=dpi, pdfs_dir=pdfs_dir)
336
+ console.print(f"Generated {len(cover_paths)} cover images in {fullsize_dir}")
337
+
338
+ # Generate thumbnails
339
+ if not no_thumbnails:
340
+ thumbs_dir = output_dir / collection / "thumbnails"
341
+ count = generate_thumbnails(fullsize_dir, thumbs_dir, width=thumbnail_width)
342
+ console.print(f"Generated {count} thumbnails ({thumbnail_width}px wide) in {thumbs_dir}")
343
+
344
+ # Add cover paths to collection CSV (sibling export-csv directory)
345
+ csv_dir = output_dir.parent / "export-csv"
346
+ csv_dir.mkdir(parents=True, exist_ok=True)
347
+ csv_path = csv_dir / f"{collection}.csv"
348
+ if csv_path.exists():
349
+ items_df = pl.read_csv(csv_path)
350
+ else:
351
+ items_df = extract_cv_items(db, collection)
352
+
353
+ cover_map = {item_id: str(path) for item_id, path in cover_paths.items()}
354
+ items_df = items_df.with_columns(
355
+ pl.col("itemID").replace_strict(cover_map, default=None).alias("cover")
356
+ )
357
+ items_df.write_csv(csv_path)
358
+ console.print(f"Saved: {csv_path}")
359
+
360
+ if skipped:
361
+ console.print(f"[yellow]Skipped {len(skipped)} missing PDFs:[/yellow]")
362
+ for item in skipped:
363
+ console.print(f" - {item}")
364
+
365
+
366
+ @app.command("export-csv")
367
+ def export_csv(
368
+ database: Annotated[
369
+ Path | None,
370
+ typer.Option("--database", "-d", help="Path to zotero.sqlite"),
371
+ ] = None,
372
+ output_dir: Annotated[
373
+ Path,
374
+ typer.Option("--output", "-o", help="Output directory"),
375
+ ] = Path("output/export-csv"),
376
+ collection: Annotated[
377
+ str | None,
378
+ typer.Option("--collection", "-c", help="Filter to collection name"),
379
+ ] = None,
380
+ ):
381
+ """Export bibliographic data from Zotero database as CSV.
382
+
383
+ Examples:
384
+ zotlib export-csv
385
+ zotlib export-csv -c publications
386
+ zotlib export-csv -d /path/to/zotero.sqlite -c rer
387
+ """
388
+ db_path = get_database_path(database)
389
+ console.print(f"Using database: {db_path}")
390
+
391
+ db = ZoteroDatabase(db_path)
392
+ output_dir.mkdir(parents=True, exist_ok=True)
393
+
394
+ if collection:
395
+ items = extract_cv_items(db, collection)
396
+ csv_path = output_dir / f"{collection}.csv"
397
+ items.write_csv(csv_path)
398
+ console.print(f"Saved: {csv_path} ({len(items)} items)")
399
+ else:
400
+ items = extract_items(db)
401
+ creators = extract_creators(db)
402
+ collections = extract_collections(db)
403
+ libraries = extract_libraries(db)
404
+
405
+ for name, df in [
406
+ ("items", items),
407
+ ("creators", creators),
408
+ ("collections", collections),
409
+ ("libraries", libraries),
410
+ ]:
411
+ path = output_dir / f"{name}.csv"
412
+ df.write_csv(path)
413
+ console.print(f"Saved: {path} ({len(df)} rows)")
414
+
415
+
416
+ # --- Manage ---
417
+
418
+
419
+ @app.command()
420
+ def backup(
421
+ database: Annotated[
422
+ Path | None,
423
+ typer.Option("--database", "-d", help="Path to zotero.sqlite"),
424
+ ] = None,
425
+ output: Annotated[
426
+ Path | None,
427
+ typer.Option("--output", "-o", help="Output archive path"),
428
+ ] = None,
429
+ ):
430
+ """Back up the Zotero data directory as a .tar.bz2 archive.
431
+
432
+ Examples:
433
+ zotlib backup
434
+ zotlib backup -o ~/backups/zotero-2026-03-02.tar.bz2
435
+ zotlib backup -d /path/to/zotero.sqlite
436
+ """
437
+ db_path = get_database_path(database)
438
+ source_dir = db_path.parent
439
+
440
+ if output is None:
441
+ output = default_backup_path()
442
+
443
+ output.parent.mkdir(parents=True, exist_ok=True)
444
+ create_backup(source_dir, output, console)
445
+
446
+
447
+ if __name__ == "__main__":
448
+ app()