ebk 0.3.1__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/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 +1097 -9
- 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/exports/__init__.py +0 -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 +59 -0
- ebk/exports/jinja_export.py +287 -0
- ebk/exports/multi_facet_export.py +164 -0
- ebk/exports/symlink_dag.py +479 -0
- ebk/exports/zip.py +25 -0
- ebk/library_db.py +155 -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/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.1.dist-info → ebk-0.3.2.dist-info}/METADATA +1 -1
- ebk-0.3.2.dist-info/RECORD +69 -0
- ebk-0.3.2.dist-info/entry_points.txt +2 -0
- ebk-0.3.2.dist-info/top_level.txt +1 -0
- ebk-0.3.1.dist-info/RECORD +0 -19
- ebk-0.3.1.dist-info/entry_points.txt +0 -6
- ebk-0.3.1.dist-info/top_level.txt +0 -2
- {ebk-0.3.1.dist-info → ebk-0.3.2.dist-info}/WHEEL +0 -0
- {ebk-0.3.1.dist-info → ebk-0.3.2.dist-info}/licenses/LICENSE +0 -0
ebk/cli.py
CHANGED
|
@@ -48,14 +48,20 @@ logger = logging.getLogger(__name__)
|
|
|
48
48
|
app = typer.Typer()
|
|
49
49
|
|
|
50
50
|
# Command groups
|
|
51
|
+
import_app = typer.Typer(help="Import books from various sources")
|
|
51
52
|
export_app = typer.Typer(help="Export library data to various formats")
|
|
52
53
|
vlib_app = typer.Typer(help="Manage virtual libraries (collection views)")
|
|
53
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)")
|
|
54
57
|
|
|
55
58
|
# Register command groups
|
|
59
|
+
app.add_typer(import_app, name="import")
|
|
56
60
|
app.add_typer(export_app, name="export")
|
|
57
61
|
app.add_typer(vlib_app, name="vlib")
|
|
58
62
|
app.add_typer(note_app, name="note")
|
|
63
|
+
app.add_typer(tag_app, name="tag")
|
|
64
|
+
app.add_typer(vfs_app, name="vfs")
|
|
59
65
|
|
|
60
66
|
@app.callback()
|
|
61
67
|
def main(
|
|
@@ -99,6 +105,7 @@ def about():
|
|
|
99
105
|
console.print(" ebk export <subcommand> Export library data")
|
|
100
106
|
console.print(" ebk vlib <subcommand> Manage virtual libraries")
|
|
101
107
|
console.print(" ebk note <subcommand> Manage annotations")
|
|
108
|
+
console.print(" ebk tag <subcommand> Manage hierarchical tags")
|
|
102
109
|
console.print("")
|
|
103
110
|
console.print("[bold]Getting Started:[/bold]")
|
|
104
111
|
console.print(" 1. Initialize: ebk init ~/my-library")
|
|
@@ -112,6 +119,46 @@ def about():
|
|
|
112
119
|
# Core Library Commands
|
|
113
120
|
# ============================================================================
|
|
114
121
|
|
|
122
|
+
@app.command()
|
|
123
|
+
def shell(
|
|
124
|
+
library_path: Path = typer.Argument(..., help="Path to the library"),
|
|
125
|
+
):
|
|
126
|
+
"""
|
|
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
|
|
143
|
+
"""
|
|
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
|
+
|
|
161
|
+
|
|
115
162
|
@app.command()
|
|
116
163
|
def init(
|
|
117
164
|
library_path: Path = typer.Argument(..., help="Path to create the library"),
|
|
@@ -146,8 +193,69 @@ def init(
|
|
|
146
193
|
raise typer.Exit(code=1)
|
|
147
194
|
|
|
148
195
|
|
|
149
|
-
@app.command(
|
|
150
|
-
def
|
|
196
|
+
@app.command()
|
|
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"),
|
|
200
|
+
):
|
|
201
|
+
"""
|
|
202
|
+
Run database migrations on an existing library.
|
|
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)
|
|
207
|
+
|
|
208
|
+
Example:
|
|
209
|
+
ebk migrate ~/my-library
|
|
210
|
+
ebk migrate ~/my-library --check
|
|
211
|
+
"""
|
|
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.")
|
|
224
|
+
raise typer.Exit(code=1)
|
|
225
|
+
|
|
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}")
|
|
238
|
+
else:
|
|
239
|
+
console.print(f"[cyan]Running migrations on {library_path}...[/cyan]")
|
|
240
|
+
results = run_all_migrations(library_path)
|
|
241
|
+
|
|
242
|
+
applied = [name for name, was_applied in results.items() if was_applied]
|
|
243
|
+
|
|
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}")
|
|
250
|
+
|
|
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)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@import_app.command(name="add")
|
|
258
|
+
def import_add(
|
|
151
259
|
file_path: Path = typer.Argument(..., help="Path to ebook file"),
|
|
152
260
|
library_path: Path = typer.Argument(..., help="Path to library"),
|
|
153
261
|
title: Optional[str] = typer.Option(None, "--title", "-t", help="Book title"),
|
|
@@ -159,14 +267,14 @@ def import_book(
|
|
|
159
267
|
auto_metadata: bool = typer.Option(True, "--auto-metadata/--no-auto-metadata", help="Extract metadata from file")
|
|
160
268
|
):
|
|
161
269
|
"""
|
|
162
|
-
Import
|
|
270
|
+
Import a single ebook file into the library.
|
|
163
271
|
|
|
164
272
|
Extracts metadata, text, and cover images automatically unless disabled.
|
|
165
273
|
Supports PDF, EPUB, MOBI, and plaintext files.
|
|
166
274
|
|
|
167
275
|
Examples:
|
|
168
|
-
ebk import book.pdf ~/my-library
|
|
169
|
-
ebk import book.epub ~/my-library --title "My Book" --authors "Author Name"
|
|
276
|
+
ebk import add book.pdf ~/my-library
|
|
277
|
+
ebk import add book.epub ~/my-library --title "My Book" --authors "Author Name"
|
|
170
278
|
"""
|
|
171
279
|
from .library_db import Library
|
|
172
280
|
from .extract_metadata import extract_metadata
|
|
@@ -229,21 +337,21 @@ def import_book(
|
|
|
229
337
|
raise typer.Exit(code=1)
|
|
230
338
|
|
|
231
339
|
|
|
232
|
-
@
|
|
340
|
+
@import_app.command(name="calibre")
|
|
233
341
|
def import_calibre(
|
|
234
342
|
calibre_path: Path = typer.Argument(..., help="Path to Calibre library"),
|
|
235
343
|
library_path: Path = typer.Argument(..., help="Path to ebk library"),
|
|
236
344
|
limit: Optional[int] = typer.Option(None, "--limit", help="Limit number of books to import")
|
|
237
345
|
):
|
|
238
346
|
"""
|
|
239
|
-
Import books from a Calibre library
|
|
347
|
+
Import books from a Calibre library.
|
|
240
348
|
|
|
241
349
|
Reads Calibre's metadata.opf files and imports ebooks with full metadata.
|
|
242
350
|
Supports all Calibre-managed formats (PDF, EPUB, MOBI, etc.).
|
|
243
351
|
|
|
244
352
|
Examples:
|
|
245
|
-
ebk import
|
|
246
|
-
ebk import
|
|
353
|
+
ebk import calibre ~/Calibre/Library ~/my-library
|
|
354
|
+
ebk import calibre ~/Calibre/Library ~/my-library --limit 100
|
|
247
355
|
"""
|
|
248
356
|
from .library_db import Library
|
|
249
357
|
|
|
@@ -305,6 +413,125 @@ def import_calibre(
|
|
|
305
413
|
raise typer.Exit(code=1)
|
|
306
414
|
|
|
307
415
|
|
|
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.
|
|
431
|
+
|
|
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
|
|
436
|
+
"""
|
|
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)
|
|
443
|
+
|
|
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)
|
|
452
|
+
|
|
453
|
+
try:
|
|
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}"))
|
|
466
|
+
else:
|
|
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
|
+
|
|
529
|
+
except Exception as e:
|
|
530
|
+
console.print(f"[red]Error importing folder: {e}[/red]")
|
|
531
|
+
logger.exception("Folder import error details:")
|
|
532
|
+
raise typer.Exit(code=1)
|
|
533
|
+
|
|
534
|
+
|
|
308
535
|
@app.command()
|
|
309
536
|
def search(
|
|
310
537
|
query: str = typer.Argument(..., help="Search query"),
|
|
@@ -1935,5 +2162,866 @@ def config(
|
|
|
1935
2162
|
console.print("[dim]Use 'ebk config --show' to view current settings[/dim]")
|
|
1936
2163
|
|
|
1937
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]")
|
|
3023
|
+
raise typer.Exit(code=1)
|
|
3024
|
+
|
|
3025
|
+
|
|
1938
3026
|
if __name__ == "__main__":
|
|
1939
3027
|
app()
|