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.

Files changed (61) hide show
  1. ebk/ai/__init__.py +23 -0
  2. ebk/ai/knowledge_graph.py +443 -0
  3. ebk/ai/llm_providers/__init__.py +21 -0
  4. ebk/ai/llm_providers/base.py +230 -0
  5. ebk/ai/llm_providers/ollama.py +362 -0
  6. ebk/ai/metadata_enrichment.py +396 -0
  7. ebk/ai/question_generator.py +328 -0
  8. ebk/ai/reading_companion.py +224 -0
  9. ebk/ai/semantic_search.py +434 -0
  10. ebk/ai/text_extractor.py +394 -0
  11. ebk/cli.py +1097 -9
  12. ebk/db/__init__.py +37 -0
  13. ebk/db/migrations.py +180 -0
  14. ebk/db/models.py +526 -0
  15. ebk/db/session.py +144 -0
  16. ebk/exports/__init__.py +0 -0
  17. ebk/exports/base_exporter.py +218 -0
  18. ebk/exports/html_library.py +1390 -0
  19. ebk/exports/html_utils.py +117 -0
  20. ebk/exports/hugo.py +59 -0
  21. ebk/exports/jinja_export.py +287 -0
  22. ebk/exports/multi_facet_export.py +164 -0
  23. ebk/exports/symlink_dag.py +479 -0
  24. ebk/exports/zip.py +25 -0
  25. ebk/library_db.py +155 -0
  26. ebk/repl/__init__.py +9 -0
  27. ebk/repl/find.py +126 -0
  28. ebk/repl/grep.py +174 -0
  29. ebk/repl/shell.py +1677 -0
  30. ebk/repl/text_utils.py +320 -0
  31. ebk/services/__init__.py +11 -0
  32. ebk/services/import_service.py +442 -0
  33. ebk/services/tag_service.py +282 -0
  34. ebk/services/text_extraction.py +317 -0
  35. ebk/similarity/__init__.py +77 -0
  36. ebk/similarity/base.py +154 -0
  37. ebk/similarity/core.py +445 -0
  38. ebk/similarity/extractors.py +168 -0
  39. ebk/similarity/metrics.py +376 -0
  40. ebk/vfs/__init__.py +101 -0
  41. ebk/vfs/base.py +301 -0
  42. ebk/vfs/library_vfs.py +124 -0
  43. ebk/vfs/nodes/__init__.py +54 -0
  44. ebk/vfs/nodes/authors.py +196 -0
  45. ebk/vfs/nodes/books.py +480 -0
  46. ebk/vfs/nodes/files.py +155 -0
  47. ebk/vfs/nodes/metadata.py +385 -0
  48. ebk/vfs/nodes/root.py +100 -0
  49. ebk/vfs/nodes/similar.py +165 -0
  50. ebk/vfs/nodes/subjects.py +184 -0
  51. ebk/vfs/nodes/tags.py +371 -0
  52. ebk/vfs/resolver.py +228 -0
  53. {ebk-0.3.1.dist-info → ebk-0.3.2.dist-info}/METADATA +1 -1
  54. ebk-0.3.2.dist-info/RECORD +69 -0
  55. ebk-0.3.2.dist-info/entry_points.txt +2 -0
  56. ebk-0.3.2.dist-info/top_level.txt +1 -0
  57. ebk-0.3.1.dist-info/RECORD +0 -19
  58. ebk-0.3.1.dist-info/entry_points.txt +0 -6
  59. ebk-0.3.1.dist-info/top_level.txt +0 -2
  60. {ebk-0.3.1.dist-info → ebk-0.3.2.dist-info}/WHEEL +0 -0
  61. {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(name="import")
150
- def import_book(
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 an ebook file into a database-backed library.
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
- @app.command(name="import-calibre")
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 into database-backed 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-calibre ~/Calibre/Library ~/my-library
246
- ebk import-calibre ~/Calibre/Library ~/my-library --limit 100
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()