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