ebk 0.1.0__py3-none-any.whl → 0.3.1__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.

Potentially problematic release.


This version of ebk might be problematic. Click here for more details.

ebk/cli.py CHANGED
@@ -1,13 +1,13 @@
1
1
  import os
2
- import networkx as nx
3
- import subprocess
4
2
  import sys
5
3
  import json
6
4
  import shutil
5
+ import csv
7
6
  from pathlib import Path
8
7
  import logging
9
8
  import re
10
9
  from typing import List, Optional
10
+ from datetime import datetime
11
11
  import typer
12
12
  from rich.console import Console
13
13
  from rich.logging import RichHandler
@@ -15,16 +15,19 @@ from rich.progress import Progress
15
15
  from rich.prompt import Confirm
16
16
  from rich.traceback import install
17
17
  from rich.table import Table
18
- from rich import print_json as print_json_as_table
19
- from rich.json import JSON
20
-
21
- from .exports.hugo import export_hugo
22
- from .exports.zip import export_zipfile
23
- from .imports import ebooks, calibre
24
- from .merge import merge_libraries
25
- from .utils import enumerate_ebooks, load_library, get_unique_filename, search_regex, search_jmes, get_library_statistics, get_index_by_unique_id, print_json_as_table
18
+
19
+ from .decorators import handle_library_errors
26
20
  from .ident import add_unique_id
27
- from .llm import query_llm
21
+
22
+ # Export functions (still available)
23
+ try:
24
+ from .exports.hugo import export_hugo
25
+ from .exports.zip import export_zipfile
26
+ from .exports.jinja_export import JinjaExporter
27
+ except ImportError:
28
+ export_hugo = None
29
+ export_zipfile = None
30
+ JinjaExporter = None
28
31
 
29
32
  # Initialize Rich Traceback for better error messages
30
33
  install(show_locals=True)
@@ -41,839 +44,1896 @@ logging.basicConfig(
41
44
  )
42
45
  logger = logging.getLogger(__name__)
43
46
 
47
+ # Main app
44
48
  app = typer.Typer()
45
49
 
50
+ # Command groups
51
+ export_app = typer.Typer(help="Export library data to various formats")
52
+ vlib_app = typer.Typer(help="Manage virtual libraries (collection views)")
53
+ note_app = typer.Typer(help="Manage book annotations and notes")
54
+
55
+ # Register command groups
56
+ app.add_typer(export_app, name="export")
57
+ app.add_typer(vlib_app, name="vlib")
58
+ app.add_typer(note_app, name="note")
59
+
46
60
  @app.callback()
47
61
  def main(
48
62
  ctx: typer.Context,
49
63
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose mode"),
50
64
  ):
51
65
  """
52
- ebk - A lightweight tool for managing eBook metadata.
66
+ ebk - eBook metadata management tool with SQLAlchemy + SQLite backend.
67
+
68
+ Manage your ebook library with full-text search, automatic text extraction,
69
+ and hash-based deduplication.
53
70
  """
54
71
  if verbose:
55
72
  logger.setLevel(logging.DEBUG)
56
73
  console.print("[bold green]Verbose mode enabled.[/bold green]")
57
74
 
75
+
58
76
  @app.command()
59
- def import_zip(
60
- zip_file: str = typer.Argument(..., help="Path to the Zip file containing the ebk library"),
61
- output_dir: str = typer.Option(None, "--output-dir", "-o", help="Output directory for the ebk library (default: <zip_file>_ebk)"),
77
+ def about():
78
+ """Display information about ebk."""
79
+ console.print("[bold cyan]ebk - eBook Metadata Management Tool[/bold cyan]")
80
+ console.print("")
81
+ console.print("A powerful tool for managing ebook libraries with:")
82
+ console.print(" • SQLAlchemy + SQLite database backend")
83
+ console.print(" • Full-text search (FTS5)")
84
+ console.print(" • Automatic text extraction from PDFs, EPUBs, plaintext")
85
+ console.print(" • Hash-based file deduplication")
86
+ console.print(" • Cover extraction and thumbnails")
87
+ console.print(" • Virtual libraries and personal metadata")
88
+ console.print("")
89
+ console.print("[bold]Core Commands:[/bold]")
90
+ console.print(" ebk init <path> Initialize new library")
91
+ console.print(" ebk import <file> <lib> Import ebook")
92
+ console.print(" ebk import-calibre <src> Import from Calibre")
93
+ console.print(" ebk search <query> <lib> Full-text search")
94
+ console.print(" ebk list <lib> List books")
95
+ console.print(" ebk stats <lib> Show statistics")
96
+ console.print(" ebk view <id> <lib> View book content")
97
+ console.print("")
98
+ console.print("[bold]Command Groups:[/bold]")
99
+ console.print(" ebk export <subcommand> Export library data")
100
+ console.print(" ebk vlib <subcommand> Manage virtual libraries")
101
+ console.print(" ebk note <subcommand> Manage annotations")
102
+ console.print("")
103
+ console.print("[bold]Getting Started:[/bold]")
104
+ console.print(" 1. Initialize: ebk init ~/my-library")
105
+ console.print(" 2. Import: ebk import book.pdf ~/my-library")
106
+ console.print(" 3. Search: ebk search 'python' ~/my-library")
107
+ console.print("")
108
+ console.print("For more info: https://github.com/queelius/ebk")
109
+
110
+
111
+ # ============================================================================
112
+ # Core Library Commands
113
+ # ============================================================================
114
+
115
+ @app.command()
116
+ def init(
117
+ library_path: Path = typer.Argument(..., help="Path to create the library"),
118
+ echo_sql: bool = typer.Option(False, "--echo-sql", help="Echo SQL statements for debugging")
62
119
  ):
63
120
  """
64
- Import an ebk library from a Zip file.
121
+ Initialize a new database-backed library.
122
+
123
+ This creates a new library directory with SQLite database backend,
124
+ including directories for files, covers, and vector embeddings.
125
+
126
+ Example:
127
+ ebk init ~/my-library
65
128
  """
66
- output_dir = output_dir or f"{zip_file.rstrip('.zip')}"
67
- with Progress(console=console) as progress:
68
- task = progress.add_task("[cyan]Importing Zip file...", total=None)
69
- try:
70
- if Path(output_dir).exists():
71
- output_dir = get_unique_filename(output_dir)
72
- with progress:
73
- shutil.unpack_archive(zip_file, output_dir)
74
- progress.update(task, description="[green]Zip file imported successfully!")
75
- logger.info(f"Zip file imported to {output_dir}")
76
- except Exception as e:
77
- progress.update(task, description="[red]Failed to import Zip file.")
78
- logger.error(f"Error importing Zip file: {e}")
79
- raise typer.Exit(code=1)
80
-
129
+ from .library_db import Library
81
130
 
82
- @app.command()
83
- def import_calibre(
84
- calibre_dir: str = typer.Argument(..., help="Path to the Calibre library directory"),
85
- output_dir: str = typer.Option(None, "--output-dir", "-o", help="Output directory for the ebk library (default: <calibre_dir>_ebk)")
131
+ library_path = Path(library_path)
132
+
133
+ if library_path.exists() and any(library_path.iterdir()):
134
+ console.print(f"[yellow]Warning: Directory {library_path} already exists and is not empty[/yellow]")
135
+ if not Confirm.ask("Continue anyway?"):
136
+ raise typer.Exit(code=0)
137
+
138
+ try:
139
+ lib = Library.open(library_path, echo=echo_sql)
140
+ lib.close()
141
+ console.print(f"[green]✓ Library initialized at {library_path}[/green]")
142
+ console.print(f" Database: {library_path / 'library.db'}")
143
+ console.print(f" Use 'ebk import' to add books")
144
+ except Exception as e:
145
+ console.print(f"[red]Error initializing library: {e}[/red]")
146
+ raise typer.Exit(code=1)
147
+
148
+
149
+ @app.command(name="import")
150
+ def import_book(
151
+ file_path: Path = typer.Argument(..., help="Path to ebook file"),
152
+ library_path: Path = typer.Argument(..., help="Path to library"),
153
+ title: Optional[str] = typer.Option(None, "--title", "-t", help="Book title"),
154
+ authors: Optional[str] = typer.Option(None, "--authors", "-a", help="Authors (comma-separated)"),
155
+ subjects: Optional[str] = typer.Option(None, "--subjects", "-s", help="Subjects/tags (comma-separated)"),
156
+ language: str = typer.Option("en", "--language", "-l", help="Language code"),
157
+ no_text: bool = typer.Option(False, "--no-text", help="Skip text extraction"),
158
+ no_cover: bool = typer.Option(False, "--no-cover", help="Skip cover extraction"),
159
+ auto_metadata: bool = typer.Option(True, "--auto-metadata/--no-auto-metadata", help="Extract metadata from file")
86
160
  ):
87
161
  """
88
- Import a Calibre library.
162
+ Import an ebook file into a database-backed library.
163
+
164
+ Extracts metadata, text, and cover images automatically unless disabled.
165
+ Supports PDF, EPUB, MOBI, and plaintext files.
166
+
167
+ Examples:
168
+ ebk import book.pdf ~/my-library
169
+ ebk import book.epub ~/my-library --title "My Book" --authors "Author Name"
89
170
  """
90
- output_dir = output_dir or f"{calibre_dir.rstrip('/')}-ebk"
91
- with Progress(console=console) as progress:
92
- task = progress.add_task("[cyan]Importing Calibre library...", total=None)
93
- try:
94
- calibre.import_calibre(calibre_dir, output_dir)
95
- progress.update(task, description="[green]Calibre library imported successfully!")
96
- logger.info(f"Calibre library imported to {output_dir}")
97
- except Exception as e:
98
- progress.update(task, description="[red]Failed to import Calibre library.")
99
- logger.error(f"Error importing Calibre library: {e}")
100
- raise typer.Exit(code=1)
171
+ from .library_db import Library
172
+ from .extract_metadata import extract_metadata
101
173
 
102
- @app.command()
103
- def import_ebooks(
104
- ebooks_dir: str = typer.Argument(..., help="Path to the directory containing ebook files"),
105
- output_dir: str = typer.Option(None, "--output-dir", "-o", help="Output directory for the ebk library (default: <ebooks_dir>_ebk)"),
106
- ebook_formats: List[str] = typer.Option(
107
- ["pdf", "epub", "mobi", "azw3", "txt", "markdown", "html", "docx", "rtf", "djvu", "fb2", "cbz", "cbr"],
108
- "--ebook-formats", "-f",
109
- help="List of ebook formats to import"
110
- )
174
+ if not file_path.exists():
175
+ console.print(f"[red]Error: File not found: {file_path}[/red]")
176
+ raise typer.Exit(code=1)
177
+
178
+ if not library_path.exists():
179
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
180
+ console.print(f"[yellow]Initialize a library first with: ebk init {library_path}[/yellow]")
181
+ raise typer.Exit(code=1)
182
+
183
+ try:
184
+ lib = Library.open(library_path)
185
+
186
+ # Build metadata dict
187
+ metadata = {}
188
+
189
+ # Auto-extract metadata from file if enabled
190
+ if auto_metadata:
191
+ extracted = extract_metadata(str(file_path))
192
+ metadata.update(extracted)
193
+
194
+ # Override with CLI arguments
195
+ if title:
196
+ metadata['title'] = title
197
+ if authors:
198
+ metadata['creators'] = [a.strip() for a in authors.split(',')]
199
+ if subjects:
200
+ metadata['subjects'] = [s.strip() for s in subjects.split(',')]
201
+ if language:
202
+ metadata['language'] = language
203
+
204
+ # Ensure title exists
205
+ if 'title' not in metadata:
206
+ metadata['title'] = file_path.stem
207
+
208
+ # Import book
209
+ book = lib.add_book(
210
+ file_path,
211
+ metadata,
212
+ extract_text=not no_text,
213
+ extract_cover=not no_cover
214
+ )
215
+
216
+ if book:
217
+ console.print(f"[green]✓ Imported: {book.title}[/green]")
218
+ console.print(f" ID: {book.id}")
219
+ console.print(f" Authors: {', '.join(a.name for a in book.authors)}")
220
+ console.print(f" Files: {len(book.files)}")
221
+ else:
222
+ console.print("[yellow]Import failed or book already exists[/yellow]")
223
+
224
+ lib.close()
225
+
226
+ except Exception as e:
227
+ console.print(f"[red]Error importing book: {e}[/red]")
228
+ logger.exception("Import error details:")
229
+ raise typer.Exit(code=1)
230
+
231
+
232
+ @app.command(name="import-calibre")
233
+ def import_calibre(
234
+ calibre_path: Path = typer.Argument(..., help="Path to Calibre library"),
235
+ library_path: Path = typer.Argument(..., help="Path to ebk library"),
236
+ limit: Optional[int] = typer.Option(None, "--limit", help="Limit number of books to import")
111
237
  ):
112
238
  """
113
- Recursively import a directory of ebooks. The metadata will be inferred from the file.
239
+ Import books from a Calibre library into database-backed library.
240
+
241
+ Reads Calibre's metadata.opf files and imports ebooks with full metadata.
242
+ Supports all Calibre-managed formats (PDF, EPUB, MOBI, etc.).
243
+
244
+ Examples:
245
+ ebk import-calibre ~/Calibre/Library ~/my-library
246
+ ebk import-calibre ~/Calibre/Library ~/my-library --limit 100
114
247
  """
115
- output_dir = output_dir or f"{ebooks_dir.rstrip('/')}-ebk"
116
- with Progress(console=console) as progress:
117
- progress.add_task("[cyan]Importing raw ebooks...", total=None)
118
- try:
119
- ebooks.import_ebooks(ebooks_dir, output_dir, ebook_formats)
120
- except Exception as e:
121
- logger.error(f"Error importing raw ebooks: {e}")
122
- raise typer.Exit(code=1)
248
+ from .library_db import Library
249
+
250
+ if not calibre_path.exists():
251
+ console.print(f"[red]Error: Calibre library not found: {calibre_path}[/red]")
252
+ raise typer.Exit(code=1)
253
+
254
+ if not library_path.exists():
255
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
256
+ console.print(f"[yellow]Initialize a library first with: ebk init {library_path}[/yellow]")
257
+ raise typer.Exit(code=1)
258
+
259
+ try:
260
+ lib = Library.open(library_path)
261
+
262
+ # Find all metadata.opf files
263
+ console.print(f"Scanning Calibre library...")
264
+ opf_files = list(calibre_path.rglob("metadata.opf"))
265
+
266
+ if limit:
267
+ opf_files = opf_files[:limit]
268
+
269
+ console.print(f"Found {len(opf_files)} books in Calibre library")
270
+
271
+ if len(opf_files) == 0:
272
+ console.print("[yellow]No books found. Make sure this is a Calibre library directory.[/yellow]")
273
+ lib.close()
274
+ raise typer.Exit(code=0)
275
+
276
+ imported = 0
277
+ failed = 0
278
+
279
+ with Progress() as progress:
280
+ task = progress.add_task("[green]Importing...", total=len(opf_files))
281
+
282
+ for opf_path in opf_files:
283
+ try:
284
+ book = lib.add_calibre_book(opf_path)
285
+ if book:
286
+ imported += 1
287
+ else:
288
+ failed += 1
289
+ except Exception as e:
290
+ failed += 1
291
+ logger.debug(f"Failed to import {opf_path.parent.name}: {e}")
292
+
293
+ progress.advance(task)
294
+
295
+ console.print(f"[green]✓ Import complete[/green]")
296
+ console.print(f" Successfully imported: {imported}")
297
+ if failed > 0:
298
+ console.print(f" Failed: {failed}")
299
+
300
+ lib.close()
301
+
302
+ except Exception as e:
303
+ console.print(f"[red]Error importing Calibre library: {e}[/red]")
304
+ logger.exception("Calibre import error details:")
305
+ raise typer.Exit(code=1)
306
+
123
307
 
124
308
  @app.command()
125
- def export(
126
- format: str = typer.Argument(..., help="Export format (e.g., 'hugo', 'zip')"),
127
- lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to export (contains `metadata.json` and ebook-related files)"),
128
- destination: Optional[str] = typer.Argument(
129
- None,
130
- help="Destination path (Hugo site directory or Zip file path). If not provided for 'zip' format, defaults to '<lib_dir>.zip' or '<lib_dir> (j).zip' to avoid overwriting."
131
- )
309
+ def search(
310
+ query: str = typer.Argument(..., help="Search query"),
311
+ library_path: Path = typer.Argument(..., help="Path to library"),
312
+ limit: int = typer.Option(20, "--limit", "-n", help="Maximum number of results")
132
313
  ):
133
314
  """
134
- Export the ebk library to the specified format.
135
- """
136
- format = format.lower()
137
- lib_path = Path(lib_dir)
138
-
139
- if not lib_path.exists() or not lib_path.is_dir():
140
- console.print(f"[red]Library directory '{lib_dir}' does not exist or is not a directory.[/red]")
141
- raise typer.Exit(code=1)
142
-
143
- if format == "zip":
144
- # Determine the destination filename
145
- if destination:
146
- dest_path = Path(destination)
147
- if dest_path.exists():
148
- console.print(f"[yellow]Destination '{destination}' already exists. Finding an available filename...[/yellow]")
149
- dest_str = get_unique_filename(destination)
150
- dest_path = Path(dest_str)
151
- console.print(f"[green]Using '{dest_path.name}' as the destination.[/green]")
315
+ Search books in database-backed library using full-text search.
316
+
317
+ Searches across titles, descriptions, and extracted text content using
318
+ SQLite's FTS5 engine for fast, relevance-ranked results.
319
+
320
+ Examples:
321
+ ebk search "python programming" ~/my-library
322
+ ebk search "machine learning" ~/my-library --limit 50
323
+ """
324
+ from .library_db import Library
325
+
326
+ if not library_path.exists():
327
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
328
+ raise typer.Exit(code=1)
329
+
330
+ try:
331
+ lib = Library.open(library_path)
332
+
333
+ results = lib.search(query, limit=limit)
334
+
335
+ if not results:
336
+ console.print(f"[yellow]No results found for: {query}[/yellow]")
152
337
  else:
153
- dest_str = get_unique_filename(lib_dir + ".zip")
154
- dest_path = Path(dest_str)
155
- console.print(f"[bold]No destination provided[/bold]. Using default [bold green]{dest_path.name}.[/bold green]")
156
-
338
+ table = Table(title=f"Search Results: '{query}'")
339
+ table.add_column("ID", style="cyan")
340
+ table.add_column("Title", style="green")
341
+ table.add_column("Authors", style="blue")
342
+ table.add_column("Language", style="magenta")
343
+
344
+ for book in results:
345
+ authors = ", ".join(a.name for a in book.authors[:2])
346
+ if len(book.authors) > 2:
347
+ authors += f" +{len(book.authors) - 2} more"
348
+
349
+ table.add_row(
350
+ str(book.id),
351
+ book.title[:50],
352
+ authors,
353
+ book.language or "?"
354
+ )
157
355
 
158
- with Progress(console=console) as progress:
159
- task = progress.add_task("[cyan]Exporting to Zip...", total=None)
160
- try:
161
- export_zipfile(str(lib_path), str(dest_path))
162
- console.print(f"[bold green]Exported library to '{dest_path}'.[/bold green]")
163
- except Exception as e:
164
- progress.update(task, description="[red]Failed to export to Zip.")
165
- logger.error(f"Error exporting to Zip: {e}")
166
- console.print(f"[bold red]Failed to export to Zip: {e}[/bold red]")
167
- raise typer.Exit(code=1)
168
-
169
- elif format == "hugo":
170
- if not destination:
171
- console.print(f"[red]Destination directory is required for 'hugo' export format.[/red]")
172
- raise typer.Exit(code=1)
173
-
174
- dest_path = Path(destination)
175
- if not dest_path.exists():
176
- try:
177
- dest_path.mkdir(parents=True, exist_ok=True)
178
- console.print(f"[green]Created destination directory '{destination}'.[/green]")
179
- except Exception as e:
180
- console.print(f"[red]Failed to create destination directory '{destination}': {e}[/red]")
181
- raise typer.Exit(code=1)
182
- elif not dest_path.is_dir():
183
- console.print(f"[red]Destination '{destination}' exists and is not a directory.[/red]")
184
- raise typer.Exit(code=1)
185
-
186
- with Progress(console=console) as progress:
187
- task = progress.add_task("[cyan]Exporting to Hugo...", total=None)
188
- try:
189
- export_hugo(str(lib_path), str(dest_path))
190
- progress.update(task, description="[green]Exported to Hugo successfully!")
191
- logger.info(f"Library exported to Hugo at {dest_path}")
192
- console.print(f"[bold green]Exported library to Hugo directory '{dest_path}'.[/bold green]")
193
- except Exception as e:
194
- progress.update(task, description="[red]Failed to export to Hugo.")
195
- logger.error(f"Error exporting to Hugo: {e}")
196
- console.print(f"[bold red]Failed to export to Hugo: {e}[/bold red]")
197
- raise typer.Exit(code=1)
198
-
199
- else:
200
- console.print(f"[red]Unsupported export format: '{format}'. Supported formats are 'zip' and 'hugo'.[/red]")
356
+ console.print(table)
357
+ console.print(f"\n[dim]Showing {len(results)} results[/dim]")
358
+
359
+ lib.close()
360
+
361
+ except Exception as e:
362
+ console.print(f"[red]Error searching library: {e}[/red]")
201
363
  raise typer.Exit(code=1)
202
-
364
+
365
+
203
366
  @app.command()
204
- def show_index(
205
- lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to display"),
206
- indices: list[int] = typer.Argument(..., help="Index of the entry to display"),
207
- output_json: bool = typer.Option(False, "--json", help="Output as JSON")
367
+ def stats(
368
+ library_path: Path = typer.Argument(..., help="Path to library")
208
369
  ):
209
370
  """
210
- Display the index of the ebk library.
371
+ Show statistics for database-backed library.
372
+
373
+ Displays book counts, author counts, language distribution,
374
+ format distribution, and reading progress.
375
+
376
+ Example:
377
+ ebk stats ~/my-library
378
+ """
379
+ from .library_db import Library
380
+
381
+ if not library_path.exists():
382
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
383
+ raise typer.Exit(code=1)
384
+
385
+ try:
386
+ lib = Library.open(library_path)
387
+
388
+ stats = lib.stats()
389
+
390
+ table = Table(title="Library Statistics")
391
+ table.add_column("Metric", style="cyan")
392
+ table.add_column("Count", style="green", justify="right")
393
+
394
+ table.add_row("Total Books", str(stats['total_books']))
395
+ table.add_row("Total Authors", str(stats['total_authors']))
396
+ table.add_row("Total Subjects", str(stats['total_subjects']))
397
+ table.add_row("Total Files", str(stats['total_files']))
398
+ table.add_row("Books Read", str(stats['read_count']))
399
+ table.add_row("Currently Reading", str(stats['reading_count']))
400
+
401
+ console.print(table)
402
+
403
+ # Language distribution
404
+ if stats['languages']:
405
+ console.print("\n[bold]Languages:[/bold]")
406
+ for lang, count in sorted(stats['languages'].items(), key=lambda x: x[1], reverse=True):
407
+ console.print(f" {lang}: {count}")
408
+
409
+ # Format distribution
410
+ if stats['formats']:
411
+ console.print("\n[bold]Formats:[/bold]")
412
+ for fmt, count in sorted(stats['formats'].items(), key=lambda x: x[1], reverse=True):
413
+ console.print(f" {fmt}: {count}")
414
+
415
+ lib.close()
416
+
417
+ except Exception as e:
418
+ console.print(f"[red]Error getting library stats: {e}[/red]")
419
+ raise typer.Exit(code=1)
420
+
211
421
 
212
- Args:
213
- lib_dir (str): Path to the ebk library directory to display
214
- index (int): Index of the entry to display
422
+ @app.command(name="list")
423
+ def list_books(
424
+ library_path: Path = typer.Argument(..., help="Path to library"),
425
+ limit: int = typer.Option(50, "--limit", "-n", help="Maximum number of books to show"),
426
+ offset: int = typer.Option(0, "--offset", help="Starting offset"),
427
+ author: Optional[str] = typer.Option(None, "--author", "-a", help="Filter by author"),
428
+ subject: Optional[str] = typer.Option(None, "--subject", "-s", help="Filter by subject"),
429
+ language: Optional[str] = typer.Option(None, "--language", "-l", help="Filter by language")
430
+ ):
431
+ """
432
+ List books in database-backed library with optional filtering.
215
433
 
434
+ Supports pagination and filtering by author, subject, or language.
216
435
 
217
- Raises:
218
- typer.Exit: If the library directory is invalid or the index is out of range
436
+ Examples:
437
+ ebk list ~/my-library
438
+ ebk list ~/my-library --author "Knuth"
439
+ ebk list ~/my-library --subject "Python" --limit 20
219
440
  """
220
- metadata_list = load_library(lib_dir)
221
- if not metadata_list:
222
- console.print("[red]Failed to load library.[/red]")
441
+ from .library_db import Library
442
+
443
+ if not library_path.exists():
444
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
223
445
  raise typer.Exit(code=1)
224
-
225
- total_books = len(metadata_list)
226
- for index in indices:
227
- if index < 0 or index >= total_books:
228
- console.print(f"[red]Index {index} is out of range (0-{total_books - 1}).[/red]")
229
- raise typer.Exit(code=1)
230
446
 
231
- for index in indices:
232
- entry = metadata_list[index]
233
- if output_json:
234
- console.print_json(json.dumps(entry, indent=2))
235
- else:
236
- # Create a table
237
- table = Table(title="ebk Ebook Entry", show_lines=True)
447
+ try:
448
+ lib = Library.open(library_path)
449
+
450
+ # Build query with filters
451
+ query = lib.query()
452
+
453
+ if author:
454
+ query = query.filter_by_author(author)
455
+ if subject:
456
+ query = query.filter_by_subject(subject)
457
+ if language:
458
+ query = query.filter_by_language(language)
459
+
460
+ query = query.order_by('title').limit(limit).offset(offset)
238
461
 
239
- # Add column headers dynamically based on JSON keys
240
- columns = entry.keys() # Assuming all objects have the same structure
241
- for column in columns:
242
- table.add_column(column, justify="center", style="bold cyan")
462
+ books = query.all()
463
+ total = query.count()
243
464
 
244
- # Add rows dynamically
245
- for item in entry:
246
- table.add_row(*(str(entry[col]) for col in columns))
465
+ if not books:
466
+ console.print("[yellow]No books found[/yellow]")
467
+ else:
468
+ table = Table(title="Books")
469
+ table.add_column("ID", style="cyan")
470
+ table.add_column("Title", style="green")
471
+ table.add_column("Authors", style="blue")
472
+ table.add_column("Language", style="magenta")
473
+ table.add_column("Formats", style="yellow")
474
+
475
+ for book in books:
476
+ authors = ", ".join(a.name for a in book.authors[:2])
477
+ if len(book.authors) > 2:
478
+ authors += f" +{len(book.authors) - 2}"
479
+
480
+ formats = ", ".join(f.format for f in book.files)
481
+
482
+ table.add_row(
483
+ str(book.id),
484
+ book.title[:40],
485
+ authors[:30],
486
+ book.language or "?",
487
+ formats
488
+ )
247
489
 
248
- # Print the table
249
490
  console.print(table)
491
+ console.print(f"\n[dim]Showing {len(books)} of {total} books (offset: {offset})[/dim]")
492
+
493
+ lib.close()
494
+
495
+ except Exception as e:
496
+ console.print(f"[red]Error listing books: {e}[/red]")
497
+ raise typer.Exit(code=1)
498
+
499
+
500
+ # ============================================================================
501
+ # Personal Metadata Commands (Tags, Ratings, Favorites, Annotations)
502
+ # ============================================================================
250
503
 
251
504
  @app.command()
252
- def about():
505
+ def rate(
506
+ book_id: int = typer.Argument(..., help="Book ID"),
507
+ library_path: Path = typer.Argument(..., help="Path to library"),
508
+ rating: float = typer.Option(..., "--rating", "-r", help="Rating (0-5 stars)")
509
+ ):
253
510
  """
254
- Display information about ebk.
511
+ Rate a book (0-5 stars).
512
+
513
+ Example:
514
+ ebk rate 42 ~/my-library --rating 4.5
255
515
  """
256
-
257
- console.print("[bold green]Welcome to ebk![/bold green]\n")
258
- console.print("A lightweight and efficient tool for managing eBook metadata.\n")
516
+ from .library_db import Library
259
517
 
260
- console.print("[bold]Usage:[/bold]")
261
- console.print(" - Run [bold]ebk --help[/bold] for general help.")
262
- console.print(" - Use [bold]ebk <command> --help[/bold] for detailed command-specific help.\n")
518
+ if not library_path.exists():
519
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
520
+ raise typer.Exit(code=1)
521
+
522
+ if not (0 <= rating <= 5):
523
+ console.print(f"[red]Error: Rating must be between 0 and 5[/red]")
524
+ raise typer.Exit(code=1)
525
+
526
+ try:
527
+ lib = Library.open(library_path)
528
+ lib.update_reading_status(book_id, "unread", rating=rating)
529
+
530
+ book = lib.get_book(book_id)
531
+ if book:
532
+ console.print(f"[green]✓ Rated '{book.title}': {rating} stars[/green]")
533
+ else:
534
+ console.print(f"[yellow]Book {book_id} not found[/yellow]")
535
+
536
+ lib.close()
537
+
538
+ except Exception as e:
539
+ console.print(f"[red]Error rating book: {e}[/red]")
540
+ raise typer.Exit(code=1)
263
541
 
264
- console.print("[bold]More Information:[/bold]")
265
- console.print(" 📖 GitHub: [link=https://github.com/queelius/ebk]github.com/queelius/ebk[/link]")
266
- console.print(" 🌐 Website: [link=https://metafunctor.com]metafunctor.com[/link]")
267
- console.print(" 📧 Contact: [link=mailto:lex@metafunctor.com]lex@metafunctor.com[/link]\n")
268
542
 
269
- console.print("Developed by [bold]Alex Towell[/bold]. Enjoy using ebk! 🚀")
270
-
271
543
  @app.command()
272
- def merge(
273
- operation: str = typer.Argument(..., help="Set-theoretic operation to apply (union, intersect, diff, symdiff)"),
274
- output_dir: str = typer.Argument(..., help="Output directory for the merged ebk library"),
275
- libs: List[str] = typer.Argument(..., help="Paths to the source ebk library directories", min=2)
544
+ def favorite(
545
+ book_id: int = typer.Argument(..., help="Book ID"),
546
+ library_path: Path = typer.Argument(..., help="Path to library"),
547
+ unfavorite: bool = typer.Option(False, "--unfavorite", "-u", help="Remove from favorites")
276
548
  ):
277
549
  """
278
- Merge multiple ebk libraries using set-theoretic operations.
550
+ Mark/unmark a book as favorite.
551
+
552
+ Examples:
553
+ ebk favorite 42 ~/my-library
554
+ ebk favorite 42 ~/my-library --unfavorite
555
+ """
556
+ from .library_db import Library
279
557
 
280
- Args:
281
- operation (str): Set-theoretic operation to apply (union, intersect, diff, symdiff)
282
- output_dir (str): Output directory for the merged ebk library
283
- libs (List[str]): Paths to the source ebk library directories
558
+ if not library_path.exists():
559
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
560
+ raise typer.Exit(code=1)
284
561
 
285
- Raises:
286
- typer.Exit: If the library directory is invalid or the index is out of range
562
+ try:
563
+ lib = Library.open(library_path)
564
+ lib.set_favorite(book_id, favorite=not unfavorite)
565
+
566
+ book = lib.get_book(book_id)
567
+ if book:
568
+ action = "Removed from" if unfavorite else "Added to"
569
+ console.print(f"[green]✓ {action} favorites: '{book.title}'[/green]")
570
+ else:
571
+ console.print(f"[yellow]Book {book_id} not found[/yellow]")
572
+
573
+ lib.close()
574
+
575
+ except Exception as e:
576
+ console.print(f"[red]Error updating favorite: {e}[/red]")
577
+ raise typer.Exit(code=1)
287
578
 
288
- Output:
289
- Merges the specified libraries using the set-theoretic operation and saves the result in the output directory.
290
- """
291
- with Progress(console=console) as progress:
292
- task = progress.add_task(f"[cyan]Merging libraries with operation '{operation}'...", total=None)
293
- try:
294
- merge_libraries(libs, output_dir, operation)
295
- progress.update(task, description=f"[green]Libraries merged into {output_dir}")
296
- console.print(f"[bold green]Libraries merged with operation '{operation}' into {output_dir}[/bold green]")
297
- except Exception as e:
298
- progress.update(task, description="[red]Failed to merge libraries.")
299
- logger.error(f"Error merging libraries: {e}")
300
- console.print(f"[bold red]Failed to merge libraries: {e}[/bold red]")
301
- raise typer.Exit(code=1)
302
579
 
303
580
  @app.command()
304
- def stats(
305
- lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to get stats"),
306
- keywords: List[str] = typer.Option(
307
- ["python", "data", "machine learning"],
308
- "--keywords",
309
- "-k",
310
- help="Keywords to search for in titles"
311
- )
581
+ def tag(
582
+ book_id: int = typer.Argument(..., help="Book ID"),
583
+ library_path: Path = typer.Argument(..., help="Path to library"),
584
+ tags: str = typer.Option(..., "--tags", "-t", help="Tags (comma-separated)"),
585
+ remove: bool = typer.Option(False, "--remove", "-r", help="Remove tags instead of adding")
312
586
  ):
313
587
  """
314
- Get statistics about the ebk library.
588
+ Add or remove personal tags from a book.
315
589
 
316
- Args:
317
- lib_dir (str): Path to the ebk library directory to get stats
318
- keywords (List[str]): Keywords to search for in titles
590
+ Examples:
591
+ ebk tag 42 ~/my-library --tags "to-read,programming"
592
+ ebk tag 42 ~/my-library --tags "to-read" --remove
593
+ """
594
+ from .library_db import Library
319
595
 
320
- Raises:
321
- typer.Exit: If the library directory is invalid
596
+ if not library_path.exists():
597
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
598
+ raise typer.Exit(code=1)
322
599
 
323
- Output:
324
- Prints the statistics about the library.
325
- """
326
600
  try:
327
- stats = get_library_statistics(lib_dir, keywords)
328
- console.print_json(json.dumps(stats, indent=2))
601
+ lib = Library.open(library_path)
602
+ tag_list = [t.strip() for t in tags.split(',')]
603
+
604
+ if remove:
605
+ lib.remove_tags(book_id, tag_list)
606
+ action = "Removed tags from"
607
+ else:
608
+ lib.add_tags(book_id, tag_list)
609
+ action = "Added tags to"
610
+
611
+ book = lib.get_book(book_id)
612
+ if book:
613
+ console.print(f"[green]✓ {action} '{book.title}': {', '.join(tag_list)}[/green]")
614
+ else:
615
+ console.print(f"[yellow]Book {book_id} not found[/yellow]")
616
+
617
+ lib.close()
618
+
329
619
  except Exception as e:
330
- logger.error(f"Error generating statistics: {e}")
331
- console.print(f"[bold red]Failed to generate statistics: {e}[/bold red]")
620
+ console.print(f"[red]Error updating tags: {e}[/red]")
332
621
  raise typer.Exit(code=1)
333
-
622
+
623
+
334
624
  @app.command()
335
- def list_indices(
336
- lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to list"),
337
- indices: List[int] = typer.Argument(..., help="Indices of entries to list"),
338
- output_json: bool = typer.Option(False, "--json", help="Output as JSON"),
339
- detailed: bool = typer.Option(False, "--detailed", "-d", help="Show detailed information")):
625
+ def purge(
626
+ library_path: Path = typer.Argument(..., help="Path to library"),
627
+ # Filtering criteria
628
+ no_files: bool = typer.Option(False, "--no-files", help="Purge books with no file attachments"),
629
+ no_supported_formats: bool = typer.Option(False, "--no-supported-formats", help="Purge books without supported ebook formats (pdf, epub, mobi, azw3)"),
630
+ language: Optional[str] = typer.Option(None, "--language", help="Purge books in this language"),
631
+ format_filter: Optional[str] = typer.Option(None, "--format", help="Purge books with this format only"),
632
+ unread: bool = typer.Option(False, "--unread", help="Purge unread books only"),
633
+ max_rating: Optional[int] = typer.Option(None, "--max-rating", help="Purge books with rating <= this (1-5)"),
634
+ author: Optional[str] = typer.Option(None, "--author", help="Purge books by this author (partial match)"),
635
+ subject: Optional[str] = typer.Option(None, "--subject", help="Purge books with this subject (partial match)"),
636
+ # Safety options
637
+ dry_run: bool = typer.Option(True, "--dry-run/--execute", help="Show what would be deleted without deleting"),
638
+ delete_files: bool = typer.Option(False, "--delete-files", help="Also delete associated files from disk")
639
+ ):
340
640
  """
341
- List the entries in the ebk library directory by index.
641
+ Remove books from library based on filtering criteria.
642
+
643
+ By default runs in dry-run mode to show what would be deleted.
644
+ Use --execute to actually perform the deletion.
342
645
 
343
- Args:
344
- lib_dir (str): Path to the ebk library directory to list
345
- indices (List[int]): Indices of entries to list
346
- output_json (bool): Output as JSON
347
- detailed (bool): Show detailed information
646
+ WARNING: This operation cannot be undone!
348
647
 
349
- Raises:
350
- typer.Exit: If the library directory is invalid or the index is out of range
648
+ Examples:
649
+ # Preview books without files
650
+ ebk purge ~/my-library --no-files
351
651
 
352
- Output:
353
- Prints the list of entries in the library directory.
652
+ # Preview books without supported ebook formats
653
+ ebk purge ~/my-library --no-supported-formats
654
+
655
+ # Delete books without files (after confirming)
656
+ ebk purge ~/my-library --no-files --execute
657
+
658
+ # Delete books with only unsupported formats
659
+ ebk purge ~/my-library --no-supported-formats --execute
660
+
661
+ # Delete unread books with rating <= 2 and their files
662
+ ebk purge ~/my-library --unread --max-rating 2 --execute --delete-files
663
+
664
+ # Delete all books in a specific language
665
+ ebk purge ~/my-library --language fr --execute
354
666
  """
355
- lib_path = Path(lib_dir)
356
- if not lib_path.exists():
357
- console.print(f"[bold red]Error:[/bold red] The library directory '{lib_dir}' does not exist.")
358
- sys.exit(1)
667
+ from .library_db import Library
668
+ from rich.table import Table
359
669
 
360
- if not lib_path.is_dir():
361
- console.print(f"[bold red]Error:[/bold red] The path '{lib_dir}' is not a directory.")
362
- sys.exit(1)
670
+ if not library_path.exists():
671
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
672
+ raise typer.Exit(code=1)
363
673
 
364
674
  try:
365
- metadata_list = load_library(lib_dir)
366
- if output_json:
367
- console.print_json(json.dumps(metadata_list, indent=2))
675
+ lib = Library.open(library_path)
676
+
677
+ # Build filtered query
678
+ query = lib.query()
679
+
680
+ # Apply filters
681
+ if language:
682
+ query = query.filter_by_language(language)
683
+ if author:
684
+ query = query.filter_by_author(author)
685
+ if subject:
686
+ query = query.filter_by_subject(subject)
687
+ if format_filter:
688
+ query = query.filter_by_format(format_filter)
689
+ if max_rating is not None:
690
+ # Get books with rating <= max_rating
691
+ query = query.filter_by_rating(0, max_rating)
692
+ if unread:
693
+ # Filter for unread status
694
+ from .db.models import PersonalMetadata
695
+ query.query = query.query.join(PersonalMetadata).filter(
696
+ PersonalMetadata.reading_status == 'unread'
697
+ )
698
+
699
+ books = query.all()
700
+
701
+ # Filter for no files if requested
702
+ if no_files:
703
+ books = [b for b in books if len(b.files) == 0]
704
+
705
+ # Filter for no supported formats if requested
706
+ if no_supported_formats:
707
+ SUPPORTED_FORMATS = {'pdf', 'epub', 'mobi', 'azw3', 'azw', 'djvu', 'fb2', 'txt'}
708
+ books = [
709
+ b for b in books
710
+ if len(b.files) == 0 or not any(f.format.lower() in SUPPORTED_FORMATS for f in b.files)
711
+ ]
712
+
713
+ if not books:
714
+ console.print("[yellow]No books match the specified criteria[/yellow]")
715
+ lib.close()
716
+ return
717
+
718
+ # Display what will be purged
719
+ table = Table(title=f"Books to {'DELETE' if not dry_run else 'purge'} ({len(books)} total)")
720
+ table.add_column("ID", style="cyan")
721
+ table.add_column("Title", style="white")
722
+ table.add_column("Authors", style="blue")
723
+ table.add_column("Files", style="magenta")
724
+ table.add_column("Language", style="green")
725
+
726
+ for book in books[:20]: # Show first 20
727
+ authors = ", ".join(a.name for a in book.authors) or "Unknown"
728
+ files = ", ".join(f.format for f in book.files) or "None"
729
+ table.add_row(
730
+ str(book.id),
731
+ book.title[:50],
732
+ authors[:30],
733
+ files,
734
+ book.language or "?"
735
+ )
736
+
737
+ if len(books) > 20:
738
+ table.add_row("...", f"and {len(books) - 20} more", "", "", "")
739
+
740
+ console.print(table)
741
+
742
+ if dry_run:
743
+ console.print("\n[yellow]This is a DRY RUN - no changes will be made[/yellow]")
744
+ console.print("[yellow]Use --execute to actually delete these books[/yellow]")
745
+ if delete_files:
746
+ console.print("[yellow]--delete-files will also remove files from disk[/yellow]")
368
747
  else:
369
- enumerate_ebooks(metadata_list, lib_path, indices, detailed)
748
+ # Confirm deletion
749
+ console.print("\n[red]WARNING: This will permanently delete these books![/red]")
750
+ if delete_files:
751
+ console.print("[red]This will also DELETE FILES from disk![/red]")
752
+
753
+ confirm = typer.confirm("Are you sure you want to proceed?")
754
+ if not confirm:
755
+ console.print("[yellow]Purge cancelled[/yellow]")
756
+ lib.close()
757
+ return
758
+
759
+ # Perform deletion
760
+ deleted_count = 0
761
+ files_deleted = 0
762
+ total_size = 0
763
+
764
+ for book in books:
765
+ # Delete files from disk if requested
766
+ if delete_files:
767
+ for file in book.files:
768
+ file_path = library_path / file.path
769
+ if file_path.exists():
770
+ total_size += file.size_bytes
771
+ file_path.unlink()
772
+ files_deleted += 1
773
+
774
+ # Delete from database
775
+ lib.session.delete(book)
776
+ deleted_count += 1
777
+
778
+ lib.session.commit()
779
+
780
+ console.print(f"\n[green]✓ Deleted {deleted_count} books from database[/green]")
781
+ if delete_files:
782
+ console.print(f"[green]✓ Deleted {files_deleted} files ({total_size / (1024**2):.1f} MB)[/green]")
783
+
784
+ lib.close()
785
+
370
786
  except Exception as e:
371
- logger.error(f"Error listing ebooks: {e}")
372
- console.print(f"[bold red]Failed to list ebooks: {e}[/bold red]")
787
+ console.print(f"[red]Error during purge: {e}[/red]")
788
+ import traceback
789
+ traceback.print_exc()
373
790
  raise typer.Exit(code=1)
374
791
 
375
- @app.command()
376
- def list(
377
- lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to list"),
378
- output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON")):
379
- """
380
- List the entries in the ebk library directory.
381
-
382
- Args:
383
- lib_dir (str): Path to the ebk library directory to list
384
- output_json (bool): Output as JSON
385
-
386
- Raises:
387
- typer.Exit: If the library directory is invalid
388
792
 
389
- Output:
390
- Prints the list of entries in the library directory.
793
+ @note_app.command(name="add")
794
+ def note_add(
795
+ book_id: int = typer.Argument(..., help="Book ID"),
796
+ library_path: Path = typer.Argument(..., help="Path to library"),
797
+ content: str = typer.Option(..., "--content", "-c", help="Note/annotation text"),
798
+ page: Optional[int] = typer.Option(None, "--page", "-p", help="Page number"),
799
+ note_type: str = typer.Option("note", "--type", "-t", help="Annotation type (note, highlight, bookmark)")
800
+ ):
391
801
  """
392
-
393
- lib_path = Path(lib_dir)
802
+ Add a note/annotation to a book.
394
803
 
395
- if not lib_path.exists():
396
- console.print(f"[bold red]Error:[/bold red] The library directory '{lib_dir}' does not exist.")
397
- sys.exit(1)
804
+ Examples:
805
+ ebk note add 42 ~/my-library --content "Great explanation of algorithms"
806
+ ebk note add 42 ~/my-library --content "Important theorem" --page 42
807
+ ebk note add 42 ~/my-library --content "Key passage" --type highlight
808
+ """
809
+ from .library_db import Library
398
810
 
399
- if not lib_path.is_dir():
400
- console.print(f"[bold red]Error:[/bold red] The path '{lib_dir}' is not a directory.")
401
- sys.exit(1)
811
+ if not library_path.exists():
812
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
813
+ raise typer.Exit(code=1)
402
814
 
403
815
  try:
404
- metadata_list = load_library(lib_dir)
405
- if output_json:
406
- console.print_json(json.dumps(metadata_list, indent=2))
816
+ lib = Library.open(library_path)
817
+ annotation_id = lib.add_annotation(book_id, content, page=page, annotation_type=note_type)
818
+
819
+ book = lib.get_book(book_id)
820
+ if book:
821
+ loc_info = f" (page {page})" if page else ""
822
+ console.print(f"[green]✓ Added {note_type} to '{book.title}'{loc_info}[/green]")
823
+ console.print(f" Annotation ID: {annotation_id}")
407
824
  else:
408
- enumerate_ebooks(metadata_list, lib_path)
825
+ console.print(f"[yellow]Book {book_id} not found[/yellow]")
826
+
827
+ lib.close()
828
+
409
829
  except Exception as e:
410
- logger.error(f"Error listing ebooks: {e}")
411
- console.print(f"[bold red]Failed to list ebooks: {e}[/bold red]")
830
+ console.print(f"[red]Error adding note: {e}[/red]")
412
831
  raise typer.Exit(code=1)
413
832
 
414
- @app.command()
415
- def add(
416
- lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to modify"),
417
- json_file: str = typer.Option(None, "--json", help="JSON file containing entry info to add"),
418
- title: str = typer.Option(None, "--title", help="Title of the entry to add"),
419
- creators: List[str] = typer.Option(None, "--creators", help="Creators of the entry to add"),
420
- ebooks: List[str] = typer.Option(None, "--ebooks", help="Paths to the ebook files to add"),
421
- cover: str = typer.Option(None, "--cover", help="Path to the cover image to add")
833
+
834
+ @note_app.command(name="list")
835
+ def note_list(
836
+ book_id: int = typer.Argument(..., help="Book ID"),
837
+ library_path: Path = typer.Argument(..., help="Path to library")
422
838
  ):
423
839
  """
424
- Add entries to the ebk library.
840
+ List all notes/annotations for a book.
425
841
 
426
- Args:
427
- lib_dir (str): Path to the ebk library directory to modify
428
- json_file (str): Path to a JSON file containing entry info to add
429
- title (str): Title of the entry to add
430
- creators (List[str]): Creators of the entry to add
431
- ebooks (List[str]): Paths to the ebook files to add
432
- cover (str): Path to the cover image to add
842
+ Example:
843
+ ebk note list 42 ~/my-library
844
+ """
845
+ from .library_db import Library
433
846
 
434
- Raises:
435
- typer.Exit: If the library directory is invalid or the entry is invalid
847
+ if not library_path.exists():
848
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
849
+ raise typer.Exit(code=1)
436
850
 
437
- Output:
438
- Adds the specified entry to the library and updates the metadata file in-place.
439
- """
440
851
  try:
441
- metadata_list = load_library(lib_dir)
442
- if not metadata_list:
443
- console.print("[red]Failed to load library.[/red]")
852
+ lib = Library.open(library_path)
853
+ book = lib.get_book(book_id)
854
+
855
+ if not book:
856
+ console.print(f"[yellow]Book {book_id} not found[/yellow]")
857
+ lib.close()
444
858
  raise typer.Exit(code=1)
445
- console.print(f"Loaded [bold]{len(metadata_list)}[/bold] entries from [green]{lib_dir}[/green]")
446
-
447
- if json_file:
448
- with open(json_file, "r") as f:
449
- new_entries = json.load(f)
450
- for entry in new_entries:
451
- add_unique_id(entry)
452
- metadata_list.append(entry)
453
- console.print(f"[green]Added {len(new_entries)} entries from {json_file}[/green]")
859
+
860
+ annotations = lib.get_annotations(book_id)
861
+
862
+ if not annotations:
863
+ console.print(f"[yellow]No notes found for '{book.title}'[/yellow]")
454
864
  else:
455
- if not title or not creators:
456
- console.print("[red]Title and creators are required when not using a JSON file.[/red]")
457
- raise typer.Exit(code=1)
458
- new_entry = {
459
- "title": title,
460
- "creators": creators,
461
- "file_paths": ebooks or [],
462
- "cover_path": cover,
463
- }
464
- add_unique_id(new_entry)
465
- metadata_list.append(new_entry)
466
- console.print(f"Adding new entry: [bold]{new_entry['title']}[/bold]")
467
-
468
- # Save updated metadata
469
- with open(Path(lib_dir) / "metadata.json", "w") as f:
470
- json.dump(metadata_list, f, indent=2)
471
-
472
- # Use Rich's Progress to copy files
473
- with Progress(console=console) as progress:
474
- if ebooks:
475
- task = progress.add_task("[cyan]Copying ebook files...", total=len(ebooks))
476
- for ebook in ebooks:
477
- shutil.copy(ebook, lib_dir)
478
- progress.advance(task)
479
- logger.debug(f"Copied ebook file: {ebook}")
480
- if cover:
481
- task = progress.add_task("[cyan]Copying cover image...", total=1)
482
- shutil.copy(cover, lib_dir)
483
- progress.advance(task)
484
- logger.debug(f"Copied cover image: {cover}")
485
-
486
- console.print(f"[bold green]Added new entry: {new_entry['title']}[/bold green]")
487
-
865
+ console.print(f"\n[bold]Notes for: {book.title}[/bold]\n")
866
+
867
+ for i, ann in enumerate(annotations, 1):
868
+ type_info = f"[{ann.annotation_type}]" if ann.annotation_type else "[note]"
869
+ page_info = f" Page {ann.page_number}" if ann.page_number else ""
870
+ console.print(f"{i}. {type_info}{page_info}")
871
+ console.print(f" {ann.content}")
872
+ console.print(f" [dim]ID: {ann.id} | Added: {ann.created_at.strftime('%Y-%m-%d %H:%M')}[/dim]\n")
873
+
874
+ lib.close()
875
+
488
876
  except Exception as e:
489
- logger.error(f"Error adding entry: {e}")
490
- console.print(f"[bold red]Failed to add entry: {e}[/bold red]")
877
+ console.print(f"[red]Error listing notes: {e}[/red]")
491
878
  raise typer.Exit(code=1)
492
879
 
493
- @app.command()
494
- def remove(
495
- lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to modify"),
496
- regex: str = typer.Argument(..., help="Regex search expression to remove entries"),
497
- force: bool = typer.Option(False, "--force", help="Force removal without confirmation"),
498
- apply_to: List[str] = typer.Option(
499
- ["title"],
500
- "--apply-to",
501
- help="Apply the removal to ebooks, covers, or all files",
502
- show_default=True
503
- )
880
+
881
+ # ============================================================================
882
+ # Export Commands
883
+ # ============================================================================
884
+
885
+ @export_app.command(name="json")
886
+ def export_json(
887
+ library_path: Path = typer.Argument(..., help="Path to library"),
888
+ output_file: Path = typer.Argument(..., help="Output JSON file"),
889
+ include_annotations: bool = typer.Option(True, "--annotations/--no-annotations", help="Include annotations")
504
890
  ):
505
891
  """
506
- Remove entries from the ebk library.
892
+ Export library to JSON format.
507
893
 
508
- Args:
509
- lib_dir (str): Path to the ebk library directory to modify
510
- regex (str): Regex search expression to remove entries
511
- force (bool): Force removal without confirmation
512
- apply_to (List[str]): Apply the removal to ebooks, covers, or all files
894
+ Example:
895
+ ebk export json ~/my-library ~/backup.json
896
+ """
897
+ from .library_db import Library
898
+ import json
513
899
 
514
- Raises:
515
- typer.Exit: If the library directory is invalid or the index is out of range
900
+ if not library_path.exists():
901
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
902
+ raise typer.Exit(code=1)
516
903
 
517
- Output:
518
- Removed entries from the library directory and associated files in-place.
519
- """
520
904
  try:
521
- metadata_list = load_library(lib_dir)
522
- if not metadata_list:
523
- console.print("[red]Failed to load library.[/red]")
524
- raise typer.Exit(code=1)
525
- console.print(f"Loaded [bold]{len(metadata_list)}[/bold] entries from [green]{lib_dir}[/green]")
526
-
527
- rem_list = []
528
- if "title" in apply_to:
529
- rem_list += [entry for entry in metadata_list if re.search(regex, entry.get("title", ""))]
530
- if "creators" in apply_to:
531
- rem_list += [entry for entry in metadata_list if any(re.search(regex, creator) for creator in entry.get("creators", []))]
532
- if "identifiers" in apply_to:
533
- rem_list += [entry for entry in metadata_list if any(re.search(regex, identifier) for identifier in entry.get("identifiers", {}).values())]
534
-
535
- # Remove duplicates based on unique_id
536
- rem_list = list({entry['unique_id']: entry for entry in rem_list}.values())
537
-
538
- if not rem_list:
539
- console.print("[yellow]No matching entries found for removal.[/yellow]")
540
- raise typer.Exit()
541
-
542
- for entry in rem_list:
543
- if not force:
544
- console.print(f"Remove entry: [bold]{entry.get('title', 'No Title')}[/bold]")
545
- confirm = Confirm.ask("Confirm removal?")
546
- if not confirm:
547
- continue
548
-
549
- metadata_list.remove(entry)
550
- console.print(f"[green]Removed entry: {entry.get('title', 'No Title')}[/green]")
551
- logger.debug(f"Removed entry: {entry}")
552
-
553
- with open(Path(lib_dir) / "metadata.json", "w") as f:
554
- json.dump(metadata_list, f, indent=2)
555
-
556
- console.print(f"[bold green]Removed {len(rem_list)} entries from {lib_dir}[/bold green]")
557
-
905
+ lib = Library.open(library_path)
906
+ books = lib.get_all_books()
907
+
908
+ export_data = {
909
+ "exported_at": datetime.now().isoformat(),
910
+ "total_books": len(books),
911
+ "books": []
912
+ }
913
+
914
+ for book in books:
915
+ book_data = {
916
+ "id": book.id,
917
+ "unique_id": book.unique_id,
918
+ "title": book.title,
919
+ "subtitle": book.subtitle,
920
+ "authors": [a.name for a in book.authors],
921
+ "subjects": [s.name for s in book.subjects],
922
+ "language": book.language,
923
+ "publisher": book.publisher,
924
+ "publication_date": book.publication_date,
925
+ "description": book.description,
926
+ "page_count": book.page_count,
927
+ "word_count": book.word_count,
928
+ "files": [{"format": f.format, "size": f.size_bytes, "path": f.path} for f in book.files],
929
+ "created_at": book.created_at.isoformat(),
930
+ }
931
+
932
+ # Add personal metadata if exists
933
+ if book.personal:
934
+ book_data["personal"] = {
935
+ "reading_status": book.personal.reading_status,
936
+ "reading_progress": book.personal.reading_progress,
937
+ "rating": book.personal.rating,
938
+ "favorite": book.personal.favorite,
939
+ "tags": book.personal.personal_tags
940
+ }
941
+
942
+ # Add annotations if requested
943
+ if include_annotations:
944
+ annotations = lib.get_annotations(book.id)
945
+ book_data["annotations"] = [
946
+ {
947
+ "id": ann.id,
948
+ "type": ann.annotation_type,
949
+ "content": ann.content,
950
+ "page": ann.page_number,
951
+ "position": ann.position,
952
+ "created_at": ann.created_at.isoformat()
953
+ }
954
+ for ann in annotations
955
+ ]
956
+
957
+ export_data["books"].append(book_data)
958
+
959
+ # Write JSON file
960
+ with open(output_file, 'w', encoding='utf-8') as f:
961
+ json.dump(export_data, f, indent=2, ensure_ascii=False)
962
+
963
+ console.print(f"[green]✓ Exported {len(books)} books to {output_file}[/green]")
964
+ lib.close()
965
+
558
966
  except Exception as e:
559
- logger.error(f"Error removing entries: {e}")
560
- console.print(f"[bold red]Failed to remove entries: {e}[/bold red]")
967
+ console.print(f"[red]Error exporting to JSON: {e}[/red]")
561
968
  raise typer.Exit(code=1)
562
969
 
563
- @app.command()
564
- def remove_id(lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to modify"),
565
- unique_id: str = typer.Argument(..., help="Unique ID of the entry to remove")):
970
+
971
+ @export_app.command(name="csv")
972
+ def export_csv(
973
+ library_path: Path = typer.Argument(..., help="Path to library"),
974
+ output_file: Path = typer.Argument(..., help="Output CSV file")
975
+ ):
566
976
  """
567
- Remove an entry from the ebk library by unique ID.
977
+ Export library to CSV format.
568
978
 
569
- Args:
570
- lib_dir (str): Path to the ebk library directory to modify
571
- unique_id (str): Unique ID of the entry to remove
979
+ Example:
980
+ ebk export csv ~/my-library ~/books.csv
572
981
  """
573
- id = get_index_by_unique_id(lib_dir, unique_id)
574
- remove_index(lib_dir, [id])
982
+ from .library_db import Library
983
+ import csv
575
984
 
576
-
577
- @app.command()
578
- def update_index(
579
- lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to modify"),
580
- index: int = typer.Argument(..., help="Index of the entry to update"),
581
- json_file: str = typer.Option(None, "--json", help="JSON file containing updated entry info"),
582
- title: str = typer.Option(None, "--title", help="New title for the entry"),
583
- creators: List[str] = typer.Option(None, "--creators", help="New creators for the entry"),
584
- ebooks: List[str] = typer.Option(None, "--ebooks", help="Paths to the new ebook files"),
585
- cover: str = typer.Option(None, "--cover", help="Path to the new cover image")
985
+ if not library_path.exists():
986
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
987
+ raise typer.Exit(code=1)
988
+
989
+ try:
990
+ lib = Library.open(library_path)
991
+ books = lib.get_all_books()
992
+
993
+ # Write CSV file
994
+ with open(output_file, 'w', newline='', encoding='utf-8') as f:
995
+ writer = csv.writer(f)
996
+
997
+ # Header
998
+ writer.writerow([
999
+ 'ID', 'Title', 'Authors', 'Publisher', 'Publication Date',
1000
+ 'Language', 'Subjects', 'Page Count', 'Rating', 'Favorite',
1001
+ 'Reading Status', 'Tags', 'Formats'
1002
+ ])
1003
+
1004
+ # Data
1005
+ for book in books:
1006
+ authors = '; '.join(a.name for a in book.authors)
1007
+ subjects = '; '.join(s.name for s in book.subjects)
1008
+ formats = ', '.join(f.format for f in book.files)
1009
+
1010
+ rating = book.personal.rating if book.personal else None
1011
+ favorite = book.personal.favorite if book.personal else False
1012
+ status = book.personal.reading_status if book.personal else 'unread'
1013
+ tags = ', '.join(book.personal.personal_tags) if book.personal and book.personal.personal_tags else ''
1014
+
1015
+ writer.writerow([
1016
+ book.id,
1017
+ book.title,
1018
+ authors,
1019
+ book.publisher or '',
1020
+ book.publication_date or '',
1021
+ book.language or '',
1022
+ subjects,
1023
+ book.page_count or '',
1024
+ rating or '',
1025
+ favorite,
1026
+ status,
1027
+ tags,
1028
+ formats
1029
+ ])
1030
+
1031
+ console.print(f"[green]✓ Exported {len(books)} books to {output_file}[/green]")
1032
+ lib.close()
1033
+
1034
+ except Exception as e:
1035
+ console.print(f"[red]Error exporting to CSV: {e}[/red]")
1036
+ raise typer.Exit(code=1)
1037
+
1038
+
1039
+ @export_app.command(name="html")
1040
+ def export_html(
1041
+ library_path: Path = typer.Argument(..., help="Path to library"),
1042
+ output_file: Path = typer.Argument(..., help="Output HTML file"),
1043
+ include_stats: bool = typer.Option(True, "--stats/--no-stats", help="Include library statistics"),
1044
+ base_url: str = typer.Option("", "--base-url", help="Base URL for file links (e.g., '/library' or 'https://example.com/books')"),
1045
+ copy_files: bool = typer.Option(False, "--copy", help="Copy referenced files to output directory"),
1046
+ # Filtering options
1047
+ language: Optional[str] = typer.Option(None, "--language", help="Filter by language code (e.g., 'en', 'es')"),
1048
+ author: Optional[str] = typer.Option(None, "--author", help="Filter by author name (partial match)"),
1049
+ subject: Optional[str] = typer.Option(None, "--subject", help="Filter by subject/tag (partial match)"),
1050
+ format_filter: Optional[str] = typer.Option(None, "--format", help="Filter by file format (e.g., 'pdf', 'epub')"),
1051
+ has_files: bool = typer.Option(True, "--has-files/--no-files", help="Only include books with file attachments"),
1052
+ favorite: Optional[bool] = typer.Option(None, "--favorite", help="Filter by favorite status"),
1053
+ min_rating: Optional[int] = typer.Option(None, "--min-rating", help="Minimum rating (1-5)"),
586
1054
  ):
587
1055
  """
588
- Update an entry in the ebk library by index.
1056
+ Export library to a self-contained HTML5 file.
1057
+
1058
+ Creates an interactive, searchable, filterable catalog that works offline.
1059
+ All metadata including contributors, series, keywords, etc. is preserved.
1060
+
1061
+ File links are included in the export. Use --base-url to set the URL prefix for files
1062
+ when deploying to a web server (e.g., Hugo site).
1063
+
1064
+ Use --copy to copy only the referenced files to the output directory, avoiding duplication
1065
+ of the entire library.
589
1066
 
590
- Args:
591
- lib_dir (str): Path to the ebk library directory to modify
592
- index (int): Index of the entry to update
593
- json_file (str): Path to a JSON file containing updated entry info
594
- title (str): New title for the entry
595
- creators (List[str]): New creators for the entry
596
- ebooks (List[str]): Paths to the new ebook files
597
- cover (str): Path to the new cover image
1067
+ Examples:
1068
+ # Basic export with relative paths
1069
+ ebk export html ~/my-library ~/library.html
1070
+
1071
+ # Export for Hugo deployment with file copying
1072
+ ebk export html ~/my-library ~/hugo/static/library.html \\
1073
+ --base-url /library --copy
1074
+
1075
+ # Export only English PDFs rated 4+
1076
+ ebk export html ~/my-library ~/library.html \\
1077
+ --language en --format pdf --min-rating 4
598
1078
  """
1079
+ from .library_db import Library
1080
+ from .exports.html_library import export_to_html
1081
+ import shutil
1082
+
1083
+ if not library_path.exists():
1084
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1085
+ raise typer.Exit(code=1)
599
1086
 
600
1087
  try:
601
- metadata_list = load_library(lib_dir)
602
- if not metadata_list:
603
- console.print("[red]Failed to load library.[/red]")
604
- raise typer.Exit(code=1)
605
- console.print(f"Loaded [bold]{len(metadata_list)}[/bold] entries from [green]{lib_dir}[/green]")
1088
+ console.print("[blue]Exporting library to HTML...[/blue]")
1089
+ lib = Library.open(library_path)
1090
+
1091
+ # Build filtered query
1092
+ query = lib.query()
1093
+
1094
+ # Apply filters
1095
+ if language:
1096
+ query = query.filter_by_language(language)
1097
+ if author:
1098
+ query = query.filter_by_author(author)
1099
+ if subject:
1100
+ query = query.filter_by_subject(subject)
1101
+ if format_filter:
1102
+ query = query.filter_by_format(format_filter)
1103
+ if favorite is not None:
1104
+ query = query.filter_by_favorite(favorite)
1105
+ if min_rating:
1106
+ query = query.filter_by_rating(min_rating)
1107
+
1108
+ books = query.all()
1109
+
1110
+ # Filter out books without files if requested
1111
+ if has_files:
1112
+ books = [b for b in books if len(b.files) > 0]
1113
+
1114
+ if not books:
1115
+ console.print("[yellow]No books match the specified filters[/yellow]")
1116
+ lib.close()
1117
+ return
1118
+
1119
+ # Copy files if requested
1120
+ if copy_files:
1121
+ output_dir = output_file.parent
1122
+ if not base_url:
1123
+ console.print("[yellow]Warning: --copy requires --base-url to be set[/yellow]")
1124
+ console.print("[yellow]Files will be copied but may not resolve correctly[/yellow]")
1125
+
1126
+ # Determine copy destination
1127
+ if base_url.startswith(('http://', 'https://')):
1128
+ console.print("[red]Error: --copy cannot be used with full URLs in --base-url[/red]")
1129
+ console.print("[red]Use a relative path like '/library' instead[/red]")
1130
+ lib.close()
1131
+ raise typer.Exit(code=1)
1132
+
1133
+ # Copy files to output_dir / base_url (stripping leading /)
1134
+ copy_dest = output_dir / base_url.lstrip('/')
1135
+ copy_dest.mkdir(parents=True, exist_ok=True)
1136
+
1137
+ console.print(f"[blue]Copying files to {copy_dest}...[/blue]")
1138
+ files_copied = 0
1139
+ covers_copied = 0
1140
+ total_size = 0
1141
+
1142
+ for book in books:
1143
+ # Copy ebook files
1144
+ for file in book.files:
1145
+ src = library_path / file.path
1146
+ dest = copy_dest / file.path
1147
+
1148
+ if src.exists():
1149
+ dest.parent.mkdir(parents=True, exist_ok=True)
1150
+ shutil.copy2(src, dest)
1151
+ files_copied += 1
1152
+ total_size += file.size_bytes
1153
+
1154
+ # Copy cover images
1155
+ for cover in book.covers:
1156
+ src = library_path / cover.path
1157
+ dest = copy_dest / cover.path
1158
+
1159
+ if src.exists():
1160
+ dest.parent.mkdir(parents=True, exist_ok=True)
1161
+ shutil.copy2(src, dest)
1162
+ covers_copied += 1
1163
+ total_size += src.stat().st_size
1164
+
1165
+ console.print(f"[green]✓ Copied {files_copied} files and {covers_copied} covers ({total_size / (1024**2):.1f} MB)[/green]")
1166
+
1167
+ export_to_html(books, output_file, include_stats=include_stats, base_url=base_url)
1168
+
1169
+ console.print(f"[green]✓ Exported {len(books)} books to {output_file}[/green]")
1170
+ if base_url:
1171
+ console.print(f" File links will use base URL: {base_url}")
1172
+ console.print(f" Open {output_file} in a web browser to view your library")
1173
+ lib.close()
606
1174
 
607
- if json_file:
608
- with open(json_file, "r") as f:
609
- updated_entry = json.load(f)
610
- else:
611
- updated_entry = metadata_list[index]
612
- if title:
613
- updated_entry["title"] = title
614
- if creators:
615
- updated_entry["creators"] = creators
616
- if ebooks:
617
- updated_entry["file_paths"] = ebooks
618
- if cover:
619
- updated_entry["cover_path"] = cover
620
-
621
- metadata_list[index] = updated_entry
622
- with open(Path(lib_dir) / "metadata.json", "w") as f:
623
- json.dump(metadata_list, f, indent=2)
624
-
625
- console.print(f"[bold green]Updated entry at index {index} in {lib_dir}[/bold green]")
626
1175
  except Exception as e:
627
- logger.error(f"Error updating entry by index: {e}")
628
- console.print(f"[bold red]Failed to update entry by index: {e}[/bold red]")
1176
+ console.print(f"[red]Error exporting to HTML: {e}[/red]")
1177
+ import traceback
1178
+ traceback.print_exc()
629
1179
  raise typer.Exit(code=1)
630
1180
 
631
- @app.command()
632
- def update_id(
633
- lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to modify"),
634
- unique_id: str = typer.Argument(..., help="Unique ID of the entry to update"),
635
- json_file: str = typer.Option(None, "--json", help="JSON file containing updated entry info"),
636
- title: str = typer.Option(None, "--title", help="New title for the entry"),
637
- creators: List[str] = typer.Option(None, "--creators", help="New creators for the entry"),
638
- ebooks: List[str] = typer.Option(None, "--ebooks", help="Paths to the new ebook files"),
639
- cover: str = typer.Option(None, "--cover", help="Path to the new cover image")
1181
+
1182
+ @vlib_app.command(name="add")
1183
+ def vlib_add(
1184
+ book_id: int = typer.Argument(..., help="Book ID"),
1185
+ library_path: Path = typer.Argument(..., help="Path to library"),
1186
+ vlib: str = typer.Option(..., "--library", "-l", help="Virtual library name (e.g., 'computer-science', 'mathematics')")
640
1187
  ):
641
1188
  """
642
- Update an entry in the ebk library by unique id.
1189
+ Add a book to a virtual library (collection view).
1190
+
1191
+ Virtual libraries allow organizing books into multiple collections.
1192
+ A book can belong to multiple virtual libraries.
643
1193
 
644
- Args:
645
- lib_dir (str): Path to the ebk library directory to modify
646
- id: str: Unique ID of the entry to update
647
- json_file (str): Path to a JSON file containing updated entry info
648
- title (str): New title for the entry
649
- creators (List[str]): New creators for the entry
650
- ebooks (List[str]): Paths to the new ebook files
651
- cover (str): Path to the new cover image
1194
+ Examples:
1195
+ ebk vlib add 1 ~/my-library --library computer-science
1196
+ ebk vlib add 1 ~/my-library -l mathematics
652
1197
  """
1198
+ from .library_db import Library
653
1199
 
654
- id = lambda entry: entry.get("unique_id")
655
- index = get_index_by_unique_id(lib_dir, id)
656
- if index == -1:
657
- console.print(f"[red]Entry with unique ID [bold]{unique_id}[/bold] not found.[/red]")
1200
+ if not library_path.exists():
1201
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
658
1202
  raise typer.Exit(code=1)
659
-
660
- update_index(lib_dir, index, json_file, title, creators, ebooks, cover)
661
1203
 
662
- @app.command()
663
- def remove_index(
664
- lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to modify"),
665
- indices: List[int] = typer.Argument(..., help="Indices of entries to remove")
1204
+ try:
1205
+ lib = Library.open(library_path)
1206
+ lib.add_to_virtual_library(book_id, vlib)
1207
+
1208
+ book = lib.get_book(book_id)
1209
+ if book:
1210
+ console.print(f"[green]✓ Added '{book.title}' to virtual library '{vlib}'[/green]")
1211
+
1212
+ lib.close()
1213
+
1214
+ except Exception as e:
1215
+ console.print(f"[red]Error: {e}[/red]")
1216
+ raise typer.Exit(code=1)
1217
+
1218
+
1219
+ @vlib_app.command(name="remove")
1220
+ def vlib_remove(
1221
+ book_id: int = typer.Argument(..., help="Book ID"),
1222
+ library_path: Path = typer.Argument(..., help="Path to library"),
1223
+ vlib: str = typer.Option(..., "--library", "-l", help="Virtual library name")
666
1224
  ):
667
1225
  """
668
- Remove entries from the ebk library by index.
1226
+ Remove a book from a virtual library.
669
1227
 
670
- Args:
671
- lib_dir (str): Path to the ebk library directory to modify
672
- indices (List[int]): Indices of entries to remove
1228
+ Example:
1229
+ ebk vlib remove 1 ~/my-library --library computer-science
1230
+ """
1231
+ from .library_db import Library
673
1232
 
674
- Raises:
675
- typer.Exit: If the library directory is invalid or the index is out of range
1233
+ if not library_path.exists():
1234
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1235
+ raise typer.Exit(code=1)
676
1236
 
677
- Output:
678
- Removes the specified entries from the library and updates the metadata file in-place.
679
- """
680
1237
  try:
681
- metadata_list = load_library(lib_dir)
682
- if not metadata_list:
683
- console.print("[red]Failed to load library.[/red]")
684
- raise typer.Exit(code=1)
685
- console.print(f"Loaded [bold]{len(metadata_list)}[/bold] entries from [green]{lib_dir}[/green]")
686
-
687
- indices = sorted(indices, reverse=True)
688
- with Progress(console=console) as progress:
689
- task = progress.add_task("[cyan]Removing entries...", total=len(indices))
690
- removed_count = 0
691
- for i in indices:
692
- if 0 <= i < len(metadata_list):
693
- del metadata_list[i]
694
- progress.advance(task)
695
- logger.debug(f"Removed entry at index {i}")
696
- removed_count += 1
697
- else:
698
- console.print(f"[yellow]Index {i} is out of range.[/yellow]")
1238
+ lib = Library.open(library_path)
1239
+ lib.remove_from_virtual_library(book_id, vlib)
699
1240
 
700
- with open(Path(lib_dir) / "metadata.json", "w") as f:
701
- json.dump(metadata_list, f, indent=2)
1241
+ book = lib.get_book(book_id)
1242
+ if book:
1243
+ console.print(f"[green]✓ Removed '{book.title}' from virtual library '{vlib}'[/green]")
702
1244
 
703
- console.print(f"[bold green]Removed {removed_count} entries from {lib_dir}[/bold green]")
1245
+ lib.close()
704
1246
 
705
1247
  except Exception as e:
706
- logger.error(f"Error removing entries by index: {e}")
707
- console.print(f"[bold red]Failed to remove entries: {e}[/bold red]")
1248
+ console.print(f"[red]Error: {e}[/red]")
708
1249
  raise typer.Exit(code=1)
709
1250
 
710
- @app.command()
711
- def dash(
712
- port: int = typer.Option(8501, "--port", help="Port to run the Streamlit app (default: 8501)")
1251
+
1252
+ @vlib_app.command(name="list")
1253
+ def vlib_list(
1254
+ library_path: Path = typer.Argument(..., help="Path to library"),
1255
+ vlib: Optional[str] = typer.Option(None, "--library", "-l", help="Show books in specific virtual library")
713
1256
  ):
714
1257
  """
715
- Launch the Streamlit dashboard.
1258
+ List all virtual libraries or books in a specific virtual library.
1259
+
1260
+ Examples:
1261
+ ebk vlib list ~/my-library # List all virtual libraries
1262
+ ebk vlib list ~/my-library --library mathematics # List books in 'mathematics'
716
1263
  """
1264
+ from .library_db import Library
1265
+
1266
+ if not library_path.exists():
1267
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1268
+ raise typer.Exit(code=1)
1269
+
717
1270
  try:
718
- app_path = Path(__file__).parent / 'streamlit' / 'app.py'
719
-
720
- if not app_path.exists():
721
- console.print(f"[bold red]Streamlit app not found at {app_path}[/bold red]")
722
- raise typer.Exit(code=1)
723
-
724
- subprocess.run(
725
- ['streamlit', 'run', str(app_path), "--server.port", str(port)],
726
- check=True
727
- )
728
- logger.info(f"Streamlit dashboard launched on port {port}")
729
- except FileNotFoundError:
730
- console.print("[bold red]Error:[/bold red] Streamlit is not installed. Please install it with `pip install streamlit`.")
731
- raise typer.Exit(code=1)
732
- except subprocess.CalledProcessError as e:
733
- logger.error(f"Error launching Streamlit dashboard: {e}")
734
- console.print(f"[bold red]Failed to launch Streamlit dashboard: {e}[/bold red]")
735
- raise typer.Exit(code=e.returncode)
1271
+ lib = Library.open(library_path)
1272
+
1273
+ if vlib:
1274
+ # Show books in this virtual library
1275
+ books = lib.get_virtual_library(vlib)
1276
+
1277
+ if not books:
1278
+ console.print(f"[yellow]No books found in virtual library '{vlib}'[/yellow]")
1279
+ else:
1280
+ console.print(f"\n[bold]Virtual Library: {vlib}[/bold] ({len(books)} books)\n")
1281
+
1282
+ table = Table(show_header=True, header_style="bold magenta")
1283
+ table.add_column("ID", style="dim")
1284
+ table.add_column("Title")
1285
+ table.add_column("Authors")
1286
+
1287
+ for book in books:
1288
+ authors = ", ".join(a.name for a in book.authors[:2])
1289
+ if len(book.authors) > 2:
1290
+ authors += "..."
1291
+
1292
+ table.add_row(
1293
+ str(book.id),
1294
+ book.title[:50] + "..." if len(book.title) > 50 else book.title,
1295
+ authors
1296
+ )
1297
+
1298
+ console.print(table)
1299
+ else:
1300
+ # List all virtual libraries
1301
+ libraries = lib.list_virtual_libraries()
1302
+
1303
+ if not libraries:
1304
+ console.print("[yellow]No virtual libraries found[/yellow]")
1305
+ console.print("[dim]Use 'ebk vlib add' to create virtual libraries[/dim]")
1306
+ else:
1307
+ console.print(f"\n[bold]Virtual Libraries[/bold] ({len(libraries)} total)\n")
1308
+
1309
+ for vlib_name in libraries:
1310
+ books = lib.get_virtual_library(vlib_name)
1311
+ console.print(f" • {vlib_name} ({len(books)} books)")
1312
+
1313
+ lib.close()
1314
+
736
1315
  except Exception as e:
737
- logger.error(f"Unexpected error launching Streamlit dashboard: {e}")
738
- console.print(f"[bold red]An unexpected error occurred: {e}[/bold red]")
1316
+ console.print(f"[red]Error: {e}[/red]")
739
1317
  raise typer.Exit(code=1)
740
1318
 
1319
+
741
1320
  @app.command()
742
- def regex(
743
- query: str = typer.Argument(..., help="Regex search expression."),
744
- lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to search"),
745
- json_out: bool = typer.Option(False, "--json", "-j", help="Output search results as JSON"),
746
- fields: List[str] = typer.Option(["title"], "--fields", "-f", help="Fields to search in (default: title)")):
1321
+ def view(
1322
+ book_id: int = typer.Argument(..., help="Book ID to view"),
1323
+ library_path: Path = typer.Argument(..., help="Path to library"),
1324
+ text: bool = typer.Option(False, "--text", help="Display extracted text in console"),
1325
+ page: Optional[int] = typer.Option(None, "--page", help="View specific page (for text mode)"),
1326
+ format_choice: Optional[str] = typer.Option(None, "--format", help="Choose specific format (pdf, epub, txt, etc.)")
1327
+ ):
747
1328
  """
748
- Search entries in an ebk library using a regex expression on specified fields.
1329
+ View a book's content.
749
1330
 
750
- Args:
751
- query (str): Regex search expression
752
- lib_dir (str): Path to the ebk library directory to search
753
- json_out (bool): Output search results as JSON
754
- fields (List[str]): Fields to search in (default: title)
755
-
756
- Returns:
757
- Search results as a table or JSON
1331
+ Without --text: Opens the ebook file in the default application.
1332
+ With --text: Displays extracted text in the console with paging.
758
1333
  """
1334
+ import subprocess
1335
+ import platform
1336
+ from .library_db import Library
1337
+ from .db.models import ExtractedText
1338
+
759
1339
  try:
760
- results = search_regex(lib_dir, query, fields)
761
- if json_out:
762
- console.print_json(json.dumps(results, indent=2))
1340
+ lib = Library.open(library_path)
1341
+ book = lib.get_book(book_id)
1342
+
1343
+ if not book:
1344
+ console.print(f"[red]Book with ID {book_id} not found[/red]")
1345
+ lib.close()
1346
+ raise typer.Exit(code=1)
1347
+
1348
+ if text:
1349
+ # Display extracted text in console
1350
+ # ExtractedText is linked to File, not Book directly
1351
+ extracted_text = None
1352
+ for file in book.files:
1353
+ if file.extracted_text and file.extracted_text.content:
1354
+ extracted_text = file.extracted_text.content
1355
+ break
1356
+
1357
+ if not extracted_text:
1358
+ console.print(f"[yellow]No extracted text available for '{book.title}'[/yellow]")
1359
+ console.print("[dim]Try re-importing the book with text extraction enabled[/dim]")
1360
+ lib.close()
1361
+ raise typer.Exit(code=1)
1362
+
1363
+ # Display book info
1364
+ console.print(f"\n[bold blue]{book.title}[/bold blue]")
1365
+ if book.authors:
1366
+ console.print(f"[dim]by {', '.join(a.name for a in book.authors)}[/dim]")
1367
+ console.print()
1368
+
1369
+ # If page specified, try to show just that page
1370
+ # (This is approximate - we don't have exact page boundaries)
1371
+ if page is not None:
1372
+ # Estimate ~400 words per page
1373
+ words = extracted_text.split()
1374
+ words_per_page = 400
1375
+ start_idx = (page - 1) * words_per_page
1376
+ end_idx = start_idx + words_per_page
1377
+
1378
+ if start_idx >= len(words):
1379
+ console.print(f"[yellow]Page {page} exceeds document length[/yellow]")
1380
+ lib.close()
1381
+ raise typer.Exit(code=1)
1382
+
1383
+ page_words = words[start_idx:end_idx]
1384
+ text_content = ' '.join(page_words)
1385
+ console.print(f"[dim]Approximate page {page} (words {start_idx+1}-{end_idx}):[/dim]\n")
1386
+ console.print(text_content)
1387
+ else:
1388
+ # Show full text with paging
1389
+ # Use rich pager for long text
1390
+ with console.pager(styles=True):
1391
+ console.print(extracted_text)
763
1392
  else:
764
- enumerate_ebooks(results, Path(lib_dir))
1393
+ # Open file in default application
1394
+ if not book.files:
1395
+ console.print(f"[yellow]No files available for '{book.title}'[/yellow]")
1396
+ lib.close()
1397
+ raise typer.Exit(code=1)
1398
+
1399
+ # Select file to open
1400
+ file_to_open = None
1401
+ if format_choice:
1402
+ # Find file matching requested format
1403
+ for f in book.files:
1404
+ if f.format.lower() == format_choice.lower():
1405
+ file_to_open = f
1406
+ break
1407
+ if not file_to_open:
1408
+ console.print(f"[yellow]No {format_choice} file found for this book[/yellow]")
1409
+ console.print(f"Available formats: {', '.join(f.format for f in book.files)}")
1410
+ lib.close()
1411
+ raise typer.Exit(code=1)
1412
+ else:
1413
+ # Use first file (prefer PDF > EPUB > others)
1414
+ formats_priority = ['pdf', 'epub', 'mobi', 'azw3', 'txt']
1415
+ for fmt in formats_priority:
1416
+ for f in book.files:
1417
+ if f.format.lower() == fmt:
1418
+ file_to_open = f
1419
+ break
1420
+ if file_to_open:
1421
+ break
1422
+
1423
+ if not file_to_open:
1424
+ file_to_open = book.files[0]
1425
+
1426
+ file_path = library_path / file_to_open.path
1427
+
1428
+ if not file_path.exists():
1429
+ console.print(f"[red]File not found: {file_path}[/red]")
1430
+ lib.close()
1431
+ raise typer.Exit(code=1)
1432
+
1433
+ console.print(f"[blue]Opening '{book.title}' ({file_to_open.format})[/blue]")
1434
+
1435
+ # Open with default application based on OS
1436
+ system = platform.system()
1437
+ try:
1438
+ if system == 'Darwin': # macOS
1439
+ subprocess.run(['open', str(file_path)], check=True)
1440
+ elif system == 'Windows':
1441
+ subprocess.run(['start', '', str(file_path)], shell=True, check=True)
1442
+ else: # Linux and others
1443
+ subprocess.run(['xdg-open', str(file_path)], check=True)
1444
+
1445
+ console.print("[green]✓ File opened successfully[/green]")
1446
+ except subprocess.CalledProcessError as e:
1447
+ console.print(f"[red]Failed to open file: {e}[/red]")
1448
+ console.print(f"[dim]File location: {file_path}[/dim]")
1449
+ lib.close()
1450
+ raise typer.Exit(code=1)
1451
+
1452
+ lib.close()
1453
+
765
1454
  except Exception as e:
766
- logger.error(f"Error searching library with regex: {e}")
767
- console.print(f"[bold red]Failed to search library with regex: {e}[/bold red]")
1455
+ console.print(f"[red]Error viewing book: {e}[/red]")
768
1456
  raise typer.Exit(code=1)
769
1457
 
1458
+
770
1459
  @app.command()
771
- def jmespath(
772
- lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to query"),
773
- query: str = typer.Argument(..., help="JMESPath query string to search in the library"),
774
- json_out: bool = typer.Option(False, "--json", "-j", help="Output search results as JSON")):
1460
+ def serve(
1461
+ library_path: Optional[Path] = typer.Argument(None, help="Path to library (defaults from config)"),
1462
+ host: Optional[str] = typer.Option(None, "--host", help="Host to bind to (defaults from config)"),
1463
+ port: Optional[int] = typer.Option(None, "--port", help="Port to bind to (defaults from config)"),
1464
+ reload: bool = typer.Option(False, "--reload", help="Enable auto-reload for development"),
1465
+ no_open: bool = typer.Option(False, "--no-open", help="Don't auto-open browser")
1466
+ ):
775
1467
  """
776
- Query the ebk library using JMESPath.
1468
+ Start the web server for library management.
1469
+
1470
+ Provides a browser-based interface for managing your ebook library.
1471
+ Access the interface at http://localhost:8000 (or the specified host/port).
1472
+
1473
+ Configuration:
1474
+ Default server settings are loaded from ~/.config/ebk/config.json
1475
+ Command-line options override config file values.
1476
+ Use 'ebk config' to set default library path and server settings.
777
1477
 
778
- Args:
779
- lib_dir (str): Path to the ebk library directory to query
780
- query (str): JMESPath query string to search in the library
781
- output_json (bool): Output search results as JSON
1478
+ Examples:
1479
+ # Start server with configured defaults
1480
+ ebk serve
782
1481
 
783
- Returns:
784
- JMEPSath query results, either pretty printed or as JSON.
1482
+ # Override config for one-time use
1483
+ ebk serve ~/my-library --port 8080
1484
+
1485
+ # Start with auto-reload (development)
1486
+ ebk serve --reload
785
1487
  """
786
- try:
787
- results = search_jmes(lib_dir, query)
788
- if json_out:
789
- console.print_json(json.dumps(results, indent=2))
1488
+ from ebk.config import load_config
1489
+ import webbrowser
1490
+
1491
+ # Load config
1492
+ config = load_config()
1493
+
1494
+ # Resolve library path
1495
+ if library_path is None:
1496
+ if config.library.default_path:
1497
+ library_path = Path(config.library.default_path)
790
1498
  else:
791
- print_json_as_table(results)
1499
+ console.print("[red]Error: No library path specified[/red]")
1500
+ console.print("[yellow]Either provide a path or set default with:[/yellow]")
1501
+ console.print("[yellow] ebk config --library-path ~/my-library[/yellow]")
1502
+ raise typer.Exit(code=1)
1503
+
1504
+ if not library_path.exists():
1505
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1506
+ raise typer.Exit(code=1)
1507
+
1508
+ # Resolve host and port
1509
+ server_host = host if host is not None else config.server.host
1510
+ server_port = port if port is not None else config.server.port
1511
+ auto_open = config.server.auto_open_browser and not no_open
1512
+
1513
+ try:
1514
+ import uvicorn
1515
+ except ImportError:
1516
+ console.print("[red]Error: uvicorn is not installed[/red]")
1517
+ console.print("[yellow]Install with: pip install uvicorn[/yellow]")
1518
+ raise typer.Exit(code=1)
1519
+
1520
+ try:
1521
+ from .server import create_app
1522
+
1523
+ console.print(f"[blue]Starting ebk server...[/blue]")
1524
+ console.print(f"[blue]Library: {library_path}[/blue]")
1525
+ console.print(f"[green]Server running at http://{server_host}:{server_port}[/green]")
1526
+ console.print("[dim]Press Ctrl+C to stop[/dim]")
1527
+
1528
+ # Auto-open browser
1529
+ if auto_open:
1530
+ # Use localhost for browser even if binding to 0.0.0.0
1531
+ browser_host = "localhost" if server_host == "0.0.0.0" else server_host
1532
+ url = f"http://{browser_host}:{server_port}"
1533
+ console.print(f"[dim]Opening browser to {url}...[/dim]")
1534
+ webbrowser.open(url)
1535
+
1536
+ # Create app with library
1537
+ app_instance = create_app(library_path)
1538
+
1539
+ # Run server
1540
+ uvicorn.run(
1541
+ app_instance,
1542
+ host=server_host,
1543
+ port=server_port,
1544
+ reload=reload,
1545
+ log_level="info"
1546
+ )
1547
+
1548
+ except KeyboardInterrupt:
1549
+ console.print("\n[yellow]Server stopped[/yellow]")
792
1550
  except Exception as e:
793
- logger.error(f"Error querying library with JMESPath: {e}")
794
- console.print(f"[bold red]Failed to query library with JMESPath: {e}[/bold red]")
1551
+ console.print(f"[red]Error starting server: {e}[/red]")
1552
+ import traceback
1553
+ traceback.print_exc()
795
1554
  raise typer.Exit(code=1)
796
1555
 
1556
+
797
1557
  @app.command()
798
- def llm(
799
- lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to query"),
800
- query: str = typer.Argument(..., help="Query string to search in the library")
1558
+ def enrich(
1559
+ library_path: Path = typer.Argument(..., help="Path to library"),
1560
+ provider: Optional[str] = typer.Option(None, help="LLM provider (ollama, openai) - defaults from config"),
1561
+ model: Optional[str] = typer.Option(None, help="Model name - defaults from config"),
1562
+ host: Optional[str] = typer.Option(None, help="Ollama host (for remote GPU) - defaults from config"),
1563
+ port: Optional[int] = typer.Option(None, help="Ollama port - defaults from config"),
1564
+ api_key: Optional[str] = typer.Option(None, help="API key (for OpenAI)"),
1565
+ book_id: Optional[int] = typer.Option(None, help="Enrich specific book ID only"),
1566
+ generate_tags: bool = typer.Option(True, help="Generate tags"),
1567
+ categorize: bool = typer.Option(True, help="Categorize books"),
1568
+ enhance_descriptions: bool = typer.Option(False, help="Enhance descriptions"),
1569
+ assess_difficulty: bool = typer.Option(False, help="Assess difficulty levels"),
1570
+ dry_run: bool = typer.Option(False, help="Show what would be done without saving"),
801
1571
  ):
802
1572
  """
803
- Query the ebk library using the LLM (Large Language Model) endpoint.
1573
+ Enrich book metadata using LLM.
1574
+
1575
+ Uses LLM to generate tags, categorize books, enhance descriptions,
1576
+ and assess difficulty levels based on existing metadata and extracted text.
1577
+
1578
+ Configuration:
1579
+ Default LLM settings are loaded from ~/.config/ebk/config.json
1580
+ Command-line options override config file values.
1581
+ Use 'ebk config' to view/edit your default configuration.
804
1582
 
805
- Args:
806
- lib_dir (str): Path to the ebk library directory to query
807
- query (str): Natural language query to interact with the library
1583
+ Examples:
1584
+ # Enrich all books using configured defaults
1585
+ ebk enrich ~/my-library
808
1586
 
809
- Returns:
810
- LLM query results
1587
+ # Override config to use different host
1588
+ ebk enrich ~/my-library --host 192.168.1.100
1589
+
1590
+ # Enrich specific book
1591
+ ebk enrich ~/my-library --book-id 42
1592
+
1593
+ # Generate tags and descriptions
1594
+ ebk enrich ~/my-library --enhance-descriptions
1595
+
1596
+ # Dry run to see what would be generated
1597
+ ebk enrich ~/my-library --dry-run
811
1598
  """
1599
+ import asyncio
1600
+ from ebk.library_db import Library
1601
+ from ebk.ai.llm_providers.ollama import OllamaProvider
1602
+ from ebk.ai.llm_providers.base import LLMConfig
1603
+ from ebk.ai.metadata_enrichment import MetadataEnrichmentService
1604
+ from ebk.config import load_config
1605
+
1606
+ if not library_path.exists():
1607
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1608
+ raise typer.Exit(code=1)
1609
+
1610
+ # Load configuration and apply CLI overrides
1611
+ config = load_config()
1612
+ llm_cfg = config.llm
1613
+
1614
+ # Override config with CLI options if provided
1615
+ if provider is not None:
1616
+ llm_cfg.provider = provider
1617
+ if model is not None:
1618
+ llm_cfg.model = model
1619
+ if host is not None:
1620
+ llm_cfg.host = host
1621
+ if port is not None:
1622
+ llm_cfg.port = port
1623
+ if api_key is not None:
1624
+ llm_cfg.api_key = api_key
1625
+
1626
+ console.print(f"[dim]Using provider: {llm_cfg.provider}[/dim]")
1627
+ console.print(f"[dim]Model: {llm_cfg.model}[/dim]")
1628
+ console.print(f"[dim]Host: {llm_cfg.host}:{llm_cfg.port}[/dim]")
1629
+
1630
+ async def enrich_library():
1631
+ # Initialize LLM provider
1632
+ console.print(f"[blue]Initializing {llm_cfg.provider} provider...[/blue]")
1633
+
1634
+ if llm_cfg.provider == "ollama":
1635
+ llm_provider = OllamaProvider.remote(
1636
+ host=llm_cfg.host,
1637
+ port=llm_cfg.port,
1638
+ model=llm_cfg.model,
1639
+ temperature=llm_cfg.temperature
1640
+ )
1641
+ elif llm_cfg.provider == "openai":
1642
+ if not llm_cfg.api_key:
1643
+ console.print("[red]Error: API key required for OpenAI (use --api-key or set in config)[/red]")
1644
+ raise typer.Exit(code=1)
1645
+ config = LLMConfig(
1646
+ base_url="https://api.openai.com/v1",
1647
+ api_key=llm_cfg.api_key,
1648
+ model=llm_cfg.model
1649
+ )
1650
+ # Would need OpenAI provider implementation
1651
+ console.print("[red]OpenAI provider not yet implemented[/red]")
1652
+ raise typer.Exit(code=1)
1653
+ else:
1654
+ console.print(f"[red]Unknown provider: {llm_cfg.provider}[/red]")
1655
+ raise typer.Exit(code=1)
1656
+
1657
+ # Initialize provider
1658
+ await llm_provider.initialize()
1659
+
1660
+ try:
1661
+ # Test connection by listing models
1662
+ models = await llm_provider.list_models()
1663
+ console.print(f"[green]Connected! Available models: {', '.join(models[:5])}[/green]")
1664
+
1665
+ # Initialize service
1666
+ service = MetadataEnrichmentService(llm_provider)
1667
+
1668
+ # Open library
1669
+ console.print(f"[blue]Opening library: {library_path}[/blue]")
1670
+ lib = Library.open(library_path)
1671
+
1672
+ try:
1673
+ # Get books to process
1674
+ if book_id:
1675
+ books = [lib.get_book(book_id)]
1676
+ if not books[0]:
1677
+ console.print(f"[red]Book ID {book_id} not found[/red]")
1678
+ raise typer.Exit(code=1)
1679
+ else:
1680
+ books = lib.query().all()
1681
+
1682
+ console.print(f"[blue]Processing {len(books)} books...[/blue]")
1683
+
1684
+ with Progress() as progress:
1685
+ task = progress.add_task("Enriching metadata...", total=len(books))
1686
+
1687
+ for book in books:
1688
+ progress.console.print(f"\n[cyan]Processing: {book.title}[/cyan]")
1689
+
1690
+ # Get extracted text if available
1691
+ text_sample = None
1692
+ if book.files:
1693
+ for file in book.files:
1694
+ if file.extracted_text and file.extracted_text.content:
1695
+ text_sample = file.extracted_text.content[:5000]
1696
+ break
1697
+
1698
+ # Generate tags
1699
+ if generate_tags:
1700
+ progress.console.print(" Generating tags...")
1701
+ tags = await service.generate_tags(
1702
+ title=book.title,
1703
+ authors=[a.name for a in book.authors],
1704
+ subjects=[s.name for s in book.subjects],
1705
+ description=book.description,
1706
+ text_sample=text_sample
1707
+ )
1708
+
1709
+ if tags:
1710
+ progress.console.print(f" [green]Tags: {', '.join(tags)}[/green]")
1711
+ if not dry_run:
1712
+ lib.add_tags(book.id, tags)
1713
+
1714
+ # Categorize
1715
+ if categorize:
1716
+ progress.console.print(" Categorizing...")
1717
+ categories = await service.categorize(
1718
+ title=book.title,
1719
+ subjects=[s.name for s in book.subjects],
1720
+ description=book.description
1721
+ )
1722
+
1723
+ if categories:
1724
+ progress.console.print(f" [green]Categories: {', '.join(categories)}[/green]")
1725
+ if not dry_run:
1726
+ # Add categories as subjects
1727
+ for cat in categories:
1728
+ lib.add_subject(book.id, cat)
1729
+
1730
+ # Enhance description
1731
+ if enhance_descriptions and (not book.description or len(book.description) < 100):
1732
+ progress.console.print(" Enhancing description...")
1733
+ description = await service.enhance_description(
1734
+ title=book.title,
1735
+ existing_description=book.description,
1736
+ text_sample=text_sample
1737
+ )
1738
+
1739
+ if description and description != book.description:
1740
+ progress.console.print(f" [green]New description: {description[:100]}...[/green]")
1741
+ if not dry_run:
1742
+ book.description = description
1743
+ lib.session.commit()
1744
+
1745
+ # Assess difficulty
1746
+ if assess_difficulty and text_sample:
1747
+ progress.console.print(" Assessing difficulty...")
1748
+ difficulty = await service.assess_difficulty(
1749
+ text_sample=text_sample,
1750
+ subjects=[s.name for s in book.subjects]
1751
+ )
1752
+
1753
+ progress.console.print(f" [green]Difficulty: {difficulty}[/green]")
1754
+ # Could store in keywords or custom field
1755
+
1756
+ progress.update(task, advance=1)
1757
+
1758
+ if dry_run:
1759
+ console.print("\n[yellow]Dry run completed - no changes saved[/yellow]")
1760
+ else:
1761
+ lib.session.commit()
1762
+ console.print("\n[green]Enrichment completed![/green]")
1763
+
1764
+ finally:
1765
+ lib.close()
1766
+
1767
+ finally:
1768
+ await llm_provider.cleanup()
1769
+
1770
+ # Run async function
812
1771
  try:
813
- query_llm(lib_dir, query)
1772
+ asyncio.run(enrich_library())
1773
+ except KeyboardInterrupt:
1774
+ console.print("\n[yellow]Enrichment cancelled[/yellow]")
814
1775
  except Exception as e:
815
- logger.error(f"Error querying library with LLM: {e}")
816
- console.print(f"[bold red]Failed to query library with LLM: {e}[/bold red]")
1776
+ console.print(f"[red]Enrichment failed: {e}[/red]")
1777
+ import traceback
1778
+ traceback.print_exc()
817
1779
  raise typer.Exit(code=1)
818
1780
 
1781
+
819
1782
  @app.command()
820
- def visualize(lib_dir: str = typer.Argument(..., help="Path to the ebk library directory to generate a complex network"),
821
- output_file: str = typer.Option(None, "--output-file", "-o", help="Output file for the graph visualization"),
822
- pretty_stats: bool = typer.Option(True, "--stats", "-s", help="Pretty print complex network statistics"),
823
- json_stats: bool = typer.Option(False, "--json-stats", "-j", help="Output complex network statistics as JSON")):
824
-
825
- """
826
- Generate a complex network visualization from the ebk library.
827
-
828
- Args:
829
- lib_dir (str): Path to the ebk library directory to generate a complex network
830
- output_file (str): Output file for the graph visualization
831
- pretty_stats (bool): Pretty print complex network statistics
832
- json_stats (bool): Output complex network statistics as JSON
833
-
834
- Returns:
835
- Complex network visualization and statistics
836
- """
837
-
838
- if output_file and not output_file.endswith(('.html', '.png', '.json')):
839
- logging.error("Output file must be either an HTML file, PNG file, or JSON file.")
840
- sys.exit(1)
841
-
842
- if not os.path.isdir(lib_dir):
843
- logging.error(f"The specified library directory '{lib_dir}' does not exist or is not a directory.")
844
- sys.exit(1)
845
-
846
- metadata_list = load_library(lib_dir)
847
- if not metadata_list:
848
- logging.error(f"No metadata found in the library directory '{lib_dir}'.")
849
- sys.exit(1)
850
-
851
- net = visualize.generate_complex_network(metadata_list)
852
-
853
- if output_file:
854
- if output_file.endswith('.html'):
855
- # Interactive visualization with pyvis
856
- visualize.as_pyvis(net, output_file)
857
- elif output_file.endswith('.json'):
858
- net_json = nx.node_link_data(net) # Convert to node-link format
859
- console.print(JSON(json.dumps(net_json, indent=2)))
860
- elif output_file.endswith('.png'):
861
- visualize.as_png(net, output_file)
862
-
863
- if pretty_stats:
864
- console.print(nx.info(net))
865
- # console.print(f"[bold green]Complex network generated successfully![/bold green]")
866
- # console.print(f"Nodes: {net.number_of_nodes()}")
867
- # console.print(f"Edges: {net.number_of_edges()}")
868
- # console.print(f"Average Degree: {np.mean([d for n, d in net.degree()])}")
869
- # console.print(f"Average Clustering Coefficient: {nx.average_clustering(net)}")
870
- # console.print(f"Transitivity: {nx.transitivity(net)}")
871
- # console.print(f"Average Shortest Path Length: {nx.average_shortest_path_length(net)}")
872
- # console.print(f"Global Clustering Coefficient: {nx.transitivity(net)}")
873
- # console.print(f"Global Efficiency: {nx.global_efficiency(net)}")
874
- # console.print(f"Modularity: {community.modularity(community.best_partition(net), net)}")
875
- if json_stats:
876
- console.print_json(json.dumps(nx.info(net), indent=2))
1783
+ def config(
1784
+ show: bool = typer.Option(False, "--show", help="Show current configuration"),
1785
+ init: bool = typer.Option(False, "--init", help="Initialize config file with defaults"),
1786
+ # LLM settings
1787
+ set_provider: Optional[str] = typer.Option(None, "--llm-provider", help="Set LLM provider (ollama, openai)"),
1788
+ set_model: Optional[str] = typer.Option(None, "--llm-model", help="Set default model name"),
1789
+ set_llm_host: Optional[str] = typer.Option(None, "--llm-host", help="Set Ollama/LLM host"),
1790
+ set_llm_port: Optional[int] = typer.Option(None, "--llm-port", help="Set Ollama/LLM port"),
1791
+ set_api_key: Optional[str] = typer.Option(None, "--llm-api-key", help="Set LLM API key"),
1792
+ set_temperature: Optional[float] = typer.Option(None, "--llm-temperature", help="Set temperature (0.0-1.0)"),
1793
+ # Server settings
1794
+ set_server_host: Optional[str] = typer.Option(None, "--server-host", help="Set web server host"),
1795
+ set_server_port: Optional[int] = typer.Option(None, "--server-port", help="Set web server port"),
1796
+ set_auto_open: Optional[bool] = typer.Option(None, "--server-auto-open/--no-server-auto-open", help="Auto-open browser on server start"),
1797
+ # Library settings
1798
+ set_library_path: Optional[str] = typer.Option(None, "--library-path", help="Set default library path"),
1799
+ # CLI settings
1800
+ set_verbose: Optional[bool] = typer.Option(None, "--cli-verbose/--no-cli-verbose", help="Enable verbose output by default"),
1801
+ set_color: Optional[bool] = typer.Option(None, "--cli-color/--no-cli-color", help="Enable colored output by default"),
1802
+ ):
1803
+ """
1804
+ View or edit EBK configuration.
1805
+
1806
+ Configuration is stored at ~/.config/ebk/config.json (or ~/.ebk/config.json).
1807
+
1808
+ Examples:
1809
+ # Show current configuration
1810
+ ebk config --show
1811
+
1812
+ # Initialize config file with defaults
1813
+ ebk config --init
1814
+
1815
+ # Set default library path
1816
+ ebk config --library-path ~/my-library
1817
+
1818
+ # Set remote Ollama host
1819
+ ebk config --llm-host 192.168.0.225
1820
+
1821
+ # Set server to auto-open browser
1822
+ ebk config --server-auto-open --server-host 0.0.0.0
1823
+
1824
+ # Set multiple values
1825
+ ebk config --llm-host 192.168.0.225 --llm-model llama3.2 --server-port 9000
1826
+ """
1827
+ from ebk.config import (
1828
+ load_config, save_config, ensure_config_exists,
1829
+ update_config, get_config_path
1830
+ )
1831
+ import json
1832
+
1833
+ # Handle --init
1834
+ if init:
1835
+ config_path = ensure_config_exists()
1836
+ console.print(f"[green]Configuration initialized at {config_path}[/green]")
1837
+ return
1838
+
1839
+ # Check if any settings provided
1840
+ has_settings = any([
1841
+ set_provider, set_model, set_llm_host, set_llm_port, set_api_key, set_temperature,
1842
+ set_server_host, set_server_port, set_auto_open is not None,
1843
+ set_library_path, set_verbose is not None, set_color is not None
1844
+ ])
1845
+
1846
+ # Handle --show or no args (default to show)
1847
+ if show or not has_settings:
1848
+ config = load_config()
1849
+ config_path = get_config_path()
1850
+
1851
+ console.print(f"\n[bold]EBK Configuration[/bold]")
1852
+ console.print(f"[dim]Location: {config_path}[/dim]\n")
1853
+
1854
+ console.print("[bold cyan]Library Settings:[/bold cyan]")
1855
+ if config.library.default_path:
1856
+ console.print(f" Default Path: {config.library.default_path}")
1857
+ else:
1858
+ console.print(f" Default Path: [dim]not set[/dim]")
1859
+
1860
+ console.print("\n[bold cyan]LLM Settings:[/bold cyan]")
1861
+ console.print(f" Provider: {config.llm.provider}")
1862
+ console.print(f" Model: {config.llm.model}")
1863
+ console.print(f" Host: {config.llm.host}")
1864
+ console.print(f" Port: {config.llm.port}")
1865
+ console.print(f" Temperature: {config.llm.temperature}")
1866
+ if config.llm.api_key:
1867
+ masked = f"{config.llm.api_key[:4]}...{config.llm.api_key[-4:]}"
1868
+ console.print(f" API Key: {masked}")
1869
+ else:
1870
+ console.print(f" API Key: [dim]not set[/dim]")
1871
+
1872
+ console.print("\n[bold cyan]Server Settings:[/bold cyan]")
1873
+ console.print(f" Host: {config.server.host}")
1874
+ console.print(f" Port: {config.server.port}")
1875
+ console.print(f" Auto-open: {config.server.auto_open_browser}")
1876
+ console.print(f" Page Size: {config.server.page_size}")
1877
+
1878
+ console.print("\n[bold cyan]CLI Settings:[/bold cyan]")
1879
+ console.print(f" Verbose: {config.cli.verbose}")
1880
+ console.print(f" Color: {config.cli.color}")
1881
+ console.print(f" Page Size: {config.cli.page_size}")
1882
+
1883
+ console.print(f"\n[dim]Edit with: ebk config --library-path <path> --llm-host <host> etc.[/dim]")
1884
+ console.print(f"[dim]Or edit directly: {config_path}[/dim]\n")
1885
+ return
1886
+
1887
+ # Handle setting values
1888
+ changes = []
1889
+
1890
+ if set_provider is not None:
1891
+ changes.append(f"LLM provider: {set_provider}")
1892
+ if set_model is not None:
1893
+ changes.append(f"LLM model: {set_model}")
1894
+ if set_llm_host is not None:
1895
+ changes.append(f"LLM host: {set_llm_host}")
1896
+ if set_llm_port is not None:
1897
+ changes.append(f"LLM port: {set_llm_port}")
1898
+ if set_api_key is not None:
1899
+ changes.append("LLM API key: ****")
1900
+ if set_temperature is not None:
1901
+ changes.append(f"LLM temperature: {set_temperature}")
1902
+ if set_server_host is not None:
1903
+ changes.append(f"Server host: {set_server_host}")
1904
+ if set_server_port is not None:
1905
+ changes.append(f"Server port: {set_server_port}")
1906
+ if set_auto_open is not None:
1907
+ changes.append(f"Server auto-open: {set_auto_open}")
1908
+ if set_library_path is not None:
1909
+ changes.append(f"Library path: {set_library_path}")
1910
+ if set_verbose is not None:
1911
+ changes.append(f"CLI verbose: {set_verbose}")
1912
+ if set_color is not None:
1913
+ changes.append(f"CLI color: {set_color}")
1914
+
1915
+ if changes:
1916
+ console.print("[blue]Updating configuration:[/blue]")
1917
+ for change in changes:
1918
+ console.print(f" • {change}")
1919
+
1920
+ update_config(
1921
+ llm_provider=set_provider,
1922
+ llm_model=set_model,
1923
+ llm_host=set_llm_host,
1924
+ llm_port=set_llm_port,
1925
+ llm_api_key=set_api_key,
1926
+ llm_temperature=set_temperature,
1927
+ server_host=set_server_host,
1928
+ server_port=set_server_port,
1929
+ server_auto_open=set_auto_open,
1930
+ library_default_path=set_library_path,
1931
+ cli_verbose=set_verbose,
1932
+ cli_color=set_color,
1933
+ )
1934
+ console.print("[green]✓ Configuration updated![/green]")
1935
+ console.print("[dim]Use 'ebk config --show' to view current settings[/dim]")
1936
+
877
1937
 
878
1938
  if __name__ == "__main__":
879
1939
  app()