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

Files changed (84) hide show
  1. ebk/__init__.py +35 -0
  2. ebk/ai/__init__.py +23 -0
  3. ebk/ai/knowledge_graph.py +443 -0
  4. ebk/ai/llm_providers/__init__.py +21 -0
  5. ebk/ai/llm_providers/base.py +230 -0
  6. ebk/ai/llm_providers/ollama.py +362 -0
  7. ebk/ai/metadata_enrichment.py +396 -0
  8. ebk/ai/question_generator.py +328 -0
  9. ebk/ai/reading_companion.py +224 -0
  10. ebk/ai/semantic_search.py +434 -0
  11. ebk/ai/text_extractor.py +394 -0
  12. ebk/cli.py +2828 -680
  13. ebk/config.py +260 -22
  14. ebk/db/__init__.py +37 -0
  15. ebk/db/migrations.py +180 -0
  16. ebk/db/models.py +526 -0
  17. ebk/db/session.py +144 -0
  18. ebk/decorators.py +132 -0
  19. ebk/exports/base_exporter.py +218 -0
  20. ebk/exports/html_library.py +1390 -0
  21. ebk/exports/html_utils.py +117 -0
  22. ebk/exports/hugo.py +7 -3
  23. ebk/exports/jinja_export.py +287 -0
  24. ebk/exports/multi_facet_export.py +164 -0
  25. ebk/exports/symlink_dag.py +479 -0
  26. ebk/extract_metadata.py +76 -7
  27. ebk/library_db.py +899 -0
  28. ebk/plugins/__init__.py +42 -0
  29. ebk/plugins/base.py +502 -0
  30. ebk/plugins/hooks.py +444 -0
  31. ebk/plugins/registry.py +500 -0
  32. ebk/repl/__init__.py +9 -0
  33. ebk/repl/find.py +126 -0
  34. ebk/repl/grep.py +174 -0
  35. ebk/repl/shell.py +1677 -0
  36. ebk/repl/text_utils.py +320 -0
  37. ebk/search_parser.py +413 -0
  38. ebk/server.py +1633 -0
  39. ebk/services/__init__.py +11 -0
  40. ebk/services/import_service.py +442 -0
  41. ebk/services/tag_service.py +282 -0
  42. ebk/services/text_extraction.py +317 -0
  43. ebk/similarity/__init__.py +77 -0
  44. ebk/similarity/base.py +154 -0
  45. ebk/similarity/core.py +445 -0
  46. ebk/similarity/extractors.py +168 -0
  47. ebk/similarity/metrics.py +376 -0
  48. ebk/vfs/__init__.py +101 -0
  49. ebk/vfs/base.py +301 -0
  50. ebk/vfs/library_vfs.py +124 -0
  51. ebk/vfs/nodes/__init__.py +54 -0
  52. ebk/vfs/nodes/authors.py +196 -0
  53. ebk/vfs/nodes/books.py +480 -0
  54. ebk/vfs/nodes/files.py +155 -0
  55. ebk/vfs/nodes/metadata.py +385 -0
  56. ebk/vfs/nodes/root.py +100 -0
  57. ebk/vfs/nodes/similar.py +165 -0
  58. ebk/vfs/nodes/subjects.py +184 -0
  59. ebk/vfs/nodes/tags.py +371 -0
  60. ebk/vfs/resolver.py +228 -0
  61. ebk-0.3.2.dist-info/METADATA +755 -0
  62. ebk-0.3.2.dist-info/RECORD +69 -0
  63. {ebk-0.1.0.dist-info → ebk-0.3.2.dist-info}/WHEEL +1 -1
  64. ebk-0.3.2.dist-info/licenses/LICENSE +21 -0
  65. ebk/imports/__init__.py +0 -0
  66. ebk/imports/calibre.py +0 -144
  67. ebk/imports/ebooks.py +0 -116
  68. ebk/llm.py +0 -58
  69. ebk/manager.py +0 -44
  70. ebk/merge.py +0 -308
  71. ebk/streamlit/__init__.py +0 -0
  72. ebk/streamlit/__pycache__/__init__.cpython-310.pyc +0 -0
  73. ebk/streamlit/__pycache__/display.cpython-310.pyc +0 -0
  74. ebk/streamlit/__pycache__/filters.cpython-310.pyc +0 -0
  75. ebk/streamlit/__pycache__/utils.cpython-310.pyc +0 -0
  76. ebk/streamlit/app.py +0 -185
  77. ebk/streamlit/display.py +0 -168
  78. ebk/streamlit/filters.py +0 -151
  79. ebk/streamlit/utils.py +0 -58
  80. ebk/utils.py +0 -311
  81. ebk-0.1.0.dist-info/METADATA +0 -457
  82. ebk-0.1.0.dist-info/RECORD +0 -29
  83. {ebk-0.1.0.dist-info → ebk-0.3.2.dist-info}/entry_points.txt +0 -0
  84. {ebk-0.1.0.dist-info → ebk-0.3.2.dist-info}/top_level.txt +0 -0
ebk/cli.py CHANGED
@@ -1,13 +1,13 @@
1
1
  import os
2
- import networkx as nx
3
- import subprocess
4
2
  import sys
5
3
  import json
6
4
  import shutil
5
+ import csv
7
6
  from pathlib import Path
8
7
  import logging
9
8
  import re
10
9
  from typing import List, Optional
10
+ from datetime import datetime
11
11
  import typer
12
12
  from rich.console import Console
13
13
  from rich.logging import RichHandler
@@ -15,16 +15,19 @@ from rich.progress import Progress
15
15
  from rich.prompt import Confirm
16
16
  from rich.traceback import install
17
17
  from rich.table import Table
18
- from rich import print_json as print_json_as_table
19
- from rich.json import JSON
20
-
21
- from .exports.hugo import export_hugo
22
- from .exports.zip import export_zipfile
23
- from .imports import ebooks, calibre
24
- from .merge import merge_libraries
25
- from .utils import enumerate_ebooks, load_library, get_unique_filename, search_regex, search_jmes, get_library_statistics, get_index_by_unique_id, print_json_as_table
18
+
19
+ from .decorators import handle_library_errors
26
20
  from .ident import add_unique_id
27
- from .llm import query_llm
21
+
22
+ # Export functions (still available)
23
+ try:
24
+ from .exports.hugo import export_hugo
25
+ from .exports.zip import export_zipfile
26
+ from .exports.jinja_export import JinjaExporter
27
+ except ImportError:
28
+ export_hugo = None
29
+ export_zipfile = None
30
+ JinjaExporter = None
28
31
 
29
32
  # Initialize Rich Traceback for better error messages
30
33
  install(show_locals=True)
@@ -41,839 +44,2984 @@ logging.basicConfig(
41
44
  )
42
45
  logger = logging.getLogger(__name__)
43
46
 
47
+ # Main app
44
48
  app = typer.Typer()
45
49
 
50
+ # Command groups
51
+ import_app = typer.Typer(help="Import books from various sources")
52
+ export_app = typer.Typer(help="Export library data to various formats")
53
+ vlib_app = typer.Typer(help="Manage virtual libraries (collection views)")
54
+ note_app = typer.Typer(help="Manage book annotations and notes")
55
+ tag_app = typer.Typer(help="Manage hierarchical tags for organizing books")
56
+ vfs_app = typer.Typer(help="VFS commands (ln, mv, rm, ls, cat, mkdir)")
57
+
58
+ # Register command groups
59
+ app.add_typer(import_app, name="import")
60
+ app.add_typer(export_app, name="export")
61
+ app.add_typer(vlib_app, name="vlib")
62
+ app.add_typer(note_app, name="note")
63
+ app.add_typer(tag_app, name="tag")
64
+ app.add_typer(vfs_app, name="vfs")
65
+
46
66
  @app.callback()
47
67
  def main(
48
68
  ctx: typer.Context,
49
69
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose mode"),
50
70
  ):
51
71
  """
52
- ebk - A lightweight tool for managing eBook metadata.
72
+ ebk - eBook metadata management tool with SQLAlchemy + SQLite backend.
73
+
74
+ Manage your ebook library with full-text search, automatic text extraction,
75
+ and hash-based deduplication.
53
76
  """
54
77
  if verbose:
55
78
  logger.setLevel(logging.DEBUG)
56
79
  console.print("[bold green]Verbose mode enabled.[/bold green]")
57
80
 
81
+
58
82
  @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
-
83
+ def about():
84
+ """Display information about ebk."""
85
+ console.print("[bold cyan]ebk - eBook Metadata Management Tool[/bold cyan]")
86
+ console.print("")
87
+ console.print("A powerful tool for managing ebook libraries with:")
88
+ console.print(" • SQLAlchemy + SQLite database backend")
89
+ console.print(" • Full-text search (FTS5)")
90
+ console.print(" • Automatic text extraction from PDFs, EPUBs, plaintext")
91
+ console.print(" • Hash-based file deduplication")
92
+ console.print(" Cover extraction and thumbnails")
93
+ console.print(" • Virtual libraries and personal metadata")
94
+ console.print("")
95
+ console.print("[bold]Core Commands:[/bold]")
96
+ console.print(" ebk init <path> Initialize new library")
97
+ console.print(" ebk import <file> <lib> Import ebook")
98
+ console.print(" ebk import-calibre <src> Import from Calibre")
99
+ console.print(" ebk search <query> <lib> Full-text search")
100
+ console.print(" ebk list <lib> List books")
101
+ console.print(" ebk stats <lib> Show statistics")
102
+ console.print(" ebk view <id> <lib> View book content")
103
+ console.print("")
104
+ console.print("[bold]Command Groups:[/bold]")
105
+ console.print(" ebk export <subcommand> Export library data")
106
+ console.print(" ebk vlib <subcommand> Manage virtual libraries")
107
+ console.print(" ebk note <subcommand> Manage annotations")
108
+ console.print(" ebk tag <subcommand> Manage hierarchical tags")
109
+ console.print("")
110
+ console.print("[bold]Getting Started:[/bold]")
111
+ console.print(" 1. Initialize: ebk init ~/my-library")
112
+ console.print(" 2. Import: ebk import book.pdf ~/my-library")
113
+ console.print(" 3. Search: ebk search 'python' ~/my-library")
114
+ console.print("")
115
+ console.print("For more info: https://github.com/queelius/ebk")
116
+
117
+
118
+ # ============================================================================
119
+ # Core Library Commands
120
+ # ============================================================================
81
121
 
82
122
  @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)")
123
+ def shell(
124
+ library_path: Path = typer.Argument(..., help="Path to the library"),
86
125
  ):
87
126
  """
88
- Import a Calibre library.
127
+ Launch interactive shell for navigating the library.
128
+
129
+ The shell provides a Linux-like interface for browsing and
130
+ managing your library through a virtual filesystem.
131
+
132
+ Commands:
133
+ cd, pwd, ls - Navigate the VFS
134
+ cat - Read file content
135
+ grep, find - Search and query
136
+ open - Open files
137
+ !<bash> - Execute bash commands
138
+ !ebk <cmd> - Pass through to ebk CLI
139
+ help - Show help
140
+
141
+ Example:
142
+ ebk shell ~/my-library
89
143
  """
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)
144
+ from .repl import LibraryShell
145
+
146
+ library_path = Path(library_path)
147
+
148
+ if not library_path.exists():
149
+ console.print(f"[red]Error: Library not found at {library_path}[/red]")
150
+ console.print("Use 'ebk init' to create a new library.")
151
+ raise typer.Exit(code=1)
152
+
153
+ try:
154
+ shell = LibraryShell(library_path)
155
+ shell.run()
156
+ except Exception as e:
157
+ from rich.markup import escape
158
+ console.print(f"[red]Error launching shell: {escape(str(e))}[/red]")
159
+ raise typer.Exit(code=1)
160
+
101
161
 
102
162
  @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
- )
163
+ def init(
164
+ library_path: Path = typer.Argument(..., help="Path to create the library"),
165
+ echo_sql: bool = typer.Option(False, "--echo-sql", help="Echo SQL statements for debugging")
111
166
  ):
112
167
  """
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)
168
+ Initialize a new database-backed library.
123
169
 
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
- ):
170
+ This creates a new library directory with SQLite database backend,
171
+ including directories for files, covers, and vector embeddings.
172
+
173
+ Example:
174
+ ebk init ~/my-library
133
175
  """
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
-
176
+ from .library_db import Library
157
177
 
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]")
178
+ library_path = Path(library_path)
179
+
180
+ if library_path.exists() and any(library_path.iterdir()):
181
+ console.print(f"[yellow]Warning: Directory {library_path} already exists and is not empty[/yellow]")
182
+ if not Confirm.ask("Continue anyway?"):
183
+ raise typer.Exit(code=0)
184
+
185
+ try:
186
+ lib = Library.open(library_path, echo=echo_sql)
187
+ lib.close()
188
+ console.print(f"[green]✓ Library initialized at {library_path}[/green]")
189
+ console.print(f" Database: {library_path / 'library.db'}")
190
+ console.print(f" Use 'ebk import' to add books")
191
+ except Exception as e:
192
+ console.print(f"[red]Error initializing library: {e}[/red]")
201
193
  raise typer.Exit(code=1)
202
-
194
+
195
+
203
196
  @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")
197
+ def migrate(
198
+ library_path: Path = typer.Argument(..., help="Path to the library"),
199
+ check_only: bool = typer.Option(False, "--check", help="Check which migrations are needed without applying"),
208
200
  ):
209
201
  """
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
202
+ Run database migrations on an existing library.
215
203
 
204
+ This upgrades the database schema to support new features without losing data.
205
+ Currently supports:
206
+ - Adding hierarchical tags table (for user-defined organization)
216
207
 
217
- Raises:
218
- typer.Exit: If the library directory is invalid or the index is out of range
208
+ Example:
209
+ ebk migrate ~/my-library
210
+ ebk migrate ~/my-library --check
219
211
  """
220
- metadata_list = load_library(lib_dir)
221
- if not metadata_list:
222
- console.print("[red]Failed to load library.[/red]")
212
+ from .db.migrations import run_all_migrations, check_migrations
213
+
214
+ library_path = Path(library_path)
215
+
216
+ if not library_path.exists():
217
+ console.print(f"[red]Error: Library not found at {library_path}[/red]")
218
+ raise typer.Exit(code=1)
219
+
220
+ db_path = library_path / 'library.db'
221
+ if not db_path.exists():
222
+ console.print(f"[red]Error: Database not found at {db_path}[/red]")
223
+ console.print("Use 'ebk init' to create a new library.")
223
224
  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
225
 
231
- for index in indices:
232
- entry = metadata_list[index]
233
- if output_json:
234
- console.print_json(json.dumps(entry, indent=2))
226
+ try:
227
+ if check_only:
228
+ console.print(f"[cyan]Checking migrations for {library_path}...[/cyan]")
229
+ results = check_migrations(library_path)
230
+
231
+ if not any(results.values()):
232
+ console.print("[green]✓ Database is up-to-date, no migrations needed[/green]")
233
+ else:
234
+ console.print("[yellow]Migrations needed:[/yellow]")
235
+ for name, needed in results.items():
236
+ if needed:
237
+ console.print(f" • {name}")
235
238
  else:
236
- # Create a table
237
- table = Table(title="ebk Ebook Entry", show_lines=True)
239
+ console.print(f"[cyan]Running migrations on {library_path}...[/cyan]")
240
+ results = run_all_migrations(library_path)
238
241
 
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")
242
+ applied = [name for name, was_applied in results.items() if was_applied]
243
243
 
244
- # Add rows dynamically
245
- for item in entry:
246
- table.add_row(*(str(entry[col]) for col in columns))
244
+ if not applied:
245
+ console.print("[green]✓ Database is up-to-date, no migrations applied[/green]")
246
+ else:
247
+ console.print("[green]✓ Migrations completed successfully:[/green]")
248
+ for name in applied:
249
+ console.print(f" • {name}")
247
250
 
248
- # Print the table
249
- console.print(table)
251
+ except Exception as e:
252
+ console.print(f"[red]Error during migration: {e}[/red]")
253
+ logger.exception("Migration failed")
254
+ raise typer.Exit(code=1)
250
255
 
251
- @app.command()
252
- def about():
256
+
257
+ @import_app.command(name="add")
258
+ def import_add(
259
+ file_path: Path = typer.Argument(..., help="Path to ebook file"),
260
+ library_path: Path = typer.Argument(..., help="Path to library"),
261
+ title: Optional[str] = typer.Option(None, "--title", "-t", help="Book title"),
262
+ authors: Optional[str] = typer.Option(None, "--authors", "-a", help="Authors (comma-separated)"),
263
+ subjects: Optional[str] = typer.Option(None, "--subjects", "-s", help="Subjects/tags (comma-separated)"),
264
+ language: str = typer.Option("en", "--language", "-l", help="Language code"),
265
+ no_text: bool = typer.Option(False, "--no-text", help="Skip text extraction"),
266
+ no_cover: bool = typer.Option(False, "--no-cover", help="Skip cover extraction"),
267
+ auto_metadata: bool = typer.Option(True, "--auto-metadata/--no-auto-metadata", help="Extract metadata from file")
268
+ ):
253
269
  """
254
- Display information about ebk.
270
+ Import a single ebook file into the library.
271
+
272
+ Extracts metadata, text, and cover images automatically unless disabled.
273
+ Supports PDF, EPUB, MOBI, and plaintext files.
274
+
275
+ Examples:
276
+ ebk import add book.pdf ~/my-library
277
+ ebk import add book.epub ~/my-library --title "My Book" --authors "Author Name"
255
278
  """
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")
279
+ from .library_db import Library
280
+ from .extract_metadata import extract_metadata
259
281
 
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")
282
+ if not file_path.exists():
283
+ console.print(f"[red]Error: File not found: {file_path}[/red]")
284
+ raise typer.Exit(code=1)
263
285
 
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")
286
+ if not library_path.exists():
287
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
288
+ console.print(f"[yellow]Initialize a library first with: ebk init {library_path}[/yellow]")
289
+ raise typer.Exit(code=1)
268
290
 
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.
291
+ try:
292
+ lib = Library.open(library_path)
293
+
294
+ # Build metadata dict
295
+ metadata = {}
296
+
297
+ # Auto-extract metadata from file if enabled
298
+ if auto_metadata:
299
+ extracted = extract_metadata(str(file_path))
300
+ metadata.update(extracted)
301
+
302
+ # Override with CLI arguments
303
+ if title:
304
+ metadata['title'] = title
305
+ if authors:
306
+ metadata['creators'] = [a.strip() for a in authors.split(',')]
307
+ if subjects:
308
+ metadata['subjects'] = [s.strip() for s in subjects.split(',')]
309
+ if language:
310
+ metadata['language'] = language
311
+
312
+ # Ensure title exists
313
+ if 'title' not in metadata:
314
+ metadata['title'] = file_path.stem
315
+
316
+ # Import book
317
+ book = lib.add_book(
318
+ file_path,
319
+ metadata,
320
+ extract_text=not no_text,
321
+ extract_cover=not no_cover
322
+ )
279
323
 
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
324
+ if book:
325
+ console.print(f"[green]✓ Imported: {book.title}[/green]")
326
+ console.print(f" ID: {book.id}")
327
+ console.print(f" Authors: {', '.join(a.name for a in book.authors)}")
328
+ console.print(f" Files: {len(book.files)}")
329
+ else:
330
+ console.print("[yellow]Import failed or book already exists[/yellow]")
284
331
 
285
- Raises:
286
- typer.Exit: If the library directory is invalid or the index is out of range
332
+ lib.close()
287
333
 
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)
334
+ except Exception as e:
335
+ console.print(f"[red]Error importing book: {e}[/red]")
336
+ logger.exception("Import error details:")
337
+ raise typer.Exit(code=1)
302
338
 
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
- )
339
+
340
+ @import_app.command(name="calibre")
341
+ def import_calibre(
342
+ calibre_path: Path = typer.Argument(..., help="Path to Calibre library"),
343
+ library_path: Path = typer.Argument(..., help="Path to ebk library"),
344
+ limit: Optional[int] = typer.Option(None, "--limit", help="Limit number of books to import")
312
345
  ):
313
346
  """
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
347
+ Import books from a Calibre library.
319
348
 
320
- Raises:
321
- typer.Exit: If the library directory is invalid
349
+ Reads Calibre's metadata.opf files and imports ebooks with full metadata.
350
+ Supports all Calibre-managed formats (PDF, EPUB, MOBI, etc.).
322
351
 
323
- Output:
324
- Prints the statistics about the library.
352
+ Examples:
353
+ ebk import calibre ~/Calibre/Library ~/my-library
354
+ ebk import calibre ~/Calibre/Library ~/my-library --limit 100
325
355
  """
356
+ from .library_db import Library
357
+
358
+ if not calibre_path.exists():
359
+ console.print(f"[red]Error: Calibre library not found: {calibre_path}[/red]")
360
+ raise typer.Exit(code=1)
361
+
362
+ if not library_path.exists():
363
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
364
+ console.print(f"[yellow]Initialize a library first with: ebk init {library_path}[/yellow]")
365
+ raise typer.Exit(code=1)
366
+
326
367
  try:
327
- stats = get_library_statistics(lib_dir, keywords)
328
- console.print_json(json.dumps(stats, indent=2))
368
+ lib = Library.open(library_path)
369
+
370
+ # Find all metadata.opf files
371
+ console.print(f"Scanning Calibre library...")
372
+ opf_files = list(calibre_path.rglob("metadata.opf"))
373
+
374
+ if limit:
375
+ opf_files = opf_files[:limit]
376
+
377
+ console.print(f"Found {len(opf_files)} books in Calibre library")
378
+
379
+ if len(opf_files) == 0:
380
+ console.print("[yellow]No books found. Make sure this is a Calibre library directory.[/yellow]")
381
+ lib.close()
382
+ raise typer.Exit(code=0)
383
+
384
+ imported = 0
385
+ failed = 0
386
+
387
+ with Progress() as progress:
388
+ task = progress.add_task("[green]Importing...", total=len(opf_files))
389
+
390
+ for opf_path in opf_files:
391
+ try:
392
+ book = lib.add_calibre_book(opf_path)
393
+ if book:
394
+ imported += 1
395
+ else:
396
+ failed += 1
397
+ except Exception as e:
398
+ failed += 1
399
+ logger.debug(f"Failed to import {opf_path.parent.name}: {e}")
400
+
401
+ progress.advance(task)
402
+
403
+ console.print(f"[green]✓ Import complete[/green]")
404
+ console.print(f" Successfully imported: {imported}")
405
+ if failed > 0:
406
+ console.print(f" Failed: {failed}")
407
+
408
+ lib.close()
409
+
329
410
  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]")
411
+ console.print(f"[red]Error importing Calibre library: {e}[/red]")
412
+ logger.exception("Calibre import error details:")
332
413
  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
414
 
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
415
 
349
- Raises:
350
- typer.Exit: If the library directory is invalid or the index is out of range
416
+ @import_app.command(name="folder")
417
+ def import_folder(
418
+ folder_path: Path = typer.Argument(..., help="Path to folder containing ebooks"),
419
+ library_path: Path = typer.Argument(..., help="Path to ebk library"),
420
+ recursive: bool = typer.Option(True, "--recursive/--no-recursive", "-r", help="Search subdirectories recursively"),
421
+ extensions: Optional[str] = typer.Option("pdf,epub,mobi,azw3,txt", "--extensions", "-e", help="File extensions to import (comma-separated)"),
422
+ limit: Optional[int] = typer.Option(None, "--limit", help="Limit number of books to import"),
423
+ no_text: bool = typer.Option(False, "--no-text", help="Skip text extraction"),
424
+ no_cover: bool = typer.Option(False, "--no-cover", help="Skip cover extraction"),
425
+ ):
426
+ """
427
+ Import all ebook files from a folder (batch import).
428
+
429
+ Scans a directory for ebook files and imports them with automatic
430
+ metadata extraction. Useful for importing large collections.
351
431
 
352
- Output:
353
- Prints the list of entries in the library directory.
432
+ Examples:
433
+ ebk import folder ~/Downloads/Books ~/my-library
434
+ ebk import folder ~/Books ~/my-library --no-recursive
435
+ ebk import folder ~/Books ~/my-library --extensions pdf,epub --limit 100
354
436
  """
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)
437
+ from .library_db import Library
438
+ from .extract_metadata import extract_metadata
439
+
440
+ if not folder_path.exists():
441
+ console.print(f"[red]Error: Folder not found: {folder_path}[/red]")
442
+ raise typer.Exit(code=1)
359
443
 
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)
444
+ if not folder_path.is_dir():
445
+ console.print(f"[red]Error: Not a directory: {folder_path}[/red]")
446
+ raise typer.Exit(code=1)
447
+
448
+ if not library_path.exists():
449
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
450
+ console.print(f"[yellow]Initialize a library first with: ebk init {library_path}[/yellow]")
451
+ raise typer.Exit(code=1)
363
452
 
364
453
  try:
365
- metadata_list = load_library(lib_dir)
366
- if output_json:
367
- console.print_json(json.dumps(metadata_list, indent=2))
454
+ lib = Library.open(library_path)
455
+
456
+ # Parse extensions
457
+ ext_list = [f".{ext.strip().lower()}" for ext in extensions.split(",")]
458
+
459
+ # Find all ebook files
460
+ console.print(f"Scanning folder for ebooks...")
461
+ ebook_files = []
462
+
463
+ if recursive:
464
+ for ext in ext_list:
465
+ ebook_files.extend(folder_path.rglob(f"*{ext}"))
368
466
  else:
369
- enumerate_ebooks(metadata_list, lib_path, indices, detailed)
467
+ for ext in ext_list:
468
+ ebook_files.extend(folder_path.glob(f"*{ext}"))
469
+
470
+ # Remove duplicates and sort
471
+ ebook_files = sorted(set(ebook_files))
472
+
473
+ if limit:
474
+ ebook_files = ebook_files[:limit]
475
+
476
+ console.print(f"Found {len(ebook_files)} ebook files")
477
+
478
+ if len(ebook_files) == 0:
479
+ console.print("[yellow]No ebook files found.[/yellow]")
480
+ lib.close()
481
+ raise typer.Exit(code=0)
482
+
483
+ imported = 0
484
+ failed = 0
485
+ skipped = 0
486
+
487
+ with Progress() as progress:
488
+ task = progress.add_task("[cyan]Importing books...", total=len(ebook_files))
489
+
490
+ for file_path in ebook_files:
491
+ progress.update(task, description=f"[cyan]Importing: {file_path.name}")
492
+
493
+ try:
494
+ # Extract metadata
495
+ metadata = extract_metadata(str(file_path))
496
+
497
+ # Ensure title exists
498
+ if 'title' not in metadata or not metadata['title']:
499
+ metadata['title'] = file_path.stem
500
+
501
+ # Import book
502
+ book = lib.add_book(
503
+ file_path,
504
+ metadata,
505
+ extract_text=not no_text,
506
+ extract_cover=not no_cover
507
+ )
508
+
509
+ if book:
510
+ imported += 1
511
+ else:
512
+ skipped += 1 # Already exists
513
+
514
+ except Exception as e:
515
+ failed += 1
516
+ logger.debug(f"Failed to import {file_path}: {e}")
517
+
518
+ progress.advance(task)
519
+
520
+ # Summary
521
+ console.print(f"\n[bold]Import Summary:[/bold]")
522
+ console.print(f" Imported: {imported}")
523
+ console.print(f" Skipped (duplicates): {skipped}")
524
+ if failed > 0:
525
+ console.print(f" Failed: {failed}")
526
+
527
+ lib.close()
528
+
370
529
  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]")
530
+ console.print(f"[red]Error importing folder: {e}[/red]")
531
+ logger.exception("Folder import error details:")
373
532
  raise typer.Exit(code=1)
374
533
 
534
+
375
535
  @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")):
536
+ def search(
537
+ query: str = typer.Argument(..., help="Search query"),
538
+ library_path: Path = typer.Argument(..., help="Path to library"),
539
+ limit: int = typer.Option(20, "--limit", "-n", help="Maximum number of results")
540
+ ):
379
541
  """
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
542
+ Search books in database-backed library using full-text search.
385
543
 
386
- Raises:
387
- typer.Exit: If the library directory is invalid
544
+ Searches across titles, descriptions, and extracted text content using
545
+ SQLite's FTS5 engine for fast, relevance-ranked results.
388
546
 
389
- Output:
390
- Prints the list of entries in the library directory.
547
+ Examples:
548
+ ebk search "python programming" ~/my-library
549
+ ebk search "machine learning" ~/my-library --limit 50
391
550
  """
392
-
393
- lib_path = Path(lib_dir)
551
+ from .library_db import Library
394
552
 
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)
553
+ if not library_path.exists():
554
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
555
+ raise typer.Exit(code=1)
402
556
 
403
557
  try:
404
- metadata_list = load_library(lib_dir)
405
- if output_json:
406
- console.print_json(json.dumps(metadata_list, indent=2))
558
+ lib = Library.open(library_path)
559
+
560
+ results = lib.search(query, limit=limit)
561
+
562
+ if not results:
563
+ console.print(f"[yellow]No results found for: {query}[/yellow]")
407
564
  else:
408
- enumerate_ebooks(metadata_list, lib_path)
565
+ table = Table(title=f"Search Results: '{query}'")
566
+ table.add_column("ID", style="cyan")
567
+ table.add_column("Title", style="green")
568
+ table.add_column("Authors", style="blue")
569
+ table.add_column("Language", style="magenta")
570
+
571
+ for book in results:
572
+ authors = ", ".join(a.name for a in book.authors[:2])
573
+ if len(book.authors) > 2:
574
+ authors += f" +{len(book.authors) - 2} more"
575
+
576
+ table.add_row(
577
+ str(book.id),
578
+ book.title[:50],
579
+ authors,
580
+ book.language or "?"
581
+ )
582
+
583
+ console.print(table)
584
+ console.print(f"\n[dim]Showing {len(results)} results[/dim]")
585
+
586
+ lib.close()
587
+
409
588
  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]")
589
+ console.print(f"[red]Error searching library: {e}[/red]")
412
590
  raise typer.Exit(code=1)
413
591
 
592
+
414
593
  @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")
594
+ def stats(
595
+ library_path: Path = typer.Argument(..., help="Path to library")
422
596
  ):
423
597
  """
424
- Add entries to the ebk library.
598
+ Show statistics for database-backed library.
425
599
 
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
600
+ Displays book counts, author counts, language distribution,
601
+ format distribution, and reading progress.
433
602
 
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.
603
+ Example:
604
+ ebk stats ~/my-library
439
605
  """
606
+ from .library_db import Library
607
+
608
+ if not library_path.exists():
609
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
610
+ raise typer.Exit(code=1)
611
+
440
612
  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
-
613
+ lib = Library.open(library_path)
614
+
615
+ stats = lib.stats()
616
+
617
+ table = Table(title="Library Statistics")
618
+ table.add_column("Metric", style="cyan")
619
+ table.add_column("Count", style="green", justify="right")
620
+
621
+ table.add_row("Total Books", str(stats['total_books']))
622
+ table.add_row("Total Authors", str(stats['total_authors']))
623
+ table.add_row("Total Subjects", str(stats['total_subjects']))
624
+ table.add_row("Total Files", str(stats['total_files']))
625
+ table.add_row("Books Read", str(stats['read_count']))
626
+ table.add_row("Currently Reading", str(stats['reading_count']))
627
+
628
+ console.print(table)
629
+
630
+ # Language distribution
631
+ if stats['languages']:
632
+ console.print("\n[bold]Languages:[/bold]")
633
+ for lang, count in sorted(stats['languages'].items(), key=lambda x: x[1], reverse=True):
634
+ console.print(f" {lang}: {count}")
635
+
636
+ # Format distribution
637
+ if stats['formats']:
638
+ console.print("\n[bold]Formats:[/bold]")
639
+ for fmt, count in sorted(stats['formats'].items(), key=lambda x: x[1], reverse=True):
640
+ console.print(f" {fmt}: {count}")
641
+
642
+ lib.close()
643
+
488
644
  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]")
645
+ console.print(f"[red]Error getting library stats: {e}[/red]")
491
646
  raise typer.Exit(code=1)
492
647
 
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
- )
648
+
649
+ @app.command(name="list")
650
+ def list_books(
651
+ library_path: Path = typer.Argument(..., help="Path to library"),
652
+ limit: int = typer.Option(50, "--limit", "-n", help="Maximum number of books to show"),
653
+ offset: int = typer.Option(0, "--offset", help="Starting offset"),
654
+ author: Optional[str] = typer.Option(None, "--author", "-a", help="Filter by author"),
655
+ subject: Optional[str] = typer.Option(None, "--subject", "-s", help="Filter by subject"),
656
+ language: Optional[str] = typer.Option(None, "--language", "-l", help="Filter by language")
504
657
  ):
505
658
  """
506
- Remove entries from the ebk library.
659
+ List books in database-backed library with optional filtering.
507
660
 
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
661
+ Supports pagination and filtering by author, subject, or language.
513
662
 
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.
663
+ Examples:
664
+ ebk list ~/my-library
665
+ ebk list ~/my-library --author "Knuth"
666
+ ebk list ~/my-library --subject "Python" --limit 20
519
667
  """
668
+ from .library_db import Library
669
+
670
+ if not library_path.exists():
671
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
672
+ raise typer.Exit(code=1)
673
+
520
674
  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]")
675
+ lib = Library.open(library_path)
676
+
677
+ # Build query with filters
678
+ query = lib.query()
679
+
680
+ if author:
681
+ query = query.filter_by_author(author)
682
+ if subject:
683
+ query = query.filter_by_subject(subject)
684
+ if language:
685
+ query = query.filter_by_language(language)
686
+
687
+ query = query.order_by('title').limit(limit).offset(offset)
688
+
689
+ books = query.all()
690
+ total = query.count()
691
+
692
+ if not books:
693
+ console.print("[yellow]No books found[/yellow]")
694
+ else:
695
+ table = Table(title="Books")
696
+ table.add_column("ID", style="cyan")
697
+ table.add_column("Title", style="green")
698
+ table.add_column("Authors", style="blue")
699
+ table.add_column("Language", style="magenta")
700
+ table.add_column("Formats", style="yellow")
701
+
702
+ for book in books:
703
+ authors = ", ".join(a.name for a in book.authors[:2])
704
+ if len(book.authors) > 2:
705
+ authors += f" +{len(book.authors) - 2}"
706
+
707
+ formats = ", ".join(f.format for f in book.files)
708
+
709
+ table.add_row(
710
+ str(book.id),
711
+ book.title[:40],
712
+ authors[:30],
713
+ book.language or "?",
714
+ formats
715
+ )
716
+
717
+ console.print(table)
718
+ console.print(f"\n[dim]Showing {len(books)} of {total} books (offset: {offset})[/dim]")
719
+
720
+ lib.close()
721
+
722
+ except Exception as e:
723
+ console.print(f"[red]Error listing books: {e}[/red]")
561
724
  raise typer.Exit(code=1)
562
725
 
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
726
 
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])
727
+ # ============================================================================
728
+ # Personal Metadata Commands (Tags, Ratings, Favorites, Annotations)
729
+ # ============================================================================
575
730
 
576
-
577
731
  @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")
732
+ def rate(
733
+ book_id: int = typer.Argument(..., help="Book ID"),
734
+ library_path: Path = typer.Argument(..., help="Path to library"),
735
+ rating: float = typer.Option(..., "--rating", "-r", help="Rating (0-5 stars)")
586
736
  ):
587
737
  """
588
- Update an entry in the ebk library by index.
738
+ Rate a book (0-5 stars).
589
739
 
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
740
+ Example:
741
+ ebk rate 42 ~/my-library --rating 4.5
598
742
  """
743
+ from .library_db import Library
744
+
745
+ if not library_path.exists():
746
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
747
+ raise typer.Exit(code=1)
748
+
749
+ if not (0 <= rating <= 5):
750
+ console.print(f"[red]Error: Rating must be between 0 and 5[/red]")
751
+ raise typer.Exit(code=1)
599
752
 
600
753
  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]")
754
+ lib = Library.open(library_path)
755
+ lib.update_reading_status(book_id, "unread", rating=rating)
606
756
 
607
- if json_file:
608
- with open(json_file, "r") as f:
609
- updated_entry = json.load(f)
757
+ book = lib.get_book(book_id)
758
+ if book:
759
+ console.print(f"[green]✓ Rated '{book.title}': {rating} stars[/green]")
610
760
  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]")
761
+ console.print(f"[yellow]Book {book_id} not found[/yellow]")
762
+
763
+ lib.close()
764
+
765
+ except Exception as e:
766
+ console.print(f"[red]Error rating book: {e}[/red]")
629
767
  raise typer.Exit(code=1)
630
768
 
769
+
631
770
  @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")
771
+ def favorite(
772
+ book_id: int = typer.Argument(..., help="Book ID"),
773
+ library_path: Path = typer.Argument(..., help="Path to library"),
774
+ unfavorite: bool = typer.Option(False, "--unfavorite", "-u", help="Remove from favorites")
640
775
  ):
641
776
  """
642
- Update an entry in the ebk library by unique id.
777
+ Mark/unmark a book as favorite.
643
778
 
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
779
+ Examples:
780
+ ebk favorite 42 ~/my-library
781
+ ebk favorite 42 ~/my-library --unfavorite
652
782
  """
783
+ from .library_db import Library
784
+
785
+ if not library_path.exists():
786
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
787
+ raise typer.Exit(code=1)
788
+
789
+ try:
790
+ lib = Library.open(library_path)
791
+ lib.set_favorite(book_id, favorite=not unfavorite)
653
792
 
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]")
793
+ book = lib.get_book(book_id)
794
+ if book:
795
+ action = "Removed from" if unfavorite else "Added to"
796
+ console.print(f"[green] {action} favorites: '{book.title}'[/green]")
797
+ else:
798
+ console.print(f"[yellow]Book {book_id} not found[/yellow]")
799
+
800
+ lib.close()
801
+
802
+ except Exception as e:
803
+ console.print(f"[red]Error updating favorite: {e}[/red]")
658
804
  raise typer.Exit(code=1)
659
-
660
- update_index(lib_dir, index, json_file, title, creators, ebooks, cover)
805
+
661
806
 
662
807
  @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")
808
+ def tag(
809
+ book_id: int = typer.Argument(..., help="Book ID"),
810
+ library_path: Path = typer.Argument(..., help="Path to library"),
811
+ tags: str = typer.Option(..., "--tags", "-t", help="Tags (comma-separated)"),
812
+ remove: bool = typer.Option(False, "--remove", "-r", help="Remove tags instead of adding")
666
813
  ):
667
814
  """
668
- Remove entries from the ebk library by index.
815
+ Add or remove personal tags from a book.
669
816
 
670
- Args:
671
- lib_dir (str): Path to the ebk library directory to modify
672
- indices (List[int]): Indices of entries to remove
817
+ Examples:
818
+ ebk tag 42 ~/my-library --tags "to-read,programming"
819
+ ebk tag 42 ~/my-library --tags "to-read" --remove
820
+ """
821
+ from .library_db import Library
673
822
 
674
- Raises:
675
- typer.Exit: If the library directory is invalid or the index is out of range
823
+ if not library_path.exists():
824
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
825
+ raise typer.Exit(code=1)
676
826
 
677
- Output:
678
- Removes the specified entries from the library and updates the metadata file in-place.
679
- """
680
827
  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]")
828
+ lib = Library.open(library_path)
829
+ tag_list = [t.strip() for t in tags.split(',')]
830
+
831
+ if remove:
832
+ lib.remove_tags(book_id, tag_list)
833
+ action = "Removed tags from"
834
+ else:
835
+ lib.add_tags(book_id, tag_list)
836
+ action = "Added tags to"
699
837
 
700
- with open(Path(lib_dir) / "metadata.json", "w") as f:
701
- json.dump(metadata_list, f, indent=2)
838
+ book = lib.get_book(book_id)
839
+ if book:
840
+ console.print(f"[green]✓ {action} '{book.title}': {', '.join(tag_list)}[/green]")
841
+ else:
842
+ console.print(f"[yellow]Book {book_id} not found[/yellow]")
702
843
 
703
- console.print(f"[bold green]Removed {removed_count} entries from {lib_dir}[/bold green]")
844
+ lib.close()
704
845
 
705
846
  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]")
847
+ console.print(f"[red]Error updating tags: {e}[/red]")
708
848
  raise typer.Exit(code=1)
709
849
 
850
+
710
851
  @app.command()
711
- def dash(
712
- port: int = typer.Option(8501, "--port", help="Port to run the Streamlit app (default: 8501)")
852
+ def purge(
853
+ library_path: Path = typer.Argument(..., help="Path to library"),
854
+ # Filtering criteria
855
+ no_files: bool = typer.Option(False, "--no-files", help="Purge books with no file attachments"),
856
+ no_supported_formats: bool = typer.Option(False, "--no-supported-formats", help="Purge books without supported ebook formats (pdf, epub, mobi, azw3)"),
857
+ language: Optional[str] = typer.Option(None, "--language", help="Purge books in this language"),
858
+ format_filter: Optional[str] = typer.Option(None, "--format", help="Purge books with this format only"),
859
+ unread: bool = typer.Option(False, "--unread", help="Purge unread books only"),
860
+ max_rating: Optional[int] = typer.Option(None, "--max-rating", help="Purge books with rating <= this (1-5)"),
861
+ author: Optional[str] = typer.Option(None, "--author", help="Purge books by this author (partial match)"),
862
+ subject: Optional[str] = typer.Option(None, "--subject", help="Purge books with this subject (partial match)"),
863
+ # Safety options
864
+ dry_run: bool = typer.Option(True, "--dry-run/--execute", help="Show what would be deleted without deleting"),
865
+ delete_files: bool = typer.Option(False, "--delete-files", help="Also delete associated files from disk")
713
866
  ):
714
867
  """
715
- Launch the Streamlit dashboard.
868
+ Remove books from library based on filtering criteria.
869
+
870
+ By default runs in dry-run mode to show what would be deleted.
871
+ Use --execute to actually perform the deletion.
872
+
873
+ WARNING: This operation cannot be undone!
874
+
875
+ Examples:
876
+ # Preview books without files
877
+ ebk purge ~/my-library --no-files
878
+
879
+ # Preview books without supported ebook formats
880
+ ebk purge ~/my-library --no-supported-formats
881
+
882
+ # Delete books without files (after confirming)
883
+ ebk purge ~/my-library --no-files --execute
884
+
885
+ # Delete books with only unsupported formats
886
+ ebk purge ~/my-library --no-supported-formats --execute
887
+
888
+ # Delete unread books with rating <= 2 and their files
889
+ ebk purge ~/my-library --unread --max-rating 2 --execute --delete-files
890
+
891
+ # Delete all books in a specific language
892
+ ebk purge ~/my-library --language fr --execute
716
893
  """
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`.")
894
+ from .library_db import Library
895
+ from rich.table import Table
896
+
897
+ if not library_path.exists():
898
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
731
899
  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)
900
+
901
+ try:
902
+ lib = Library.open(library_path)
903
+
904
+ # Build filtered query
905
+ query = lib.query()
906
+
907
+ # Apply filters
908
+ if language:
909
+ query = query.filter_by_language(language)
910
+ if author:
911
+ query = query.filter_by_author(author)
912
+ if subject:
913
+ query = query.filter_by_subject(subject)
914
+ if format_filter:
915
+ query = query.filter_by_format(format_filter)
916
+ if max_rating is not None:
917
+ # Get books with rating <= max_rating
918
+ query = query.filter_by_rating(0, max_rating)
919
+ if unread:
920
+ # Filter for unread status
921
+ from .db.models import PersonalMetadata
922
+ query.query = query.query.join(PersonalMetadata).filter(
923
+ PersonalMetadata.reading_status == 'unread'
924
+ )
925
+
926
+ books = query.all()
927
+
928
+ # Filter for no files if requested
929
+ if no_files:
930
+ books = [b for b in books if len(b.files) == 0]
931
+
932
+ # Filter for no supported formats if requested
933
+ if no_supported_formats:
934
+ SUPPORTED_FORMATS = {'pdf', 'epub', 'mobi', 'azw3', 'azw', 'djvu', 'fb2', 'txt'}
935
+ books = [
936
+ b for b in books
937
+ if len(b.files) == 0 or not any(f.format.lower() in SUPPORTED_FORMATS for f in b.files)
938
+ ]
939
+
940
+ if not books:
941
+ console.print("[yellow]No books match the specified criteria[/yellow]")
942
+ lib.close()
943
+ return
944
+
945
+ # Display what will be purged
946
+ table = Table(title=f"Books to {'DELETE' if not dry_run else 'purge'} ({len(books)} total)")
947
+ table.add_column("ID", style="cyan")
948
+ table.add_column("Title", style="white")
949
+ table.add_column("Authors", style="blue")
950
+ table.add_column("Files", style="magenta")
951
+ table.add_column("Language", style="green")
952
+
953
+ for book in books[:20]: # Show first 20
954
+ authors = ", ".join(a.name for a in book.authors) or "Unknown"
955
+ files = ", ".join(f.format for f in book.files) or "None"
956
+ table.add_row(
957
+ str(book.id),
958
+ book.title[:50],
959
+ authors[:30],
960
+ files,
961
+ book.language or "?"
962
+ )
963
+
964
+ if len(books) > 20:
965
+ table.add_row("...", f"and {len(books) - 20} more", "", "", "")
966
+
967
+ console.print(table)
968
+
969
+ if dry_run:
970
+ console.print("\n[yellow]This is a DRY RUN - no changes will be made[/yellow]")
971
+ console.print("[yellow]Use --execute to actually delete these books[/yellow]")
972
+ if delete_files:
973
+ console.print("[yellow]--delete-files will also remove files from disk[/yellow]")
974
+ else:
975
+ # Confirm deletion
976
+ console.print("\n[red]WARNING: This will permanently delete these books![/red]")
977
+ if delete_files:
978
+ console.print("[red]This will also DELETE FILES from disk![/red]")
979
+
980
+ confirm = typer.confirm("Are you sure you want to proceed?")
981
+ if not confirm:
982
+ console.print("[yellow]Purge cancelled[/yellow]")
983
+ lib.close()
984
+ return
985
+
986
+ # Perform deletion
987
+ deleted_count = 0
988
+ files_deleted = 0
989
+ total_size = 0
990
+
991
+ for book in books:
992
+ # Delete files from disk if requested
993
+ if delete_files:
994
+ for file in book.files:
995
+ file_path = library_path / file.path
996
+ if file_path.exists():
997
+ total_size += file.size_bytes
998
+ file_path.unlink()
999
+ files_deleted += 1
1000
+
1001
+ # Delete from database
1002
+ lib.session.delete(book)
1003
+ deleted_count += 1
1004
+
1005
+ lib.session.commit()
1006
+
1007
+ console.print(f"\n[green]✓ Deleted {deleted_count} books from database[/green]")
1008
+ if delete_files:
1009
+ console.print(f"[green]✓ Deleted {files_deleted} files ({total_size / (1024**2):.1f} MB)[/green]")
1010
+
1011
+ lib.close()
1012
+
736
1013
  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]")
1014
+ console.print(f"[red]Error during purge: {e}[/red]")
1015
+ import traceback
1016
+ traceback.print_exc()
739
1017
  raise typer.Exit(code=1)
740
1018
 
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
1019
 
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)
1020
+ @note_app.command(name="add")
1021
+ def note_add(
1022
+ book_id: int = typer.Argument(..., help="Book ID"),
1023
+ library_path: Path = typer.Argument(..., help="Path to library"),
1024
+ content: str = typer.Option(..., "--content", "-c", help="Note/annotation text"),
1025
+ page: Optional[int] = typer.Option(None, "--page", "-p", help="Page number"),
1026
+ note_type: str = typer.Option("note", "--type", "-t", help="Annotation type (note, highlight, bookmark)")
1027
+ ):
1028
+ """
1029
+ Add a note/annotation to a book.
755
1030
 
756
- Returns:
757
- Search results as a table or JSON
1031
+ Examples:
1032
+ ebk note add 42 ~/my-library --content "Great explanation of algorithms"
1033
+ ebk note add 42 ~/my-library --content "Important theorem" --page 42
1034
+ ebk note add 42 ~/my-library --content "Key passage" --type highlight
758
1035
  """
1036
+ from .library_db import Library
1037
+
1038
+ if not library_path.exists():
1039
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1040
+ raise typer.Exit(code=1)
1041
+
759
1042
  try:
760
- results = search_regex(lib_dir, query, fields)
761
- if json_out:
762
- console.print_json(json.dumps(results, indent=2))
1043
+ lib = Library.open(library_path)
1044
+ annotation_id = lib.add_annotation(book_id, content, page=page, annotation_type=note_type)
1045
+
1046
+ book = lib.get_book(book_id)
1047
+ if book:
1048
+ loc_info = f" (page {page})" if page else ""
1049
+ console.print(f"[green]✓ Added {note_type} to '{book.title}'{loc_info}[/green]")
1050
+ console.print(f" Annotation ID: {annotation_id}")
763
1051
  else:
764
- enumerate_ebooks(results, Path(lib_dir))
1052
+ console.print(f"[yellow]Book {book_id} not found[/yellow]")
1053
+
1054
+ lib.close()
1055
+
765
1056
  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]")
1057
+ console.print(f"[red]Error adding note: {e}[/red]")
768
1058
  raise typer.Exit(code=1)
769
1059
 
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
1060
 
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
1061
+ @note_app.command(name="list")
1062
+ def note_list(
1063
+ book_id: int = typer.Argument(..., help="Book ID"),
1064
+ library_path: Path = typer.Argument(..., help="Path to library")
1065
+ ):
1066
+ """
1067
+ List all notes/annotations for a book.
782
1068
 
783
- Returns:
784
- JMEPSath query results, either pretty printed or as JSON.
1069
+ Example:
1070
+ ebk note list 42 ~/my-library
785
1071
  """
1072
+ from .library_db import Library
1073
+
1074
+ if not library_path.exists():
1075
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1076
+ raise typer.Exit(code=1)
1077
+
786
1078
  try:
787
- results = search_jmes(lib_dir, query)
788
- if json_out:
789
- console.print_json(json.dumps(results, indent=2))
1079
+ lib = Library.open(library_path)
1080
+ book = lib.get_book(book_id)
1081
+
1082
+ if not book:
1083
+ console.print(f"[yellow]Book {book_id} not found[/yellow]")
1084
+ lib.close()
1085
+ raise typer.Exit(code=1)
1086
+
1087
+ annotations = lib.get_annotations(book_id)
1088
+
1089
+ if not annotations:
1090
+ console.print(f"[yellow]No notes found for '{book.title}'[/yellow]")
790
1091
  else:
791
- print_json_as_table(results)
1092
+ console.print(f"\n[bold]Notes for: {book.title}[/bold]\n")
1093
+
1094
+ for i, ann in enumerate(annotations, 1):
1095
+ type_info = f"[{ann.annotation_type}]" if ann.annotation_type else "[note]"
1096
+ page_info = f" Page {ann.page_number}" if ann.page_number else ""
1097
+ console.print(f"{i}. {type_info}{page_info}")
1098
+ console.print(f" {ann.content}")
1099
+ console.print(f" [dim]ID: {ann.id} | Added: {ann.created_at.strftime('%Y-%m-%d %H:%M')}[/dim]\n")
1100
+
1101
+ lib.close()
1102
+
792
1103
  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]")
1104
+ console.print(f"[red]Error listing notes: {e}[/red]")
795
1105
  raise typer.Exit(code=1)
796
1106
 
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")
1107
+
1108
+ # ============================================================================
1109
+ # Export Commands
1110
+ # ============================================================================
1111
+
1112
+ @export_app.command(name="json")
1113
+ def export_json(
1114
+ library_path: Path = typer.Argument(..., help="Path to library"),
1115
+ output_file: Path = typer.Argument(..., help="Output JSON file"),
1116
+ include_annotations: bool = typer.Option(True, "--annotations/--no-annotations", help="Include annotations")
801
1117
  ):
802
1118
  """
803
- Query the ebk library using the LLM (Large Language Model) endpoint.
1119
+ Export library to JSON format.
804
1120
 
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
1121
+ Example:
1122
+ ebk export json ~/my-library ~/backup.json
811
1123
  """
1124
+ from .library_db import Library
1125
+ import json
1126
+
1127
+ if not library_path.exists():
1128
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1129
+ raise typer.Exit(code=1)
1130
+
812
1131
  try:
813
- query_llm(lib_dir, query)
1132
+ lib = Library.open(library_path)
1133
+ books = lib.get_all_books()
1134
+
1135
+ export_data = {
1136
+ "exported_at": datetime.now().isoformat(),
1137
+ "total_books": len(books),
1138
+ "books": []
1139
+ }
1140
+
1141
+ for book in books:
1142
+ book_data = {
1143
+ "id": book.id,
1144
+ "unique_id": book.unique_id,
1145
+ "title": book.title,
1146
+ "subtitle": book.subtitle,
1147
+ "authors": [a.name for a in book.authors],
1148
+ "subjects": [s.name for s in book.subjects],
1149
+ "language": book.language,
1150
+ "publisher": book.publisher,
1151
+ "publication_date": book.publication_date,
1152
+ "description": book.description,
1153
+ "page_count": book.page_count,
1154
+ "word_count": book.word_count,
1155
+ "files": [{"format": f.format, "size": f.size_bytes, "path": f.path} for f in book.files],
1156
+ "created_at": book.created_at.isoformat(),
1157
+ }
1158
+
1159
+ # Add personal metadata if exists
1160
+ if book.personal:
1161
+ book_data["personal"] = {
1162
+ "reading_status": book.personal.reading_status,
1163
+ "reading_progress": book.personal.reading_progress,
1164
+ "rating": book.personal.rating,
1165
+ "favorite": book.personal.favorite,
1166
+ "tags": book.personal.personal_tags
1167
+ }
1168
+
1169
+ # Add annotations if requested
1170
+ if include_annotations:
1171
+ annotations = lib.get_annotations(book.id)
1172
+ book_data["annotations"] = [
1173
+ {
1174
+ "id": ann.id,
1175
+ "type": ann.annotation_type,
1176
+ "content": ann.content,
1177
+ "page": ann.page_number,
1178
+ "position": ann.position,
1179
+ "created_at": ann.created_at.isoformat()
1180
+ }
1181
+ for ann in annotations
1182
+ ]
1183
+
1184
+ export_data["books"].append(book_data)
1185
+
1186
+ # Write JSON file
1187
+ with open(output_file, 'w', encoding='utf-8') as f:
1188
+ json.dump(export_data, f, indent=2, ensure_ascii=False)
1189
+
1190
+ console.print(f"[green]✓ Exported {len(books)} books to {output_file}[/green]")
1191
+ lib.close()
1192
+
814
1193
  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]")
1194
+ console.print(f"[red]Error exporting to JSON: {e}[/red]")
1195
+ raise typer.Exit(code=1)
1196
+
1197
+
1198
+ @export_app.command(name="csv")
1199
+ def export_csv(
1200
+ library_path: Path = typer.Argument(..., help="Path to library"),
1201
+ output_file: Path = typer.Argument(..., help="Output CSV file")
1202
+ ):
1203
+ """
1204
+ Export library to CSV format.
1205
+
1206
+ Example:
1207
+ ebk export csv ~/my-library ~/books.csv
1208
+ """
1209
+ from .library_db import Library
1210
+ import csv
1211
+
1212
+ if not library_path.exists():
1213
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1214
+ raise typer.Exit(code=1)
1215
+
1216
+ try:
1217
+ lib = Library.open(library_path)
1218
+ books = lib.get_all_books()
1219
+
1220
+ # Write CSV file
1221
+ with open(output_file, 'w', newline='', encoding='utf-8') as f:
1222
+ writer = csv.writer(f)
1223
+
1224
+ # Header
1225
+ writer.writerow([
1226
+ 'ID', 'Title', 'Authors', 'Publisher', 'Publication Date',
1227
+ 'Language', 'Subjects', 'Page Count', 'Rating', 'Favorite',
1228
+ 'Reading Status', 'Tags', 'Formats'
1229
+ ])
1230
+
1231
+ # Data
1232
+ for book in books:
1233
+ authors = '; '.join(a.name for a in book.authors)
1234
+ subjects = '; '.join(s.name for s in book.subjects)
1235
+ formats = ', '.join(f.format for f in book.files)
1236
+
1237
+ rating = book.personal.rating if book.personal else None
1238
+ favorite = book.personal.favorite if book.personal else False
1239
+ status = book.personal.reading_status if book.personal else 'unread'
1240
+ tags = ', '.join(book.personal.personal_tags) if book.personal and book.personal.personal_tags else ''
1241
+
1242
+ writer.writerow([
1243
+ book.id,
1244
+ book.title,
1245
+ authors,
1246
+ book.publisher or '',
1247
+ book.publication_date or '',
1248
+ book.language or '',
1249
+ subjects,
1250
+ book.page_count or '',
1251
+ rating or '',
1252
+ favorite,
1253
+ status,
1254
+ tags,
1255
+ formats
1256
+ ])
1257
+
1258
+ console.print(f"[green]✓ Exported {len(books)} books to {output_file}[/green]")
1259
+ lib.close()
1260
+
1261
+ except Exception as e:
1262
+ console.print(f"[red]Error exporting to CSV: {e}[/red]")
1263
+ raise typer.Exit(code=1)
1264
+
1265
+
1266
+ @export_app.command(name="html")
1267
+ def export_html(
1268
+ library_path: Path = typer.Argument(..., help="Path to library"),
1269
+ output_file: Path = typer.Argument(..., help="Output HTML file"),
1270
+ include_stats: bool = typer.Option(True, "--stats/--no-stats", help="Include library statistics"),
1271
+ base_url: str = typer.Option("", "--base-url", help="Base URL for file links (e.g., '/library' or 'https://example.com/books')"),
1272
+ copy_files: bool = typer.Option(False, "--copy", help="Copy referenced files to output directory"),
1273
+ # Filtering options
1274
+ language: Optional[str] = typer.Option(None, "--language", help="Filter by language code (e.g., 'en', 'es')"),
1275
+ author: Optional[str] = typer.Option(None, "--author", help="Filter by author name (partial match)"),
1276
+ subject: Optional[str] = typer.Option(None, "--subject", help="Filter by subject/tag (partial match)"),
1277
+ format_filter: Optional[str] = typer.Option(None, "--format", help="Filter by file format (e.g., 'pdf', 'epub')"),
1278
+ has_files: bool = typer.Option(True, "--has-files/--no-files", help="Only include books with file attachments"),
1279
+ favorite: Optional[bool] = typer.Option(None, "--favorite", help="Filter by favorite status"),
1280
+ min_rating: Optional[int] = typer.Option(None, "--min-rating", help="Minimum rating (1-5)"),
1281
+ ):
1282
+ """
1283
+ Export library to a self-contained HTML5 file.
1284
+
1285
+ Creates an interactive, searchable, filterable catalog that works offline.
1286
+ All metadata including contributors, series, keywords, etc. is preserved.
1287
+
1288
+ File links are included in the export. Use --base-url to set the URL prefix for files
1289
+ when deploying to a web server (e.g., Hugo site).
1290
+
1291
+ Use --copy to copy only the referenced files to the output directory, avoiding duplication
1292
+ of the entire library.
1293
+
1294
+ Examples:
1295
+ # Basic export with relative paths
1296
+ ebk export html ~/my-library ~/library.html
1297
+
1298
+ # Export for Hugo deployment with file copying
1299
+ ebk export html ~/my-library ~/hugo/static/library.html \\
1300
+ --base-url /library --copy
1301
+
1302
+ # Export only English PDFs rated 4+
1303
+ ebk export html ~/my-library ~/library.html \\
1304
+ --language en --format pdf --min-rating 4
1305
+ """
1306
+ from .library_db import Library
1307
+ from .exports.html_library import export_to_html
1308
+ import shutil
1309
+
1310
+ if not library_path.exists():
1311
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1312
+ raise typer.Exit(code=1)
1313
+
1314
+ try:
1315
+ console.print("[blue]Exporting library to HTML...[/blue]")
1316
+ lib = Library.open(library_path)
1317
+
1318
+ # Build filtered query
1319
+ query = lib.query()
1320
+
1321
+ # Apply filters
1322
+ if language:
1323
+ query = query.filter_by_language(language)
1324
+ if author:
1325
+ query = query.filter_by_author(author)
1326
+ if subject:
1327
+ query = query.filter_by_subject(subject)
1328
+ if format_filter:
1329
+ query = query.filter_by_format(format_filter)
1330
+ if favorite is not None:
1331
+ query = query.filter_by_favorite(favorite)
1332
+ if min_rating:
1333
+ query = query.filter_by_rating(min_rating)
1334
+
1335
+ books = query.all()
1336
+
1337
+ # Filter out books without files if requested
1338
+ if has_files:
1339
+ books = [b for b in books if len(b.files) > 0]
1340
+
1341
+ if not books:
1342
+ console.print("[yellow]No books match the specified filters[/yellow]")
1343
+ lib.close()
1344
+ return
1345
+
1346
+ # Copy files if requested
1347
+ if copy_files:
1348
+ output_dir = output_file.parent
1349
+ if not base_url:
1350
+ console.print("[yellow]Warning: --copy requires --base-url to be set[/yellow]")
1351
+ console.print("[yellow]Files will be copied but may not resolve correctly[/yellow]")
1352
+
1353
+ # Determine copy destination
1354
+ if base_url.startswith(('http://', 'https://')):
1355
+ console.print("[red]Error: --copy cannot be used with full URLs in --base-url[/red]")
1356
+ console.print("[red]Use a relative path like '/library' instead[/red]")
1357
+ lib.close()
1358
+ raise typer.Exit(code=1)
1359
+
1360
+ # Copy files to output_dir / base_url (stripping leading /)
1361
+ copy_dest = output_dir / base_url.lstrip('/')
1362
+ copy_dest.mkdir(parents=True, exist_ok=True)
1363
+
1364
+ console.print(f"[blue]Copying files to {copy_dest}...[/blue]")
1365
+ files_copied = 0
1366
+ covers_copied = 0
1367
+ total_size = 0
1368
+
1369
+ for book in books:
1370
+ # Copy ebook files
1371
+ for file in book.files:
1372
+ src = library_path / file.path
1373
+ dest = copy_dest / file.path
1374
+
1375
+ if src.exists():
1376
+ dest.parent.mkdir(parents=True, exist_ok=True)
1377
+ shutil.copy2(src, dest)
1378
+ files_copied += 1
1379
+ total_size += file.size_bytes
1380
+
1381
+ # Copy cover images
1382
+ for cover in book.covers:
1383
+ src = library_path / cover.path
1384
+ dest = copy_dest / cover.path
1385
+
1386
+ if src.exists():
1387
+ dest.parent.mkdir(parents=True, exist_ok=True)
1388
+ shutil.copy2(src, dest)
1389
+ covers_copied += 1
1390
+ total_size += src.stat().st_size
1391
+
1392
+ console.print(f"[green]✓ Copied {files_copied} files and {covers_copied} covers ({total_size / (1024**2):.1f} MB)[/green]")
1393
+
1394
+ export_to_html(books, output_file, include_stats=include_stats, base_url=base_url)
1395
+
1396
+ console.print(f"[green]✓ Exported {len(books)} books to {output_file}[/green]")
1397
+ if base_url:
1398
+ console.print(f" File links will use base URL: {base_url}")
1399
+ console.print(f" Open {output_file} in a web browser to view your library")
1400
+ lib.close()
1401
+
1402
+ except Exception as e:
1403
+ console.print(f"[red]Error exporting to HTML: {e}[/red]")
1404
+ import traceback
1405
+ traceback.print_exc()
1406
+ raise typer.Exit(code=1)
1407
+
1408
+
1409
+ @vlib_app.command(name="add")
1410
+ def vlib_add(
1411
+ book_id: int = typer.Argument(..., help="Book ID"),
1412
+ library_path: Path = typer.Argument(..., help="Path to library"),
1413
+ vlib: str = typer.Option(..., "--library", "-l", help="Virtual library name (e.g., 'computer-science', 'mathematics')")
1414
+ ):
1415
+ """
1416
+ Add a book to a virtual library (collection view).
1417
+
1418
+ Virtual libraries allow organizing books into multiple collections.
1419
+ A book can belong to multiple virtual libraries.
1420
+
1421
+ Examples:
1422
+ ebk vlib add 1 ~/my-library --library computer-science
1423
+ ebk vlib add 1 ~/my-library -l mathematics
1424
+ """
1425
+ from .library_db import Library
1426
+
1427
+ if not library_path.exists():
1428
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1429
+ raise typer.Exit(code=1)
1430
+
1431
+ try:
1432
+ lib = Library.open(library_path)
1433
+ lib.add_to_virtual_library(book_id, vlib)
1434
+
1435
+ book = lib.get_book(book_id)
1436
+ if book:
1437
+ console.print(f"[green]✓ Added '{book.title}' to virtual library '{vlib}'[/green]")
1438
+
1439
+ lib.close()
1440
+
1441
+ except Exception as e:
1442
+ console.print(f"[red]Error: {e}[/red]")
1443
+ raise typer.Exit(code=1)
1444
+
1445
+
1446
+ @vlib_app.command(name="remove")
1447
+ def vlib_remove(
1448
+ book_id: int = typer.Argument(..., help="Book ID"),
1449
+ library_path: Path = typer.Argument(..., help="Path to library"),
1450
+ vlib: str = typer.Option(..., "--library", "-l", help="Virtual library name")
1451
+ ):
1452
+ """
1453
+ Remove a book from a virtual library.
1454
+
1455
+ Example:
1456
+ ebk vlib remove 1 ~/my-library --library computer-science
1457
+ """
1458
+ from .library_db import Library
1459
+
1460
+ if not library_path.exists():
1461
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1462
+ raise typer.Exit(code=1)
1463
+
1464
+ try:
1465
+ lib = Library.open(library_path)
1466
+ lib.remove_from_virtual_library(book_id, vlib)
1467
+
1468
+ book = lib.get_book(book_id)
1469
+ if book:
1470
+ console.print(f"[green]✓ Removed '{book.title}' from virtual library '{vlib}'[/green]")
1471
+
1472
+ lib.close()
1473
+
1474
+ except Exception as e:
1475
+ console.print(f"[red]Error: {e}[/red]")
1476
+ raise typer.Exit(code=1)
1477
+
1478
+
1479
+ @vlib_app.command(name="list")
1480
+ def vlib_list(
1481
+ library_path: Path = typer.Argument(..., help="Path to library"),
1482
+ vlib: Optional[str] = typer.Option(None, "--library", "-l", help="Show books in specific virtual library")
1483
+ ):
1484
+ """
1485
+ List all virtual libraries or books in a specific virtual library.
1486
+
1487
+ Examples:
1488
+ ebk vlib list ~/my-library # List all virtual libraries
1489
+ ebk vlib list ~/my-library --library mathematics # List books in 'mathematics'
1490
+ """
1491
+ from .library_db import Library
1492
+
1493
+ if not library_path.exists():
1494
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1495
+ raise typer.Exit(code=1)
1496
+
1497
+ try:
1498
+ lib = Library.open(library_path)
1499
+
1500
+ if vlib:
1501
+ # Show books in this virtual library
1502
+ books = lib.get_virtual_library(vlib)
1503
+
1504
+ if not books:
1505
+ console.print(f"[yellow]No books found in virtual library '{vlib}'[/yellow]")
1506
+ else:
1507
+ console.print(f"\n[bold]Virtual Library: {vlib}[/bold] ({len(books)} books)\n")
1508
+
1509
+ table = Table(show_header=True, header_style="bold magenta")
1510
+ table.add_column("ID", style="dim")
1511
+ table.add_column("Title")
1512
+ table.add_column("Authors")
1513
+
1514
+ for book in books:
1515
+ authors = ", ".join(a.name for a in book.authors[:2])
1516
+ if len(book.authors) > 2:
1517
+ authors += "..."
1518
+
1519
+ table.add_row(
1520
+ str(book.id),
1521
+ book.title[:50] + "..." if len(book.title) > 50 else book.title,
1522
+ authors
1523
+ )
1524
+
1525
+ console.print(table)
1526
+ else:
1527
+ # List all virtual libraries
1528
+ libraries = lib.list_virtual_libraries()
1529
+
1530
+ if not libraries:
1531
+ console.print("[yellow]No virtual libraries found[/yellow]")
1532
+ console.print("[dim]Use 'ebk vlib add' to create virtual libraries[/dim]")
1533
+ else:
1534
+ console.print(f"\n[bold]Virtual Libraries[/bold] ({len(libraries)} total)\n")
1535
+
1536
+ for vlib_name in libraries:
1537
+ books = lib.get_virtual_library(vlib_name)
1538
+ console.print(f" • {vlib_name} ({len(books)} books)")
1539
+
1540
+ lib.close()
1541
+
1542
+ except Exception as e:
1543
+ console.print(f"[red]Error: {e}[/red]")
1544
+ raise typer.Exit(code=1)
1545
+
1546
+
1547
+ @app.command()
1548
+ def view(
1549
+ book_id: int = typer.Argument(..., help="Book ID to view"),
1550
+ library_path: Path = typer.Argument(..., help="Path to library"),
1551
+ text: bool = typer.Option(False, "--text", help="Display extracted text in console"),
1552
+ page: Optional[int] = typer.Option(None, "--page", help="View specific page (for text mode)"),
1553
+ format_choice: Optional[str] = typer.Option(None, "--format", help="Choose specific format (pdf, epub, txt, etc.)")
1554
+ ):
1555
+ """
1556
+ View a book's content.
1557
+
1558
+ Without --text: Opens the ebook file in the default application.
1559
+ With --text: Displays extracted text in the console with paging.
1560
+ """
1561
+ import subprocess
1562
+ import platform
1563
+ from .library_db import Library
1564
+ from .db.models import ExtractedText
1565
+
1566
+ try:
1567
+ lib = Library.open(library_path)
1568
+ book = lib.get_book(book_id)
1569
+
1570
+ if not book:
1571
+ console.print(f"[red]Book with ID {book_id} not found[/red]")
1572
+ lib.close()
1573
+ raise typer.Exit(code=1)
1574
+
1575
+ if text:
1576
+ # Display extracted text in console
1577
+ # ExtractedText is linked to File, not Book directly
1578
+ extracted_text = None
1579
+ for file in book.files:
1580
+ if file.extracted_text and file.extracted_text.content:
1581
+ extracted_text = file.extracted_text.content
1582
+ break
1583
+
1584
+ if not extracted_text:
1585
+ console.print(f"[yellow]No extracted text available for '{book.title}'[/yellow]")
1586
+ console.print("[dim]Try re-importing the book with text extraction enabled[/dim]")
1587
+ lib.close()
1588
+ raise typer.Exit(code=1)
1589
+
1590
+ # Display book info
1591
+ console.print(f"\n[bold blue]{book.title}[/bold blue]")
1592
+ if book.authors:
1593
+ console.print(f"[dim]by {', '.join(a.name for a in book.authors)}[/dim]")
1594
+ console.print()
1595
+
1596
+ # If page specified, try to show just that page
1597
+ # (This is approximate - we don't have exact page boundaries)
1598
+ if page is not None:
1599
+ # Estimate ~400 words per page
1600
+ words = extracted_text.split()
1601
+ words_per_page = 400
1602
+ start_idx = (page - 1) * words_per_page
1603
+ end_idx = start_idx + words_per_page
1604
+
1605
+ if start_idx >= len(words):
1606
+ console.print(f"[yellow]Page {page} exceeds document length[/yellow]")
1607
+ lib.close()
1608
+ raise typer.Exit(code=1)
1609
+
1610
+ page_words = words[start_idx:end_idx]
1611
+ text_content = ' '.join(page_words)
1612
+ console.print(f"[dim]Approximate page {page} (words {start_idx+1}-{end_idx}):[/dim]\n")
1613
+ console.print(text_content)
1614
+ else:
1615
+ # Show full text with paging
1616
+ # Use rich pager for long text
1617
+ with console.pager(styles=True):
1618
+ console.print(extracted_text)
1619
+ else:
1620
+ # Open file in default application
1621
+ if not book.files:
1622
+ console.print(f"[yellow]No files available for '{book.title}'[/yellow]")
1623
+ lib.close()
1624
+ raise typer.Exit(code=1)
1625
+
1626
+ # Select file to open
1627
+ file_to_open = None
1628
+ if format_choice:
1629
+ # Find file matching requested format
1630
+ for f in book.files:
1631
+ if f.format.lower() == format_choice.lower():
1632
+ file_to_open = f
1633
+ break
1634
+ if not file_to_open:
1635
+ console.print(f"[yellow]No {format_choice} file found for this book[/yellow]")
1636
+ console.print(f"Available formats: {', '.join(f.format for f in book.files)}")
1637
+ lib.close()
1638
+ raise typer.Exit(code=1)
1639
+ else:
1640
+ # Use first file (prefer PDF > EPUB > others)
1641
+ formats_priority = ['pdf', 'epub', 'mobi', 'azw3', 'txt']
1642
+ for fmt in formats_priority:
1643
+ for f in book.files:
1644
+ if f.format.lower() == fmt:
1645
+ file_to_open = f
1646
+ break
1647
+ if file_to_open:
1648
+ break
1649
+
1650
+ if not file_to_open:
1651
+ file_to_open = book.files[0]
1652
+
1653
+ file_path = library_path / file_to_open.path
1654
+
1655
+ if not file_path.exists():
1656
+ console.print(f"[red]File not found: {file_path}[/red]")
1657
+ lib.close()
1658
+ raise typer.Exit(code=1)
1659
+
1660
+ console.print(f"[blue]Opening '{book.title}' ({file_to_open.format})[/blue]")
1661
+
1662
+ # Open with default application based on OS
1663
+ system = platform.system()
1664
+ try:
1665
+ if system == 'Darwin': # macOS
1666
+ subprocess.run(['open', str(file_path)], check=True)
1667
+ elif system == 'Windows':
1668
+ subprocess.run(['start', '', str(file_path)], shell=True, check=True)
1669
+ else: # Linux and others
1670
+ subprocess.run(['xdg-open', str(file_path)], check=True)
1671
+
1672
+ console.print("[green]✓ File opened successfully[/green]")
1673
+ except subprocess.CalledProcessError as e:
1674
+ console.print(f"[red]Failed to open file: {e}[/red]")
1675
+ console.print(f"[dim]File location: {file_path}[/dim]")
1676
+ lib.close()
1677
+ raise typer.Exit(code=1)
1678
+
1679
+ lib.close()
1680
+
1681
+ except Exception as e:
1682
+ console.print(f"[red]Error viewing book: {e}[/red]")
1683
+ raise typer.Exit(code=1)
1684
+
1685
+
1686
+ @app.command()
1687
+ def serve(
1688
+ library_path: Optional[Path] = typer.Argument(None, help="Path to library (defaults from config)"),
1689
+ host: Optional[str] = typer.Option(None, "--host", help="Host to bind to (defaults from config)"),
1690
+ port: Optional[int] = typer.Option(None, "--port", help="Port to bind to (defaults from config)"),
1691
+ reload: bool = typer.Option(False, "--reload", help="Enable auto-reload for development"),
1692
+ no_open: bool = typer.Option(False, "--no-open", help="Don't auto-open browser")
1693
+ ):
1694
+ """
1695
+ Start the web server for library management.
1696
+
1697
+ Provides a browser-based interface for managing your ebook library.
1698
+ Access the interface at http://localhost:8000 (or the specified host/port).
1699
+
1700
+ Configuration:
1701
+ Default server settings are loaded from ~/.config/ebk/config.json
1702
+ Command-line options override config file values.
1703
+ Use 'ebk config' to set default library path and server settings.
1704
+
1705
+ Examples:
1706
+ # Start server with configured defaults
1707
+ ebk serve
1708
+
1709
+ # Override config for one-time use
1710
+ ebk serve ~/my-library --port 8080
1711
+
1712
+ # Start with auto-reload (development)
1713
+ ebk serve --reload
1714
+ """
1715
+ from ebk.config import load_config
1716
+ import webbrowser
1717
+
1718
+ # Load config
1719
+ config = load_config()
1720
+
1721
+ # Resolve library path
1722
+ if library_path is None:
1723
+ if config.library.default_path:
1724
+ library_path = Path(config.library.default_path)
1725
+ else:
1726
+ console.print("[red]Error: No library path specified[/red]")
1727
+ console.print("[yellow]Either provide a path or set default with:[/yellow]")
1728
+ console.print("[yellow] ebk config --library-path ~/my-library[/yellow]")
1729
+ raise typer.Exit(code=1)
1730
+
1731
+ if not library_path.exists():
1732
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1733
+ raise typer.Exit(code=1)
1734
+
1735
+ # Resolve host and port
1736
+ server_host = host if host is not None else config.server.host
1737
+ server_port = port if port is not None else config.server.port
1738
+ auto_open = config.server.auto_open_browser and not no_open
1739
+
1740
+ try:
1741
+ import uvicorn
1742
+ except ImportError:
1743
+ console.print("[red]Error: uvicorn is not installed[/red]")
1744
+ console.print("[yellow]Install with: pip install uvicorn[/yellow]")
1745
+ raise typer.Exit(code=1)
1746
+
1747
+ try:
1748
+ from .server import create_app
1749
+
1750
+ console.print(f"[blue]Starting ebk server...[/blue]")
1751
+ console.print(f"[blue]Library: {library_path}[/blue]")
1752
+ console.print(f"[green]Server running at http://{server_host}:{server_port}[/green]")
1753
+ console.print("[dim]Press Ctrl+C to stop[/dim]")
1754
+
1755
+ # Auto-open browser
1756
+ if auto_open:
1757
+ # Use localhost for browser even if binding to 0.0.0.0
1758
+ browser_host = "localhost" if server_host == "0.0.0.0" else server_host
1759
+ url = f"http://{browser_host}:{server_port}"
1760
+ console.print(f"[dim]Opening browser to {url}...[/dim]")
1761
+ webbrowser.open(url)
1762
+
1763
+ # Create app with library
1764
+ app_instance = create_app(library_path)
1765
+
1766
+ # Run server
1767
+ uvicorn.run(
1768
+ app_instance,
1769
+ host=server_host,
1770
+ port=server_port,
1771
+ reload=reload,
1772
+ log_level="info"
1773
+ )
1774
+
1775
+ except KeyboardInterrupt:
1776
+ console.print("\n[yellow]Server stopped[/yellow]")
1777
+ except Exception as e:
1778
+ console.print(f"[red]Error starting server: {e}[/red]")
1779
+ import traceback
1780
+ traceback.print_exc()
1781
+ raise typer.Exit(code=1)
1782
+
1783
+
1784
+ @app.command()
1785
+ def enrich(
1786
+ library_path: Path = typer.Argument(..., help="Path to library"),
1787
+ provider: Optional[str] = typer.Option(None, help="LLM provider (ollama, openai) - defaults from config"),
1788
+ model: Optional[str] = typer.Option(None, help="Model name - defaults from config"),
1789
+ host: Optional[str] = typer.Option(None, help="Ollama host (for remote GPU) - defaults from config"),
1790
+ port: Optional[int] = typer.Option(None, help="Ollama port - defaults from config"),
1791
+ api_key: Optional[str] = typer.Option(None, help="API key (for OpenAI)"),
1792
+ book_id: Optional[int] = typer.Option(None, help="Enrich specific book ID only"),
1793
+ generate_tags: bool = typer.Option(True, help="Generate tags"),
1794
+ categorize: bool = typer.Option(True, help="Categorize books"),
1795
+ enhance_descriptions: bool = typer.Option(False, help="Enhance descriptions"),
1796
+ assess_difficulty: bool = typer.Option(False, help="Assess difficulty levels"),
1797
+ dry_run: bool = typer.Option(False, help="Show what would be done without saving"),
1798
+ ):
1799
+ """
1800
+ Enrich book metadata using LLM.
1801
+
1802
+ Uses LLM to generate tags, categorize books, enhance descriptions,
1803
+ and assess difficulty levels based on existing metadata and extracted text.
1804
+
1805
+ Configuration:
1806
+ Default LLM settings are loaded from ~/.config/ebk/config.json
1807
+ Command-line options override config file values.
1808
+ Use 'ebk config' to view/edit your default configuration.
1809
+
1810
+ Examples:
1811
+ # Enrich all books using configured defaults
1812
+ ebk enrich ~/my-library
1813
+
1814
+ # Override config to use different host
1815
+ ebk enrich ~/my-library --host 192.168.1.100
1816
+
1817
+ # Enrich specific book
1818
+ ebk enrich ~/my-library --book-id 42
1819
+
1820
+ # Generate tags and descriptions
1821
+ ebk enrich ~/my-library --enhance-descriptions
1822
+
1823
+ # Dry run to see what would be generated
1824
+ ebk enrich ~/my-library --dry-run
1825
+ """
1826
+ import asyncio
1827
+ from ebk.library_db import Library
1828
+ from ebk.ai.llm_providers.ollama import OllamaProvider
1829
+ from ebk.ai.llm_providers.base import LLMConfig
1830
+ from ebk.ai.metadata_enrichment import MetadataEnrichmentService
1831
+ from ebk.config import load_config
1832
+
1833
+ if not library_path.exists():
1834
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
1835
+ raise typer.Exit(code=1)
1836
+
1837
+ # Load configuration and apply CLI overrides
1838
+ config = load_config()
1839
+ llm_cfg = config.llm
1840
+
1841
+ # Override config with CLI options if provided
1842
+ if provider is not None:
1843
+ llm_cfg.provider = provider
1844
+ if model is not None:
1845
+ llm_cfg.model = model
1846
+ if host is not None:
1847
+ llm_cfg.host = host
1848
+ if port is not None:
1849
+ llm_cfg.port = port
1850
+ if api_key is not None:
1851
+ llm_cfg.api_key = api_key
1852
+
1853
+ console.print(f"[dim]Using provider: {llm_cfg.provider}[/dim]")
1854
+ console.print(f"[dim]Model: {llm_cfg.model}[/dim]")
1855
+ console.print(f"[dim]Host: {llm_cfg.host}:{llm_cfg.port}[/dim]")
1856
+
1857
+ async def enrich_library():
1858
+ # Initialize LLM provider
1859
+ console.print(f"[blue]Initializing {llm_cfg.provider} provider...[/blue]")
1860
+
1861
+ if llm_cfg.provider == "ollama":
1862
+ llm_provider = OllamaProvider.remote(
1863
+ host=llm_cfg.host,
1864
+ port=llm_cfg.port,
1865
+ model=llm_cfg.model,
1866
+ temperature=llm_cfg.temperature
1867
+ )
1868
+ elif llm_cfg.provider == "openai":
1869
+ if not llm_cfg.api_key:
1870
+ console.print("[red]Error: API key required for OpenAI (use --api-key or set in config)[/red]")
1871
+ raise typer.Exit(code=1)
1872
+ config = LLMConfig(
1873
+ base_url="https://api.openai.com/v1",
1874
+ api_key=llm_cfg.api_key,
1875
+ model=llm_cfg.model
1876
+ )
1877
+ # Would need OpenAI provider implementation
1878
+ console.print("[red]OpenAI provider not yet implemented[/red]")
1879
+ raise typer.Exit(code=1)
1880
+ else:
1881
+ console.print(f"[red]Unknown provider: {llm_cfg.provider}[/red]")
1882
+ raise typer.Exit(code=1)
1883
+
1884
+ # Initialize provider
1885
+ await llm_provider.initialize()
1886
+
1887
+ try:
1888
+ # Test connection by listing models
1889
+ models = await llm_provider.list_models()
1890
+ console.print(f"[green]Connected! Available models: {', '.join(models[:5])}[/green]")
1891
+
1892
+ # Initialize service
1893
+ service = MetadataEnrichmentService(llm_provider)
1894
+
1895
+ # Open library
1896
+ console.print(f"[blue]Opening library: {library_path}[/blue]")
1897
+ lib = Library.open(library_path)
1898
+
1899
+ try:
1900
+ # Get books to process
1901
+ if book_id:
1902
+ books = [lib.get_book(book_id)]
1903
+ if not books[0]:
1904
+ console.print(f"[red]Book ID {book_id} not found[/red]")
1905
+ raise typer.Exit(code=1)
1906
+ else:
1907
+ books = lib.query().all()
1908
+
1909
+ console.print(f"[blue]Processing {len(books)} books...[/blue]")
1910
+
1911
+ with Progress() as progress:
1912
+ task = progress.add_task("Enriching metadata...", total=len(books))
1913
+
1914
+ for book in books:
1915
+ progress.console.print(f"\n[cyan]Processing: {book.title}[/cyan]")
1916
+
1917
+ # Get extracted text if available
1918
+ text_sample = None
1919
+ if book.files:
1920
+ for file in book.files:
1921
+ if file.extracted_text and file.extracted_text.content:
1922
+ text_sample = file.extracted_text.content[:5000]
1923
+ break
1924
+
1925
+ # Generate tags
1926
+ if generate_tags:
1927
+ progress.console.print(" Generating tags...")
1928
+ tags = await service.generate_tags(
1929
+ title=book.title,
1930
+ authors=[a.name for a in book.authors],
1931
+ subjects=[s.name for s in book.subjects],
1932
+ description=book.description,
1933
+ text_sample=text_sample
1934
+ )
1935
+
1936
+ if tags:
1937
+ progress.console.print(f" [green]Tags: {', '.join(tags)}[/green]")
1938
+ if not dry_run:
1939
+ lib.add_tags(book.id, tags)
1940
+
1941
+ # Categorize
1942
+ if categorize:
1943
+ progress.console.print(" Categorizing...")
1944
+ categories = await service.categorize(
1945
+ title=book.title,
1946
+ subjects=[s.name for s in book.subjects],
1947
+ description=book.description
1948
+ )
1949
+
1950
+ if categories:
1951
+ progress.console.print(f" [green]Categories: {', '.join(categories)}[/green]")
1952
+ if not dry_run:
1953
+ # Add categories as subjects
1954
+ for cat in categories:
1955
+ lib.add_subject(book.id, cat)
1956
+
1957
+ # Enhance description
1958
+ if enhance_descriptions and (not book.description or len(book.description) < 100):
1959
+ progress.console.print(" Enhancing description...")
1960
+ description = await service.enhance_description(
1961
+ title=book.title,
1962
+ existing_description=book.description,
1963
+ text_sample=text_sample
1964
+ )
1965
+
1966
+ if description and description != book.description:
1967
+ progress.console.print(f" [green]New description: {description[:100]}...[/green]")
1968
+ if not dry_run:
1969
+ book.description = description
1970
+ lib.session.commit()
1971
+
1972
+ # Assess difficulty
1973
+ if assess_difficulty and text_sample:
1974
+ progress.console.print(" Assessing difficulty...")
1975
+ difficulty = await service.assess_difficulty(
1976
+ text_sample=text_sample,
1977
+ subjects=[s.name for s in book.subjects]
1978
+ )
1979
+
1980
+ progress.console.print(f" [green]Difficulty: {difficulty}[/green]")
1981
+ # Could store in keywords or custom field
1982
+
1983
+ progress.update(task, advance=1)
1984
+
1985
+ if dry_run:
1986
+ console.print("\n[yellow]Dry run completed - no changes saved[/yellow]")
1987
+ else:
1988
+ lib.session.commit()
1989
+ console.print("\n[green]Enrichment completed![/green]")
1990
+
1991
+ finally:
1992
+ lib.close()
1993
+
1994
+ finally:
1995
+ await llm_provider.cleanup()
1996
+
1997
+ # Run async function
1998
+ try:
1999
+ asyncio.run(enrich_library())
2000
+ except KeyboardInterrupt:
2001
+ console.print("\n[yellow]Enrichment cancelled[/yellow]")
2002
+ except Exception as e:
2003
+ console.print(f"[red]Enrichment failed: {e}[/red]")
2004
+ import traceback
2005
+ traceback.print_exc()
2006
+ raise typer.Exit(code=1)
2007
+
2008
+
2009
+ @app.command()
2010
+ def config(
2011
+ show: bool = typer.Option(False, "--show", help="Show current configuration"),
2012
+ init: bool = typer.Option(False, "--init", help="Initialize config file with defaults"),
2013
+ # LLM settings
2014
+ set_provider: Optional[str] = typer.Option(None, "--llm-provider", help="Set LLM provider (ollama, openai)"),
2015
+ set_model: Optional[str] = typer.Option(None, "--llm-model", help="Set default model name"),
2016
+ set_llm_host: Optional[str] = typer.Option(None, "--llm-host", help="Set Ollama/LLM host"),
2017
+ set_llm_port: Optional[int] = typer.Option(None, "--llm-port", help="Set Ollama/LLM port"),
2018
+ set_api_key: Optional[str] = typer.Option(None, "--llm-api-key", help="Set LLM API key"),
2019
+ set_temperature: Optional[float] = typer.Option(None, "--llm-temperature", help="Set temperature (0.0-1.0)"),
2020
+ # Server settings
2021
+ set_server_host: Optional[str] = typer.Option(None, "--server-host", help="Set web server host"),
2022
+ set_server_port: Optional[int] = typer.Option(None, "--server-port", help="Set web server port"),
2023
+ set_auto_open: Optional[bool] = typer.Option(None, "--server-auto-open/--no-server-auto-open", help="Auto-open browser on server start"),
2024
+ # Library settings
2025
+ set_library_path: Optional[str] = typer.Option(None, "--library-path", help="Set default library path"),
2026
+ # CLI settings
2027
+ set_verbose: Optional[bool] = typer.Option(None, "--cli-verbose/--no-cli-verbose", help="Enable verbose output by default"),
2028
+ set_color: Optional[bool] = typer.Option(None, "--cli-color/--no-cli-color", help="Enable colored output by default"),
2029
+ ):
2030
+ """
2031
+ View or edit EBK configuration.
2032
+
2033
+ Configuration is stored at ~/.config/ebk/config.json (or ~/.ebk/config.json).
2034
+
2035
+ Examples:
2036
+ # Show current configuration
2037
+ ebk config --show
2038
+
2039
+ # Initialize config file with defaults
2040
+ ebk config --init
2041
+
2042
+ # Set default library path
2043
+ ebk config --library-path ~/my-library
2044
+
2045
+ # Set remote Ollama host
2046
+ ebk config --llm-host 192.168.0.225
2047
+
2048
+ # Set server to auto-open browser
2049
+ ebk config --server-auto-open --server-host 0.0.0.0
2050
+
2051
+ # Set multiple values
2052
+ ebk config --llm-host 192.168.0.225 --llm-model llama3.2 --server-port 9000
2053
+ """
2054
+ from ebk.config import (
2055
+ load_config, save_config, ensure_config_exists,
2056
+ update_config, get_config_path
2057
+ )
2058
+ import json
2059
+
2060
+ # Handle --init
2061
+ if init:
2062
+ config_path = ensure_config_exists()
2063
+ console.print(f"[green]Configuration initialized at {config_path}[/green]")
2064
+ return
2065
+
2066
+ # Check if any settings provided
2067
+ has_settings = any([
2068
+ set_provider, set_model, set_llm_host, set_llm_port, set_api_key, set_temperature,
2069
+ set_server_host, set_server_port, set_auto_open is not None,
2070
+ set_library_path, set_verbose is not None, set_color is not None
2071
+ ])
2072
+
2073
+ # Handle --show or no args (default to show)
2074
+ if show or not has_settings:
2075
+ config = load_config()
2076
+ config_path = get_config_path()
2077
+
2078
+ console.print(f"\n[bold]EBK Configuration[/bold]")
2079
+ console.print(f"[dim]Location: {config_path}[/dim]\n")
2080
+
2081
+ console.print("[bold cyan]Library Settings:[/bold cyan]")
2082
+ if config.library.default_path:
2083
+ console.print(f" Default Path: {config.library.default_path}")
2084
+ else:
2085
+ console.print(f" Default Path: [dim]not set[/dim]")
2086
+
2087
+ console.print("\n[bold cyan]LLM Settings:[/bold cyan]")
2088
+ console.print(f" Provider: {config.llm.provider}")
2089
+ console.print(f" Model: {config.llm.model}")
2090
+ console.print(f" Host: {config.llm.host}")
2091
+ console.print(f" Port: {config.llm.port}")
2092
+ console.print(f" Temperature: {config.llm.temperature}")
2093
+ if config.llm.api_key:
2094
+ masked = f"{config.llm.api_key[:4]}...{config.llm.api_key[-4:]}"
2095
+ console.print(f" API Key: {masked}")
2096
+ else:
2097
+ console.print(f" API Key: [dim]not set[/dim]")
2098
+
2099
+ console.print("\n[bold cyan]Server Settings:[/bold cyan]")
2100
+ console.print(f" Host: {config.server.host}")
2101
+ console.print(f" Port: {config.server.port}")
2102
+ console.print(f" Auto-open: {config.server.auto_open_browser}")
2103
+ console.print(f" Page Size: {config.server.page_size}")
2104
+
2105
+ console.print("\n[bold cyan]CLI Settings:[/bold cyan]")
2106
+ console.print(f" Verbose: {config.cli.verbose}")
2107
+ console.print(f" Color: {config.cli.color}")
2108
+ console.print(f" Page Size: {config.cli.page_size}")
2109
+
2110
+ console.print(f"\n[dim]Edit with: ebk config --library-path <path> --llm-host <host> etc.[/dim]")
2111
+ console.print(f"[dim]Or edit directly: {config_path}[/dim]\n")
2112
+ return
2113
+
2114
+ # Handle setting values
2115
+ changes = []
2116
+
2117
+ if set_provider is not None:
2118
+ changes.append(f"LLM provider: {set_provider}")
2119
+ if set_model is not None:
2120
+ changes.append(f"LLM model: {set_model}")
2121
+ if set_llm_host is not None:
2122
+ changes.append(f"LLM host: {set_llm_host}")
2123
+ if set_llm_port is not None:
2124
+ changes.append(f"LLM port: {set_llm_port}")
2125
+ if set_api_key is not None:
2126
+ changes.append("LLM API key: ****")
2127
+ if set_temperature is not None:
2128
+ changes.append(f"LLM temperature: {set_temperature}")
2129
+ if set_server_host is not None:
2130
+ changes.append(f"Server host: {set_server_host}")
2131
+ if set_server_port is not None:
2132
+ changes.append(f"Server port: {set_server_port}")
2133
+ if set_auto_open is not None:
2134
+ changes.append(f"Server auto-open: {set_auto_open}")
2135
+ if set_library_path is not None:
2136
+ changes.append(f"Library path: {set_library_path}")
2137
+ if set_verbose is not None:
2138
+ changes.append(f"CLI verbose: {set_verbose}")
2139
+ if set_color is not None:
2140
+ changes.append(f"CLI color: {set_color}")
2141
+
2142
+ if changes:
2143
+ console.print("[blue]Updating configuration:[/blue]")
2144
+ for change in changes:
2145
+ console.print(f" • {change}")
2146
+
2147
+ update_config(
2148
+ llm_provider=set_provider,
2149
+ llm_model=set_model,
2150
+ llm_host=set_llm_host,
2151
+ llm_port=set_llm_port,
2152
+ llm_api_key=set_api_key,
2153
+ llm_temperature=set_temperature,
2154
+ server_host=set_server_host,
2155
+ server_port=set_server_port,
2156
+ server_auto_open=set_auto_open,
2157
+ library_default_path=set_library_path,
2158
+ cli_verbose=set_verbose,
2159
+ cli_color=set_color,
2160
+ )
2161
+ console.print("[green]✓ Configuration updated![/green]")
2162
+ console.print("[dim]Use 'ebk config --show' to view current settings[/dim]")
2163
+
2164
+
2165
+ # ============================================================================
2166
+ # Similarity Search Commands
2167
+ # ============================================================================
2168
+
2169
+ @app.command(name="similar")
2170
+ def find_similar(
2171
+ book_id: int = typer.Argument(..., help="Book ID to find similar books for"),
2172
+ library_path: Path = typer.Argument(..., help="Path to library"),
2173
+ top_k: int = typer.Option(10, "--top-k", "-k", help="Number of similar books to return"),
2174
+ same_language: bool = typer.Option(True, "--same-language", help="Filter by same language"),
2175
+ preset: Optional[str] = typer.Option(None, "--preset", "-p", help="Similarity preset (balanced, content_only, metadata_only)"),
2176
+ content_weight: Optional[float] = typer.Option(None, "--content-weight", help="Custom content similarity weight"),
2177
+ authors_weight: Optional[float] = typer.Option(None, "--authors-weight", help="Custom authors similarity weight"),
2178
+ subjects_weight: Optional[float] = typer.Option(None, "--subjects-weight", help="Custom subjects similarity weight"),
2179
+ temporal_weight: Optional[float] = typer.Option(None, "--temporal-weight", help="Custom temporal similarity weight"),
2180
+ show_scores: bool = typer.Option(True, "--show-scores/--hide-scores", help="Show similarity scores"),
2181
+ ):
2182
+ """
2183
+ Find books similar to the given book using semantic similarity.
2184
+
2185
+ Uses a combination of content similarity (TF-IDF), author overlap,
2186
+ subject overlap, temporal proximity, and other features.
2187
+
2188
+ Examples:
2189
+ # Find 10 similar books using balanced preset
2190
+ ebk similar 42 ~/my-library
2191
+
2192
+ # Find 20 similar books using content-only similarity
2193
+ ebk similar 42 ~/my-library --top-k 20 --preset content_only
2194
+
2195
+ # Custom weights
2196
+ ebk similar 42 ~/my-library --content-weight 4.0 --authors-weight 2.0
2197
+ """
2198
+ from .library_db import Library
2199
+ from .similarity import BookSimilarity
2200
+
2201
+ if not library_path.exists():
2202
+ console.print(f"[red]Error: Library not found: {library_path}[/red]")
2203
+ raise typer.Exit(code=1)
2204
+
2205
+ try:
2206
+ lib = Library.open(library_path)
2207
+
2208
+ # Get query book
2209
+ query_book = lib.get_book(book_id)
2210
+ if not query_book:
2211
+ console.print(f"[red]Error: Book {book_id} not found[/red]")
2212
+ lib.close()
2213
+ raise typer.Exit(code=1)
2214
+
2215
+ console.print(f"\n[bold]Finding books similar to:[/bold]")
2216
+ console.print(f" {query_book.title}")
2217
+ if query_book.authors:
2218
+ authors_str = ", ".join(a.name for a in query_book.authors)
2219
+ console.print(f" [dim]by {authors_str}[/dim]")
2220
+ console.print()
2221
+
2222
+ # Configure similarity
2223
+ sim_config = None
2224
+
2225
+ if preset == "balanced":
2226
+ sim_config = BookSimilarity().balanced()
2227
+ elif preset == "content_only":
2228
+ sim_config = BookSimilarity().content_only()
2229
+ elif preset == "metadata_only":
2230
+ sim_config = BookSimilarity().metadata_only()
2231
+ elif any([content_weight, authors_weight, subjects_weight, temporal_weight]):
2232
+ # Custom weights
2233
+ sim_config = BookSimilarity()
2234
+ if content_weight is not None:
2235
+ sim_config = sim_config.content(weight=content_weight)
2236
+ if authors_weight is not None:
2237
+ sim_config = sim_config.authors(weight=authors_weight)
2238
+ if subjects_weight is not None:
2239
+ sim_config = sim_config.subjects(weight=subjects_weight)
2240
+ if temporal_weight is not None:
2241
+ sim_config = sim_config.temporal(weight=temporal_weight)
2242
+
2243
+ # Find similar books
2244
+ with Progress() as progress:
2245
+ task = progress.add_task("[cyan]Computing similarities...", total=None)
2246
+ results = lib.find_similar(
2247
+ book_id,
2248
+ top_k=top_k,
2249
+ similarity_config=sim_config,
2250
+ filter_language=same_language,
2251
+ )
2252
+ progress.update(task, completed=True)
2253
+
2254
+ if not results:
2255
+ console.print("[yellow]No similar books found[/yellow]")
2256
+ lib.close()
2257
+ return
2258
+
2259
+ # Display results in table
2260
+ table = Table(title=f"Top {len(results)} Similar Books")
2261
+ table.add_column("ID", style="cyan", no_wrap=True)
2262
+ table.add_column("Title", style="green")
2263
+ table.add_column("Authors", style="blue")
2264
+ if show_scores:
2265
+ table.add_column("Score", justify="right", style="magenta")
2266
+ table.add_column("Year", justify="center", style="yellow")
2267
+ table.add_column("Language", justify="center", style="dim")
2268
+
2269
+ for book, score in results:
2270
+ authors_str = ", ".join(a.name for a in book.authors) if book.authors else ""
2271
+ year_str = str(book.published_date)[:4] if book.published_date else ""
2272
+ lang_str = book.language or ""
2273
+
2274
+ row = [
2275
+ str(book.id),
2276
+ book.title or "(No title)",
2277
+ authors_str[:40] + "..." if len(authors_str) > 40 else authors_str,
2278
+ ]
2279
+ if show_scores:
2280
+ row.append(f"{score:.3f}")
2281
+ row.extend([year_str, lang_str])
2282
+
2283
+ table.add_row(*row)
2284
+
2285
+ console.print(table)
2286
+
2287
+ lib.close()
2288
+
2289
+ except Exception as e:
2290
+ console.print(f"[red]Error finding similar books: {e}[/red]")
2291
+ import traceback
2292
+ traceback.print_exc()
2293
+ raise typer.Exit(code=1)
2294
+
2295
+
2296
+ # ============================================================================
2297
+ # Tag Management Commands
2298
+ # ============================================================================
2299
+
2300
+ @tag_app.command(name="list")
2301
+ def tag_list(
2302
+ library_path: Path = typer.Argument(..., help="Path to library"),
2303
+ tag_path: Optional[str] = typer.Option(None, "--tag", "-t", help="List books with specific tag"),
2304
+ include_subtags: bool = typer.Option(False, "--subtags", "-s", help="Include books from subtags"),
2305
+ ):
2306
+ """
2307
+ List all tags or books with a specific tag.
2308
+
2309
+ Examples:
2310
+ ebk tag list ~/my-library - List all tags
2311
+ ebk tag list ~/my-library -t Work - List books tagged with "Work"
2312
+ ebk tag list ~/my-library -t Work -s - Include books from Work/* subtags
2313
+ """
2314
+ from ebk.library_db import Library
2315
+ from ebk.services.tag_service import TagService
2316
+
2317
+ library_path = Path(library_path)
2318
+ if not library_path.exists():
2319
+ console.print(f"[red]Error: Library not found at {library_path}[/red]")
2320
+ raise typer.Exit(code=1)
2321
+
2322
+ try:
2323
+ lib = Library.open(library_path)
2324
+ tag_service = TagService(lib.session)
2325
+
2326
+ if tag_path:
2327
+ # List books with specific tag
2328
+ books = tag_service.get_books_with_tag(tag_path, include_subtags=include_subtags)
2329
+
2330
+ if not books:
2331
+ console.print(f"[yellow]No books found with tag '{tag_path}'[/yellow]")
2332
+ lib.close()
2333
+ raise typer.Exit(code=0)
2334
+
2335
+ table = Table(title=f"Books with tag '{tag_path}'", show_header=True, header_style="bold magenta")
2336
+ table.add_column("ID", style="cyan", width=6)
2337
+ table.add_column("Title", style="white")
2338
+ table.add_column("Authors", style="green")
2339
+
2340
+ for book in books:
2341
+ authors = ", ".join([a.name for a in book.authors]) if book.authors else "Unknown"
2342
+ table.add_row(str(book.id), book.title or "Untitled", authors)
2343
+
2344
+ console.print(table)
2345
+ console.print(f"\n[cyan]Total:[/cyan] {len(books)} books")
2346
+
2347
+ else:
2348
+ # List all tags
2349
+ tags = tag_service.get_all_tags()
2350
+
2351
+ if not tags:
2352
+ console.print("[yellow]No tags found in library[/yellow]")
2353
+ lib.close()
2354
+ raise typer.Exit(code=0)
2355
+
2356
+ table = Table(title="All Tags", show_header=True, header_style="bold magenta")
2357
+ table.add_column("Path", style="cyan")
2358
+ table.add_column("Books", style="white", justify="right")
2359
+ table.add_column("Subtags", style="green", justify="right")
2360
+ table.add_column("Description", style="yellow")
2361
+
2362
+ for tag in tags:
2363
+ stats = tag_service.get_tag_stats(tag.path)
2364
+ desc = tag.description[:50] + "..." if tag.description and len(tag.description) > 50 else tag.description or ""
2365
+ table.add_row(
2366
+ tag.path,
2367
+ str(stats.get('book_count', 0)),
2368
+ str(stats.get('subtag_count', 0)),
2369
+ desc
2370
+ )
2371
+
2372
+ console.print(table)
2373
+ console.print(f"\n[cyan]Total:[/cyan] {len(tags)} tags")
2374
+
2375
+ lib.close()
2376
+
2377
+ except Exception as e:
2378
+ console.print(f"[red]Error listing tags: {e}[/red]")
2379
+ raise typer.Exit(code=1)
2380
+
2381
+
2382
+ @tag_app.command(name="tree")
2383
+ def tag_tree(
2384
+ library_path: Path = typer.Argument(..., help="Path to library"),
2385
+ root: Optional[str] = typer.Option(None, "--root", "-r", help="Root tag to display (default: all)"),
2386
+ ):
2387
+ """
2388
+ Display hierarchical tag tree.
2389
+
2390
+ Examples:
2391
+ ebk tag tree ~/my-library - Show all tags as tree
2392
+ ebk tag tree ~/my-library -r Work - Show Work tag subtree
2393
+ """
2394
+ from ebk.library_db import Library
2395
+ from ebk.services.tag_service import TagService
2396
+
2397
+ library_path = Path(library_path)
2398
+ if not library_path.exists():
2399
+ console.print(f"[red]Error: Library not found at {library_path}[/red]")
2400
+ raise typer.Exit(code=1)
2401
+
2402
+ def print_tree(tag, tag_service, prefix="", is_last=True):
2403
+ """Recursively print tag tree."""
2404
+ # Tree characters
2405
+ connector = "└── " if is_last else "├── "
2406
+ extension = " " if is_last else "│ "
2407
+
2408
+ # Get stats
2409
+ stats = tag_service.get_tag_stats(tag.path)
2410
+ book_count = stats.get('book_count', 0)
2411
+
2412
+ # Format tag name with book count
2413
+ tag_display = f"[cyan]{tag.name}[/cyan]"
2414
+ if book_count > 0:
2415
+ tag_display += f" [dim]({book_count} books)[/dim]"
2416
+
2417
+ console.print(f"{prefix}{connector}{tag_display}")
2418
+
2419
+ # Get and print children
2420
+ children = tag_service.get_children(tag)
2421
+ for i, child in enumerate(children):
2422
+ is_last_child = (i == len(children) - 1)
2423
+ print_tree(child, tag_service, prefix + extension, is_last_child)
2424
+
2425
+ try:
2426
+ lib = Library.open(library_path)
2427
+ tag_service = TagService(lib.session)
2428
+
2429
+ if root:
2430
+ # Display specific subtree
2431
+ root_tag = tag_service.get_tag(root)
2432
+ if not root_tag:
2433
+ console.print(f"[red]Tag '{root}' not found[/red]")
2434
+ lib.close()
2435
+ raise typer.Exit(code=1)
2436
+
2437
+ console.print(f"[bold]Tag Tree: {root}[/bold]\n")
2438
+ print_tree(root_tag, tag_service, "", True)
2439
+
2440
+ else:
2441
+ # Display entire tree
2442
+ root_tags = tag_service.get_root_tags()
2443
+
2444
+ if not root_tags:
2445
+ console.print("[yellow]No tags found in library[/yellow]")
2446
+ lib.close()
2447
+ raise typer.Exit(code=0)
2448
+
2449
+ console.print("[bold]Tag Tree[/bold]\n")
2450
+ for i, tag in enumerate(root_tags):
2451
+ is_last = (i == len(root_tags) - 1)
2452
+ print_tree(tag, tag_service, "", is_last)
2453
+
2454
+ lib.close()
2455
+
2456
+ except Exception as e:
2457
+ console.print(f"[red]Error displaying tag tree: {e}[/red]")
2458
+ raise typer.Exit(code=1)
2459
+
2460
+
2461
+ @tag_app.command(name="add")
2462
+ def tag_add(
2463
+ book_id: int = typer.Argument(..., help="Book ID"),
2464
+ tag_path: str = typer.Argument(..., help="Tag path (e.g., 'Work/Project-2024')"),
2465
+ library_path: Path = typer.Argument(..., help="Path to library"),
2466
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="Tag description (for new tags)"),
2467
+ color: Optional[str] = typer.Option(None, "--color", "-c", help="Tag color in hex (e.g., '#FF5733')"),
2468
+ ):
2469
+ """
2470
+ [DEPRECATED] Add a tag to a book.
2471
+
2472
+ This command is deprecated. Use VFS commands instead:
2473
+ ebk vfs ln <library> /books/<id> /tags/<tag-path>/
2474
+
2475
+ Creates tag hierarchy automatically if it doesn't exist.
2476
+
2477
+ Examples:
2478
+ ebk tag add 42 Work ~/my-library
2479
+ ebk tag add 42 Work/Project-2024 ~/my-library -d "2024 project books"
2480
+ ebk tag add 42 Reading-List ~/my-library -c "#3498db"
2481
+
2482
+ Migrating to VFS:
2483
+ ebk vfs ln ~/my-library /books/42 /tags/Work/
2484
+ ebk vfs mkdir ~/my-library /tags/Work/Project-2024/
2485
+ ebk vfs ln ~/my-library /books/42 /tags/Work/Project-2024/
2486
+ """
2487
+ console.print("[yellow]⚠ Warning: 'ebk tag add' is deprecated. Use 'ebk vfs ln' instead.[/yellow]")
2488
+ console.print(f"[yellow] Example: ebk vfs ln {library_path} /books/{book_id} /tags/{tag_path}/[/yellow]\n")
2489
+ from ebk.library_db import Library
2490
+ from ebk.services.tag_service import TagService
2491
+ from ebk.db.models import Book
2492
+
2493
+ library_path = Path(library_path)
2494
+ if not library_path.exists():
2495
+ console.print(f"[red]Error: Library not found at {library_path}[/red]")
2496
+ raise typer.Exit(code=1)
2497
+
2498
+ try:
2499
+ lib = Library.open(library_path)
2500
+ tag_service = TagService(lib.session)
2501
+
2502
+ # Get book
2503
+ book = lib.session.query(Book).filter_by(id=book_id).first()
2504
+ if not book:
2505
+ console.print(f"[red]Book {book_id} not found[/red]")
2506
+ lib.close()
2507
+ raise typer.Exit(code=1)
2508
+
2509
+ # Add tag to book
2510
+ tag = tag_service.add_tag_to_book(book, tag_path)
2511
+
2512
+ # Update tag metadata if provided (only for leaf tag)
2513
+ if description and tag.description != description:
2514
+ tag.description = description
2515
+ lib.session.commit()
2516
+
2517
+ if color and tag.color != color:
2518
+ tag.color = color
2519
+ lib.session.commit()
2520
+
2521
+ console.print(f"[green]✓ Added tag '{tag.path}' to book {book.id}[/green]")
2522
+ if book.title:
2523
+ console.print(f" Book: {book.title}")
2524
+
2525
+ lib.close()
2526
+
2527
+ except Exception as e:
2528
+ console.print(f"[red]Error adding tag: {e}[/red]")
2529
+ raise typer.Exit(code=1)
2530
+
2531
+
2532
+ @tag_app.command(name="remove")
2533
+ def tag_remove(
2534
+ book_id: int = typer.Argument(..., help="Book ID"),
2535
+ tag_path: str = typer.Argument(..., help="Tag path (e.g., 'Work/Project-2024')"),
2536
+ library_path: Path = typer.Argument(..., help="Path to library"),
2537
+ ):
2538
+ """
2539
+ [DEPRECATED] Remove a tag from a book.
2540
+
2541
+ This command is deprecated. Use VFS commands instead:
2542
+ ebk vfs rm <library> /tags/<tag-path>/<book-id>
2543
+
2544
+ Examples:
2545
+ ebk tag remove 42 Work ~/my-library
2546
+ ebk tag remove 42 Work/Project-2024 ~/my-library
2547
+
2548
+ Migrating to VFS:
2549
+ ebk vfs rm ~/my-library /tags/Work/42
2550
+ ebk vfs rm ~/my-library /tags/Work/Project-2024/42
2551
+ """
2552
+ console.print("[yellow]⚠ Warning: 'ebk tag remove' is deprecated. Use 'ebk vfs rm' instead.[/yellow]")
2553
+ console.print(f"[yellow] Example: ebk vfs rm {library_path} /tags/{tag_path}/{book_id}[/yellow]\n")
2554
+ from ebk.library_db import Library
2555
+ from ebk.services.tag_service import TagService
2556
+ from ebk.db.models import Book
2557
+
2558
+ library_path = Path(library_path)
2559
+ if not library_path.exists():
2560
+ console.print(f"[red]Error: Library not found at {library_path}[/red]")
2561
+ raise typer.Exit(code=1)
2562
+
2563
+ try:
2564
+ lib = Library.open(library_path)
2565
+ tag_service = TagService(lib.session)
2566
+
2567
+ # Get book
2568
+ book = lib.session.query(Book).filter_by(id=book_id).first()
2569
+ if not book:
2570
+ console.print(f"[red]Book {book_id} not found[/red]")
2571
+ lib.close()
2572
+ raise typer.Exit(code=1)
2573
+
2574
+ # Remove tag from book
2575
+ removed = tag_service.remove_tag_from_book(book, tag_path)
2576
+
2577
+ if removed:
2578
+ console.print(f"[green]✓ Removed tag '{tag_path}' from book {book.id}[/green]")
2579
+ if book.title:
2580
+ console.print(f" Book: {book.title}")
2581
+ else:
2582
+ console.print(f"[yellow]Book {book.id} didn't have tag '{tag_path}'[/yellow]")
2583
+
2584
+ lib.close()
2585
+
2586
+ except Exception as e:
2587
+ console.print(f"[red]Error removing tag: {e}[/red]")
2588
+ raise typer.Exit(code=1)
2589
+
2590
+
2591
+ @tag_app.command(name="rename")
2592
+ def tag_rename(
2593
+ old_path: str = typer.Argument(..., help="Current tag path"),
2594
+ new_path: str = typer.Argument(..., help="New tag path"),
2595
+ library_path: Path = typer.Argument(..., help="Path to library"),
2596
+ ):
2597
+ """
2598
+ Rename a tag and update all descendant paths.
2599
+
2600
+ Examples:
2601
+ ebk tag rename Work Archive ~/my-library
2602
+ ebk tag rename Work/Old Work/Completed ~/my-library
2603
+ """
2604
+ from ebk.library_db import Library
2605
+ from ebk.services.tag_service import TagService
2606
+
2607
+ library_path = Path(library_path)
2608
+ if not library_path.exists():
2609
+ console.print(f"[red]Error: Library not found at {library_path}[/red]")
2610
+ raise typer.Exit(code=1)
2611
+
2612
+ try:
2613
+ lib = Library.open(library_path)
2614
+ tag_service = TagService(lib.session)
2615
+
2616
+ # Get tag stats before rename
2617
+ old_tag = tag_service.get_tag(old_path)
2618
+ if not old_tag:
2619
+ console.print(f"[red]Tag '{old_path}' not found[/red]")
2620
+ lib.close()
2621
+ raise typer.Exit(code=1)
2622
+
2623
+ stats = tag_service.get_tag_stats(old_path)
2624
+ book_count = stats.get('book_count', 0)
2625
+ subtag_count = stats.get('subtag_count', 0)
2626
+
2627
+ # Rename tag
2628
+ tag = tag_service.rename_tag(old_path, new_path)
2629
+
2630
+ console.print(f"[green]✓ Renamed tag '{old_path}' → '{new_path}'[/green]")
2631
+ if book_count > 0:
2632
+ console.print(f" Books: {book_count}")
2633
+ if subtag_count > 0:
2634
+ console.print(f" Subtags updated: {subtag_count}")
2635
+
2636
+ lib.close()
2637
+
2638
+ except ValueError as e:
2639
+ console.print(f"[red]Error: {e}[/red]")
2640
+ raise typer.Exit(code=1)
2641
+ except Exception as e:
2642
+ console.print(f"[red]Error renaming tag: {e}[/red]")
2643
+ raise typer.Exit(code=1)
2644
+
2645
+
2646
+ @tag_app.command(name="delete")
2647
+ def tag_delete(
2648
+ tag_path: str = typer.Argument(..., help="Tag path to delete"),
2649
+ library_path: Path = typer.Argument(..., help="Path to library"),
2650
+ recursive: bool = typer.Option(False, "--recursive", "-r", help="Delete tag and all children"),
2651
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
2652
+ ):
2653
+ """
2654
+ [DEPRECATED] Delete a tag.
2655
+
2656
+ This command is deprecated. Use VFS commands instead:
2657
+ ebk vfs rm <library> /tags/<tag-path>/ [-r]
2658
+
2659
+ Examples:
2660
+ ebk tag delete OldTag ~/my-library
2661
+ ebk tag delete OldProject ~/my-library -r - Delete with children
2662
+ ebk tag delete Archive ~/my-library -r -f - Delete without confirmation
2663
+
2664
+ Migrating to VFS:
2665
+ ebk vfs rm ~/my-library /tags/OldTag/
2666
+ ebk vfs rm ~/my-library /tags/OldProject/ -r
2667
+ """
2668
+ console.print("[yellow]⚠ Warning: 'ebk tag delete' is deprecated. Use 'ebk vfs rm' instead.[/yellow]")
2669
+ console.print(f"[yellow] Example: ebk vfs rm {library_path} /tags/{tag_path}/{ ' -r' if recursive else ''}[/yellow]\n")
2670
+ from ebk.library_db import Library
2671
+ from ebk.services.tag_service import TagService
2672
+
2673
+ library_path = Path(library_path)
2674
+ if not library_path.exists():
2675
+ console.print(f"[red]Error: Library not found at {library_path}[/red]")
2676
+ raise typer.Exit(code=1)
2677
+
2678
+ try:
2679
+ lib = Library.open(library_path)
2680
+ tag_service = TagService(lib.session)
2681
+
2682
+ # Get tag stats
2683
+ tag = tag_service.get_tag(tag_path)
2684
+ if not tag:
2685
+ console.print(f"[red]Tag '{tag_path}' not found[/red]")
2686
+ lib.close()
2687
+ raise typer.Exit(code=1)
2688
+
2689
+ stats = tag_service.get_tag_stats(tag_path)
2690
+ book_count = stats.get('book_count', 0)
2691
+ subtag_count = stats.get('subtag_count', 0)
2692
+
2693
+ # Confirm deletion if not forced
2694
+ if not force:
2695
+ console.print(f"[yellow]About to delete tag:[/yellow] {tag_path}")
2696
+ if book_count > 0:
2697
+ console.print(f" Books: {book_count}")
2698
+ if subtag_count > 0:
2699
+ console.print(f" Subtags: {subtag_count}")
2700
+ if not recursive:
2701
+ console.print(f"[red]Error: Tag has {subtag_count} children. Use -r to delete recursively.[/red]")
2702
+ lib.close()
2703
+ raise typer.Exit(code=1)
2704
+
2705
+ if not Confirm.ask("Are you sure?"):
2706
+ console.print("[cyan]Cancelled[/cyan]")
2707
+ lib.close()
2708
+ raise typer.Exit(code=0)
2709
+
2710
+ # Delete tag
2711
+ deleted = tag_service.delete_tag(tag_path, delete_children=recursive)
2712
+
2713
+ if deleted:
2714
+ console.print(f"[green]✓ Deleted tag '{tag_path}'[/green]")
2715
+ else:
2716
+ console.print(f"[yellow]Tag '{tag_path}' not found[/yellow]")
2717
+
2718
+ lib.close()
2719
+
2720
+ except ValueError as e:
2721
+ console.print(f"[red]Error: {e}[/red]")
2722
+ console.print(f"[yellow]Hint: Use -r flag to delete tag with children[/yellow]")
2723
+ raise typer.Exit(code=1)
2724
+ except Exception as e:
2725
+ console.print(f"[red]Error deleting tag: {e}[/red]")
2726
+ raise typer.Exit(code=1)
2727
+
2728
+
2729
+ @tag_app.command(name="stats")
2730
+ def tag_stats(
2731
+ library_path: Path = typer.Argument(..., help="Path to library"),
2732
+ tag_path: Optional[str] = typer.Option(None, "--tag", "-t", help="Specific tag to show stats for"),
2733
+ ):
2734
+ """
2735
+ Show tag statistics.
2736
+
2737
+ Examples:
2738
+ ebk tag stats ~/my-library - Overall tag statistics
2739
+ ebk tag stats ~/my-library -t Work - Stats for specific tag
2740
+ """
2741
+ from ebk.library_db import Library
2742
+ from ebk.services.tag_service import TagService
2743
+ from ebk.db.models import Tag
2744
+
2745
+ library_path = Path(library_path)
2746
+ if not library_path.exists():
2747
+ console.print(f"[red]Error: Library not found at {library_path}[/red]")
2748
+ raise typer.Exit(code=1)
2749
+
2750
+ try:
2751
+ lib = Library.open(library_path)
2752
+ tag_service = TagService(lib.session)
2753
+
2754
+ if tag_path:
2755
+ # Show stats for specific tag
2756
+ tag = tag_service.get_tag(tag_path)
2757
+ if not tag:
2758
+ console.print(f"[red]Tag '{tag_path}' not found[/red]")
2759
+ lib.close()
2760
+ raise typer.Exit(code=1)
2761
+
2762
+ stats = tag_service.get_tag_stats(tag_path)
2763
+
2764
+ console.print(f"[bold cyan]Tag Statistics: {tag_path}[/bold cyan]\n")
2765
+ console.print(f"Name: {tag.name}")
2766
+ console.print(f"Path: {tag.path}")
2767
+ console.print(f"Depth: {stats.get('depth', 0)}")
2768
+ console.print(f"Books: {stats.get('book_count', 0)}")
2769
+ console.print(f"Subtags: {stats.get('subtag_count', 0)}")
2770
+
2771
+ if tag.description:
2772
+ console.print(f"Description: {tag.description}")
2773
+
2774
+ if tag.color:
2775
+ console.print(f"Color: {tag.color}")
2776
+
2777
+ if stats.get('created_at'):
2778
+ console.print(f"Created: {stats['created_at']}")
2779
+
2780
+ else:
2781
+ # Show overall statistics
2782
+ total_tags = lib.session.query(Tag).count()
2783
+ root_tags = len(tag_service.get_root_tags())
2784
+
2785
+ # Count tagged books
2786
+ from ebk.db.models import book_tags
2787
+ tagged_books = lib.session.query(book_tags.c.book_id).distinct().count()
2788
+
2789
+ console.print("[bold cyan]Tag Statistics[/bold cyan]\n")
2790
+ console.print(f"Total tags: {total_tags}")
2791
+ console.print(f"Root tags: {root_tags}")
2792
+ console.print(f"Tagged books: {tagged_books}")
2793
+
2794
+ if total_tags > 0:
2795
+ # Find most popular tags
2796
+ all_tags = tag_service.get_all_tags()
2797
+ tags_with_counts = [(tag, len(tag.books)) for tag in all_tags]
2798
+ tags_with_counts.sort(key=lambda x: x[1], reverse=True)
2799
+
2800
+ console.print("\n[bold]Most Popular Tags:[/bold]")
2801
+ for tag, count in tags_with_counts[:10]:
2802
+ if count > 0:
2803
+ console.print(f" {tag.path:<40} {count:>3} books")
2804
+
2805
+ lib.close()
2806
+
2807
+ except Exception as e:
2808
+ console.print(f"[red]Error getting tag statistics: {e}[/red]")
2809
+ raise typer.Exit(code=1)
2810
+
2811
+
2812
+ # ==============================================================================
2813
+ # VFS Commands - Operate on VFS paths
2814
+ # ==============================================================================
2815
+
2816
+ @vfs_app.command(name="ln")
2817
+ def vfs_ln(
2818
+ library_path: Path = typer.Argument(..., help="Path to library"),
2819
+ source: str = typer.Argument(..., help="Source path (e.g., /books/42 or /tags/Work/42)"),
2820
+ dest: str = typer.Argument(..., help="Destination tag path (e.g., /tags/Archive/)"),
2821
+ ):
2822
+ """Link a book to a tag.
2823
+
2824
+ Examples:
2825
+ ebk vfs ln ~/library /books/42 /tags/Work/
2826
+ ebk vfs ln ~/library /tags/Work/42 /tags/Archive/
2827
+ ebk vfs ln ~/library /subjects/computers/42 /tags/Reading/
2828
+ """
2829
+ from .library_db import Library
2830
+ from .repl.shell import LibraryShell
2831
+
2832
+ try:
2833
+ lib = Library.open(library_path)
2834
+ shell = LibraryShell(lib)
2835
+
2836
+ # Execute ln command in silent mode to capture output
2837
+ shell.cmd_ln([source, dest], silent=False)
2838
+
2839
+ shell.cleanup()
2840
+ lib.close()
2841
+ except Exception as e:
2842
+ console.print(f"[red]Error: {e}[/red]")
2843
+ raise typer.Exit(code=1)
2844
+
2845
+
2846
+ @vfs_app.command(name="mv")
2847
+ def vfs_mv(
2848
+ library_path: Path = typer.Argument(..., help="Path to library"),
2849
+ source: str = typer.Argument(..., help="Source path (e.g., /tags/Work/42)"),
2850
+ dest: str = typer.Argument(..., help="Destination tag path (e.g., /tags/Archive/)"),
2851
+ ):
2852
+ """Move a book between tags.
2853
+
2854
+ Examples:
2855
+ ebk vfs mv ~/library /tags/Work/42 /tags/Archive/
2856
+ """
2857
+ from .library_db import Library
2858
+ from .repl.shell import LibraryShell
2859
+
2860
+ try:
2861
+ lib = Library.open(library_path)
2862
+ shell = LibraryShell(lib)
2863
+
2864
+ shell.cmd_mv([source, dest], silent=False)
2865
+
2866
+ shell.cleanup()
2867
+ lib.close()
2868
+ except Exception as e:
2869
+ console.print(f"[red]Error: {e}[/red]")
2870
+ raise typer.Exit(code=1)
2871
+
2872
+
2873
+ @vfs_app.command(name="rm")
2874
+ def vfs_rm(
2875
+ library_path: Path = typer.Argument(..., help="Path to library"),
2876
+ path: str = typer.Argument(..., help="Path to remove (e.g., /tags/Work/42 or /books/42/)"),
2877
+ recursive: bool = typer.Option(False, "-r", "--recursive", help="Recursively delete tag and children"),
2878
+ ):
2879
+ """Remove tag from book, delete tag, or DELETE book.
2880
+
2881
+ Examples:
2882
+ ebk vfs rm ~/library /tags/Work/42 # Remove tag from book
2883
+ ebk vfs rm ~/library /tags/Work/ # Delete tag
2884
+ ebk vfs rm ~/library /tags/Work/ -r # Delete tag recursively
2885
+ ebk vfs rm ~/library /books/42/ # DELETE book (with confirmation)
2886
+ """
2887
+ from .library_db import Library
2888
+ from .repl.shell import LibraryShell
2889
+
2890
+ try:
2891
+ lib = Library.open(library_path)
2892
+ shell = LibraryShell(lib)
2893
+
2894
+ args = [path]
2895
+ if recursive:
2896
+ args.insert(0, '-r')
2897
+
2898
+ shell.cmd_rm(args, silent=False)
2899
+
2900
+ shell.cleanup()
2901
+ lib.close()
2902
+ except Exception as e:
2903
+ console.print(f"[red]Error: {e}[/red]")
2904
+ raise typer.Exit(code=1)
2905
+
2906
+
2907
+ @vfs_app.command(name="mkdir")
2908
+ def vfs_mkdir(
2909
+ library_path: Path = typer.Argument(..., help="Path to library"),
2910
+ path: str = typer.Argument(..., help="Tag path to create (e.g., /tags/Work/Project-2024/)"),
2911
+ ):
2912
+ """Create a new tag directory.
2913
+
2914
+ Examples:
2915
+ ebk vfs mkdir ~/library /tags/Work/
2916
+ ebk vfs mkdir ~/library /tags/Work/Project-2024/
2917
+ ebk vfs mkdir ~/library /tags/Reading/Fiction/Sci-Fi/
2918
+ """
2919
+ from .library_db import Library
2920
+ from .repl.shell import LibraryShell
2921
+
2922
+ try:
2923
+ lib = Library.open(library_path)
2924
+ shell = LibraryShell(lib)
2925
+
2926
+ shell.cmd_mkdir([path], silent=False)
2927
+
2928
+ shell.cleanup()
2929
+ lib.close()
2930
+ except Exception as e:
2931
+ console.print(f"[red]Error: {e}[/red]")
2932
+ raise typer.Exit(code=1)
2933
+
2934
+
2935
+ @vfs_app.command(name="ls")
2936
+ def vfs_ls(
2937
+ library_path: Path = typer.Argument(..., help="Path to library"),
2938
+ path: str = typer.Argument("/", help="VFS path to list (e.g., /books/ or /tags/Work/)"),
2939
+ ):
2940
+ """List contents of a VFS directory.
2941
+
2942
+ Examples:
2943
+ ebk vfs ls ~/library /
2944
+ ebk vfs ls ~/library /books/
2945
+ ebk vfs ls ~/library /tags/Work/
2946
+ ebk vfs ls ~/library /books/42/
2947
+ """
2948
+ from .library_db import Library
2949
+ from .repl.shell import LibraryShell
2950
+
2951
+ try:
2952
+ lib = Library.open(library_path)
2953
+ shell = LibraryShell(lib)
2954
+
2955
+ shell.cmd_ls([path], silent=False)
2956
+
2957
+ shell.cleanup()
2958
+ lib.close()
2959
+ except Exception as e:
2960
+ console.print(f"[red]Error: {e}[/red]")
2961
+ raise typer.Exit(code=1)
2962
+
2963
+
2964
+ @vfs_app.command(name="cat")
2965
+ def vfs_cat(
2966
+ library_path: Path = typer.Argument(..., help="Path to library"),
2967
+ path: str = typer.Argument(..., help="VFS file path (e.g., /books/42/title or /tags/Work/description)"),
2968
+ ):
2969
+ """Read contents of a VFS file.
2970
+
2971
+ Examples:
2972
+ ebk vfs cat ~/library /books/42/title
2973
+ ebk vfs cat ~/library /tags/Work/description
2974
+ ebk vfs cat ~/library /tags/Work/color
2975
+ """
2976
+ from .library_db import Library
2977
+ from .repl.shell import LibraryShell
2978
+
2979
+ try:
2980
+ lib = Library.open(library_path)
2981
+ shell = LibraryShell(lib)
2982
+
2983
+ shell.cmd_cat([path], silent=False)
2984
+
2985
+ shell.cleanup()
2986
+ lib.close()
2987
+ except Exception as e:
2988
+ console.print(f"[red]Error: {e}[/red]")
2989
+ raise typer.Exit(code=1)
2990
+
2991
+
2992
+ @vfs_app.command(name="exec")
2993
+ def vfs_exec(
2994
+ library_path: Path = typer.Argument(..., help="Path to library"),
2995
+ command: str = typer.Argument(..., help="Shell command to execute"),
2996
+ ):
2997
+ """Execute a shell command with VFS context.
2998
+
2999
+ This runs a command in the shell environment, allowing you to use
3000
+ shell syntax like pipes, redirection, and multiple commands.
3001
+
3002
+ Examples:
3003
+ ebk vfs exec ~/library "ls /tags/"
3004
+ ebk vfs exec ~/library "cat /books/42/title"
3005
+ ebk vfs exec ~/library "echo 'My notes' > /tags/Work/description"
3006
+ ebk vfs exec ~/library "find author:Knuth | wc -l"
3007
+ ebk vfs exec ~/library "cat /tags/Work/description | grep -i project"
3008
+ """
3009
+ from .library_db import Library
3010
+ from .repl.shell import LibraryShell
3011
+
3012
+ try:
3013
+ lib = Library.open(library_path)
3014
+ shell = LibraryShell(lib)
3015
+
3016
+ # Execute the command
3017
+ shell.execute(command)
3018
+
3019
+ shell.cleanup()
3020
+ lib.close()
3021
+ except Exception as e:
3022
+ console.print(f"[red]Error: {e}[/red]")
817
3023
  raise typer.Exit(code=1)
818
3024
 
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
3025
 
878
3026
  if __name__ == "__main__":
879
3027
  app()