ebk 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.

Potentially problematic release.


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

ebk/cli.py ADDED
@@ -0,0 +1,879 @@
1
+ import os
2
+ import networkx as nx
3
+ import subprocess
4
+ import sys
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+ import logging
9
+ import re
10
+ from typing import List, Optional
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.logging import RichHandler
14
+ from rich.progress import Progress
15
+ from rich.prompt import Confirm
16
+ from rich.traceback import install
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
26
+ from .ident import add_unique_id
27
+ from .llm import query_llm
28
+
29
+ # Initialize Rich Traceback for better error messages
30
+ install(show_locals=True)
31
+
32
+ # Initialize Rich Console
33
+ console = Console()
34
+
35
+ # Configure logging to use Rich's RichHandler
36
+ logging.basicConfig(
37
+ level=logging.INFO, # Set to INFO by default, DEBUG if verbose
38
+ format="%(message)s",
39
+ datefmt="[%X]",
40
+ handlers=[RichHandler(rich_tracebacks=True)]
41
+ )
42
+ logger = logging.getLogger(__name__)
43
+
44
+ app = typer.Typer()
45
+
46
+ @app.callback()
47
+ def main(
48
+ ctx: typer.Context,
49
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose mode"),
50
+ ):
51
+ """
52
+ ebk - A lightweight tool for managing eBook metadata.
53
+ """
54
+ if verbose:
55
+ logger.setLevel(logging.DEBUG)
56
+ console.print("[bold green]Verbose mode enabled.[/bold green]")
57
+
58
+ @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)"),
62
+ ):
63
+ """
64
+ Import an ebk library from a Zip file.
65
+ """
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
+
81
+
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)")
86
+ ):
87
+ """
88
+ Import a Calibre library.
89
+ """
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)
101
+
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
+ )
111
+ ):
112
+ """
113
+ Recursively import a directory of ebooks. The metadata will be inferred from the file.
114
+ """
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)
123
+
124
+ @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
+ )
132
+ ):
133
+ """
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]")
152
+ 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
+
157
+
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]")
201
+ raise typer.Exit(code=1)
202
+
203
+ @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")
208
+ ):
209
+ """
210
+ Display the index of the ebk library.
211
+
212
+ Args:
213
+ lib_dir (str): Path to the ebk library directory to display
214
+ index (int): Index of the entry to display
215
+
216
+
217
+ Raises:
218
+ typer.Exit: If the library directory is invalid or the index is out of range
219
+ """
220
+ metadata_list = load_library(lib_dir)
221
+ if not metadata_list:
222
+ console.print("[red]Failed to load library.[/red]")
223
+ 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
+
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)
238
+
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")
243
+
244
+ # Add rows dynamically
245
+ for item in entry:
246
+ table.add_row(*(str(entry[col]) for col in columns))
247
+
248
+ # Print the table
249
+ console.print(table)
250
+
251
+ @app.command()
252
+ def about():
253
+ """
254
+ Display information about ebk.
255
+ """
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")
259
+
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")
263
+
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
+
269
+ console.print("Developed by [bold]Alex Towell[/bold]. Enjoy using ebk! 🚀")
270
+
271
+ @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)
276
+ ):
277
+ """
278
+ Merge multiple ebk libraries using set-theoretic operations.
279
+
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
284
+
285
+ Raises:
286
+ typer.Exit: If the library directory is invalid or the index is out of range
287
+
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
+
303
+ @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
+ )
312
+ ):
313
+ """
314
+ Get statistics about the ebk library.
315
+
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
319
+
320
+ Raises:
321
+ typer.Exit: If the library directory is invalid
322
+
323
+ Output:
324
+ Prints the statistics about the library.
325
+ """
326
+ try:
327
+ stats = get_library_statistics(lib_dir, keywords)
328
+ console.print_json(json.dumps(stats, indent=2))
329
+ 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]")
332
+ raise typer.Exit(code=1)
333
+
334
+ @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")):
340
+ """
341
+ List the entries in the ebk library directory by index.
342
+
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
348
+
349
+ Raises:
350
+ typer.Exit: If the library directory is invalid or the index is out of range
351
+
352
+ Output:
353
+ Prints the list of entries in the library directory.
354
+ """
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)
359
+
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)
363
+
364
+ try:
365
+ metadata_list = load_library(lib_dir)
366
+ if output_json:
367
+ console.print_json(json.dumps(metadata_list, indent=2))
368
+ else:
369
+ enumerate_ebooks(metadata_list, lib_path, indices, detailed)
370
+ 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]")
373
+ raise typer.Exit(code=1)
374
+
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
+
389
+ Output:
390
+ Prints the list of entries in the library directory.
391
+ """
392
+
393
+ lib_path = Path(lib_dir)
394
+
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)
398
+
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)
402
+
403
+ try:
404
+ metadata_list = load_library(lib_dir)
405
+ if output_json:
406
+ console.print_json(json.dumps(metadata_list, indent=2))
407
+ else:
408
+ enumerate_ebooks(metadata_list, lib_path)
409
+ 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]")
412
+ raise typer.Exit(code=1)
413
+
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")
422
+ ):
423
+ """
424
+ Add entries to the ebk library.
425
+
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
433
+
434
+ Raises:
435
+ typer.Exit: If the library directory is invalid or the entry is invalid
436
+
437
+ Output:
438
+ Adds the specified entry to the library and updates the metadata file in-place.
439
+ """
440
+ try:
441
+ metadata_list = load_library(lib_dir)
442
+ if not metadata_list:
443
+ console.print("[red]Failed to load library.[/red]")
444
+ 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]")
454
+ 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
+
488
+ 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]")
491
+ raise typer.Exit(code=1)
492
+
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
+ )
504
+ ):
505
+ """
506
+ Remove entries from the ebk library.
507
+
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
513
+
514
+ Raises:
515
+ typer.Exit: If the library directory is invalid or the index is out of range
516
+
517
+ Output:
518
+ Removed entries from the library directory and associated files in-place.
519
+ """
520
+ 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
+
558
+ 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]")
561
+ raise typer.Exit(code=1)
562
+
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")):
566
+ """
567
+ Remove an entry from the ebk library by unique ID.
568
+
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
572
+ """
573
+ id = get_index_by_unique_id(lib_dir, unique_id)
574
+ remove_index(lib_dir, [id])
575
+
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")
586
+ ):
587
+ """
588
+ Update an entry in the ebk library by index.
589
+
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
598
+ """
599
+
600
+ 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]")
606
+
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
+ 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]")
629
+ raise typer.Exit(code=1)
630
+
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")
640
+ ):
641
+ """
642
+ Update an entry in the ebk library by unique id.
643
+
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
652
+ """
653
+
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]")
658
+ raise typer.Exit(code=1)
659
+
660
+ update_index(lib_dir, index, json_file, title, creators, ebooks, cover)
661
+
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")
666
+ ):
667
+ """
668
+ Remove entries from the ebk library by index.
669
+
670
+ Args:
671
+ lib_dir (str): Path to the ebk library directory to modify
672
+ indices (List[int]): Indices of entries to remove
673
+
674
+ Raises:
675
+ typer.Exit: If the library directory is invalid or the index is out of range
676
+
677
+ Output:
678
+ Removes the specified entries from the library and updates the metadata file in-place.
679
+ """
680
+ 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]")
699
+
700
+ with open(Path(lib_dir) / "metadata.json", "w") as f:
701
+ json.dump(metadata_list, f, indent=2)
702
+
703
+ console.print(f"[bold green]Removed {removed_count} entries from {lib_dir}[/bold green]")
704
+
705
+ 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]")
708
+ raise typer.Exit(code=1)
709
+
710
+ @app.command()
711
+ def dash(
712
+ port: int = typer.Option(8501, "--port", help="Port to run the Streamlit app (default: 8501)")
713
+ ):
714
+ """
715
+ Launch the Streamlit dashboard.
716
+ """
717
+ 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)
736
+ 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]")
739
+ raise typer.Exit(code=1)
740
+
741
+ @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)")):
747
+ """
748
+ Search entries in an ebk library using a regex expression on specified fields.
749
+
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
758
+ """
759
+ try:
760
+ results = search_regex(lib_dir, query, fields)
761
+ if json_out:
762
+ console.print_json(json.dumps(results, indent=2))
763
+ else:
764
+ enumerate_ebooks(results, Path(lib_dir))
765
+ 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]")
768
+ raise typer.Exit(code=1)
769
+
770
+ @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")):
775
+ """
776
+ Query the ebk library using JMESPath.
777
+
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
782
+
783
+ Returns:
784
+ JMEPSath query results, either pretty printed or as JSON.
785
+ """
786
+ try:
787
+ results = search_jmes(lib_dir, query)
788
+ if json_out:
789
+ console.print_json(json.dumps(results, indent=2))
790
+ else:
791
+ print_json_as_table(results)
792
+ 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]")
795
+ raise typer.Exit(code=1)
796
+
797
+ @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")
801
+ ):
802
+ """
803
+ Query the ebk library using the LLM (Large Language Model) endpoint.
804
+
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
808
+
809
+ Returns:
810
+ LLM query results
811
+ """
812
+ try:
813
+ query_llm(lib_dir, query)
814
+ 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]")
817
+ raise typer.Exit(code=1)
818
+
819
+ @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))
877
+
878
+ if __name__ == "__main__":
879
+ app()