ebk 0.3.1__py3-none-any.whl → 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of ebk might be problematic. Click here for more details.
- ebk/ai/__init__.py +23 -0
- ebk/ai/knowledge_graph.py +443 -0
- ebk/ai/llm_providers/__init__.py +21 -0
- ebk/ai/llm_providers/base.py +230 -0
- ebk/ai/llm_providers/ollama.py +362 -0
- ebk/ai/metadata_enrichment.py +396 -0
- ebk/ai/question_generator.py +328 -0
- ebk/ai/reading_companion.py +224 -0
- ebk/ai/semantic_search.py +434 -0
- ebk/ai/text_extractor.py +394 -0
- ebk/cli.py +1097 -9
- ebk/db/__init__.py +37 -0
- ebk/db/migrations.py +180 -0
- ebk/db/models.py +526 -0
- ebk/db/session.py +144 -0
- ebk/exports/__init__.py +0 -0
- ebk/exports/base_exporter.py +218 -0
- ebk/exports/html_library.py +1390 -0
- ebk/exports/html_utils.py +117 -0
- ebk/exports/hugo.py +59 -0
- ebk/exports/jinja_export.py +287 -0
- ebk/exports/multi_facet_export.py +164 -0
- ebk/exports/symlink_dag.py +479 -0
- ebk/exports/zip.py +25 -0
- ebk/library_db.py +155 -0
- ebk/repl/__init__.py +9 -0
- ebk/repl/find.py +126 -0
- ebk/repl/grep.py +174 -0
- ebk/repl/shell.py +1677 -0
- ebk/repl/text_utils.py +320 -0
- ebk/services/__init__.py +11 -0
- ebk/services/import_service.py +442 -0
- ebk/services/tag_service.py +282 -0
- ebk/services/text_extraction.py +317 -0
- ebk/similarity/__init__.py +77 -0
- ebk/similarity/base.py +154 -0
- ebk/similarity/core.py +445 -0
- ebk/similarity/extractors.py +168 -0
- ebk/similarity/metrics.py +376 -0
- ebk/vfs/__init__.py +101 -0
- ebk/vfs/base.py +301 -0
- ebk/vfs/library_vfs.py +124 -0
- ebk/vfs/nodes/__init__.py +54 -0
- ebk/vfs/nodes/authors.py +196 -0
- ebk/vfs/nodes/books.py +480 -0
- ebk/vfs/nodes/files.py +155 -0
- ebk/vfs/nodes/metadata.py +385 -0
- ebk/vfs/nodes/root.py +100 -0
- ebk/vfs/nodes/similar.py +165 -0
- ebk/vfs/nodes/subjects.py +184 -0
- ebk/vfs/nodes/tags.py +371 -0
- ebk/vfs/resolver.py +228 -0
- {ebk-0.3.1.dist-info → ebk-0.3.2.dist-info}/METADATA +1 -1
- ebk-0.3.2.dist-info/RECORD +69 -0
- ebk-0.3.2.dist-info/entry_points.txt +2 -0
- ebk-0.3.2.dist-info/top_level.txt +1 -0
- ebk-0.3.1.dist-info/RECORD +0 -19
- ebk-0.3.1.dist-info/entry_points.txt +0 -6
- ebk-0.3.1.dist-info/top_level.txt +0 -2
- {ebk-0.3.1.dist-info → ebk-0.3.2.dist-info}/WHEEL +0 -0
- {ebk-0.3.1.dist-info → ebk-0.3.2.dist-info}/licenses/LICENSE +0 -0
ebk/vfs/base.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Base classes for the Virtual File System.
|
|
2
|
+
|
|
3
|
+
The VFS maps the library database to a filesystem-like structure
|
|
4
|
+
that can be navigated with shell commands (cd, ls, cat, etc.).
|
|
5
|
+
|
|
6
|
+
Architecture:
|
|
7
|
+
- Node: Base class for all VFS nodes
|
|
8
|
+
- DirectoryNode: Nodes that can contain children (cd into them)
|
|
9
|
+
- FileNode: Leaf nodes with content (cat them)
|
|
10
|
+
- VirtualNode: Dynamically computed nodes (e.g., similar books)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Dict, List, Optional, Any
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NodeType(Enum):
|
|
20
|
+
"""Type of VFS node."""
|
|
21
|
+
DIRECTORY = "directory"
|
|
22
|
+
FILE = "file"
|
|
23
|
+
VIRTUAL = "virtual"
|
|
24
|
+
SYMLINK = "symlink"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Node(ABC):
|
|
28
|
+
"""Base class for all VFS nodes.
|
|
29
|
+
|
|
30
|
+
A Node represents an entry in the virtual filesystem. It can be
|
|
31
|
+
a directory (navigable), a file (readable), or something virtual
|
|
32
|
+
(dynamically computed).
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
name: The name of this node (e.g., "title", "books", "42")
|
|
36
|
+
parent: Parent directory node (None for root)
|
|
37
|
+
node_type: Type of node (directory, file, virtual, symlink)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
name: str,
|
|
43
|
+
parent: Optional['DirectoryNode'] = None,
|
|
44
|
+
node_type: NodeType = NodeType.FILE,
|
|
45
|
+
):
|
|
46
|
+
"""Initialize a VFS node.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
name: Name of this node
|
|
50
|
+
parent: Parent directory (None for root)
|
|
51
|
+
node_type: Type of node
|
|
52
|
+
"""
|
|
53
|
+
self.name = name
|
|
54
|
+
self.parent = parent
|
|
55
|
+
self.node_type = node_type
|
|
56
|
+
self._metadata: Dict[str, Any] = {}
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def get_info(self) -> Dict[str, Any]:
|
|
60
|
+
"""Get metadata about this node for display.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Dict with keys like: size, modified, type, description
|
|
64
|
+
"""
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
def get_path(self) -> str:
|
|
68
|
+
"""Get absolute path to this node.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Path like /books/42/title
|
|
72
|
+
"""
|
|
73
|
+
if self.parent is None:
|
|
74
|
+
return "/"
|
|
75
|
+
|
|
76
|
+
parts = []
|
|
77
|
+
node = self
|
|
78
|
+
while node.parent is not None:
|
|
79
|
+
parts.append(node.name)
|
|
80
|
+
node = node.parent
|
|
81
|
+
|
|
82
|
+
if not parts:
|
|
83
|
+
return "/"
|
|
84
|
+
|
|
85
|
+
return "/" + "/".join(reversed(parts))
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return f"{self.__class__.__name__}(name='{self.name}', path='{self.get_path()}')"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class DirectoryNode(Node):
|
|
92
|
+
"""A directory node that can contain children.
|
|
93
|
+
|
|
94
|
+
Directory nodes can be navigated into with `cd` and their
|
|
95
|
+
children can be listed with `ls`.
|
|
96
|
+
|
|
97
|
+
Children can be:
|
|
98
|
+
- Static: Fixed set of children
|
|
99
|
+
- Dynamic: Children computed on-demand (e.g., book list from DB)
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, name: str, parent: Optional['DirectoryNode'] = None):
|
|
103
|
+
"""Initialize a directory node.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
name: Name of this directory
|
|
107
|
+
parent: Parent directory
|
|
108
|
+
"""
|
|
109
|
+
super().__init__(name, parent, NodeType.DIRECTORY)
|
|
110
|
+
self._children: Dict[str, Node] = {}
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
def list_children(self) -> List[Node]:
|
|
114
|
+
"""List all children of this directory.
|
|
115
|
+
|
|
116
|
+
This may compute children dynamically from the database.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of child nodes
|
|
120
|
+
"""
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
@abstractmethod
|
|
124
|
+
def get_child(self, name: str) -> Optional[Node]:
|
|
125
|
+
"""Get a child node by name.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
name: Name of child node
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Child node or None if not found
|
|
132
|
+
"""
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
def get_info(self) -> Dict[str, Any]:
|
|
136
|
+
"""Get directory metadata.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Dict with directory information
|
|
140
|
+
"""
|
|
141
|
+
children = self.list_children()
|
|
142
|
+
return {
|
|
143
|
+
"type": "directory",
|
|
144
|
+
"name": self.name,
|
|
145
|
+
"children_count": len(children),
|
|
146
|
+
"path": self.get_path(),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class FileNode(Node):
|
|
151
|
+
"""A file node with readable content.
|
|
152
|
+
|
|
153
|
+
File nodes represent data that can be read with `cat`.
|
|
154
|
+
Examples: book title, description, full text, etc.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(
|
|
158
|
+
self,
|
|
159
|
+
name: str,
|
|
160
|
+
parent: Optional[DirectoryNode] = None,
|
|
161
|
+
size: Optional[int] = None,
|
|
162
|
+
):
|
|
163
|
+
"""Initialize a file node.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
name: Name of this file
|
|
167
|
+
parent: Parent directory
|
|
168
|
+
size: Size in bytes (if known)
|
|
169
|
+
"""
|
|
170
|
+
super().__init__(name, parent, NodeType.FILE)
|
|
171
|
+
self._size = size
|
|
172
|
+
self._content_cache: Optional[str] = None
|
|
173
|
+
|
|
174
|
+
@abstractmethod
|
|
175
|
+
def read_content(self) -> str:
|
|
176
|
+
"""Read the content of this file.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
File content as string
|
|
180
|
+
"""
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
def write_content(self, content: str) -> None:
|
|
184
|
+
"""Write content to this file.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
content: Content to write
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
NotImplementedError: If the file is read-only
|
|
191
|
+
"""
|
|
192
|
+
raise NotImplementedError(f"File '{self.name}' is read-only")
|
|
193
|
+
|
|
194
|
+
def is_writable(self) -> bool:
|
|
195
|
+
"""Check if this file is writable.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
True if file supports writing, False otherwise
|
|
199
|
+
"""
|
|
200
|
+
# By default, files are read-only unless they override write_content
|
|
201
|
+
try:
|
|
202
|
+
# Try calling write_content with empty string to see if it raises NotImplementedError
|
|
203
|
+
# This is a bit hacky but works
|
|
204
|
+
return hasattr(self.__class__, 'write_content') and \
|
|
205
|
+
self.__class__.write_content != FileNode.write_content
|
|
206
|
+
except:
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
def get_info(self) -> Dict[str, Any]:
|
|
210
|
+
"""Get file metadata.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Dict with file information
|
|
214
|
+
"""
|
|
215
|
+
return {
|
|
216
|
+
"type": "file",
|
|
217
|
+
"name": self.name,
|
|
218
|
+
"size": self._size,
|
|
219
|
+
"path": self.get_path(),
|
|
220
|
+
"writable": self.is_writable(),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class VirtualNode(DirectoryNode):
|
|
225
|
+
"""A virtual directory with dynamically computed children.
|
|
226
|
+
|
|
227
|
+
Virtual nodes don't have a fixed set of children - they compute
|
|
228
|
+
them on-demand from the database or other sources.
|
|
229
|
+
|
|
230
|
+
Examples:
|
|
231
|
+
- /books/ - Lists all books from DB
|
|
232
|
+
- /books/42/similar/ - Computes similar books on-demand
|
|
233
|
+
- /authors/ - Lists all authors from DB
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self, name: str, parent: Optional[DirectoryNode] = None):
|
|
237
|
+
"""Initialize a virtual directory node.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
name: Name of this directory
|
|
241
|
+
parent: Parent directory
|
|
242
|
+
"""
|
|
243
|
+
super().__init__(name, parent)
|
|
244
|
+
self.node_type = NodeType.VIRTUAL
|
|
245
|
+
|
|
246
|
+
def get_info(self) -> Dict[str, Any]:
|
|
247
|
+
"""Get virtual directory metadata.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Dict with virtual directory information
|
|
251
|
+
"""
|
|
252
|
+
info = super().get_info()
|
|
253
|
+
info["type"] = "virtual"
|
|
254
|
+
return info
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class SymlinkNode(Node):
|
|
258
|
+
"""A symbolic link pointing to another node.
|
|
259
|
+
|
|
260
|
+
Used for creating convenient shortcuts, like similar books
|
|
261
|
+
appearing as links in /books/42/similar/.
|
|
262
|
+
|
|
263
|
+
Attributes:
|
|
264
|
+
target_path: Path to the target node
|
|
265
|
+
metadata: Optional metadata dict to include in get_info()
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(
|
|
269
|
+
self,
|
|
270
|
+
name: str,
|
|
271
|
+
target_path: str,
|
|
272
|
+
parent: Optional[DirectoryNode] = None,
|
|
273
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
274
|
+
):
|
|
275
|
+
"""Initialize a symlink node.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
name: Name of this symlink
|
|
279
|
+
target_path: Path to target node
|
|
280
|
+
parent: Parent directory
|
|
281
|
+
metadata: Optional metadata to include in get_info()
|
|
282
|
+
"""
|
|
283
|
+
super().__init__(name, parent, NodeType.SYMLINK)
|
|
284
|
+
self.target_path = target_path
|
|
285
|
+
self.metadata = metadata or {}
|
|
286
|
+
|
|
287
|
+
def get_info(self) -> Dict[str, Any]:
|
|
288
|
+
"""Get symlink metadata.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Dict with symlink information plus any provided metadata
|
|
292
|
+
"""
|
|
293
|
+
info = {
|
|
294
|
+
"type": "symlink",
|
|
295
|
+
"name": self.name,
|
|
296
|
+
"target": self.target_path,
|
|
297
|
+
"path": self.get_path(),
|
|
298
|
+
}
|
|
299
|
+
# Merge in any provided metadata
|
|
300
|
+
info.update(self.metadata)
|
|
301
|
+
return info
|
ebk/vfs/library_vfs.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Main LibraryVFS class - entry point for VFS access."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path as FilePath
|
|
4
|
+
|
|
5
|
+
from ebk.library_db import Library
|
|
6
|
+
from ebk.vfs.base import DirectoryNode, Node
|
|
7
|
+
from ebk.vfs.resolver import PathResolver
|
|
8
|
+
from ebk.vfs.nodes import RootNode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LibraryVFS:
|
|
12
|
+
"""Virtual File System for a library.
|
|
13
|
+
|
|
14
|
+
This is the main entry point for accessing the VFS. It creates
|
|
15
|
+
the root node and provides a resolver for path navigation.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
>>> lib = Library.open("/path/to/library")
|
|
19
|
+
>>> vfs = LibraryVFS(lib)
|
|
20
|
+
>>>
|
|
21
|
+
>>> # Navigate using resolver
|
|
22
|
+
>>> books_dir = vfs.resolver.resolve("/books", vfs.root)
|
|
23
|
+
>>> book_node = vfs.resolver.resolve("/books/42", vfs.root)
|
|
24
|
+
>>>
|
|
25
|
+
>>> # List children
|
|
26
|
+
>>> children = books_dir.list_children()
|
|
27
|
+
>>>
|
|
28
|
+
>>> # Read file content
|
|
29
|
+
>>> title_node = vfs.resolver.resolve("/books/42/title", vfs.root)
|
|
30
|
+
>>> if isinstance(title_node, FileNode):
|
|
31
|
+
>>> print(title_node.read_content())
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, library: Library):
|
|
35
|
+
"""Initialize VFS for a library.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
library: Library instance
|
|
39
|
+
"""
|
|
40
|
+
self.library = library
|
|
41
|
+
self.root = RootNode(library)
|
|
42
|
+
self.resolver = PathResolver(self.root)
|
|
43
|
+
self.current = self.root # Current working directory
|
|
44
|
+
|
|
45
|
+
def cd(self, path: str) -> bool:
|
|
46
|
+
"""Change current directory.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
path: Path to navigate to
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
True if successful, False otherwise
|
|
53
|
+
"""
|
|
54
|
+
new_dir = self.resolver.resolve_directory(path, self.current)
|
|
55
|
+
if new_dir is None:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
self.current = new_dir
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
def pwd(self) -> str:
|
|
62
|
+
"""Get current working directory path.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Current path
|
|
66
|
+
"""
|
|
67
|
+
return self.current.get_path()
|
|
68
|
+
|
|
69
|
+
def ls(self, path: str = ".") -> list:
|
|
70
|
+
"""List children of a directory.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
path: Path to list (default: current directory)
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
List of nodes
|
|
77
|
+
"""
|
|
78
|
+
node = self.resolver.resolve(path, self.current)
|
|
79
|
+
if node is None or not isinstance(node, DirectoryNode):
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
return node.list_children()
|
|
83
|
+
|
|
84
|
+
def cat(self, path: str) -> str:
|
|
85
|
+
"""Read content of a file node.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
path: Path to file
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
File content or error message
|
|
92
|
+
"""
|
|
93
|
+
from ebk.vfs.base import FileNode
|
|
94
|
+
|
|
95
|
+
node = self.resolver.resolve(path, self.current)
|
|
96
|
+
if node is None:
|
|
97
|
+
return f"cat: {path}: No such file or directory"
|
|
98
|
+
|
|
99
|
+
if not isinstance(node, FileNode):
|
|
100
|
+
return f"cat: {path}: Is a directory"
|
|
101
|
+
|
|
102
|
+
return node.read_content()
|
|
103
|
+
|
|
104
|
+
def get_node(self, path: str) -> Node:
|
|
105
|
+
"""Resolve a path to a node.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
path: Path to resolve
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Resolved node or None
|
|
112
|
+
"""
|
|
113
|
+
return self.resolver.resolve(path, self.current)
|
|
114
|
+
|
|
115
|
+
def complete(self, partial: str) -> list:
|
|
116
|
+
"""Get tab completion candidates.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
partial: Partial path
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of completion candidates
|
|
123
|
+
"""
|
|
124
|
+
return self.resolver.complete_path(partial, self.current)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""VFS node implementations."""
|
|
2
|
+
|
|
3
|
+
from ebk.vfs.nodes.root import RootNode
|
|
4
|
+
from ebk.vfs.nodes.books import BooksDirectoryNode, BookNode
|
|
5
|
+
from ebk.vfs.nodes.metadata import (
|
|
6
|
+
TitleFileNode,
|
|
7
|
+
AuthorsFileNode,
|
|
8
|
+
SubjectsFileNode,
|
|
9
|
+
DescriptionFileNode,
|
|
10
|
+
TextFileNode,
|
|
11
|
+
YearFileNode,
|
|
12
|
+
LanguageFileNode,
|
|
13
|
+
PublisherFileNode,
|
|
14
|
+
MetadataFileNode,
|
|
15
|
+
)
|
|
16
|
+
from ebk.vfs.nodes.files import FilesDirectoryNode, PhysicalFileNode
|
|
17
|
+
from ebk.vfs.nodes.similar import SimilarDirectoryNode, SimilarBookSymlink
|
|
18
|
+
from ebk.vfs.nodes.authors import AuthorsDirectoryNode, AuthorNode
|
|
19
|
+
from ebk.vfs.nodes.subjects import SubjectsDirectoryNode, SubjectNode
|
|
20
|
+
from ebk.vfs.nodes.tags import (
|
|
21
|
+
TagsDirectoryNode,
|
|
22
|
+
TagNode,
|
|
23
|
+
TagDescriptionFile,
|
|
24
|
+
TagColorFile,
|
|
25
|
+
TagStatsFile,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"RootNode",
|
|
30
|
+
"BooksDirectoryNode",
|
|
31
|
+
"BookNode",
|
|
32
|
+
"TitleFileNode",
|
|
33
|
+
"AuthorsFileNode",
|
|
34
|
+
"SubjectsFileNode",
|
|
35
|
+
"DescriptionFileNode",
|
|
36
|
+
"TextFileNode",
|
|
37
|
+
"YearFileNode",
|
|
38
|
+
"LanguageFileNode",
|
|
39
|
+
"PublisherFileNode",
|
|
40
|
+
"MetadataFileNode",
|
|
41
|
+
"FilesDirectoryNode",
|
|
42
|
+
"PhysicalFileNode",
|
|
43
|
+
"SimilarDirectoryNode",
|
|
44
|
+
"SimilarBookSymlink",
|
|
45
|
+
"AuthorsDirectoryNode",
|
|
46
|
+
"AuthorNode",
|
|
47
|
+
"SubjectsDirectoryNode",
|
|
48
|
+
"SubjectNode",
|
|
49
|
+
"TagsDirectoryNode",
|
|
50
|
+
"TagNode",
|
|
51
|
+
"TagDescriptionFile",
|
|
52
|
+
"TagColorFile",
|
|
53
|
+
"TagStatsFile",
|
|
54
|
+
]
|
ebk/vfs/nodes/authors.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Author-related VFS nodes."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Dict, Any
|
|
4
|
+
|
|
5
|
+
from ebk.vfs.base import VirtualNode, DirectoryNode, SymlinkNode, Node
|
|
6
|
+
from ebk.library_db import Library
|
|
7
|
+
from ebk.db.models import Author
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthorsDirectoryNode(VirtualNode):
|
|
11
|
+
"""/authors/ - Virtual directory listing all authors.
|
|
12
|
+
|
|
13
|
+
Each child is an AuthorNode representing books by that author.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, library: Library, parent: Optional[DirectoryNode] = None):
|
|
17
|
+
"""Initialize authors directory.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
library: Library instance
|
|
21
|
+
parent: Parent node (usually root)
|
|
22
|
+
"""
|
|
23
|
+
super().__init__(name="authors", parent=parent)
|
|
24
|
+
self.library = library
|
|
25
|
+
|
|
26
|
+
def list_children(self) -> List[Node]:
|
|
27
|
+
"""List all authors.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
List of AuthorNode instances
|
|
31
|
+
"""
|
|
32
|
+
# Query all authors from database
|
|
33
|
+
# For now, we'll get unique authors from books
|
|
34
|
+
authors_query = self.library.session.query(Author).all()
|
|
35
|
+
|
|
36
|
+
author_nodes = []
|
|
37
|
+
for author in authors_query:
|
|
38
|
+
# Create a slug from author name
|
|
39
|
+
slug = self._make_slug(author.name)
|
|
40
|
+
node = AuthorNode(author, slug, self.library, parent=self)
|
|
41
|
+
author_nodes.append(node)
|
|
42
|
+
|
|
43
|
+
return author_nodes
|
|
44
|
+
|
|
45
|
+
def get_child(self, name: str) -> Optional[Node]:
|
|
46
|
+
"""Get an author by slug.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
name: Author slug (e.g., "knuth-donald")
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
AuthorNode or None
|
|
53
|
+
"""
|
|
54
|
+
# Try to find author by matching slug
|
|
55
|
+
authors = self.library.session.query(Author).all()
|
|
56
|
+
|
|
57
|
+
for author in authors:
|
|
58
|
+
slug = self._make_slug(author.name)
|
|
59
|
+
if slug == name:
|
|
60
|
+
return AuthorNode(author, slug, self.library, parent=self)
|
|
61
|
+
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def _make_slug(self, name: str) -> str:
|
|
65
|
+
"""Convert author name to filesystem-safe slug.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
name: Author name
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Slugified name (e.g., "Donald Knuth" -> "knuth-donald")
|
|
72
|
+
"""
|
|
73
|
+
# Simple slugification: lowercase, replace spaces with hyphens
|
|
74
|
+
# Reverse name order (Last, First -> first-last)
|
|
75
|
+
parts = name.lower().split()
|
|
76
|
+
if len(parts) >= 2:
|
|
77
|
+
# Assume "First Last" or "Last, First"
|
|
78
|
+
if "," in name:
|
|
79
|
+
# "Last, First" format
|
|
80
|
+
slug = "-".join(reversed([p.strip(",") for p in parts]))
|
|
81
|
+
else:
|
|
82
|
+
# "First Last" format - reverse to "last-first"
|
|
83
|
+
slug = "-".join(reversed(parts))
|
|
84
|
+
else:
|
|
85
|
+
slug = "-".join(parts)
|
|
86
|
+
|
|
87
|
+
# Remove special characters
|
|
88
|
+
slug = "".join(c for c in slug if c.isalnum() or c == "-")
|
|
89
|
+
return slug
|
|
90
|
+
|
|
91
|
+
def get_info(self) -> Dict[str, Any]:
|
|
92
|
+
"""Get authors directory info.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Dict with directory information
|
|
96
|
+
"""
|
|
97
|
+
total = self.library.session.query(Author).count()
|
|
98
|
+
return {
|
|
99
|
+
"type": "virtual",
|
|
100
|
+
"name": "authors",
|
|
101
|
+
"total_authors": total,
|
|
102
|
+
"path": self.get_path(),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AuthorNode(VirtualNode):
|
|
107
|
+
"""/authors/knuth-donald/ - Books by a specific author.
|
|
108
|
+
|
|
109
|
+
Contains symlinks to books by this author.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
author: Author,
|
|
115
|
+
slug: str,
|
|
116
|
+
library: Library,
|
|
117
|
+
parent: Optional[DirectoryNode] = None,
|
|
118
|
+
):
|
|
119
|
+
"""Initialize author node.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
author: Author database model
|
|
123
|
+
slug: Author slug for URL
|
|
124
|
+
library: Library instance
|
|
125
|
+
parent: Parent node (usually AuthorsDirectoryNode)
|
|
126
|
+
"""
|
|
127
|
+
super().__init__(name=slug, parent=parent)
|
|
128
|
+
self.author = author
|
|
129
|
+
self.library = library
|
|
130
|
+
|
|
131
|
+
def list_children(self) -> List[Node]:
|
|
132
|
+
"""List books by this author as symlinks.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
List of SymlinkNode instances
|
|
136
|
+
"""
|
|
137
|
+
symlinks = []
|
|
138
|
+
for book in self.author.books:
|
|
139
|
+
target_path = f"/books/{book.id}"
|
|
140
|
+
name = str(book.id)
|
|
141
|
+
|
|
142
|
+
# Include book metadata for display
|
|
143
|
+
metadata = {
|
|
144
|
+
"title": book.title or "Untitled",
|
|
145
|
+
}
|
|
146
|
+
if book.authors:
|
|
147
|
+
metadata["author"] = ", ".join([a.name for a in book.authors])
|
|
148
|
+
|
|
149
|
+
symlink = SymlinkNode(name, target_path, parent=self, metadata=metadata)
|
|
150
|
+
symlinks.append(symlink)
|
|
151
|
+
|
|
152
|
+
return symlinks
|
|
153
|
+
|
|
154
|
+
def get_child(self, name: str) -> Optional[Node]:
|
|
155
|
+
"""Get a book symlink by ID.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
name: Book ID as string
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
SymlinkNode or None
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
book_id = int(name)
|
|
165
|
+
except ValueError:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
# Check if this book is by this author
|
|
169
|
+
for book in self.author.books:
|
|
170
|
+
if book.id == book_id:
|
|
171
|
+
target_path = f"/books/{book.id}"
|
|
172
|
+
|
|
173
|
+
# Include book metadata for display
|
|
174
|
+
metadata = {
|
|
175
|
+
"title": book.title or "Untitled",
|
|
176
|
+
}
|
|
177
|
+
if book.authors:
|
|
178
|
+
metadata["author"] = ", ".join([a.name for a in book.authors])
|
|
179
|
+
|
|
180
|
+
return SymlinkNode(name, target_path, parent=self, metadata=metadata)
|
|
181
|
+
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def get_info(self) -> Dict[str, Any]:
|
|
185
|
+
"""Get author node info.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Dict with author information
|
|
189
|
+
"""
|
|
190
|
+
return {
|
|
191
|
+
"type": "virtual",
|
|
192
|
+
"name": self.name,
|
|
193
|
+
"author": self.author.name,
|
|
194
|
+
"book_count": len(self.author.books),
|
|
195
|
+
"path": self.get_path(),
|
|
196
|
+
}
|