ebk 0.4.4__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.
Files changed (87) hide show
  1. ebk/__init__.py +35 -0
  2. ebk/ai/__init__.py +23 -0
  3. ebk/ai/knowledge_graph.py +450 -0
  4. ebk/ai/llm_providers/__init__.py +26 -0
  5. ebk/ai/llm_providers/anthropic.py +209 -0
  6. ebk/ai/llm_providers/base.py +295 -0
  7. ebk/ai/llm_providers/gemini.py +285 -0
  8. ebk/ai/llm_providers/ollama.py +294 -0
  9. ebk/ai/metadata_enrichment.py +394 -0
  10. ebk/ai/question_generator.py +328 -0
  11. ebk/ai/reading_companion.py +224 -0
  12. ebk/ai/semantic_search.py +433 -0
  13. ebk/ai/text_extractor.py +393 -0
  14. ebk/calibre_import.py +66 -0
  15. ebk/cli.py +6433 -0
  16. ebk/config.py +230 -0
  17. ebk/db/__init__.py +37 -0
  18. ebk/db/migrations.py +507 -0
  19. ebk/db/models.py +725 -0
  20. ebk/db/session.py +144 -0
  21. ebk/decorators.py +1 -0
  22. ebk/exports/__init__.py +0 -0
  23. ebk/exports/base_exporter.py +218 -0
  24. ebk/exports/echo_export.py +279 -0
  25. ebk/exports/html_library.py +1743 -0
  26. ebk/exports/html_utils.py +87 -0
  27. ebk/exports/hugo.py +59 -0
  28. ebk/exports/jinja_export.py +286 -0
  29. ebk/exports/multi_facet_export.py +159 -0
  30. ebk/exports/opds_export.py +232 -0
  31. ebk/exports/symlink_dag.py +479 -0
  32. ebk/exports/zip.py +25 -0
  33. ebk/extract_metadata.py +341 -0
  34. ebk/ident.py +89 -0
  35. ebk/library_db.py +1440 -0
  36. ebk/opds.py +748 -0
  37. ebk/plugins/__init__.py +42 -0
  38. ebk/plugins/base.py +502 -0
  39. ebk/plugins/hooks.py +442 -0
  40. ebk/plugins/registry.py +499 -0
  41. ebk/repl/__init__.py +9 -0
  42. ebk/repl/find.py +126 -0
  43. ebk/repl/grep.py +173 -0
  44. ebk/repl/shell.py +1677 -0
  45. ebk/repl/text_utils.py +320 -0
  46. ebk/search_parser.py +413 -0
  47. ebk/server.py +3608 -0
  48. ebk/services/__init__.py +28 -0
  49. ebk/services/annotation_extraction.py +351 -0
  50. ebk/services/annotation_service.py +380 -0
  51. ebk/services/export_service.py +577 -0
  52. ebk/services/import_service.py +447 -0
  53. ebk/services/personal_metadata_service.py +347 -0
  54. ebk/services/queue_service.py +253 -0
  55. ebk/services/tag_service.py +281 -0
  56. ebk/services/text_extraction.py +317 -0
  57. ebk/services/view_service.py +12 -0
  58. ebk/similarity/__init__.py +77 -0
  59. ebk/similarity/base.py +154 -0
  60. ebk/similarity/core.py +471 -0
  61. ebk/similarity/extractors.py +168 -0
  62. ebk/similarity/metrics.py +376 -0
  63. ebk/skills/SKILL.md +182 -0
  64. ebk/skills/__init__.py +1 -0
  65. ebk/vfs/__init__.py +101 -0
  66. ebk/vfs/base.py +298 -0
  67. ebk/vfs/library_vfs.py +122 -0
  68. ebk/vfs/nodes/__init__.py +54 -0
  69. ebk/vfs/nodes/authors.py +196 -0
  70. ebk/vfs/nodes/books.py +480 -0
  71. ebk/vfs/nodes/files.py +155 -0
  72. ebk/vfs/nodes/metadata.py +385 -0
  73. ebk/vfs/nodes/root.py +100 -0
  74. ebk/vfs/nodes/similar.py +165 -0
  75. ebk/vfs/nodes/subjects.py +184 -0
  76. ebk/vfs/nodes/tags.py +371 -0
  77. ebk/vfs/resolver.py +228 -0
  78. ebk/vfs_router.py +275 -0
  79. ebk/views/__init__.py +32 -0
  80. ebk/views/dsl.py +668 -0
  81. ebk/views/service.py +619 -0
  82. ebk-0.4.4.dist-info/METADATA +755 -0
  83. ebk-0.4.4.dist-info/RECORD +87 -0
  84. ebk-0.4.4.dist-info/WHEEL +5 -0
  85. ebk-0.4.4.dist-info/entry_points.txt +2 -0
  86. ebk-0.4.4.dist-info/licenses/LICENSE +21 -0
  87. ebk-0.4.4.dist-info/top_level.txt +1 -0
ebk/repl/shell.py ADDED
@@ -0,0 +1,1677 @@
1
+ """Interactive REPL shell for library navigation."""
2
+
3
+ import shlex
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional, List
8
+
9
+ from prompt_toolkit import PromptSession
10
+ from prompt_toolkit.history import FileHistory
11
+ from prompt_toolkit.completion import Completer, Completion
12
+ from prompt_toolkit.styles import Style
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from ebk.library_db import Library
17
+ from ebk.vfs import LibraryVFS, DirectoryNode, FileNode, SymlinkNode
18
+ from ebk.repl.grep import GrepMatcher
19
+ from ebk.repl.find import FindQuery
20
+ from ebk.repl.text_utils import (
21
+ TextUtils,
22
+ parse_head_args,
23
+ parse_tail_args,
24
+ parse_wc_args,
25
+ parse_sort_args,
26
+ parse_uniq_args,
27
+ )
28
+
29
+
30
+ class Pipeline:
31
+ """Execute a series of piped commands.
32
+
33
+ Implements Unix-like piping where stdout of one command
34
+ becomes stdin of the next.
35
+ """
36
+
37
+ def __init__(self, command_line: str):
38
+ """Parse pipeline from command line.
39
+
40
+ Args:
41
+ command_line: Full command with pipes, e.g., "cat x | grep y"
42
+ """
43
+ self.commands = [cmd.strip() for cmd in command_line.split("|")]
44
+
45
+ def execute(self, shell: "LibraryShell") -> Optional[str]:
46
+ """Execute pipeline, passing stdout between commands.
47
+
48
+ Args:
49
+ shell: Shell instance for command execution
50
+
51
+ Returns:
52
+ Final output as string, or None on error
53
+ """
54
+ output = None
55
+
56
+ for i, cmd_str in enumerate(self.commands):
57
+ # Parse command
58
+ try:
59
+ parts = shlex.split(cmd_str)
60
+ except ValueError as e:
61
+ shell.console.print(f"[red]Parse error in pipeline:[/red] {e}")
62
+ return None
63
+
64
+ if not parts:
65
+ continue
66
+
67
+ cmd_name = parts[0]
68
+ args = parts[1:] if len(parts) > 1 else []
69
+
70
+ # Determine if this is the last command (should show output)
71
+ is_last = (i == len(self.commands) - 1)
72
+
73
+ # Execute with stdin from previous command
74
+ output = shell.execute_command(cmd_name, args, stdin=output, silent=not is_last)
75
+
76
+ if output is None:
77
+ # Command failed, stop pipeline
78
+ return None
79
+
80
+ return output
81
+
82
+
83
+ class PathCompleter(Completer):
84
+ """Tab completion for VFS paths."""
85
+
86
+ def __init__(self, vfs: LibraryVFS):
87
+ self.vfs = vfs
88
+
89
+ def get_completions(self, document, complete_event):
90
+ """Get path completion candidates."""
91
+ text = document.text_before_cursor
92
+ words = text.split()
93
+
94
+ # If we're completing a path argument
95
+ if len(words) > 1:
96
+ partial = words[-1]
97
+ else:
98
+ partial = ""
99
+
100
+ # Get completions from VFS
101
+ candidates = self.vfs.complete(partial)
102
+
103
+ for candidate in candidates:
104
+ yield Completion(candidate, start_position=-len(partial))
105
+
106
+
107
+ class LibraryShell:
108
+ """Interactive shell for navigating the library VFS.
109
+
110
+ Provides a Linux-like shell interface with commands:
111
+ - cd, pwd, ls: Navigate the virtual filesystem
112
+ - cat: Read file content
113
+ - grep: Search file content
114
+ - find: Query metadata with filters
115
+ - open: Open files in system viewer
116
+ - !<bash>: Execute bash commands
117
+ - !ebk <cmd>: Pass through to ebk CLI
118
+ - help, ?, man: Context-sensitive help
119
+ - exit, quit: Exit the shell
120
+ """
121
+
122
+ def __init__(self, library_path: Path):
123
+ """Initialize the REPL shell.
124
+
125
+ Args:
126
+ library_path: Path to the library
127
+ """
128
+ self.library = Library.open(library_path)
129
+ self.vfs = LibraryVFS(self.library)
130
+ self.console = Console()
131
+ self.running = True
132
+ self.grep_matcher = GrepMatcher(self.vfs)
133
+ self.find_query = FindQuery(self.library)
134
+
135
+ # Setup prompt toolkit
136
+ history_file = library_path / ".ebk_history"
137
+ self.session = PromptSession(
138
+ history=FileHistory(str(history_file)),
139
+ completer=PathCompleter(self.vfs),
140
+ style=Style.from_dict(
141
+ {
142
+ "prompt": "ansicyan bold",
143
+ }
144
+ ),
145
+ )
146
+
147
+ # Command registry
148
+ self.commands = {
149
+ "cd": self.cmd_cd,
150
+ "pwd": self.cmd_pwd,
151
+ "ls": self.cmd_ls,
152
+ "cat": self.cmd_cat,
153
+ "grep": self.cmd_grep,
154
+ "find": self.cmd_find,
155
+ "head": self.cmd_head,
156
+ "tail": self.cmd_tail,
157
+ "wc": self.cmd_wc,
158
+ "sort": self.cmd_sort,
159
+ "uniq": self.cmd_uniq,
160
+ "more": self.cmd_more,
161
+ "ln": self.cmd_ln,
162
+ "mv": self.cmd_mv,
163
+ "rm": self.cmd_rm,
164
+ "mkdir": self.cmd_mkdir,
165
+ "echo": self.cmd_echo,
166
+ "tag": self.cmd_tag,
167
+ "untag": self.cmd_untag,
168
+ "help": self.cmd_help,
169
+ "?": self.cmd_help,
170
+ "man": self.cmd_help,
171
+ "exit": self.cmd_exit,
172
+ "quit": self.cmd_quit,
173
+ }
174
+
175
+ def get_prompt(self) -> str:
176
+ """Generate prompt showing current path.
177
+
178
+ Returns:
179
+ Prompt string like "ebk:/books/42 $ "
180
+ """
181
+ path = self.vfs.pwd()
182
+ if path == "/":
183
+ path = "/"
184
+ return f"ebk:{path} $ "
185
+
186
+ def run(self):
187
+ """Run the shell main loop."""
188
+ self.console.print(
189
+ "[bold cyan]ebk shell[/bold cyan] - Interactive library navigation", style="bold"
190
+ )
191
+ self.console.print(f"Library: {self.library.library_path}")
192
+ self.console.print("Type 'help' for available commands, 'exit' to quit.\n")
193
+
194
+ while self.running:
195
+ try:
196
+ # Get user input
197
+ line = self.session.prompt(self.get_prompt())
198
+ line = line.strip()
199
+
200
+ if not line:
201
+ continue
202
+
203
+ # Parse and execute command
204
+ self.execute(line)
205
+
206
+ except KeyboardInterrupt:
207
+ self.console.print("\nUse 'exit' or 'quit' to exit the shell.")
208
+ continue
209
+ except EOFError:
210
+ break
211
+ except Exception as e:
212
+ from rich.markup import escape
213
+ self.console.print(f"[red]Error:[/red] {escape(str(e))}", style="bold")
214
+
215
+ self.cleanup()
216
+
217
+ def execute(self, line: str):
218
+ """Parse and execute a command line.
219
+
220
+ Args:
221
+ line: Command line to execute
222
+ """
223
+ # Handle bash commands (!<bash>)
224
+ if line.startswith("!"):
225
+ self.execute_bash(line[1:])
226
+ return
227
+
228
+ # Check for output redirection (>)
229
+ if ">" in line and "|" not in line:
230
+ # Split on > for redirection
231
+ parts = line.split(">", 1)
232
+ if len(parts) == 2:
233
+ cmd_part = parts[0].strip()
234
+ file_path = parts[1].strip()
235
+
236
+ # Execute command and capture output
237
+ try:
238
+ cmd_parts = shlex.split(cmd_part)
239
+ if not cmd_parts:
240
+ return
241
+
242
+ cmd = cmd_parts[0]
243
+ args = cmd_parts[1:]
244
+
245
+ if cmd in self.commands:
246
+ # Execute with silent=True to capture output
247
+ output = self.commands[cmd](args, stdin=None, silent=True)
248
+
249
+ # Write output to VFS file
250
+ if output is not None:
251
+ self.write_to_vfs_file(file_path, output)
252
+ return
253
+ else:
254
+ self.console.print(f"[red]Unknown command:[/red] {cmd}")
255
+ return
256
+ except ValueError as e:
257
+ self.console.print(f"[red]Parse error:[/red] {e}")
258
+ return
259
+
260
+ # Check for pipes
261
+ if "|" in line:
262
+ pipeline = Pipeline(line)
263
+ pipeline.execute(self)
264
+ # Note: Last command in pipeline already printed output (silent=False)
265
+ # so we don't print it again here
266
+ return
267
+
268
+ # Parse command and arguments
269
+ try:
270
+ parts = shlex.split(line)
271
+ except ValueError as e:
272
+ self.console.print(f"[red]Parse error:[/red] {e}")
273
+ return
274
+
275
+ if not parts:
276
+ return
277
+
278
+ cmd = parts[0]
279
+ args = parts[1:]
280
+
281
+ # Find and execute command
282
+ if cmd in self.commands:
283
+ self.commands[cmd](args)
284
+ else:
285
+ self.console.print(
286
+ f"[red]Unknown command:[/red] {cmd}. Type 'help' for available commands."
287
+ )
288
+
289
+ def execute_command(
290
+ self, cmd_name: str, args: List[str], stdin: Optional[str] = None, silent: bool = False
291
+ ) -> Optional[str]:
292
+ """Execute a command with optional stdin (for piping).
293
+
294
+ Args:
295
+ cmd_name: Command name
296
+ args: Command arguments
297
+ stdin: Optional input from previous command in pipeline
298
+ silent: If True, suppress console output (for intermediate pipeline commands)
299
+
300
+ Returns:
301
+ Command output as string, or None on error
302
+ """
303
+ if cmd_name not in self.commands:
304
+ self.console.print(f"[red]Unknown command:[/red] {cmd_name}")
305
+ return None
306
+
307
+ # Call command with stdin and silent parameters
308
+ return self.commands[cmd_name](args, stdin=stdin, silent=silent)
309
+
310
+ def resolve_vfs_path_to_real(self, vfs_path: str) -> Optional[str]:
311
+ """Resolve a VFS path to a real filesystem path.
312
+
313
+ Args:
314
+ vfs_path: VFS path (e.g., /books/42/files/book.pdf)
315
+
316
+ Returns:
317
+ Real filesystem path or None if not a physical file
318
+ """
319
+ from ebk.vfs.nodes.files import PhysicalFileNode
320
+
321
+ # Handle relative paths
322
+ if not vfs_path.startswith('/'):
323
+ current = self.vfs.pwd()
324
+ if current == '/':
325
+ vfs_path = '/' + vfs_path
326
+ else:
327
+ vfs_path = current + '/' + vfs_path
328
+
329
+ # Get the node
330
+ node = self.vfs.get_node(vfs_path)
331
+ if node is None:
332
+ return None
333
+
334
+ # Check if it's a physical file
335
+ if isinstance(node, PhysicalFileNode):
336
+ # Return the real filesystem path
337
+ file_path = self.library.library_path / node.db_file.path
338
+ return str(file_path)
339
+
340
+ return None
341
+
342
+ def execute_bash(self, command: str):
343
+ """Execute a bash command.
344
+
345
+ VFS paths in the command are automatically resolved to real filesystem paths.
346
+
347
+ Args:
348
+ command: Bash command to execute
349
+ """
350
+ # Check for ebk passthrough (!ebk <cmd>)
351
+ if command.startswith("ebk "):
352
+ self.execute_ebk_passthrough(command[4:])
353
+ return
354
+
355
+ # Try to resolve VFS paths to real filesystem paths
356
+ # Parse the command to find potential VFS paths
357
+ try:
358
+ parts = shlex.split(command)
359
+ resolved_parts = []
360
+
361
+ for i, part in enumerate(parts):
362
+ # Skip the command itself (first part)
363
+ if i == 0:
364
+ resolved_parts.append(part)
365
+ continue
366
+
367
+ # Try to resolve as VFS path
368
+ real_path = self.resolve_vfs_path_to_real(part)
369
+ if real_path:
370
+ # Quote the path to handle spaces
371
+ resolved_parts.append(shlex.quote(real_path))
372
+ else:
373
+ # Keep original
374
+ resolved_parts.append(shlex.quote(part))
375
+
376
+ # Reconstruct command with resolved paths
377
+ resolved_command = ' '.join(resolved_parts)
378
+
379
+ except ValueError:
380
+ # If parsing fails, use original command
381
+ resolved_command = command
382
+
383
+ # Execute bash command
384
+ try:
385
+ result = subprocess.run(
386
+ resolved_command, shell=True, capture_output=True, text=True, cwd=str(self.library.library_path)
387
+ )
388
+
389
+ if result.stdout:
390
+ self.console.print(result.stdout, end="")
391
+ if result.stderr:
392
+ self.console.print(f"[yellow]{result.stderr}[/yellow]", end="")
393
+
394
+ if result.returncode != 0:
395
+ self.console.print(
396
+ f"[red]Command exited with code {result.returncode}[/red]"
397
+ )
398
+
399
+ except Exception as e:
400
+ self.console.print(f"[red]Error executing bash command:[/red] {e}")
401
+
402
+ def execute_ebk_passthrough(self, command: str):
403
+ """Execute an ebk CLI command.
404
+
405
+ Args:
406
+ command: ebk command (without 'ebk' prefix)
407
+ """
408
+ try:
409
+ # Execute ebk CLI command
410
+ cmd = f"ebk {command}"
411
+ result = subprocess.run(
412
+ cmd, shell=True, capture_output=True, text=True
413
+ )
414
+
415
+ if result.stdout:
416
+ self.console.print(result.stdout, end="")
417
+ if result.stderr:
418
+ self.console.print(f"[yellow]{result.stderr}[/yellow]", end="")
419
+
420
+ if result.returncode != 0:
421
+ self.console.print(
422
+ f"[red]Command exited with code {result.returncode}[/red]"
423
+ )
424
+
425
+ except Exception as e:
426
+ self.console.print(f"[red]Error executing ebk command:[/red] {e}")
427
+
428
+ # Command implementations
429
+
430
+ def cmd_cd(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
431
+ """Change directory.
432
+
433
+ Usage: cd <path>
434
+ """
435
+ if not args:
436
+ # cd with no args goes to root
437
+ path = "/"
438
+ else:
439
+ path = args[0]
440
+
441
+ if self.vfs.cd(path):
442
+ # Success - optionally show new location
443
+ return None
444
+ else:
445
+ self.console.print(f"[red]cd: {path}: No such directory[/red]")
446
+ return None
447
+
448
+ def cmd_pwd(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
449
+ """Print working directory.
450
+
451
+ Usage: pwd
452
+ """
453
+ path = self.vfs.pwd()
454
+ if not silent:
455
+ self.console.print(path)
456
+ return path
457
+
458
+ def cmd_ls(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
459
+ """List directory contents.
460
+
461
+ Usage: ls [path]
462
+ """
463
+ path = args[0] if args else "."
464
+
465
+ try:
466
+ nodes = self.vfs.ls(path)
467
+ if not nodes:
468
+ # Either empty directory or error
469
+ node = self.vfs.get_node(path)
470
+ if node is None:
471
+ if not silent:
472
+ self.console.print(f"[red]ls: {path}: No such file or directory[/red]")
473
+ return None
474
+ # Otherwise it's just empty
475
+ return None
476
+
477
+ # Create formatted table
478
+ table = Table(show_header=True, header_style="bold magenta")
479
+ table.add_column("Type", style="cyan")
480
+ table.add_column("Name", style="white")
481
+ table.add_column("Info", style="dim")
482
+
483
+ # Collect output for piping
484
+ output_lines = []
485
+
486
+ for node in nodes:
487
+ try:
488
+ # Determine type icon
489
+ if isinstance(node, DirectoryNode):
490
+ type_icon = "📁"
491
+ type_char = "d"
492
+ elif isinstance(node, SymlinkNode):
493
+ type_icon = "🔗"
494
+ type_char = "l"
495
+ else:
496
+ type_icon = "📄"
497
+ type_char = "f"
498
+
499
+ # Get node info
500
+ info = node.get_info()
501
+ info_str = self._format_node_info(info)
502
+
503
+ # Apply color to name if present
504
+ name_str = node.name
505
+ if "color" in info and info["color"]:
506
+ try:
507
+ # Validate that the color is a valid hex code
508
+ color = info["color"]
509
+ if color.startswith('#') and len(color) in [4, 7]:
510
+ name_str = f"[{color}]{node.name}[/]"
511
+ except Exception:
512
+ # If color formatting fails, just use plain name
513
+ pass
514
+
515
+ if not silent:
516
+ table.add_row(type_icon, name_str, info_str)
517
+
518
+ # Add to output for piping
519
+ output_lines.append(f"{type_char}\t{node.name}\t{info_str}")
520
+ except Exception as e:
521
+ # Skip nodes that error, but log the issue
522
+ if not silent:
523
+ self.console.print(f"[yellow]Warning: Error reading node {node.name}: {e}[/yellow]")
524
+ continue
525
+
526
+ if not silent:
527
+ self.console.print(table)
528
+ return "\n".join(output_lines) if output_lines else None
529
+ except Exception as e:
530
+ if not silent:
531
+ self.console.print(f"[red]Error: {e}[/red]")
532
+ return None
533
+
534
+ def _format_node_info(self, info: dict) -> str:
535
+ """Format node info for display.
536
+
537
+ Args:
538
+ info: Node info dict
539
+
540
+ Returns:
541
+ Formatted info string
542
+ """
543
+ # Extract key info based on node type
544
+ parts = []
545
+
546
+ # File preview (for metadata files)
547
+ if "preview" in info and info["preview"]:
548
+ parts.append(info["preview"])
549
+
550
+ if "title" in info:
551
+ parts.append(info["title"])
552
+ if "author" in info:
553
+ parts.append(f"by {info['author']}")
554
+ if "subject" in info:
555
+ parts.append(info["subject"])
556
+ if "book_count" in info:
557
+ parts.append(f"{info['book_count']} books")
558
+ if "score" in info:
559
+ parts.append(f"similarity: {info['score']:.2f}")
560
+ if "size" in info and info["size"] is not None:
561
+ size_mb = info["size"] / (1024 * 1024)
562
+ parts.append(f"{size_mb:.2f} MB")
563
+ if "format" in info:
564
+ parts.append(info["format"].upper())
565
+ if "total_size" in info and info["total_size"] is not None and info["total_size"] > 0:
566
+ size_mb = info["total_size"] / (1024 * 1024)
567
+ parts.append(f"{size_mb:.2f} MB total")
568
+ if "file_count" in info:
569
+ parts.append(f"{info['file_count']} files")
570
+ # Note: color is now applied to the name directly in cmd_ls, not shown here
571
+
572
+ return " | ".join(parts) if parts else ""
573
+
574
+ def cmd_cat(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
575
+ """Read file content.
576
+
577
+ Usage: cat <file>
578
+ """
579
+ # If stdin provided, just pass it through (for pipeline chaining)
580
+ if stdin:
581
+ if not silent:
582
+ self.console.print(stdin)
583
+ return stdin
584
+
585
+ if not args:
586
+ if not silent:
587
+ self.console.print("[red]cat: missing file argument[/red]")
588
+ return None
589
+
590
+ path = args[0]
591
+ try:
592
+ content = self.vfs.cat(path)
593
+ if not silent:
594
+ self.console.print(content)
595
+ return content
596
+ except Exception as e:
597
+ if not silent:
598
+ self.console.print(f"[red]cat: {e}[/red]")
599
+ return None
600
+
601
+ def cmd_grep(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
602
+ """Search file content (Unix-like).
603
+
604
+ Usage: grep [options] <pattern> [files...]
605
+ Options:
606
+ -r: Recursive search
607
+ -i: Case insensitive
608
+ -n: Show line numbers
609
+ """
610
+ if not args:
611
+ if not silent:
612
+ self.console.print("[red]grep: missing pattern[/red]")
613
+ self.console.print("Usage: grep [options] <pattern> [files...]")
614
+ return None
615
+
616
+ # Parse flags
617
+ recursive = False
618
+ ignore_case = False
619
+ line_numbers = False
620
+ pattern = None
621
+ paths = []
622
+
623
+ i = 0
624
+ while i < len(args):
625
+ arg = args[i]
626
+
627
+ if arg.startswith("-"):
628
+ # Parse flags
629
+ for flag in arg[1:]:
630
+ if flag == "r":
631
+ recursive = True
632
+ elif flag == "i":
633
+ ignore_case = True
634
+ elif flag == "n":
635
+ line_numbers = True
636
+ else:
637
+ if not silent:
638
+ self.console.print(f"[red]grep: unknown option: -{flag}[/red]")
639
+ return None
640
+ else:
641
+ # First non-flag arg is pattern
642
+ if pattern is None:
643
+ pattern = arg
644
+ else:
645
+ # Rest are paths
646
+ paths.append(arg)
647
+
648
+ i += 1
649
+
650
+ if pattern is None:
651
+ if not silent:
652
+ self.console.print("[red]grep: missing pattern[/red]")
653
+ return None
654
+
655
+ # If stdin provided, grep on stdin content
656
+ if stdin:
657
+ import re
658
+ flags = re.IGNORECASE if ignore_case else 0
659
+ regex = re.compile(pattern, flags)
660
+
661
+ matched_lines = []
662
+ for line_num, line in enumerate(stdin.split("\n"), 1):
663
+ if regex.search(line):
664
+ if line_numbers:
665
+ matched_lines.append(f"{line_num}:{line}")
666
+ else:
667
+ matched_lines.append(line)
668
+
669
+ output = "\n".join(matched_lines)
670
+ if output and not silent:
671
+ self.console.print(output)
672
+ return output if output else None
673
+
674
+ # Default to current directory if no paths specified
675
+ if not paths:
676
+ paths = ["."]
677
+
678
+ # Perform grep on VFS
679
+ try:
680
+ results = self.grep_matcher.grep(
681
+ pattern, paths, recursive, ignore_case, line_numbers
682
+ )
683
+
684
+ if not results:
685
+ # No matches found
686
+ return None
687
+
688
+ # Display and collect results
689
+ output_lines = []
690
+ for file_path, line_num, line_content in results:
691
+ if line_numbers and line_num > 0:
692
+ line_str = f"{file_path}:{line_num}:{line_content}"
693
+ if not silent:
694
+ self.console.print(f"[cyan]{file_path}[/cyan]:[yellow]{line_num}[/yellow]:{line_content}")
695
+ else:
696
+ line_str = f"{file_path}:{line_content}"
697
+ if not silent:
698
+ self.console.print(f"[cyan]{file_path}[/cyan]:{line_content}")
699
+ output_lines.append(line_str)
700
+
701
+ return "\n".join(output_lines) if output_lines else None
702
+
703
+ except ValueError as e:
704
+ if not silent:
705
+ self.console.print(f"[red]grep: {e}[/red]")
706
+ return None
707
+ except Exception as e:
708
+ self.console.print(f"[red]grep error: {e}[/red]")
709
+ return None
710
+
711
+ def cmd_find(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
712
+ """Find books with metadata filters.
713
+
714
+ Usage: find [filters...]
715
+ Filters: field:value (e.g., author:Knuth, subject:python)
716
+
717
+ Fields:
718
+ title:TEXT - Search by title (partial match)
719
+ author:TEXT - Search by author (partial match)
720
+ subject:TEXT - Search by subject/tag (partial match)
721
+ text:TEXT - Full-text search (title, description, extracted text)
722
+ language:CODE - Filter by language (exact, e.g., en, fr)
723
+ year:YYYY - Filter by publication year
724
+ publisher:TEXT - Search by publisher (partial match)
725
+ format:EXT - Filter by file format (pdf, epub, mobi)
726
+ limit:N - Limit results (default: 50)
727
+
728
+ Examples:
729
+ find author:Knuth
730
+ find subject:python year:2020
731
+ find language:en format:pdf
732
+ find text:"machine learning" limit:10
733
+ find text:algorithm year:1975
734
+ """
735
+ # stdin is ignored for find (it searches metadata, not text content)
736
+
737
+ if not args:
738
+ if not silent:
739
+ self.console.print("[yellow]Usage:[/yellow] find field:value [field:value ...]")
740
+ self.console.print("\n[yellow]Examples:[/yellow]")
741
+ self.console.print(" find author:Knuth")
742
+ self.console.print(" find subject:python year:2020")
743
+ self.console.print(" find language:en format:pdf")
744
+ self.console.print("\n[dim]Type 'help find' for more information.[/dim]")
745
+ return None
746
+
747
+ try:
748
+ # Parse filters
749
+ filters = self.find_query.parse_filters(args)
750
+
751
+ # Execute find
752
+ books = self.find_query.find(filters)
753
+
754
+ if not books:
755
+ if not silent:
756
+ self.console.print("[yellow]No books found matching filters.[/yellow]")
757
+ return None
758
+
759
+ # Display results
760
+ if not silent:
761
+ self.console.print(f"\n[cyan]Found {len(books)} book(s):[/cyan]\n")
762
+
763
+ table = Table(show_header=True, header_style="bold magenta")
764
+ table.add_column("ID", style="cyan", width=6)
765
+ table.add_column("Title", style="white")
766
+ table.add_column("Authors", style="green")
767
+ table.add_column("Year", style="yellow", width=6)
768
+ table.add_column("Language", style="dim", width=8)
769
+
770
+ # Collect output lines for piping
771
+ output_lines = []
772
+
773
+ for book in books:
774
+ book_id = str(book.id)
775
+ title = book.title or "(No title)"
776
+ authors = ", ".join(a.name for a in book.authors) if book.authors else "(No author)"
777
+ # Extract year from publication_date if available
778
+ year = ""
779
+ if book.publication_date:
780
+ # publication_date can be year, YYYY-MM, or YYYY-MM-DD
781
+ year = book.publication_date.split("-")[0] if "-" in book.publication_date else book.publication_date
782
+ language = book.language or ""
783
+
784
+ if not silent:
785
+ table.add_row(book_id, title, authors, year, language)
786
+
787
+ # Create plain text output for piping
788
+ output_lines.append(f"{book_id}\t{title}\t{authors}\t{year}\t{language}")
789
+
790
+ if not silent:
791
+ self.console.print(table)
792
+
793
+ # Show usage hint
794
+ self.console.print(
795
+ f"\n[dim]Tip: Use 'cd /books/<id>' to navigate to a book[/dim]"
796
+ )
797
+
798
+ return "\n".join(output_lines) if output_lines else None
799
+
800
+ except ValueError as e:
801
+ if not silent:
802
+ self.console.print(f"[red]find: {e}[/red]")
803
+ return None
804
+ except Exception as e:
805
+ if not silent:
806
+ self.console.print(f"[red]find error: {e}[/red]")
807
+ return None
808
+
809
+ def cmd_mkdir(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
810
+ """Create a new tag (directory).
811
+
812
+ Usage: mkdir <tag-path>
813
+
814
+ Examples:
815
+ mkdir /tags/Work/ - Create "Work" tag
816
+ mkdir /tags/Work/Project-2024/ - Create "Work/Project-2024" tag
817
+ mkdir /tags/Reading/Fiction/Sci-Fi/ - Create nested tag hierarchy
818
+
819
+ Note: Parent tags are created automatically if they don't exist.
820
+ Only works in /tags/ directory.
821
+ """
822
+ from ebk.services.tag_service import TagService
823
+
824
+ if len(args) < 1:
825
+ if not silent:
826
+ self.console.print("[red]Usage:[/red] mkdir <tag-path>")
827
+ return None
828
+
829
+ target_path = args[0]
830
+
831
+ # Only allow creating tags in /tags/
832
+ if not target_path.startswith('/tags/'):
833
+ if not silent:
834
+ self.console.print(f"[red]Can only create tags in /tags/ (e.g., mkdir /tags/NewTag/)[/red]")
835
+ return None
836
+
837
+ # Extract tag path from /tags/Work/Project -> Work/Project
838
+ tag_path = target_path.replace('/tags/', '').strip('/')
839
+ if not tag_path:
840
+ if not silent:
841
+ self.console.print(f"[red]Invalid tag path[/red]")
842
+ return None
843
+
844
+ # Create the tag
845
+ tag_service = TagService(self.library.session)
846
+ try:
847
+ tag = tag_service.get_or_create_tag(tag_path)
848
+ if not silent:
849
+ self.console.print(f"[green]✓ Created tag '{tag.path}'[/green]")
850
+ if tag.depth > 0:
851
+ self.console.print(f" Full path: {tag.path}")
852
+ self.console.print(f" Depth: {tag.depth}")
853
+ except Exception as e:
854
+ if not silent:
855
+ self.console.print(f"[red]Error creating tag:[/red] {e}")
856
+ return None
857
+
858
+ return None
859
+
860
+ def cmd_echo(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
861
+ """Echo text to stdout or redirect to file.
862
+
863
+ Usage: echo <text>
864
+ echo <text> > <file>
865
+
866
+ Examples:
867
+ echo "Hello World" - Print to console
868
+ echo "My description" > /tags/Work/description - Write to file
869
+
870
+ Note: Redirection (>) is handled by the execute() method.
871
+ """
872
+ # Join all arguments with spaces
873
+ text = " ".join(args)
874
+
875
+ if not silent:
876
+ self.console.print(text)
877
+
878
+ # Return the text for potential redirection
879
+ return text
880
+
881
+ def write_to_vfs_file(self, path: str, content: str) -> None:
882
+ """Write content to a VFS file.
883
+
884
+ Args:
885
+ path: VFS path to file
886
+ content: Content to write
887
+ """
888
+ from ebk.vfs.base import FileNode
889
+
890
+ # Resolve the path
891
+ node = self.vfs.get_node(path)
892
+
893
+ if node is None:
894
+ self.console.print(f"[red]File not found:[/red] {path}")
895
+ return
896
+
897
+ if not isinstance(node, FileNode):
898
+ self.console.print(f"[red]Not a file:[/red] {path}")
899
+ return
900
+
901
+ # Check if writable
902
+ if not node.is_writable():
903
+ self.console.print(f"[red]File is read-only:[/red] {path}")
904
+ return
905
+
906
+ # Write content
907
+ try:
908
+ node.write_content(content)
909
+ self.console.print(f"[green]✓ Wrote to {path}[/green]")
910
+ except Exception as e:
911
+ self.console.print(f"[red]Error writing to file:[/red] {e}")
912
+
913
+ def cmd_tag(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
914
+ """Tag a book (context-aware shorthand for ln).
915
+
916
+ Usage: tag <tag-path> [book-id]
917
+
918
+ If book-id is omitted, uses current directory (must be in /books/ID/).
919
+ Tag paths can be relative to /tags/ (no need to prefix with /tags/).
920
+
921
+ Examples:
922
+ tag Work # From /books/42/: tag current book with Work
923
+ tag Work/Project-2024 # Tag with nested tag
924
+ tag Reading 1555 # Tag book 1555 with Reading (from anywhere)
925
+ tag Fiction/Sci-Fi 1555 # Tag book 1555 with Fiction/Sci-Fi
926
+ """
927
+ from ebk.db.models import Book
928
+
929
+ if len(args) < 1:
930
+ if not silent:
931
+ self.console.print("[red]Usage:[/red] tag <tag-path> [book-id]")
932
+ return None
933
+
934
+ tag_path = args[0]
935
+ book_id = None
936
+
937
+ # If book ID provided, use it
938
+ if len(args) >= 2:
939
+ try:
940
+ book_id = int(args[1])
941
+ except ValueError:
942
+ if not silent:
943
+ self.console.print(f"[red]Invalid book ID:[/red] {args[1]}")
944
+ return None
945
+ else:
946
+ # Try to infer from current directory
947
+ pwd = self.vfs.pwd()
948
+ if pwd.startswith('/books/'):
949
+ parts = pwd.strip('/').split('/')
950
+ if len(parts) >= 2:
951
+ try:
952
+ book_id = int(parts[1])
953
+ except ValueError:
954
+ pass
955
+
956
+ if book_id is None:
957
+ if not silent:
958
+ self.console.print("[red]Error:[/red] Not in a book directory and no book ID provided")
959
+ self.console.print("[yellow]Usage:[/yellow] tag <tag-path> [book-id]")
960
+ return None
961
+
962
+ # Normalize tag path (remove /tags/ prefix if present)
963
+ if tag_path.startswith('/tags/'):
964
+ tag_path = tag_path.replace('/tags/', '').strip('/')
965
+ tag_path = tag_path.strip('/')
966
+
967
+ # Build full VFS paths for ln command
968
+ source = f"/books/{book_id}"
969
+ dest = f"/tags/{tag_path}/"
970
+
971
+ # Delegate to ln command
972
+ return self.cmd_ln([source, dest], stdin, silent)
973
+
974
+ def cmd_untag(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
975
+ """Remove a tag from a book (context-aware shorthand for rm).
976
+
977
+ Usage: untag <tag-path> [book-id]
978
+
979
+ If book-id is omitted, uses current directory (must be in /books/ID/).
980
+ Tag paths can be relative to /tags/ (no need to prefix with /tags/).
981
+
982
+ Examples:
983
+ untag Work # From /books/42/: remove Work tag from current book
984
+ untag Work/Project-2024 # Remove nested tag
985
+ untag Reading 1555 # Remove Reading tag from book 1555 (from anywhere)
986
+ untag Fiction/Sci-Fi 1555 # Remove Fiction/Sci-Fi from book 1555
987
+ """
988
+ from ebk.db.models import Book
989
+
990
+ if len(args) < 1:
991
+ if not silent:
992
+ self.console.print("[red]Usage:[/red] untag <tag-path> [book-id]")
993
+ return None
994
+
995
+ tag_path = args[0]
996
+ book_id = None
997
+
998
+ # If book ID provided, use it
999
+ if len(args) >= 2:
1000
+ try:
1001
+ book_id = int(args[1])
1002
+ except ValueError:
1003
+ if not silent:
1004
+ self.console.print(f"[red]Invalid book ID:[/red] {args[1]}")
1005
+ return None
1006
+ else:
1007
+ # Try to infer from current directory
1008
+ pwd = self.vfs.pwd()
1009
+ if pwd.startswith('/books/'):
1010
+ parts = pwd.strip('/').split('/')
1011
+ if len(parts) >= 2:
1012
+ try:
1013
+ book_id = int(parts[1])
1014
+ except ValueError:
1015
+ pass
1016
+
1017
+ if book_id is None:
1018
+ if not silent:
1019
+ self.console.print("[red]Error:[/red] Not in a book directory and no book ID provided")
1020
+ self.console.print("[yellow]Usage:[/yellow] untag <tag-path> [book-id]")
1021
+ return None
1022
+
1023
+ # Normalize tag path (remove /tags/ prefix if present)
1024
+ if tag_path.startswith('/tags/'):
1025
+ tag_path = tag_path.replace('/tags/', '').strip('/')
1026
+ tag_path = tag_path.strip('/')
1027
+
1028
+ # Build full VFS path for rm command
1029
+ target = f"/tags/{tag_path}/{book_id}"
1030
+
1031
+ # Delegate to rm command
1032
+ return self.cmd_rm([target], stdin, silent)
1033
+
1034
+ def cmd_help(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
1035
+ """Show help information.
1036
+
1037
+ Usage: help [command]
1038
+ """
1039
+ if args:
1040
+ # Show help for specific command
1041
+ cmd = args[0]
1042
+ if cmd in self.commands:
1043
+ func = self.commands[cmd]
1044
+ self.console.print(f"[bold]{cmd}[/bold]")
1045
+ self.console.print(func.__doc__ or "No documentation available.")
1046
+ else:
1047
+ self.console.print(f"[red]Unknown command:[/red] {cmd}")
1048
+ else:
1049
+ # Show general help
1050
+ self.console.print("[bold cyan]Available Commands:[/bold cyan]\n")
1051
+
1052
+ table = Table(show_header=True, header_style="bold magenta")
1053
+ table.add_column("Command", style="cyan")
1054
+ table.add_column("Description", style="white")
1055
+
1056
+ table.add_row("cd <path>", "Change directory")
1057
+ table.add_row("pwd", "Print working directory")
1058
+ table.add_row("ls [path]", "List directory contents")
1059
+ table.add_row("cat <file>", "Read file content")
1060
+ table.add_row("grep <pattern>", "Search file content (Unix-like)")
1061
+ table.add_row("find <filters>", "Find books with metadata filters")
1062
+ table.add_row("head [-n N]", "Show first N lines")
1063
+ table.add_row("tail [-n N]", "Show last N lines")
1064
+ table.add_row("wc [-lwc]", "Count lines, words, characters")
1065
+ table.add_row("sort [-r]", "Sort lines")
1066
+ table.add_row("uniq [-c]", "Remove duplicate lines")
1067
+ table.add_row("more", "Paginate output")
1068
+ table.add_row("ln <src> <dest>", "Link book to tag (ln /books/42 /tags/Work/)")
1069
+ table.add_row("mv <src> <dest>", "Move book between tags")
1070
+ table.add_row("rm [-r] <path>", "Remove tag from book, delete tag, or DELETE book")
1071
+ table.add_row("mkdir <path>", "Create new tag (mkdir /tags/Work/)")
1072
+ table.add_row("echo <text>", "Echo text (supports > redirection)")
1073
+ table.add_row("tag <tag-path> [id]", "Tag a book (context-aware)")
1074
+ table.add_row("untag <tag-path> [id]", "Remove tag from book (context-aware)")
1075
+ table.add_row("!<cmd> <file>", "Execute system command (auto-resolves VFS paths)")
1076
+ table.add_row("!ebk <cmd>", "Pass through to ebk CLI")
1077
+ table.add_row("help [cmd]", "Show help")
1078
+ table.add_row("exit, quit", "Exit the shell")
1079
+
1080
+ self.console.print(table)
1081
+
1082
+ self.console.print("\n[bold cyan]Piping:[/bold cyan]")
1083
+ self.console.print(" Commands can be chained with | (pipe)")
1084
+ self.console.print(" Example: cat text | grep python | head -20")
1085
+ self.console.print(" Example: find author:Knuth | wc -l")
1086
+
1087
+ self.console.print("\n[bold cyan]Output Redirection:[/bold cyan]")
1088
+ self.console.print(" Use > to redirect output to VFS files")
1089
+ self.console.print(" Example: echo \"My notes\" > /tags/Work/description")
1090
+ self.console.print(" Example: echo \"#FF5733\" > /tags/Work/color")
1091
+
1092
+ self.console.print("\n[bold cyan]System Commands:[/bold cyan]")
1093
+ self.console.print(" Use ! prefix to run system commands")
1094
+ self.console.print(" VFS paths are auto-resolved to real filesystem paths")
1095
+ self.console.print(" Example: !xdg-open book.pdf - Opens file in default viewer")
1096
+ self.console.print(" Example: !evince book.pdf - Opens with specific program")
1097
+ self.console.print(" Example: !ls -lh - Run any shell command")
1098
+
1099
+ self.console.print("\n[bold cyan]VFS Structure:[/bold cyan]")
1100
+ self.console.print(" /books/ - All books")
1101
+ self.console.print(" /books/42/ - Book with ID 42")
1102
+ self.console.print(" ├── title, authors, subjects, description")
1103
+ self.console.print(" ├── text - Extracted full text")
1104
+ self.console.print(" ├── files/ - Physical files (PDF, EPUB, etc.)")
1105
+ self.console.print(" ├── similar/ - Similar books")
1106
+ self.console.print(" └── tags/ - Tags for this book (symlinks)")
1107
+ self.console.print(" /authors/ - Browse by author")
1108
+ self.console.print(" /subjects/ - Browse by subject")
1109
+ self.console.print(" /tags/ - Browse by user-defined hierarchical tags")
1110
+
1111
+ return None
1112
+
1113
+ def cmd_head(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
1114
+ """Show first N lines of file or stdin.
1115
+
1116
+ Usage: head [-n NUM] [file]
1117
+ """
1118
+ try:
1119
+ lines, filename = parse_head_args(args)
1120
+
1121
+ # Get content
1122
+ if stdin:
1123
+ content = stdin
1124
+ elif filename:
1125
+ content = self.vfs.cat(filename)
1126
+ else:
1127
+ if not silent:
1128
+ self.console.print("[yellow]Usage: head [-n NUM] [file][/yellow]")
1129
+ return None
1130
+
1131
+ # Process
1132
+ output = TextUtils.head(content, lines)
1133
+ if not silent:
1134
+ self.console.print(output)
1135
+ return output
1136
+
1137
+ except ValueError as e:
1138
+ if not silent:
1139
+ self.console.print(f"[red]{e}[/red]")
1140
+ return None
1141
+
1142
+ def cmd_tail(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
1143
+ """Show last N lines of file or stdin.
1144
+
1145
+ Usage: tail [-n NUM] [file]
1146
+ """
1147
+ try:
1148
+ lines, filename = parse_tail_args(args)
1149
+
1150
+ # Get content
1151
+ if stdin:
1152
+ content = stdin
1153
+ elif filename:
1154
+ content = self.vfs.cat(filename)
1155
+ else:
1156
+ if not silent:
1157
+ self.console.print("[yellow]Usage: tail [-n NUM] [file][/yellow]")
1158
+ return None
1159
+
1160
+ # Process
1161
+ output = TextUtils.tail(content, lines)
1162
+ if not silent:
1163
+ self.console.print(output)
1164
+ return output
1165
+
1166
+ except ValueError as e:
1167
+ if not silent:
1168
+ self.console.print(f"[red]{e}[/red]")
1169
+ return None
1170
+
1171
+ def cmd_wc(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
1172
+ """Count lines, words, and characters.
1173
+
1174
+ Usage: wc [-l|-w|-c] [file]
1175
+ """
1176
+ try:
1177
+ lines_only, words_only, chars_only, filename = parse_wc_args(args)
1178
+
1179
+ # Get content
1180
+ if stdin:
1181
+ content = stdin
1182
+ elif filename:
1183
+ content = self.vfs.cat(filename)
1184
+ else:
1185
+ if not silent:
1186
+ self.console.print("[yellow]Usage: wc [-l|-w|-c] [file][/yellow]")
1187
+ return None
1188
+
1189
+ # Process
1190
+ output = TextUtils.wc(content, lines_only, words_only, chars_only)
1191
+ if not silent:
1192
+ self.console.print(output)
1193
+ return output
1194
+
1195
+ except ValueError as e:
1196
+ if not silent:
1197
+ self.console.print(f"[red]{e}[/red]")
1198
+ return None
1199
+
1200
+ def cmd_sort(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
1201
+ """Sort lines alphabetically.
1202
+
1203
+ Usage: sort [-r] [file]
1204
+ """
1205
+ try:
1206
+ reverse, filename = parse_sort_args(args)
1207
+
1208
+ # Get content
1209
+ if stdin:
1210
+ content = stdin
1211
+ elif filename:
1212
+ content = self.vfs.cat(filename)
1213
+ else:
1214
+ if not silent:
1215
+ self.console.print("[yellow]Usage: sort [-r] [file][/yellow]")
1216
+ return None
1217
+
1218
+ # Process
1219
+ output = TextUtils.sort_lines(content, reverse)
1220
+ if not silent:
1221
+ self.console.print(output)
1222
+ return output
1223
+
1224
+ except ValueError as e:
1225
+ if not silent:
1226
+ self.console.print(f"[red]{e}[/red]")
1227
+ return None
1228
+
1229
+ def cmd_uniq(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
1230
+ """Remove duplicate adjacent lines.
1231
+
1232
+ Usage: uniq [-c] [file]
1233
+ """
1234
+ try:
1235
+ count, filename = parse_uniq_args(args)
1236
+
1237
+ # Get content
1238
+ if stdin:
1239
+ content = stdin
1240
+ elif filename:
1241
+ content = self.vfs.cat(filename)
1242
+ else:
1243
+ if not silent:
1244
+ self.console.print("[yellow]Usage: uniq [-c] [file][/yellow]")
1245
+ return None
1246
+
1247
+ # Process
1248
+ output = TextUtils.uniq(content, count)
1249
+ if not silent:
1250
+ self.console.print(output)
1251
+ return output
1252
+
1253
+ except ValueError as e:
1254
+ if not silent:
1255
+ self.console.print(f"[red]{e}[/red]")
1256
+ return None
1257
+
1258
+ def cmd_more(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
1259
+ """Paginate output.
1260
+
1261
+ Usage: more [file]
1262
+ """
1263
+ # Get content
1264
+ if stdin:
1265
+ content = stdin
1266
+ elif args:
1267
+ content = self.vfs.cat(args[0])
1268
+ else:
1269
+ if not silent:
1270
+ self.console.print("[yellow]Usage: more [file][/yellow]")
1271
+ return None
1272
+
1273
+ # Use Rich's pager (only if not silent)
1274
+ if not silent:
1275
+ with self.console.pager():
1276
+ self.console.print(content)
1277
+
1278
+ return content
1279
+
1280
+ def cmd_exit(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
1281
+ """Exit the shell.
1282
+
1283
+ Usage: exit
1284
+ """
1285
+ self.running = False
1286
+ self.console.print("[cyan]Goodbye![/cyan]")
1287
+ return None
1288
+
1289
+ def cmd_quit(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
1290
+ """Quit the shell.
1291
+
1292
+ Usage: quit
1293
+ """
1294
+ return self.cmd_exit(args, stdin)
1295
+
1296
+ def cmd_ln(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
1297
+ """Link (add tag to book).
1298
+
1299
+ Usage: ln <source> <dest>
1300
+
1301
+ Examples:
1302
+ ln /books/42 /tags/Work/ - Add "Work" tag to book 42
1303
+ ln /books/42 /tags/Work/Project/ - Add "Work/Project" tag to book 42
1304
+ ln /tags/Work/42 /tags/Archive/ - Add "Archive" tag (resolves symlink)
1305
+ ln /subjects/computers/42 /tags/Reading/ - Tag book from subject
1306
+
1307
+ Note: Creates a link/relationship between a book and a tag without
1308
+ removing any existing tags. Books are canonical entities at /books/ID/,
1309
+ tags are views/links to books. Source can be a direct book path or
1310
+ a symlink (which will be automatically resolved to the book ID).
1311
+ """
1312
+ from ebk.services.tag_service import TagService
1313
+ from ebk.db.models import Book
1314
+
1315
+ if len(args) < 2:
1316
+ if not silent:
1317
+ self.console.print("[red]Usage:[/red] ln <source> <dest>")
1318
+ return None
1319
+
1320
+ source_path = args[0]
1321
+ dest_path = args[1]
1322
+
1323
+ # Resolve source to book
1324
+ source_node = self.vfs.get_node(source_path)
1325
+ if source_node is None:
1326
+ if not silent:
1327
+ self.console.print(f"[red]Source not found:[/red] {source_path}")
1328
+ return None
1329
+
1330
+ # Extract book ID from source
1331
+ # Handle:
1332
+ # 1. Direct book paths: /books/42
1333
+ # 2. Symlinks in tags: /tags/Work/42 (VFS resolves to BookNode)
1334
+ # 3. Symlinks in subjects: /subjects/computers/42
1335
+ # 4. Symlinks in authors: /authors/knuth-donald/42
1336
+ book_id = None
1337
+
1338
+ from ebk.vfs.base import SymlinkNode
1339
+ from ebk.vfs.nodes.books import BookNode
1340
+
1341
+ # If source is a BookNode (VFS auto-resolves symlinks), extract book ID
1342
+ if hasattr(source_node, 'book') and hasattr(source_node.book, 'id'):
1343
+ book_id = source_node.book.id
1344
+ # If source is a symlink, extract book ID from target path
1345
+ elif isinstance(source_node, SymlinkNode):
1346
+ # Target path is like "/books/42"
1347
+ target_parts = source_node.target_path.strip('/').split('/')
1348
+ if target_parts[0] == 'books' and len(target_parts) >= 2:
1349
+ try:
1350
+ book_id = int(target_parts[1])
1351
+ except ValueError:
1352
+ pass
1353
+ else:
1354
+ # Try to extract from original path
1355
+ path_parts = source_path.strip('/').split('/')
1356
+ if path_parts[0] == 'books' and len(path_parts) >= 2:
1357
+ try:
1358
+ book_id = int(path_parts[1])
1359
+ except ValueError:
1360
+ pass
1361
+
1362
+ if book_id is None:
1363
+ if not silent:
1364
+ self.console.print(f"[red]Source must be a book or book symlink (e.g., /books/42 or /tags/Work/42)[/red]")
1365
+ return None
1366
+
1367
+ # Get book from database
1368
+ book = self.library.session.query(Book).filter_by(id=book_id).first()
1369
+ if not book:
1370
+ if not silent:
1371
+ self.console.print(f"[red]Book {book_id} not found[/red]")
1372
+ return None
1373
+
1374
+ # Resolve destination to tag path
1375
+ if not dest_path.startswith('/tags/'):
1376
+ if not silent:
1377
+ self.console.print(f"[red]Destination must be a tag path (e.g., /tags/Work/)[/red]")
1378
+ return None
1379
+
1380
+ # Extract tag path from destination
1381
+ tag_path = dest_path.replace('/tags/', '').strip('/')
1382
+ if not tag_path:
1383
+ if not silent:
1384
+ self.console.print(f"[red]Invalid tag path[/red]")
1385
+ return None
1386
+
1387
+ # Add tag to book
1388
+ tag_service = TagService(self.library.session)
1389
+ try:
1390
+ tag = tag_service.add_tag_to_book(book, tag_path)
1391
+ if not silent:
1392
+ self.console.print(f"[green]✓ Added tag '{tag.path}' to book {book.id}[/green]")
1393
+ if book.title:
1394
+ self.console.print(f" Book: {book.title}")
1395
+ except Exception as e:
1396
+ if not silent:
1397
+ self.console.print(f"[red]Error adding tag:[/red] {e}")
1398
+ return None
1399
+
1400
+ return None
1401
+
1402
+ def cmd_mv(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
1403
+ """Move (change tag on book).
1404
+
1405
+ Usage: mv <source> <dest>
1406
+
1407
+ Examples:
1408
+ mv /tags/Work/42 /tags/Archive/ - Move book 42 from Work to Archive
1409
+
1410
+ Note: This removes the source tag and adds the destination tag.
1411
+ Use 'cp' to add a tag without removing the old one.
1412
+ """
1413
+ from ebk.services.tag_service import TagService
1414
+ from ebk.db.models import Book
1415
+
1416
+ if len(args) < 2:
1417
+ if not silent:
1418
+ self.console.print("[red]Usage:[/red] mv <source> <dest>")
1419
+ return None
1420
+
1421
+ source_path = args[0]
1422
+ dest_path = args[1]
1423
+
1424
+ # Both source and dest should be tag paths for mv
1425
+ if not source_path.startswith('/tags/'):
1426
+ if not silent:
1427
+ self.console.print(f"[red]Source must be a tag path (e.g., /tags/Work/42)[/red]")
1428
+ return None
1429
+
1430
+ if not dest_path.startswith('/tags/'):
1431
+ if not silent:
1432
+ self.console.print(f"[red]Destination must be a tag path (e.g., /tags/Archive/)[/red]")
1433
+ return None
1434
+
1435
+ # Extract source tag path and book ID
1436
+ source_parts = source_path.replace('/tags/', '').strip('/').split('/')
1437
+ if len(source_parts) < 2:
1438
+ if not silent:
1439
+ self.console.print(f"[red]Source must include tag and book ID (e.g., /tags/Work/42)[/red]")
1440
+ return None
1441
+
1442
+ # Last part is book ID
1443
+ try:
1444
+ book_id = int(source_parts[-1])
1445
+ except ValueError:
1446
+ if not silent:
1447
+ self.console.print(f"[red]Invalid book ID in source path[/red]")
1448
+ return None
1449
+
1450
+ # Everything except last part is the source tag path
1451
+ source_tag_path = '/'.join(source_parts[:-1])
1452
+
1453
+ # Get book from database
1454
+ book = self.library.session.query(Book).filter_by(id=book_id).first()
1455
+ if not book:
1456
+ if not silent:
1457
+ self.console.print(f"[red]Book {book_id} not found[/red]")
1458
+ return None
1459
+
1460
+ # Extract destination tag path
1461
+ dest_tag_path = dest_path.replace('/tags/', '').strip('/')
1462
+ if not dest_tag_path:
1463
+ if not silent:
1464
+ self.console.print(f"[red]Invalid destination tag path[/red]")
1465
+ return None
1466
+
1467
+ # Remove source tag and add destination tag
1468
+ tag_service = TagService(self.library.session)
1469
+ try:
1470
+ # Remove old tag
1471
+ removed = tag_service.remove_tag_from_book(book, source_tag_path)
1472
+ if not removed:
1473
+ if not silent:
1474
+ self.console.print(f"[yellow]Warning: Book didn't have tag '{source_tag_path}'[/yellow]")
1475
+
1476
+ # Add new tag
1477
+ tag = tag_service.add_tag_to_book(book, dest_tag_path)
1478
+
1479
+ if not silent:
1480
+ self.console.print(f"[green]✓ Moved book {book.id} from '{source_tag_path}' to '{tag.path}'[/green]")
1481
+ if book.title:
1482
+ self.console.print(f" Book: {book.title}")
1483
+ except Exception as e:
1484
+ if not silent:
1485
+ self.console.print(f"[red]Error moving tag:[/red] {e}")
1486
+ return None
1487
+
1488
+ return None
1489
+
1490
+ def _rm_book(self, target_path: str, silent: bool = False) -> Optional[str]:
1491
+ """Delete a book from the library - SCARY operation!
1492
+
1493
+ Args:
1494
+ target_path: Path like /books/42/
1495
+ silent: If True, suppress output
1496
+
1497
+ Returns:
1498
+ None
1499
+ """
1500
+ from ebk.db.models import Book
1501
+
1502
+ # Extract book ID from path
1503
+ path_parts = target_path.strip('/').split('/')
1504
+ if path_parts[0] != 'books' or len(path_parts) < 2:
1505
+ if not silent:
1506
+ self.console.print(f"[red]Invalid book path:[/red] {target_path}")
1507
+ self.console.print("[yellow]Expected format:[/yellow] /books/ID/")
1508
+ return None
1509
+
1510
+ try:
1511
+ book_id = int(path_parts[1])
1512
+ except ValueError:
1513
+ if not silent:
1514
+ self.console.print(f"[red]Invalid book ID:[/red] {path_parts[1]}")
1515
+ return None
1516
+
1517
+ # Get book from database
1518
+ book = self.library.session.query(Book).filter_by(id=book_id).first()
1519
+ if not book:
1520
+ if not silent:
1521
+ self.console.print(f"[red]Book {book_id} not found[/red]")
1522
+ return None
1523
+
1524
+ # SCARY CONFIRMATION
1525
+ if not silent:
1526
+ self.console.print("[bold red]⚠️ WARNING: DELETE BOOK ⚠️[/bold red]")
1527
+ self.console.print(f"\n[red]You are about to PERMANENTLY DELETE this book:[/red]")
1528
+ self.console.print(f" ID: {book.id}")
1529
+ if book.title:
1530
+ self.console.print(f" Title: {book.title}")
1531
+ if book.authors:
1532
+ self.console.print(f" Authors: {', '.join([a.name for a in book.authors])}")
1533
+
1534
+ # Count files
1535
+ file_count = len(book.files) if hasattr(book, 'files') else 0
1536
+ if file_count > 0:
1537
+ self.console.print(f" Files: {file_count} file(s) will be deleted")
1538
+
1539
+ self.console.print("\n[bold red]This operation CANNOT be undone![/bold red]")
1540
+ self.console.print("[yellow]Type 'DELETE' (all caps) to confirm, or anything else to cancel:[/yellow]")
1541
+
1542
+ # Get confirmation from user
1543
+ confirmation = self.session.prompt("Confirm deletion: ")
1544
+
1545
+ if confirmation != "DELETE":
1546
+ self.console.print("[green]✓ Cancelled - book was NOT deleted[/green]")
1547
+ return None
1548
+
1549
+ # Delete the book
1550
+ try:
1551
+ # Delete associated files from filesystem
1552
+ for file in book.files:
1553
+ file_path = self.library.library_path / file.path
1554
+ if file_path.exists():
1555
+ file_path.unlink()
1556
+
1557
+ # Delete from database (SQLAlchemy will handle cascading deletes)
1558
+ self.library.session.delete(book)
1559
+ self.library.session.commit()
1560
+
1561
+ if not silent:
1562
+ self.console.print(f"[bold red]✓ Book {book_id} has been DELETED[/bold red]")
1563
+ except Exception as e:
1564
+ self.library.session.rollback()
1565
+ if not silent:
1566
+ self.console.print(f"[red]Error deleting book:[/red] {e}")
1567
+ return None
1568
+
1569
+ return None
1570
+
1571
+ def cmd_rm(self, args: List[str], stdin: Optional[str] = None, silent: bool = False) -> Optional[str]:
1572
+ """Remove (remove tag from book, delete tag, or DELETE book).
1573
+
1574
+ Usage: rm <path>
1575
+
1576
+ Examples:
1577
+ rm /tags/Work/42 - Remove "Work" tag from book 42
1578
+ rm /tags/Work/ - Delete "Work" tag (requires -r flag if has children)
1579
+ rm /books/42/ - DELETE book (requires typing 'DELETE' to confirm)
1580
+
1581
+ Options:
1582
+ -r Recursively delete tag and all children
1583
+ """
1584
+ from ebk.services.tag_service import TagService
1585
+ from ebk.db.models import Book
1586
+
1587
+ if len(args) < 1:
1588
+ if not silent:
1589
+ self.console.print("[red]Usage:[/red] rm [-r] <path>")
1590
+ return None
1591
+
1592
+ # Parse flags
1593
+ recursive = False
1594
+ paths = []
1595
+ for arg in args:
1596
+ if arg == '-r':
1597
+ recursive = True
1598
+ else:
1599
+ paths.append(arg)
1600
+
1601
+ if not paths:
1602
+ if not silent:
1603
+ self.console.print("[red]Usage:[/red] rm [-r] <path>")
1604
+ return None
1605
+
1606
+ target_path = paths[0]
1607
+
1608
+ # Handle /books/ID/ deletion - SCARY!
1609
+ if target_path.startswith('/books/'):
1610
+ return self._rm_book(target_path, silent)
1611
+
1612
+ if not target_path.startswith('/tags/'):
1613
+ if not silent:
1614
+ self.console.print(f"[red]Path must be /books/ID/ or /tags/... (e.g., /tags/Work/42 or /tags/Work/)[/red]")
1615
+ return None
1616
+
1617
+ # Extract tag path components
1618
+ path_parts = target_path.replace('/tags/', '').strip('/').split('/')
1619
+
1620
+ # Check if last part is a book ID
1621
+ try:
1622
+ book_id = int(path_parts[-1])
1623
+ # This is a book within a tag - remove tag from book
1624
+ tag_path = '/'.join(path_parts[:-1])
1625
+
1626
+ # Get book from database
1627
+ book = self.library.session.query(Book).filter_by(id=book_id).first()
1628
+ if not book:
1629
+ if not silent:
1630
+ self.console.print(f"[red]Book {book_id} not found[/red]")
1631
+ return None
1632
+
1633
+ # Remove tag from book
1634
+ tag_service = TagService(self.library.session)
1635
+ try:
1636
+ removed = tag_service.remove_tag_from_book(book, tag_path)
1637
+ if removed:
1638
+ if not silent:
1639
+ self.console.print(f"[green]✓ Removed tag '{tag_path}' from book {book.id}[/green]")
1640
+ if book.title:
1641
+ self.console.print(f" Book: {book.title}")
1642
+ else:
1643
+ if not silent:
1644
+ self.console.print(f"[yellow]Book {book.id} didn't have tag '{tag_path}'[/yellow]")
1645
+ except Exception as e:
1646
+ if not silent:
1647
+ self.console.print(f"[red]Error removing tag:[/red] {e}")
1648
+ return None
1649
+
1650
+ except ValueError:
1651
+ # This is a tag directory - delete the tag itself
1652
+ tag_path = '/'.join(path_parts)
1653
+
1654
+ tag_service = TagService(self.library.session)
1655
+ try:
1656
+ deleted = tag_service.delete_tag(tag_path, delete_children=recursive)
1657
+ if deleted:
1658
+ if not silent:
1659
+ self.console.print(f"[green]✓ Deleted tag '{tag_path}'[/green]")
1660
+ else:
1661
+ if not silent:
1662
+ self.console.print(f"[yellow]Tag '{tag_path}' not found[/yellow]")
1663
+ except ValueError as e:
1664
+ if not silent:
1665
+ self.console.print(f"[red]Error:[/red] {e}")
1666
+ self.console.print(f"[yellow]Hint:[/yellow] Use 'rm -r {target_path}' to delete tag and its children")
1667
+ return None
1668
+ except Exception as e:
1669
+ if not silent:
1670
+ self.console.print(f"[red]Error deleting tag:[/red] {e}")
1671
+ return None
1672
+
1673
+ return None
1674
+
1675
+ def cleanup(self):
1676
+ """Clean up resources."""
1677
+ self.library.close()