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