bookcli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bookcli/cli.py ADDED
@@ -0,0 +1,752 @@
1
+ """Command-line interface (CLI) for BookCLI using Typer and Rich."""
2
+
3
+ import builtins
4
+ import asyncio
5
+ import csv
6
+ import json
7
+ import logging
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Optional
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+ from rich.panel import Panel
16
+ from rich.theme import Theme
17
+
18
+ from bookcli.settings import DB_PATH
19
+ from bookcli.config import load_config, save_config, AppConfig
20
+ from bookcli.database import DatabaseManager
21
+ from bookcli.exceptions import BookCLIError, BookNotFoundError
22
+ from bookcli.models import BookMetadata
23
+ from bookcli.cache import get_book_from_cache, clear_cache_db, get_cache_db_stats
24
+ from bookcli.opener import open_file_in_default_app
25
+ from bookcli.utils import format_size, clean_filename
26
+ from bookcli.services.search import search_books
27
+ from bookcli.services.history import get_search_history, clear_search_history
28
+
29
+ # Reconfigure standard output streams to UTF-8 to prevent charmap encoding errors on Windows
30
+ if hasattr(sys.stdout, "reconfigure"):
31
+ try:
32
+ sys.stdout.reconfigure(encoding="utf-8")
33
+ except Exception:
34
+ pass
35
+ if hasattr(sys.stderr, "reconfigure"):
36
+ try:
37
+ sys.stderr.reconfigure(encoding="utf-8")
38
+ except Exception:
39
+ pass
40
+
41
+ # Global rich console
42
+ console = Console()
43
+
44
+ # Initialize Typer App
45
+ app = typer.Typer(
46
+ name="book",
47
+ help="BookCLI: Search, inspect, and legally download books from public sources.",
48
+ no_args_is_help=True
49
+ )
50
+
51
+ logger = logging.getLogger("bookcli")
52
+
53
+
54
+ async def resolve_book_id(db_manager: DatabaseManager, user_input: str) -> str:
55
+ """Resolves short numeric session index or checks if it is a full string ID."""
56
+ if user_input.isdigit():
57
+ idx = int(user_input)
58
+ async with db_manager.connection() as conn:
59
+ async with conn.execute(
60
+ "SELECT book_id FROM search_results_session WHERE result_index = ?", (idx,)
61
+ ) as cursor:
62
+ row = await cursor.fetchone()
63
+ if row:
64
+ return row[0]
65
+ raise BookNotFoundError(
66
+ f"Numeric index '{idx}' not found in the last search results session. "
67
+ "Please run a search first or provide a full provider ID (e.g. gutenberg:1342)."
68
+ )
69
+ return user_input
70
+
71
+
72
+ # --- Business Logic Subroutines for Commands and Interactive Mode ---
73
+
74
+ async def show_info_internal(db_manager: DatabaseManager, book_id_input: str, config: AppConfig) -> None:
75
+ """Subroutine to display book metadata."""
76
+ book_id = await resolve_book_id(db_manager, book_id_input)
77
+
78
+ book = await get_book_from_cache(db_manager, book_id, config.cache_ttl_seconds)
79
+
80
+ if not book:
81
+ prefix = book_id.split(":", 1)[0]
82
+ with console.status(f"[bold green]Fetching details from {prefix}..."):
83
+ try:
84
+ from bookcli.providers.google_books import GoogleBooksProvider
85
+ from bookcli.providers.openlibrary import OpenLibraryProvider
86
+ from bookcli.providers.gutenberg import GutenbergProvider
87
+ from bookcli.providers.internet_archive import InternetArchiveProvider
88
+
89
+ provider_map = {
90
+ "google": GoogleBooksProvider(config.timeout_seconds),
91
+ "openlibrary": OpenLibraryProvider(config.timeout_seconds),
92
+ "gutenberg": GutenbergProvider(config.timeout_seconds),
93
+ "internet_archive": InternetArchiveProvider(config.timeout_seconds)
94
+ }
95
+
96
+ provider = provider_map.get(prefix)
97
+ if not provider:
98
+ raise BookCLIError(f"Unsupported provider prefix '{prefix}'.")
99
+
100
+ book = await provider.get_book(book_id)
101
+ except Exception as e:
102
+ raise BookCLIError(f"Error fetching book from provider: {e}")
103
+
104
+ if not book:
105
+ raise BookNotFoundError(f"Book with ID '{book_id}' could not be resolved.")
106
+
107
+ info_table = Table.grid(padding=1)
108
+ info_table.add_column(style="bold yellow", justify="right", width=18)
109
+ info_table.add_column()
110
+
111
+ info_table.add_row("Title:", book.title)
112
+ if book.subtitle:
113
+ info_table.add_row("Subtitle:", book.subtitle)
114
+ info_table.add_row("Author(s):", ", ".join(book.authors) if book.authors else "Unknown")
115
+ if book.publisher:
116
+ info_table.add_row("Publisher:", book.publisher)
117
+ if book.published_year:
118
+ info_table.add_row("Published Year:", str(book.published_year))
119
+ if book.pages:
120
+ info_table.add_row("Pages:", str(book.pages))
121
+ if book.isbn:
122
+ info_table.add_row("ISBN:", book.isbn)
123
+ if book.language:
124
+ info_table.add_row("Language:", book.language)
125
+ if book.cover_url:
126
+ info_table.add_row("Cover URL:", book.cover_url)
127
+
128
+ avail_str = "[green]Available[/green]" if book.download_availability else "[red]Not Available[/red]"
129
+ info_table.add_row("Download Status:", avail_str)
130
+ if book.download_url:
131
+ info_table.add_row("Download Link:", book.download_url)
132
+ info_table.add_row("File Format:", book.file_format or "Unknown")
133
+
134
+ info_table.add_row("Provider Source:", book.source)
135
+
136
+ if book.description:
137
+ desc_panel = Panel(book.description, title="Description", border_style="dim")
138
+ else:
139
+ desc_panel = ""
140
+
141
+ main_panel = Panel(
142
+ info_table,
143
+ title=f"Book Details: {book.id}",
144
+ border_style="green",
145
+ expand=False
146
+ )
147
+
148
+ console.print(main_panel)
149
+ if desc_panel:
150
+ console.print(desc_panel)
151
+
152
+
153
+ async def download_book_internal(
154
+ db_manager: DatabaseManager,
155
+ book_id_input: str,
156
+ config: AppConfig,
157
+ custom_path: Optional[Path] = None
158
+ ) -> str:
159
+ """Subroutine to download a book. Returns the checksum on success, raises DownloadError on failure."""
160
+ book_id = await resolve_book_id(db_manager, book_id_input)
161
+
162
+ # For internet_archive, we always query get_book to resolve the exact filename
163
+ # rather than using the guessed URL from search results cached during search.
164
+ prefix = book_id.split(":", 1)[0]
165
+ book = None
166
+ if prefix == "internet_archive":
167
+ try:
168
+ from bookcli.providers.internet_archive import InternetArchiveProvider
169
+ provider = InternetArchiveProvider(config.timeout_seconds)
170
+ book = await provider.get_book(book_id)
171
+ if book:
172
+ from bookcli.cache import save_book_to_cache
173
+ await save_book_to_cache(db_manager, book)
174
+ except Exception as e:
175
+ console.print(f"[yellow]IA Metadata resolution warning: {e}[/yellow]")
176
+
177
+ if not book:
178
+ book = await get_book_from_cache(db_manager, book_id, config.cache_ttl_seconds)
179
+
180
+ if not book:
181
+ try:
182
+ from bookcli.providers.google_books import GoogleBooksProvider
183
+ from bookcli.providers.openlibrary import OpenLibraryProvider
184
+ from bookcli.providers.gutenberg import GutenbergProvider
185
+ from bookcli.providers.internet_archive import InternetArchiveProvider
186
+
187
+ provider_map = {
188
+ "google": GoogleBooksProvider(config.timeout_seconds),
189
+ "openlibrary": OpenLibraryProvider(config.timeout_seconds),
190
+ "gutenberg": GutenbergProvider(config.timeout_seconds),
191
+ "internet_archive": InternetArchiveProvider(config.timeout_seconds)
192
+ }
193
+ provider = provider_map.get(prefix)
194
+ if provider:
195
+ book = await provider.get_book(book_id)
196
+ except Exception:
197
+ pass
198
+
199
+ if not book:
200
+ raise BookNotFoundError(f"Could not resolve book metadata for ID '{book_id}'.")
201
+
202
+ if not book.download_availability or not book.download_url:
203
+ raise BookCLIError("No legal download URL is available for this book.")
204
+
205
+ safe_title = clean_filename(book.title)
206
+ ext = f".{book.file_format}" if book.file_format else ".epub"
207
+ dest_filename = f"{safe_title}_{book.id.replace(':', '_')}{ext}"
208
+ if custom_path:
209
+ custom_path_obj = Path(custom_path)
210
+ custom_path_str = str(custom_path_obj)
211
+ if os.path.isdir(custom_path_str) or custom_path_str.endswith("/") or custom_path_str.endswith("\\") or not custom_path_obj.suffix:
212
+ dest_path = os.path.join(custom_path_str, dest_filename)
213
+ else:
214
+ dest_path = custom_path_str
215
+ else:
216
+ dest_path = os.path.join(config.download_dir, dest_filename)
217
+
218
+ # Ensure parent directory exists
219
+ dest_dir = os.path.dirname(dest_path)
220
+ if dest_dir:
221
+ os.makedirs(dest_dir, exist_ok=True)
222
+
223
+ console.print(f"Starting download to: [cyan]{dest_path}[/cyan] ...")
224
+
225
+ try:
226
+ from bookcli.downloader import download_file
227
+ checksum = await download_file(book.download_url, dest_path, config.timeout_seconds)
228
+
229
+ async with db_manager.connection() as conn:
230
+ await conn.execute(
231
+ """
232
+ INSERT OR REPLACE INTO downloads (id, title, file_path, status, checksum)
233
+ VALUES (?, ?, ?, ?, ?)
234
+ """,
235
+ (book.id, book.title, dest_path, "completed", checksum)
236
+ )
237
+ await conn.commit()
238
+
239
+ console.print(f"[bold green]Successfully downloaded![/bold green]")
240
+ console.print(f"File size: [yellow]{format_size(os.path.getsize(dest_path))}[/yellow]")
241
+ console.print(f"SHA256 Checksum: [yellow]{checksum}[/yellow]")
242
+ return checksum
243
+ except Exception as e:
244
+ async with db_manager.connection() as conn:
245
+ await conn.execute(
246
+ """
247
+ INSERT OR REPLACE INTO downloads (id, title, file_path, status, checksum)
248
+ VALUES (?, ?, ?, ?, ?)
249
+ """,
250
+ (book.id, book.title, dest_path, "failed", None)
251
+ )
252
+ await conn.commit()
253
+ raise BookCLIError(f"Download failed: {e}")
254
+
255
+
256
+ async def open_book_internal(db_manager: DatabaseManager, book_id_input: str) -> None:
257
+ """Subroutine to open a downloaded book."""
258
+ book_id = await resolve_book_id(db_manager, book_id_input)
259
+
260
+ async with db_manager.connection() as conn:
261
+ async with conn.execute(
262
+ "SELECT file_path, status FROM downloads WHERE id = ? AND status = 'completed'", (book_id,)
263
+ ) as cursor:
264
+ row = await cursor.fetchone()
265
+ if not row:
266
+ raise BookCLIError(f"Book '{book_id}' has not been downloaded yet. Run 'book download' first.")
267
+ file_path = row[0]
268
+
269
+ open_file_in_default_app(file_path)
270
+ console.print(f"[green]Opening file {file_path}...[/green]")
271
+
272
+
273
+ async def add_favorite_internal(db_manager: DatabaseManager, book_id_input: str, config: AppConfig) -> None:
274
+ """Subroutine to add a book to favorites."""
275
+ book_id = await resolve_book_id(db_manager, book_id_input)
276
+
277
+ book = await get_book_from_cache(db_manager, book_id, config.cache_ttl_seconds)
278
+ if not book:
279
+ raise BookCLIError(f"Metadata for '{book_id}' not found in cache. Search or retrieve info first.")
280
+
281
+ async with db_manager.connection() as conn:
282
+ try:
283
+ await conn.execute(
284
+ "INSERT OR REPLACE INTO favorites (book_id, title) VALUES (?, ?)",
285
+ (book.id, book.title)
286
+ )
287
+ await conn.commit()
288
+ console.print(f"[green]Added '{book.title}' to favorites.[/green]")
289
+ except Exception as e:
290
+ raise BookCLIError(f"Error saving favorite: {e}")
291
+
292
+
293
+ # --- CLI Commands ---
294
+
295
+ @app.callback()
296
+ def global_callback(
297
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose logging (INFO)"),
298
+ debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug logging (DEBUG)")
299
+ ):
300
+ """Global configuration callback for logging."""
301
+ level = logging.WARNING
302
+ if verbose:
303
+ level = logging.INFO
304
+ if debug:
305
+ level = logging.DEBUG
306
+
307
+ logging.basicConfig(
308
+ level=level,
309
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
310
+ )
311
+ logger.setLevel(level)
312
+
313
+
314
+ @app.command()
315
+ def search(
316
+ query: str = typer.Argument("", help="Free text search query"),
317
+ author: Optional[str] = typer.Option(None, "--author", "-a", help="Filter by author name"),
318
+ isbn: Optional[str] = typer.Option(None, "--isbn", help="Filter by exact ISBN"),
319
+ publisher: Optional[str] = typer.Option(None, "--publisher", help="Filter by publisher name"),
320
+ subject: Optional[str] = typer.Option(None, "--subject", help="Filter by subject/topic"),
321
+ export: Optional[str] = typer.Option(None, "--export", help="Export to 'json' or 'csv' file"),
322
+ ):
323
+ """Search for books across all enabled providers."""
324
+ async def _run():
325
+ db_manager = DatabaseManager(str(DB_PATH))
326
+ await db_manager.initialize()
327
+
328
+ config = load_config()
329
+
330
+ if not query.strip() and not (author or isbn or publisher or subject):
331
+ console.print("[red]Error: You must provide a search query or at least one filter.[/red]")
332
+ raise typer.Exit(code=1)
333
+
334
+ with console.status("[bold green]Searching enabled providers..."):
335
+ try:
336
+ results = await search_books(
337
+ query=query,
338
+ config=config,
339
+ db_manager=db_manager,
340
+ author=author,
341
+ isbn=isbn,
342
+ publisher=publisher,
343
+ subject=subject
344
+ )
345
+ except Exception as e:
346
+ console.print(Panel(f"[bold red]Search Error:[/bold red] {e}", title="Error", border_style="red"))
347
+ raise typer.Exit(code=1)
348
+
349
+ if not results:
350
+ console.print("[yellow]No books found matching the search criteria.[/yellow]")
351
+ return
352
+
353
+ # Store search results session in DB
354
+ async with db_manager.connection() as conn:
355
+ await conn.execute("DELETE FROM search_results_session")
356
+ for idx, book in enumerate(results, start=1):
357
+ await conn.execute(
358
+ "INSERT INTO search_results_session (result_index, book_id) VALUES (?, ?)",
359
+ (idx, book.id)
360
+ )
361
+ await conn.commit()
362
+
363
+ # Format output as Rich table
364
+ table = Table(title=f"Search Results for '{query or 'Filters'}'")
365
+ table.add_column("ID", justify="right", style="cyan", no_wrap=True)
366
+ table.add_column("Title", style="magenta")
367
+ table.add_column("Author(s)", style="green")
368
+ table.add_column("Year", justify="center", style="yellow")
369
+ table.add_column("Source", style="blue")
370
+ table.add_column("Download", justify="center")
371
+
372
+ for idx, book in enumerate(results, start=1):
373
+ if book.download_availability:
374
+ avail_str = f"[green]{book.file_format.upper()}[/green]" if book.file_format else "[green]Yes[/green]"
375
+ else:
376
+ avail_str = "[red]No[/red]"
377
+ author_str = ", ".join(book.authors) if book.authors else "Unknown"
378
+ year_str = str(book.published_year) if book.published_year else "N/A"
379
+ table.add_row(
380
+ str(idx),
381
+ book.title,
382
+ author_str,
383
+ year_str,
384
+ book.source,
385
+ avail_str
386
+ )
387
+
388
+ console.print(table)
389
+ console.print(f"[dim]Total: {len(results)} books.[/dim]")
390
+
391
+ # Export if requested
392
+ if export:
393
+ export_fmt = export.lower()
394
+ if export_fmt == "json":
395
+ dest_file = "search_results.json"
396
+ with builtins.open(dest_file, "w", encoding="utf-8") as f:
397
+ json.dump([b.model_dump() for b in results], f, indent=4)
398
+ console.print(f"[green]Exported results to {dest_file}[/green]")
399
+ elif export_fmt == "csv":
400
+ dest_file = "search_results.csv"
401
+ with builtins.open(dest_file, "w", newline="", encoding="utf-8") as f:
402
+ writer = csv.writer(f)
403
+ writer.writerow(["ID", "Title", "Authors", "Year", "Source", "Format", "Downloadable", "Download URL"])
404
+ for idx, b in enumerate(results, start=1):
405
+ writer.writerow([
406
+ idx, b.title, ", ".join(b.authors),
407
+ b.published_year or "N/A", b.source,
408
+ b.file_format or "N/A",
409
+ b.download_availability, b.download_url or ""
410
+ ])
411
+ console.print(f"[green]Exported results to {dest_file}[/green]")
412
+ else:
413
+ console.print("[red]Invalid export format. Supported formats: json, csv.[/red]")
414
+
415
+ # --- Interactive Explorer Loop (triggered if TTY is true) ---
416
+ if sys.stdin.isatty() or os.environ.get("BOOKCLI_INTERACTIVE") == "true":
417
+ console.print("\n[bold cyan]Interactive Explorer Mode[/bold cyan]")
418
+ console.print("[dim]Enter <ID> to download, 'i <ID>' for info, 'f <ID>' to favorite, 'o <ID>' to open, or 'q' to quit.[/dim]")
419
+ while True:
420
+ try:
421
+ action = console.input("\n[bold yellow]Action > [/bold yellow]").strip()
422
+ if not action or action.lower() in ("q", "quit", "exit"):
423
+ break
424
+
425
+ tokens = action.split()
426
+ if not tokens:
427
+ continue
428
+
429
+ if tokens[0].isdigit():
430
+ val = tokens[0]
431
+ custom_path = None
432
+ if len(tokens) >= 3 and tokens[1] in ("-o", "--output"):
433
+ custom_path = " ".join(tokens[2:])
434
+ elif len(tokens) > 1:
435
+ console.print("[red]Invalid output path syntax. Use: <ID> -o <path>[/red]")
436
+ continue
437
+
438
+ try:
439
+ await download_book_internal(db_manager, val, config, custom_path=Path(custom_path) if custom_path else None)
440
+ except BookCLIError as e:
441
+ console.print(f"[red]Error: {e}[/red]")
442
+ elif len(tokens) >= 2:
443
+ cmd, val = tokens[0].lower(), " ".join(tokens[1:])
444
+ try:
445
+ if cmd == "i":
446
+ await show_info_internal(db_manager, val, config)
447
+ elif cmd == "f":
448
+ await add_favorite_internal(db_manager, val, config)
449
+ elif cmd == "o":
450
+ await open_book_internal(db_manager, val)
451
+ else:
452
+ console.print("[red]Unknown command prefix. Use 'i', 'f', 'o', or just the ID.[/red]")
453
+ except BookCLIError as e:
454
+ console.print(f"[red]Error: {e}[/red]")
455
+ else:
456
+ console.print("[red]Invalid input. Enter an ID index number or 'q' to exit.[/red]")
457
+ except (KeyboardInterrupt, EOFError):
458
+ break
459
+
460
+ asyncio.run(_run())
461
+
462
+
463
+ @app.command()
464
+ def info(
465
+ book_id_input: str = typer.Argument(..., help="Numeric index from last search, or exact book ID"),
466
+ ):
467
+ """Show detailed metadata for a book."""
468
+ async def _run():
469
+ db_manager = DatabaseManager(str(DB_PATH))
470
+ await db_manager.initialize()
471
+ config = load_config()
472
+ try:
473
+ await show_info_internal(db_manager, book_id_input, config)
474
+ except BookCLIError as e:
475
+ console.print(f"[red]Error: {e}[/red]")
476
+ raise typer.Exit(code=1)
477
+
478
+ asyncio.run(_run())
479
+
480
+
481
+ @app.command()
482
+ def download(
483
+ book_id_input: str = typer.Argument(..., help="Numeric index from last search, or exact book ID"),
484
+ output: Optional[Path] = typer.Option(
485
+ None,
486
+ "--output",
487
+ "-o",
488
+ help="Custom path (file path or directory) to save the downloaded book."
489
+ ),
490
+ ):
491
+ """Download a book locally (if legally available)."""
492
+ async def _run():
493
+ db_manager = DatabaseManager(str(DB_PATH))
494
+ await db_manager.initialize()
495
+ config = load_config()
496
+ try:
497
+ await download_book_internal(db_manager, book_id_input, config, custom_path=output)
498
+ except BookCLIError as e:
499
+ console.print(f"[red]Error: {e}[/red]")
500
+ raise typer.Exit(code=1)
501
+
502
+ asyncio.run(_run())
503
+
504
+
505
+ @app.command()
506
+ def open(
507
+ book_id_input: str = typer.Argument(..., help="Numeric index from last search, or exact book ID"),
508
+ ):
509
+ """Open a downloaded book using the OS default application."""
510
+ async def _run():
511
+ db_manager = DatabaseManager(str(DB_PATH))
512
+ await db_manager.initialize()
513
+ try:
514
+ await open_book_internal(db_manager, book_id_input)
515
+ except BookCLIError as e:
516
+ console.print(f"[red]Error: {e}[/red]")
517
+ raise typer.Exit(code=1)
518
+
519
+ asyncio.run(_run())
520
+
521
+
522
+ @app.command()
523
+ def history():
524
+ """Display past search history."""
525
+ async def _run():
526
+ db_manager = DatabaseManager(str(DB_PATH))
527
+ await db_manager.initialize()
528
+
529
+ hist = await get_search_history(db_manager, limit=20)
530
+ if not hist:
531
+ console.print("[yellow]Search history is empty.[/yellow]")
532
+ return
533
+
534
+ table = Table(title="Search History")
535
+ table.add_column("Timestamp", style="cyan")
536
+ table.add_column("Query/Filters", style="magenta")
537
+ table.add_column("Results Count", style="green", justify="right")
538
+
539
+ for entry in hist:
540
+ table.add_row(
541
+ entry["timestamp"],
542
+ entry["query"],
543
+ str(entry["results_count"])
544
+ )
545
+
546
+ console.print(table)
547
+
548
+ asyncio.run(_run())
549
+
550
+
551
+ # Favorites subcommand group
552
+ favorites_app = typer.Typer(help="Manage favorites list.")
553
+ app.add_typer(favorites_app, name="favorite")
554
+
555
+
556
+ @favorites_app.command("add")
557
+ def favorite_add(
558
+ book_id_input: str = typer.Argument(..., help="Numeric index from last search, or exact book ID"),
559
+ ):
560
+ """Add a book to your favorites list."""
561
+ async def _run():
562
+ db_manager = DatabaseManager(str(DB_PATH))
563
+ await db_manager.initialize()
564
+ config = load_config()
565
+ try:
566
+ await add_favorite_internal(db_manager, book_id_input, config)
567
+ except BookCLIError as e:
568
+ console.print(f"[red]Error: {e}[/red]")
569
+ raise typer.Exit(code=1)
570
+
571
+ asyncio.run(_run())
572
+
573
+
574
+ @favorites_app.command("list")
575
+ def favorite_list():
576
+ """List all favorite books."""
577
+ async def _run():
578
+ db_manager = DatabaseManager(str(DB_PATH))
579
+ await db_manager.initialize()
580
+
581
+ async with db_manager.connection() as conn:
582
+ async with conn.execute("SELECT book_id, title, added_at FROM favorites ORDER BY added_at DESC") as cursor:
583
+ rows = await cursor.fetchall()
584
+
585
+ if not rows:
586
+ console.print("[yellow]Favorites list is empty.[/yellow]")
587
+ return
588
+
589
+ table = Table(title="Favorite Books")
590
+ table.add_column("Book ID", style="cyan")
591
+ table.add_column("Title", style="magenta")
592
+ table.add_column("Added At", style="green")
593
+
594
+ for row in rows:
595
+ table.add_row(
596
+ row["book_id"],
597
+ row["title"],
598
+ row["added_at"]
599
+ )
600
+
601
+ console.print(table)
602
+
603
+ asyncio.run(_run())
604
+
605
+
606
+ @favorites_app.command("remove")
607
+ def favorite_remove(
608
+ book_id_input: str = typer.Argument(..., help="Book ID to remove"),
609
+ ):
610
+ """Remove a book from favorites list."""
611
+ async def _run():
612
+ db_manager = DatabaseManager(str(DB_PATH))
613
+ await db_manager.initialize()
614
+
615
+ try:
616
+ book_id = await resolve_book_id(db_manager, book_id_input)
617
+ except BookNotFoundError:
618
+ book_id = book_id_input
619
+
620
+ async with db_manager.connection() as conn:
621
+ async with conn.execute("SELECT title FROM favorites WHERE book_id = ?", (book_id,)) as cursor:
622
+ row = await cursor.fetchone()
623
+ if not row:
624
+ console.print(f"[red]Book '{book_id}' is not in favorites.[/red]")
625
+ raise typer.Exit(code=1)
626
+ title = row[0]
627
+
628
+ await conn.execute("DELETE FROM favorites WHERE book_id = ?", (book_id,))
629
+ await conn.commit()
630
+ console.print(f"[green]Removed '{title}' from favorites.[/green]")
631
+
632
+ asyncio.run(_run())
633
+
634
+
635
+ # Cache subcommand group
636
+ cache_app = typer.Typer(help="Manage local metadata cache.")
637
+ app.add_typer(cache_app, name="cache")
638
+
639
+
640
+ @cache_app.command("clear")
641
+ def cache_clear():
642
+ """Clear all cached book metadata."""
643
+ async def _run():
644
+ db_manager = DatabaseManager(str(DB_PATH))
645
+ await db_manager.initialize()
646
+
647
+ deleted_count = await clear_cache_db(db_manager)
648
+ console.print(f"[green]Metadata cache cleared. Removed {deleted_count} items.[/green]")
649
+
650
+ asyncio.run(_run())
651
+
652
+
653
+ @cache_app.command("stats")
654
+ def cache_stats():
655
+ """View metadata cache statistics."""
656
+ async def _run():
657
+ db_manager = DatabaseManager(str(DB_PATH))
658
+ await db_manager.initialize()
659
+
660
+ stats = await get_cache_db_stats(db_manager)
661
+ console.print(Panel(
662
+ f"Total Cached Books: [green]{stats['total_items']}[/green]\n"
663
+ f"Oldest Record: [yellow]{stats['oldest_item']}[/yellow]\n"
664
+ f"Newest Record: [yellow]{stats['newest_item']}[/yellow]",
665
+ title="Metadata Cache Statistics",
666
+ border_style="cyan"
667
+ ))
668
+
669
+ asyncio.run(_run())
670
+
671
+
672
+ # Config subcommand group
673
+ config_app = typer.Typer(help="Manage configuration settings.")
674
+ app.add_typer(config_app, name="config")
675
+
676
+
677
+ @config_app.callback(invoke_without_command=True)
678
+ def config_main(ctx: typer.Context):
679
+ """Show current settings configuration or manage parameters."""
680
+ if ctx.invoked_subcommand is not None:
681
+ return
682
+
683
+ config = load_config()
684
+ table = Table(title="BookCLI Current Configuration")
685
+ table.add_column("Setting", style="cyan")
686
+ table.add_column("Value", style="yellow")
687
+
688
+ table.add_row("Download Directory", config.download_dir)
689
+ table.add_row("Cache TTL (seconds)", str(config.cache_ttl_seconds))
690
+ table.add_row("Timeout (seconds)", str(config.timeout_seconds))
691
+ table.add_row("Theme", config.theme)
692
+ table.add_row("Google Books Enabled", str(config.providers.google_books))
693
+ table.add_row("Open Library Enabled", str(config.providers.openlibrary))
694
+ table.add_row("Project Gutenberg Enabled", str(config.providers.gutenberg))
695
+ table.add_row("Internet Archive Enabled", str(config.providers.internet_archive))
696
+
697
+ console.print(table)
698
+ console.print("[dim]Use 'book config set <key> <value>' to modify settings.[/dim]")
699
+
700
+
701
+ @config_app.command("set")
702
+ def config_set(
703
+ key: str = typer.Argument(..., help="Setting key (e.g., download-dir, cache-ttl, timeout, theme, provider)"),
704
+ value: str = typer.Argument(..., help="Setting value"),
705
+ provider_name: Optional[str] = typer.Argument(None, help="Provider key name (only when modifying 'provider')")
706
+ ):
707
+ """Modify a configuration setting."""
708
+ config = load_config()
709
+ key = key.lower().replace("_", "-")
710
+
711
+ if key == "download-dir":
712
+ config.download_dir = value
713
+ os.makedirs(value, exist_ok=True)
714
+ elif key == "cache-ttl":
715
+ if not value.isdigit():
716
+ console.print("[red]Cache TTL must be an integer.[/red]")
717
+ raise typer.Exit(code=1)
718
+ config.cache_ttl_seconds = int(value)
719
+ elif key == "timeout":
720
+ if not value.isdigit():
721
+ console.print("[red]Timeout must be an integer.[/red]")
722
+ raise typer.Exit(code=1)
723
+ config.timeout_seconds = int(value)
724
+ elif key == "theme":
725
+ config.theme = value
726
+ elif key == "provider":
727
+ if not provider_name:
728
+ console.print("[red]Error: You must specify a provider name. E.g. 'book config set provider true google-books'[/red]")
729
+ raise typer.Exit(code=1)
730
+
731
+ prov_key = provider_name.lower().replace("-", "_")
732
+ bool_val = value.lower() in ("true", "1", "yes", "on")
733
+
734
+ if hasattr(config.providers, prov_key):
735
+ setattr(config.providers, prov_key, bool_val)
736
+ else:
737
+ console.print(f"[red]Error: Unknown provider '{provider_name}'.[/red]")
738
+ raise typer.Exit(code=1)
739
+ else:
740
+ console.print(f"[red]Error: Unknown config key '{key}'.[/red]")
741
+ raise typer.Exit(code=1)
742
+
743
+ try:
744
+ save_config(config)
745
+ console.print(f"[green]Successfully updated configuration setting '{key}' to '{value}'.[/green]")
746
+ except Exception as e:
747
+ console.print(f"[red]Error saving configuration: {e}[/red]")
748
+ raise typer.Exit(code=1)
749
+
750
+
751
+ if __name__ == "__main__":
752
+ app()