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/vfs/nodes/books.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Book-related VFS nodes."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Dict, Any
|
|
4
|
+
|
|
5
|
+
from ebk.vfs.base import VirtualNode, DirectoryNode, FileNode, Node
|
|
6
|
+
from ebk.library_db import Library
|
|
7
|
+
from ebk.db.models import Book
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BooksDirectoryNode(VirtualNode):
|
|
11
|
+
"""/books/ - Virtual directory listing all books.
|
|
12
|
+
|
|
13
|
+
Children are BookNode instances (one per book in library).
|
|
14
|
+
Books are accessed by ID: /books/42/
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, library: Library, parent: Optional[DirectoryNode] = None):
|
|
18
|
+
"""Initialize books directory.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
library: Library instance
|
|
22
|
+
parent: Parent node (usually root)
|
|
23
|
+
"""
|
|
24
|
+
super().__init__(name="books", parent=parent)
|
|
25
|
+
self.library = library
|
|
26
|
+
|
|
27
|
+
def list_children(self) -> List[Node]:
|
|
28
|
+
"""List all books in the library.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
List of BookNode instances
|
|
32
|
+
"""
|
|
33
|
+
# Query all books from database
|
|
34
|
+
books = self.library.query().all()
|
|
35
|
+
|
|
36
|
+
# Create BookNode for each
|
|
37
|
+
book_nodes = []
|
|
38
|
+
for book in books:
|
|
39
|
+
node = BookNode(book, self.library, parent=self)
|
|
40
|
+
book_nodes.append(node)
|
|
41
|
+
|
|
42
|
+
return book_nodes
|
|
43
|
+
|
|
44
|
+
def get_child(self, name: str) -> Optional[Node]:
|
|
45
|
+
"""Get a book by ID.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
name: Book ID as string
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
BookNode or None if not found
|
|
52
|
+
"""
|
|
53
|
+
# Parse book ID
|
|
54
|
+
try:
|
|
55
|
+
book_id = int(name)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
# Get book from database
|
|
60
|
+
book = self.library.get_book(book_id)
|
|
61
|
+
if book is None:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
return BookNode(book, self.library, parent=self)
|
|
65
|
+
|
|
66
|
+
def get_info(self) -> Dict[str, Any]:
|
|
67
|
+
"""Get books directory info.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Dict with directory information
|
|
71
|
+
"""
|
|
72
|
+
total = self.library.query().count()
|
|
73
|
+
return {
|
|
74
|
+
"type": "virtual",
|
|
75
|
+
"name": "books",
|
|
76
|
+
"total_books": total,
|
|
77
|
+
"path": self.get_path(),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class BookNode(DirectoryNode):
|
|
82
|
+
"""/books/42/ - A specific book with metadata and files.
|
|
83
|
+
|
|
84
|
+
Contains:
|
|
85
|
+
- title - Book title (file)
|
|
86
|
+
- authors - Authors list (file)
|
|
87
|
+
- subjects - Subjects/tags (file)
|
|
88
|
+
- description - Book description (file)
|
|
89
|
+
- text - Extracted full text (file)
|
|
90
|
+
- year - Publication year (file)
|
|
91
|
+
- language - Language code (file)
|
|
92
|
+
- publisher - Publisher name (file)
|
|
93
|
+
- metadata - All metadata formatted (file)
|
|
94
|
+
- files/ - Physical files (directory)
|
|
95
|
+
- similar/ - Similar books (virtual directory)
|
|
96
|
+
- annotations/ - User annotations (directory)
|
|
97
|
+
- covers/ - Cover images (directory)
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
book: Book,
|
|
103
|
+
library: Library,
|
|
104
|
+
parent: Optional[DirectoryNode] = None,
|
|
105
|
+
):
|
|
106
|
+
"""Initialize book node.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
book: Book database model
|
|
110
|
+
library: Library instance
|
|
111
|
+
parent: Parent node (usually /books/)
|
|
112
|
+
"""
|
|
113
|
+
super().__init__(name=str(book.id), parent=parent)
|
|
114
|
+
self.book = book
|
|
115
|
+
self.library = library
|
|
116
|
+
self._children_cache: Optional[Dict[str, Node]] = None
|
|
117
|
+
|
|
118
|
+
def list_children(self) -> List[Node]:
|
|
119
|
+
"""List all files and subdirectories for this book.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of child nodes
|
|
123
|
+
"""
|
|
124
|
+
if self._children_cache is None:
|
|
125
|
+
self._build_children()
|
|
126
|
+
|
|
127
|
+
return list(self._children_cache.values())
|
|
128
|
+
|
|
129
|
+
def get_child(self, name: str) -> Optional[Node]:
|
|
130
|
+
"""Get a child by name.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
name: Child name
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Child node or None
|
|
137
|
+
"""
|
|
138
|
+
if self._children_cache is None:
|
|
139
|
+
self._build_children()
|
|
140
|
+
|
|
141
|
+
return self._children_cache.get(name)
|
|
142
|
+
|
|
143
|
+
def _build_children(self) -> None:
|
|
144
|
+
"""Build child nodes."""
|
|
145
|
+
from ebk.vfs.nodes.metadata import (
|
|
146
|
+
TitleFileNode,
|
|
147
|
+
AuthorsFileNode,
|
|
148
|
+
SubjectsFileNode,
|
|
149
|
+
DescriptionFileNode,
|
|
150
|
+
TextFileNode,
|
|
151
|
+
YearFileNode,
|
|
152
|
+
LanguageFileNode,
|
|
153
|
+
PublisherFileNode,
|
|
154
|
+
MetadataFileNode,
|
|
155
|
+
BookColorFile,
|
|
156
|
+
)
|
|
157
|
+
from ebk.vfs.nodes.files import FilesDirectoryNode
|
|
158
|
+
from ebk.vfs.nodes.similar import SimilarDirectoryNode
|
|
159
|
+
|
|
160
|
+
self._children_cache = {
|
|
161
|
+
"title": TitleFileNode(self.book, parent=self),
|
|
162
|
+
"authors": AuthorsFileNode(self.book, parent=self),
|
|
163
|
+
"subjects": SubjectsFileNode(self.book, parent=self),
|
|
164
|
+
"description": DescriptionFileNode(self.book, parent=self),
|
|
165
|
+
"text": TextFileNode(self.book, parent=self),
|
|
166
|
+
"year": YearFileNode(self.book, parent=self),
|
|
167
|
+
"language": LanguageFileNode(self.book, parent=self),
|
|
168
|
+
"publisher": PublisherFileNode(self.book, parent=self),
|
|
169
|
+
"metadata": MetadataFileNode(self.book, parent=self),
|
|
170
|
+
"color": BookColorFile(self.book, self.library, parent=self),
|
|
171
|
+
"files": FilesDirectoryNode(self.book, parent=self),
|
|
172
|
+
"similar": SimilarDirectoryNode(self.book, self.library, parent=self),
|
|
173
|
+
"tags": BookTagsDirectoryNode(self.book, self.library, parent=self),
|
|
174
|
+
# TODO: annotations/, covers/
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def get_info(self) -> Dict[str, Any]:
|
|
178
|
+
"""Get book info.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Dict with book information
|
|
182
|
+
"""
|
|
183
|
+
authors_str = ", ".join(a.name for a in self.book.authors) if self.book.authors else ""
|
|
184
|
+
info = {
|
|
185
|
+
"type": "directory",
|
|
186
|
+
"name": str(self.book.id),
|
|
187
|
+
"title": self.book.title,
|
|
188
|
+
"authors": authors_str,
|
|
189
|
+
"language": self.book.language,
|
|
190
|
+
"files_count": len(self.book.files),
|
|
191
|
+
"path": self.get_path(),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# Include color if set
|
|
195
|
+
if self.book.color:
|
|
196
|
+
info["color"] = self.book.color
|
|
197
|
+
|
|
198
|
+
return info
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class BookTagsDirectoryNode(VirtualNode):
|
|
202
|
+
"""/books/42/tags/ - Tags associated with this book.
|
|
203
|
+
|
|
204
|
+
Shows symlinks to tag paths where this book appears.
|
|
205
|
+
Allows easy navigation to see all tags for a book.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
book: Book,
|
|
211
|
+
library: Library,
|
|
212
|
+
parent: Optional[DirectoryNode] = None,
|
|
213
|
+
):
|
|
214
|
+
"""Initialize book tags directory.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
book: Book database model
|
|
218
|
+
library: Library instance
|
|
219
|
+
parent: Parent BookNode
|
|
220
|
+
"""
|
|
221
|
+
super().__init__(name="tags", parent=parent)
|
|
222
|
+
self.book = book
|
|
223
|
+
self.library = library
|
|
224
|
+
|
|
225
|
+
def list_children(self) -> List[Node]:
|
|
226
|
+
"""List all tags for this book, organized hierarchically.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
List of BookTagHierarchyNode (directories) and SymlinkNode instances
|
|
230
|
+
"""
|
|
231
|
+
from ebk.vfs.base import SymlinkNode
|
|
232
|
+
|
|
233
|
+
# Collect all tag paths for this book
|
|
234
|
+
tag_paths = {tag.path for tag in self.book.tags}
|
|
235
|
+
|
|
236
|
+
# Find root-level entries (first component of each path)
|
|
237
|
+
root_entries = {}
|
|
238
|
+
for tag in self.book.tags:
|
|
239
|
+
parts = tag.path.split('/')
|
|
240
|
+
root_name = parts[0]
|
|
241
|
+
|
|
242
|
+
if root_name not in root_entries:
|
|
243
|
+
root_entries[root_name] = []
|
|
244
|
+
root_entries[root_name].append(tag.path)
|
|
245
|
+
|
|
246
|
+
# Create nodes for root-level entries
|
|
247
|
+
children = []
|
|
248
|
+
for root_name, paths in root_entries.items():
|
|
249
|
+
# Check if this root name is a complete tag itself
|
|
250
|
+
if root_name in tag_paths:
|
|
251
|
+
# It's a leaf tag - create symlink
|
|
252
|
+
tag = next(t for t in self.book.tags if t.path == root_name)
|
|
253
|
+
target_path = f"/tags/{tag.path}"
|
|
254
|
+
metadata = {
|
|
255
|
+
"path": tag.path,
|
|
256
|
+
"depth": tag.depth,
|
|
257
|
+
}
|
|
258
|
+
if tag.description:
|
|
259
|
+
metadata["description"] = tag.description[:50] + "..." if len(tag.description) > 50 else tag.description
|
|
260
|
+
|
|
261
|
+
symlink = SymlinkNode(root_name, target_path, parent=self, metadata=metadata)
|
|
262
|
+
children.append(symlink)
|
|
263
|
+
else:
|
|
264
|
+
# It's an intermediate directory - create hierarchy node
|
|
265
|
+
hierarchy_node = BookTagHierarchyNode(
|
|
266
|
+
root_name,
|
|
267
|
+
self.book,
|
|
268
|
+
self.library,
|
|
269
|
+
parent=self
|
|
270
|
+
)
|
|
271
|
+
children.append(hierarchy_node)
|
|
272
|
+
|
|
273
|
+
return children
|
|
274
|
+
|
|
275
|
+
def get_child(self, name: str) -> Optional[Node]:
|
|
276
|
+
"""Get a tag by root name.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
name: Root tag name (first component of path)
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
SymlinkNode or BookTagHierarchyNode or None
|
|
283
|
+
"""
|
|
284
|
+
from ebk.vfs.base import SymlinkNode
|
|
285
|
+
|
|
286
|
+
# Collect tag paths to check if name is a complete tag
|
|
287
|
+
tag_paths = {tag.path for tag in self.book.tags}
|
|
288
|
+
|
|
289
|
+
# Check if any tag starts with this name
|
|
290
|
+
matching_tags = [tag for tag in self.book.tags if tag.path.split('/')[0] == name]
|
|
291
|
+
if not matching_tags:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
# If name is a complete tag path, return symlink
|
|
295
|
+
if name in tag_paths:
|
|
296
|
+
tag = next(t for t in self.book.tags if t.path == name)
|
|
297
|
+
target_path = f"/tags/{tag.path}"
|
|
298
|
+
metadata = {
|
|
299
|
+
"path": tag.path,
|
|
300
|
+
"depth": tag.depth,
|
|
301
|
+
}
|
|
302
|
+
if tag.description:
|
|
303
|
+
metadata["description"] = tag.description[:50] + "..." if len(tag.description) > 50 else tag.description
|
|
304
|
+
|
|
305
|
+
return SymlinkNode(name, target_path, parent=self, metadata=metadata)
|
|
306
|
+
else:
|
|
307
|
+
# It's an intermediate directory
|
|
308
|
+
return BookTagHierarchyNode(name, self.book, self.library, parent=self)
|
|
309
|
+
|
|
310
|
+
def get_info(self) -> Dict[str, Any]:
|
|
311
|
+
"""Get tags directory info.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Dict with directory information
|
|
315
|
+
"""
|
|
316
|
+
return {
|
|
317
|
+
"type": "virtual",
|
|
318
|
+
"name": "tags",
|
|
319
|
+
"tag_count": len(self.book.tags) if hasattr(self.book, 'tags') else 0,
|
|
320
|
+
"path": self.get_path(),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class BookTagHierarchyNode(VirtualNode):
|
|
325
|
+
"""/books/42/tags/a/ - Intermediate directory in book's tag hierarchy.
|
|
326
|
+
|
|
327
|
+
Represents a level in the tag hierarchy for tags assigned to a book.
|
|
328
|
+
For example, if book has tag 'Work/Project-2024', then:
|
|
329
|
+
- /books/42/tags/Work/ is a BookTagHierarchyNode
|
|
330
|
+
- /books/42/tags/Work/Project-2024 is a SymlinkNode
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
def __init__(
|
|
334
|
+
self,
|
|
335
|
+
name: str,
|
|
336
|
+
book: Book,
|
|
337
|
+
library: Library,
|
|
338
|
+
parent: Optional[DirectoryNode] = None,
|
|
339
|
+
):
|
|
340
|
+
"""Initialize book tag hierarchy node.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
name: Directory name (e.g., "Work")
|
|
344
|
+
book: Book database model
|
|
345
|
+
library: Library instance
|
|
346
|
+
parent: Parent node
|
|
347
|
+
"""
|
|
348
|
+
super().__init__(name=name, parent=parent)
|
|
349
|
+
self.book = book
|
|
350
|
+
self.library = library
|
|
351
|
+
|
|
352
|
+
def _get_current_prefix(self) -> str:
|
|
353
|
+
"""Get the current path prefix for this node.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Path prefix like "a" or "a/b"
|
|
357
|
+
"""
|
|
358
|
+
# Build path from root tags directory
|
|
359
|
+
parts = []
|
|
360
|
+
node = self
|
|
361
|
+
while node is not None and not isinstance(node, BookTagsDirectoryNode):
|
|
362
|
+
parts.insert(0, node.name)
|
|
363
|
+
node = node.parent
|
|
364
|
+
|
|
365
|
+
return '/'.join(parts)
|
|
366
|
+
|
|
367
|
+
def list_children(self) -> List[Node]:
|
|
368
|
+
"""List tags at this level of hierarchy.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
List of BookTagHierarchyNode (subdirs) and SymlinkNode (tags)
|
|
372
|
+
"""
|
|
373
|
+
from ebk.vfs.base import SymlinkNode
|
|
374
|
+
|
|
375
|
+
prefix = self._get_current_prefix()
|
|
376
|
+
prefix_depth = len(prefix.split('/'))
|
|
377
|
+
|
|
378
|
+
# Find all tags under this prefix
|
|
379
|
+
matching_tags = [
|
|
380
|
+
tag for tag in self.book.tags
|
|
381
|
+
if tag.path.startswith(prefix + '/')
|
|
382
|
+
]
|
|
383
|
+
|
|
384
|
+
# Also check if prefix itself is a tag
|
|
385
|
+
tag_paths = {tag.path for tag in self.book.tags}
|
|
386
|
+
|
|
387
|
+
# Find next-level entries
|
|
388
|
+
next_level_entries = {}
|
|
389
|
+
for tag in matching_tags:
|
|
390
|
+
# Get the part after the prefix
|
|
391
|
+
relative_path = tag.path[len(prefix) + 1:] # Remove prefix and /
|
|
392
|
+
parts = relative_path.split('/')
|
|
393
|
+
next_name = parts[0]
|
|
394
|
+
|
|
395
|
+
if next_name not in next_level_entries:
|
|
396
|
+
next_level_entries[next_name] = []
|
|
397
|
+
next_level_entries[next_name].append(tag.path)
|
|
398
|
+
|
|
399
|
+
# Create nodes
|
|
400
|
+
children = []
|
|
401
|
+
for next_name, paths in next_level_entries.items():
|
|
402
|
+
full_path = f"{prefix}/{next_name}"
|
|
403
|
+
|
|
404
|
+
if full_path in tag_paths:
|
|
405
|
+
# It's a complete tag - create symlink
|
|
406
|
+
tag = next(t for t in self.book.tags if t.path == full_path)
|
|
407
|
+
target_path = f"/tags/{tag.path}"
|
|
408
|
+
metadata = {
|
|
409
|
+
"path": tag.path,
|
|
410
|
+
"depth": tag.depth,
|
|
411
|
+
}
|
|
412
|
+
if tag.description:
|
|
413
|
+
metadata["description"] = tag.description[:50] + "..." if len(tag.description) > 50 else tag.description
|
|
414
|
+
|
|
415
|
+
symlink = SymlinkNode(next_name, target_path, parent=self, metadata=metadata)
|
|
416
|
+
children.append(symlink)
|
|
417
|
+
else:
|
|
418
|
+
# It's an intermediate directory
|
|
419
|
+
hierarchy_node = BookTagHierarchyNode(
|
|
420
|
+
next_name,
|
|
421
|
+
self.book,
|
|
422
|
+
self.library,
|
|
423
|
+
parent=self
|
|
424
|
+
)
|
|
425
|
+
children.append(hierarchy_node)
|
|
426
|
+
|
|
427
|
+
return children
|
|
428
|
+
|
|
429
|
+
def get_child(self, name: str) -> Optional[Node]:
|
|
430
|
+
"""Get a child tag or subdirectory.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
name: Child name
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
SymlinkNode or BookTagHierarchyNode or None
|
|
437
|
+
"""
|
|
438
|
+
from ebk.vfs.base import SymlinkNode
|
|
439
|
+
|
|
440
|
+
prefix = self._get_current_prefix()
|
|
441
|
+
full_path = f"{prefix}/{name}"
|
|
442
|
+
|
|
443
|
+
# Check if this is a complete tag
|
|
444
|
+
tag_paths = {tag.path for tag in self.book.tags}
|
|
445
|
+
|
|
446
|
+
if full_path in tag_paths:
|
|
447
|
+
# It's a complete tag - return symlink
|
|
448
|
+
tag = next(t for t in self.book.tags if t.path == full_path)
|
|
449
|
+
target_path = f"/tags/{tag.path}"
|
|
450
|
+
metadata = {
|
|
451
|
+
"path": tag.path,
|
|
452
|
+
"depth": tag.depth,
|
|
453
|
+
}
|
|
454
|
+
if tag.description:
|
|
455
|
+
metadata["description"] = tag.description[:50] + "..." if len(tag.description) > 50 else tag.description
|
|
456
|
+
|
|
457
|
+
return SymlinkNode(name, target_path, parent=self, metadata=metadata)
|
|
458
|
+
else:
|
|
459
|
+
# Check if it's an intermediate directory
|
|
460
|
+
has_children = any(
|
|
461
|
+
tag.path.startswith(full_path + '/')
|
|
462
|
+
for tag in self.book.tags
|
|
463
|
+
)
|
|
464
|
+
if has_children:
|
|
465
|
+
return BookTagHierarchyNode(name, self.book, self.library, parent=self)
|
|
466
|
+
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
def get_info(self) -> Dict[str, Any]:
|
|
470
|
+
"""Get node info.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
Dict with node information
|
|
474
|
+
"""
|
|
475
|
+
return {
|
|
476
|
+
"type": "virtual",
|
|
477
|
+
"name": self.name,
|
|
478
|
+
"path": self.get_path(),
|
|
479
|
+
"tag_prefix": self._get_current_prefix(),
|
|
480
|
+
}
|
ebk/vfs/nodes/files.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""File-related VFS nodes."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Dict, Any
|
|
4
|
+
|
|
5
|
+
from ebk.vfs.base import DirectoryNode, FileNode, Node
|
|
6
|
+
from ebk.db.models import Book, File as DBFile
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FilesDirectoryNode(DirectoryNode):
|
|
10
|
+
"""/books/42/files/ - Directory listing physical file formats.
|
|
11
|
+
|
|
12
|
+
Each child is a PhysicalFileNode representing an actual ebook file
|
|
13
|
+
(PDF, EPUB, etc.).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, book: Book, parent: Optional[DirectoryNode] = None):
|
|
17
|
+
"""Initialize files directory.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
book: Book database model
|
|
21
|
+
parent: Parent node (usually BookNode)
|
|
22
|
+
"""
|
|
23
|
+
super().__init__(name="files", parent=parent)
|
|
24
|
+
self.book = book
|
|
25
|
+
|
|
26
|
+
def list_children(self) -> List[Node]:
|
|
27
|
+
"""List all physical files for this book.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
List of PhysicalFileNode instances
|
|
31
|
+
"""
|
|
32
|
+
if not self.book.files:
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
file_nodes = []
|
|
36
|
+
for db_file in self.book.files:
|
|
37
|
+
# Use original filename if available, otherwise use format
|
|
38
|
+
filename = f"{self.book.title or 'book'}.{db_file.format.lower()}"
|
|
39
|
+
node = PhysicalFileNode(db_file, filename, parent=self)
|
|
40
|
+
file_nodes.append(node)
|
|
41
|
+
|
|
42
|
+
return file_nodes
|
|
43
|
+
|
|
44
|
+
def get_child(self, name: str) -> Optional[Node]:
|
|
45
|
+
"""Get a physical file by name.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
name: Filename (e.g., "book.pdf")
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
PhysicalFileNode or None
|
|
52
|
+
"""
|
|
53
|
+
if not self.book.files:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
# Try to match by format extension
|
|
57
|
+
for db_file in self.book.files:
|
|
58
|
+
filename = f"{self.book.title or 'book'}.{db_file.format.lower()}"
|
|
59
|
+
if filename == name or name.endswith(f".{db_file.format.lower()}"):
|
|
60
|
+
return PhysicalFileNode(db_file, filename, parent=self)
|
|
61
|
+
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def get_info(self) -> Dict[str, Any]:
|
|
65
|
+
"""Get files directory info.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Dict with directory information
|
|
69
|
+
"""
|
|
70
|
+
total_size = sum(f.size_bytes for f in self.book.files if f.size_bytes is not None) if self.book.files else 0
|
|
71
|
+
return {
|
|
72
|
+
"type": "directory",
|
|
73
|
+
"name": "files",
|
|
74
|
+
"file_count": len(self.book.files) if self.book.files else 0,
|
|
75
|
+
"total_size": total_size,
|
|
76
|
+
"path": self.get_path(),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PhysicalFileNode(FileNode):
|
|
81
|
+
"""A physical ebook file (PDF, EPUB, etc.).
|
|
82
|
+
|
|
83
|
+
When cat'd, shows file metadata.
|
|
84
|
+
Use 'open' command to actually open the file.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
db_file: DBFile,
|
|
90
|
+
filename: str,
|
|
91
|
+
parent: Optional[DirectoryNode] = None,
|
|
92
|
+
):
|
|
93
|
+
"""Initialize physical file node.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
db_file: File database model
|
|
97
|
+
filename: Display filename
|
|
98
|
+
parent: Parent node (usually FilesDirectoryNode)
|
|
99
|
+
"""
|
|
100
|
+
super().__init__(name=filename, parent=parent, size=db_file.size_bytes)
|
|
101
|
+
self.db_file = db_file
|
|
102
|
+
|
|
103
|
+
def read_content(self) -> str:
|
|
104
|
+
"""Read file metadata (not the actual file content).
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Formatted file metadata
|
|
108
|
+
"""
|
|
109
|
+
lines = []
|
|
110
|
+
lines.append(f"Format: {self.db_file.format.upper()}")
|
|
111
|
+
|
|
112
|
+
if self.db_file.size_bytes:
|
|
113
|
+
# Format size nicely
|
|
114
|
+
size_mb = self.db_file.size_bytes / (1024 * 1024)
|
|
115
|
+
if size_mb < 1:
|
|
116
|
+
size_kb = self.db_file.size_bytes / 1024
|
|
117
|
+
lines.append(f"Size: {size_kb:.1f} KB")
|
|
118
|
+
else:
|
|
119
|
+
lines.append(f"Size: {size_mb:.1f} MB")
|
|
120
|
+
|
|
121
|
+
lines.append(f"Hash: {self.db_file.file_hash[:16]}...")
|
|
122
|
+
lines.append(f"Path: {self.db_file.path}")
|
|
123
|
+
|
|
124
|
+
# Check if text was extracted
|
|
125
|
+
if self.db_file.extracted_text:
|
|
126
|
+
lines.append("Text: Extracted")
|
|
127
|
+
else:
|
|
128
|
+
lines.append("Text: Not extracted")
|
|
129
|
+
|
|
130
|
+
return "\n".join(lines)
|
|
131
|
+
|
|
132
|
+
def get_info(self) -> Dict[str, Any]:
|
|
133
|
+
"""Get file metadata.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Dict with file information
|
|
137
|
+
"""
|
|
138
|
+
return {
|
|
139
|
+
"type": "file",
|
|
140
|
+
"name": self.name,
|
|
141
|
+
"format": self.db_file.format,
|
|
142
|
+
"size": self.db_file.size_bytes,
|
|
143
|
+
"hash": self.db_file.file_hash,
|
|
144
|
+
"path": self.get_path(),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def get_physical_path(self) -> str:
|
|
148
|
+
"""Get the actual filesystem path to this file.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Absolute path to physical file
|
|
152
|
+
"""
|
|
153
|
+
# This will be used by the 'open' command
|
|
154
|
+
# Reconstruct from library path + relative path
|
|
155
|
+
return str(self.db_file.path)
|