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.
- ebk/__init__.py +35 -0
- ebk/ai/__init__.py +23 -0
- ebk/ai/knowledge_graph.py +450 -0
- ebk/ai/llm_providers/__init__.py +26 -0
- ebk/ai/llm_providers/anthropic.py +209 -0
- ebk/ai/llm_providers/base.py +295 -0
- ebk/ai/llm_providers/gemini.py +285 -0
- ebk/ai/llm_providers/ollama.py +294 -0
- ebk/ai/metadata_enrichment.py +394 -0
- ebk/ai/question_generator.py +328 -0
- ebk/ai/reading_companion.py +224 -0
- ebk/ai/semantic_search.py +433 -0
- ebk/ai/text_extractor.py +393 -0
- ebk/calibre_import.py +66 -0
- ebk/cli.py +6433 -0
- ebk/config.py +230 -0
- ebk/db/__init__.py +37 -0
- ebk/db/migrations.py +507 -0
- ebk/db/models.py +725 -0
- ebk/db/session.py +144 -0
- ebk/decorators.py +1 -0
- ebk/exports/__init__.py +0 -0
- ebk/exports/base_exporter.py +218 -0
- ebk/exports/echo_export.py +279 -0
- ebk/exports/html_library.py +1743 -0
- ebk/exports/html_utils.py +87 -0
- ebk/exports/hugo.py +59 -0
- ebk/exports/jinja_export.py +286 -0
- ebk/exports/multi_facet_export.py +159 -0
- ebk/exports/opds_export.py +232 -0
- ebk/exports/symlink_dag.py +479 -0
- ebk/exports/zip.py +25 -0
- ebk/extract_metadata.py +341 -0
- ebk/ident.py +89 -0
- ebk/library_db.py +1440 -0
- ebk/opds.py +748 -0
- ebk/plugins/__init__.py +42 -0
- ebk/plugins/base.py +502 -0
- ebk/plugins/hooks.py +442 -0
- ebk/plugins/registry.py +499 -0
- ebk/repl/__init__.py +9 -0
- ebk/repl/find.py +126 -0
- ebk/repl/grep.py +173 -0
- ebk/repl/shell.py +1677 -0
- ebk/repl/text_utils.py +320 -0
- ebk/search_parser.py +413 -0
- ebk/server.py +3608 -0
- ebk/services/__init__.py +28 -0
- ebk/services/annotation_extraction.py +351 -0
- ebk/services/annotation_service.py +380 -0
- ebk/services/export_service.py +577 -0
- ebk/services/import_service.py +447 -0
- ebk/services/personal_metadata_service.py +347 -0
- ebk/services/queue_service.py +253 -0
- ebk/services/tag_service.py +281 -0
- ebk/services/text_extraction.py +317 -0
- ebk/services/view_service.py +12 -0
- ebk/similarity/__init__.py +77 -0
- ebk/similarity/base.py +154 -0
- ebk/similarity/core.py +471 -0
- ebk/similarity/extractors.py +168 -0
- ebk/similarity/metrics.py +376 -0
- ebk/skills/SKILL.md +182 -0
- ebk/skills/__init__.py +1 -0
- ebk/vfs/__init__.py +101 -0
- ebk/vfs/base.py +298 -0
- ebk/vfs/library_vfs.py +122 -0
- ebk/vfs/nodes/__init__.py +54 -0
- ebk/vfs/nodes/authors.py +196 -0
- ebk/vfs/nodes/books.py +480 -0
- ebk/vfs/nodes/files.py +155 -0
- ebk/vfs/nodes/metadata.py +385 -0
- ebk/vfs/nodes/root.py +100 -0
- ebk/vfs/nodes/similar.py +165 -0
- ebk/vfs/nodes/subjects.py +184 -0
- ebk/vfs/nodes/tags.py +371 -0
- ebk/vfs/resolver.py +228 -0
- ebk/vfs_router.py +275 -0
- ebk/views/__init__.py +32 -0
- ebk/views/dsl.py +668 -0
- ebk/views/service.py +619 -0
- ebk-0.4.4.dist-info/METADATA +755 -0
- ebk-0.4.4.dist-info/RECORD +87 -0
- ebk-0.4.4.dist-info/WHEEL +5 -0
- ebk-0.4.4.dist-info/entry_points.txt +2 -0
- ebk-0.4.4.dist-info/licenses/LICENSE +21 -0
- 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()
|