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/__init__.py +3 -0
- bookcli/cache.py +103 -0
- bookcli/cli.py +752 -0
- bookcli/config.py +72 -0
- bookcli/database/__init__.py +32 -0
- bookcli/database/migrations.py +94 -0
- bookcli/downloader.py +130 -0
- bookcli/exceptions.py +34 -0
- bookcli/models.py +23 -0
- bookcli/opener.py +32 -0
- bookcli/providers/__init__.py +1 -0
- bookcli/providers/base.py +61 -0
- bookcli/providers/google_books.py +202 -0
- bookcli/providers/gutenberg.py +211 -0
- bookcli/providers/internet_archive.py +266 -0
- bookcli/providers/openlibrary.py +256 -0
- bookcli/services/__init__.py +1 -0
- bookcli/services/history.py +69 -0
- bookcli/services/ranking.py +169 -0
- bookcli/services/search.py +96 -0
- bookcli/settings.py +28 -0
- bookcli/utils.py +30 -0
- bookcli-0.1.0.dist-info/METADATA +198 -0
- bookcli-0.1.0.dist-info/RECORD +27 -0
- bookcli-0.1.0.dist-info/WHEEL +5 -0
- bookcli-0.1.0.dist-info/entry_points.txt +2 -0
- bookcli-0.1.0.dist-info/top_level.txt +1 -0
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()
|