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/resolver.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Path resolution for the Virtual File System.
|
|
2
|
+
|
|
3
|
+
Handles path parsing and navigation (cd, ls semantics).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import PurePosixPath
|
|
7
|
+
from typing import Optional, List, Tuple
|
|
8
|
+
|
|
9
|
+
from ebk.vfs.base import Node, DirectoryNode, SymlinkNode
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PathResolver:
|
|
13
|
+
"""Resolves paths in the VFS and handles navigation.
|
|
14
|
+
|
|
15
|
+
This class provides the core navigation logic for cd, ls, etc.
|
|
16
|
+
It handles:
|
|
17
|
+
- Absolute paths: /books/42/title
|
|
18
|
+
- Relative paths: ../other, ./files
|
|
19
|
+
- Special paths: ., .., ~
|
|
20
|
+
- Symlink resolution
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, root: DirectoryNode):
|
|
24
|
+
"""Initialize path resolver.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
root: Root node of the VFS
|
|
28
|
+
"""
|
|
29
|
+
self.root = root
|
|
30
|
+
|
|
31
|
+
def resolve(
|
|
32
|
+
self,
|
|
33
|
+
path: str,
|
|
34
|
+
current: DirectoryNode,
|
|
35
|
+
follow_symlinks: bool = True,
|
|
36
|
+
) -> Optional[Node]:
|
|
37
|
+
"""Resolve a path to a node.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
path: Path to resolve (absolute or relative)
|
|
41
|
+
current: Current working directory
|
|
42
|
+
follow_symlinks: Whether to follow symlinks
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Resolved node or None if path doesn't exist
|
|
46
|
+
"""
|
|
47
|
+
# Handle empty path
|
|
48
|
+
if not path or path == ".":
|
|
49
|
+
return current
|
|
50
|
+
|
|
51
|
+
# Parse path
|
|
52
|
+
parts = self._parse_path(path)
|
|
53
|
+
|
|
54
|
+
# Start from root or current directory
|
|
55
|
+
if path.startswith("/"):
|
|
56
|
+
node = self.root
|
|
57
|
+
else:
|
|
58
|
+
node = current
|
|
59
|
+
|
|
60
|
+
# Navigate through path parts
|
|
61
|
+
for part in parts:
|
|
62
|
+
if part == "." or part == "":
|
|
63
|
+
continue
|
|
64
|
+
elif part == "..":
|
|
65
|
+
# Go to parent
|
|
66
|
+
if node.parent is not None:
|
|
67
|
+
node = node.parent
|
|
68
|
+
# Stay at root if already at root
|
|
69
|
+
else:
|
|
70
|
+
# Navigate to child
|
|
71
|
+
if not isinstance(node, DirectoryNode):
|
|
72
|
+
# Can't cd into a file
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
child = node.get_child(part)
|
|
76
|
+
if child is None:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# Follow symlinks if requested
|
|
80
|
+
if follow_symlinks and isinstance(child, SymlinkNode):
|
|
81
|
+
child = self.resolve(child.target_path, current, follow_symlinks=True)
|
|
82
|
+
if child is None:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
node = child
|
|
86
|
+
|
|
87
|
+
return node
|
|
88
|
+
|
|
89
|
+
def resolve_directory(
|
|
90
|
+
self,
|
|
91
|
+
path: str,
|
|
92
|
+
current: DirectoryNode,
|
|
93
|
+
) -> Optional[DirectoryNode]:
|
|
94
|
+
"""Resolve a path to a directory node.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
path: Path to resolve
|
|
98
|
+
current: Current working directory
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Directory node or None if path doesn't exist or isn't a directory
|
|
102
|
+
"""
|
|
103
|
+
node = self.resolve(path, current)
|
|
104
|
+
if node is None or not isinstance(node, DirectoryNode):
|
|
105
|
+
return None
|
|
106
|
+
return node
|
|
107
|
+
|
|
108
|
+
def normalize_path(self, path: str, current: DirectoryNode) -> str:
|
|
109
|
+
"""Normalize a path to absolute form.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
path: Path to normalize
|
|
113
|
+
current: Current working directory
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Normalized absolute path
|
|
117
|
+
"""
|
|
118
|
+
# Resolve to node first
|
|
119
|
+
node = self.resolve(path, current)
|
|
120
|
+
if node is None:
|
|
121
|
+
# Path doesn't exist, do best effort normalization
|
|
122
|
+
return self._normalize_nonexistent(path, current)
|
|
123
|
+
|
|
124
|
+
return node.get_path()
|
|
125
|
+
|
|
126
|
+
def complete_path(
|
|
127
|
+
self,
|
|
128
|
+
partial: str,
|
|
129
|
+
current: DirectoryNode,
|
|
130
|
+
) -> List[str]:
|
|
131
|
+
"""Get completion candidates for a partial path.
|
|
132
|
+
|
|
133
|
+
Used for tab completion.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
partial: Partial path to complete
|
|
137
|
+
current: Current working directory
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
List of completion candidates
|
|
141
|
+
"""
|
|
142
|
+
# Split into directory part and filename part
|
|
143
|
+
if "/" in partial:
|
|
144
|
+
dir_part, file_part = partial.rsplit("/", 1)
|
|
145
|
+
if partial.startswith("/"):
|
|
146
|
+
dir_part = "/" + dir_part if dir_part else "/"
|
|
147
|
+
else:
|
|
148
|
+
dir_part = ""
|
|
149
|
+
file_part = partial
|
|
150
|
+
|
|
151
|
+
# Resolve directory
|
|
152
|
+
if dir_part:
|
|
153
|
+
dir_node = self.resolve_directory(dir_part, current)
|
|
154
|
+
else:
|
|
155
|
+
dir_node = current
|
|
156
|
+
|
|
157
|
+
if dir_node is None:
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
# Get children and filter by prefix
|
|
161
|
+
children = dir_node.list_children()
|
|
162
|
+
candidates = []
|
|
163
|
+
|
|
164
|
+
for child in children:
|
|
165
|
+
if child.name.startswith(file_part):
|
|
166
|
+
if dir_part:
|
|
167
|
+
candidates.append(f"{dir_part}/{child.name}")
|
|
168
|
+
else:
|
|
169
|
+
candidates.append(child.name)
|
|
170
|
+
|
|
171
|
+
# Add trailing slash for directories
|
|
172
|
+
if isinstance(child, DirectoryNode):
|
|
173
|
+
candidates[-1] += "/"
|
|
174
|
+
|
|
175
|
+
return candidates
|
|
176
|
+
|
|
177
|
+
def _parse_path(self, path: str) -> List[str]:
|
|
178
|
+
"""Parse a path into parts.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
path: Path to parse
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
List of path components
|
|
185
|
+
"""
|
|
186
|
+
# Use PurePosixPath for Unix-style path handling
|
|
187
|
+
posix_path = PurePosixPath(path)
|
|
188
|
+
|
|
189
|
+
# Get parts (excluding the root /)
|
|
190
|
+
parts = posix_path.parts
|
|
191
|
+
if parts and parts[0] == "/":
|
|
192
|
+
parts = parts[1:]
|
|
193
|
+
|
|
194
|
+
return list(parts)
|
|
195
|
+
|
|
196
|
+
def _normalize_nonexistent(self, path: str, current: DirectoryNode) -> str:
|
|
197
|
+
"""Normalize a path that doesn't exist.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
path: Path to normalize
|
|
201
|
+
current: Current working directory
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Best-effort normalized path
|
|
205
|
+
"""
|
|
206
|
+
if path.startswith("/"):
|
|
207
|
+
# Already absolute
|
|
208
|
+
return str(PurePosixPath(path))
|
|
209
|
+
|
|
210
|
+
# Make relative path absolute
|
|
211
|
+
current_path = current.get_path()
|
|
212
|
+
combined = PurePosixPath(current_path) / path
|
|
213
|
+
return str(combined)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class PathError(Exception):
|
|
217
|
+
"""Error resolving a path."""
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class NotADirectoryError(PathError):
|
|
222
|
+
"""Attempted to cd into a non-directory."""
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class NotFoundError(PathError):
|
|
227
|
+
"""Path does not exist."""
|
|
228
|
+
pass
|
ebk/vfs_router.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""VFS REST API Router.
|
|
2
|
+
|
|
3
|
+
Provides REST endpoints for accessing the Virtual File System,
|
|
4
|
+
enabling programmatic navigation of the library structure.
|
|
5
|
+
|
|
6
|
+
Endpoints:
|
|
7
|
+
GET /api/vfs/ - Root directory listing
|
|
8
|
+
GET /api/vfs/{path:path} - Navigate to path, returns directory/file/symlink info
|
|
9
|
+
|
|
10
|
+
This allows API consumers to browse the library like a filesystem:
|
|
11
|
+
- /books/ - List all books
|
|
12
|
+
- /books/42/ - Book 42's directory
|
|
13
|
+
- /books/42/title - Read book title
|
|
14
|
+
- /books/42/files/ - List book files
|
|
15
|
+
- /authors/knuth/ - Books by author
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import Any, Dict, List, Optional, Union
|
|
19
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
from ebk.vfs import LibraryVFS, DirectoryNode, FileNode, SymlinkNode
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Pydantic models for VFS responses
|
|
26
|
+
class VFSChild(BaseModel):
|
|
27
|
+
"""Child entry in a directory listing."""
|
|
28
|
+
name: str
|
|
29
|
+
type: str # "directory", "file", "symlink", "virtual"
|
|
30
|
+
info: Dict[str, Any] = {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class VFSDirectoryResponse(BaseModel):
|
|
34
|
+
"""Response for directory nodes."""
|
|
35
|
+
type: str = "directory"
|
|
36
|
+
path: str
|
|
37
|
+
name: str
|
|
38
|
+
info: Dict[str, Any] = {}
|
|
39
|
+
children: List[VFSChild] = []
|
|
40
|
+
children_count: int = 0
|
|
41
|
+
|
|
42
|
+
class Config:
|
|
43
|
+
json_schema_extra = {
|
|
44
|
+
"example": {
|
|
45
|
+
"type": "directory",
|
|
46
|
+
"path": "/books/42",
|
|
47
|
+
"name": "42",
|
|
48
|
+
"info": {"title": "The Art of Computer Programming", "authors": "Donald Knuth"},
|
|
49
|
+
"children": [
|
|
50
|
+
{"name": "title", "type": "file", "info": {}},
|
|
51
|
+
{"name": "authors", "type": "file", "info": {}},
|
|
52
|
+
{"name": "files", "type": "directory", "info": {}},
|
|
53
|
+
{"name": "similar", "type": "virtual", "info": {}},
|
|
54
|
+
],
|
|
55
|
+
"children_count": 4,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class VFSFileResponse(BaseModel):
|
|
61
|
+
"""Response for file nodes."""
|
|
62
|
+
type: str = "file"
|
|
63
|
+
path: str
|
|
64
|
+
name: str
|
|
65
|
+
content: str
|
|
66
|
+
info: Dict[str, Any] = {}
|
|
67
|
+
|
|
68
|
+
class Config:
|
|
69
|
+
json_schema_extra = {
|
|
70
|
+
"example": {
|
|
71
|
+
"type": "file",
|
|
72
|
+
"path": "/books/42/title",
|
|
73
|
+
"name": "title",
|
|
74
|
+
"content": "The Art of Computer Programming",
|
|
75
|
+
"info": {"size": 34, "writable": False},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class VFSSymlinkResponse(BaseModel):
|
|
81
|
+
"""Response for symlink nodes."""
|
|
82
|
+
type: str = "symlink"
|
|
83
|
+
path: str
|
|
84
|
+
name: str
|
|
85
|
+
target: str
|
|
86
|
+
info: Dict[str, Any] = {}
|
|
87
|
+
|
|
88
|
+
class Config:
|
|
89
|
+
json_schema_extra = {
|
|
90
|
+
"example": {
|
|
91
|
+
"type": "symlink",
|
|
92
|
+
"path": "/books/42/similar/1",
|
|
93
|
+
"name": "1",
|
|
94
|
+
"target": "/books/101",
|
|
95
|
+
"info": {"title": "Structure and Interpretation..."},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
VFSResponse = Union[VFSDirectoryResponse, VFSFileResponse, VFSSymlinkResponse]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Create router
|
|
104
|
+
router = APIRouter(prefix="/api/vfs", tags=["vfs"])
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# Module-level VFS instance (initialized when library is set)
|
|
108
|
+
_vfs: Optional[LibraryVFS] = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_vfs() -> LibraryVFS:
|
|
112
|
+
"""Get the VFS instance."""
|
|
113
|
+
if _vfs is None:
|
|
114
|
+
raise HTTPException(status_code=500, detail="VFS not initialized")
|
|
115
|
+
return _vfs
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def set_vfs(vfs: LibraryVFS) -> None:
|
|
119
|
+
"""Set the VFS instance."""
|
|
120
|
+
global _vfs
|
|
121
|
+
_vfs = vfs
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def init_vfs_from_library(library) -> None:
|
|
125
|
+
"""Initialize VFS from a library instance."""
|
|
126
|
+
global _vfs
|
|
127
|
+
_vfs = LibraryVFS(library)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _build_response(node, path: str, include_content: bool = True) -> VFSResponse:
|
|
132
|
+
"""Build appropriate response for a node."""
|
|
133
|
+
info = node.get_info()
|
|
134
|
+
|
|
135
|
+
if isinstance(node, SymlinkNode):
|
|
136
|
+
return VFSSymlinkResponse(
|
|
137
|
+
path=path,
|
|
138
|
+
name=node.name,
|
|
139
|
+
target=node.target_path,
|
|
140
|
+
info={k: v for k, v in info.items() if k not in ("type", "name", "target", "path")},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if isinstance(node, FileNode):
|
|
144
|
+
content = ""
|
|
145
|
+
if include_content:
|
|
146
|
+
try:
|
|
147
|
+
content = node.read_content()
|
|
148
|
+
except Exception as e:
|
|
149
|
+
content = f"Error reading content: {e}"
|
|
150
|
+
|
|
151
|
+
return VFSFileResponse(
|
|
152
|
+
path=path,
|
|
153
|
+
name=node.name,
|
|
154
|
+
content=content,
|
|
155
|
+
info={k: v for k, v in info.items() if k not in ("type", "name", "path")},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if isinstance(node, DirectoryNode):
|
|
159
|
+
children = []
|
|
160
|
+
try:
|
|
161
|
+
child_nodes = node.list_children()
|
|
162
|
+
for child in child_nodes:
|
|
163
|
+
child_info = child.get_info()
|
|
164
|
+
children.append(VFSChild(
|
|
165
|
+
name=child.name,
|
|
166
|
+
type=child.node_type.value,
|
|
167
|
+
info={k: v for k, v in child_info.items() if k not in ("type", "name", "path")},
|
|
168
|
+
))
|
|
169
|
+
except Exception as e:
|
|
170
|
+
# Log error but continue
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
return VFSDirectoryResponse(
|
|
174
|
+
path=path,
|
|
175
|
+
name=node.name,
|
|
176
|
+
info={k: v for k, v in info.items() if k not in ("type", "name", "path", "children_count")},
|
|
177
|
+
children=children,
|
|
178
|
+
children_count=len(children),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Fallback for unknown node types
|
|
182
|
+
raise HTTPException(status_code=500, detail=f"Unknown node type: {type(node)}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@router.get(
|
|
186
|
+
"/",
|
|
187
|
+
response_model=VFSDirectoryResponse,
|
|
188
|
+
summary="VFS Root",
|
|
189
|
+
description="Get the root directory of the virtual filesystem.",
|
|
190
|
+
responses={
|
|
191
|
+
200: {
|
|
192
|
+
"description": "Root directory listing",
|
|
193
|
+
"content": {
|
|
194
|
+
"application/json": {
|
|
195
|
+
"example": {
|
|
196
|
+
"type": "directory",
|
|
197
|
+
"path": "/",
|
|
198
|
+
"name": "",
|
|
199
|
+
"info": {},
|
|
200
|
+
"children": [
|
|
201
|
+
{"name": "books", "type": "virtual", "info": {}},
|
|
202
|
+
{"name": "authors", "type": "virtual", "info": {}},
|
|
203
|
+
{"name": "subjects", "type": "virtual", "info": {}},
|
|
204
|
+
{"name": "tags", "type": "virtual", "info": {}},
|
|
205
|
+
],
|
|
206
|
+
"children_count": 4,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
async def get_vfs_root():
|
|
214
|
+
"""Get the VFS root directory listing.
|
|
215
|
+
|
|
216
|
+
Returns the top-level virtual directories:
|
|
217
|
+
- /books/ - Browse all books by ID
|
|
218
|
+
- /authors/ - Browse books by author
|
|
219
|
+
- /subjects/ - Browse books by subject
|
|
220
|
+
- /tags/ - Browse books by tag hierarchy
|
|
221
|
+
"""
|
|
222
|
+
vfs = get_vfs()
|
|
223
|
+
return _build_response(vfs.root, "/")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@router.get(
|
|
227
|
+
"/{path:path}",
|
|
228
|
+
response_model=VFSResponse,
|
|
229
|
+
summary="VFS Path",
|
|
230
|
+
description="Navigate to a path in the virtual filesystem.",
|
|
231
|
+
responses={
|
|
232
|
+
200: {
|
|
233
|
+
"description": "Node at the specified path (directory, file, or symlink)",
|
|
234
|
+
},
|
|
235
|
+
404: {
|
|
236
|
+
"description": "Path not found",
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
async def get_vfs_path(
|
|
241
|
+
path: str,
|
|
242
|
+
follow_symlinks: bool = Query(True, description="Follow symbolic links to their targets"),
|
|
243
|
+
include_content: bool = Query(True, description="Include file content in response (for files only)"),
|
|
244
|
+
):
|
|
245
|
+
"""Navigate to a path in the VFS and return its contents.
|
|
246
|
+
|
|
247
|
+
For directories, returns the listing of children.
|
|
248
|
+
For files, returns the content.
|
|
249
|
+
For symlinks, returns link info (or follows if follow_symlinks=true).
|
|
250
|
+
|
|
251
|
+
Examples:
|
|
252
|
+
- /books/ - List all books
|
|
253
|
+
- /books/42 - Book 42's directory
|
|
254
|
+
- /books/42/title - Read book 42's title
|
|
255
|
+
- /books/42/authors - Read book 42's authors
|
|
256
|
+
- /books/42/files/ - List book 42's files
|
|
257
|
+
- /authors/knuth-donald/ - Books by Donald Knuth
|
|
258
|
+
- /tags/Work/Projects/ - Books tagged Work/Projects
|
|
259
|
+
"""
|
|
260
|
+
vfs = get_vfs()
|
|
261
|
+
|
|
262
|
+
# Normalize path
|
|
263
|
+
if not path.startswith("/"):
|
|
264
|
+
path = "/" + path
|
|
265
|
+
|
|
266
|
+
# Handle trailing slash consistently
|
|
267
|
+
clean_path = path.rstrip("/") if path != "/" else path
|
|
268
|
+
|
|
269
|
+
# Resolve the path
|
|
270
|
+
node = vfs.resolver.resolve(clean_path, vfs.root, follow_symlinks=follow_symlinks)
|
|
271
|
+
|
|
272
|
+
if node is None:
|
|
273
|
+
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
|
|
274
|
+
|
|
275
|
+
return _build_response(node, node.get_path(), include_content=include_content)
|
ebk/views/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Views DSL for ebk.
|
|
3
|
+
|
|
4
|
+
A composable, non-destructive system for defining curated subsets of a library
|
|
5
|
+
with optional metadata overrides. Following SICP principles:
|
|
6
|
+
|
|
7
|
+
- Primitives: all, none, filter, ids, view references
|
|
8
|
+
- Combination: union, intersect, difference
|
|
9
|
+
- Abstraction: named views become new primitives
|
|
10
|
+
- Closure: combining views yields a view
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
from ebk.views import ViewEvaluator
|
|
14
|
+
|
|
15
|
+
evaluator = ViewEvaluator(session)
|
|
16
|
+
|
|
17
|
+
# Evaluate a view definition
|
|
18
|
+
books = evaluator.evaluate({
|
|
19
|
+
'select': {
|
|
20
|
+
'intersect': [
|
|
21
|
+
{'filter': {'subject': 'programming'}},
|
|
22
|
+
{'filter': {'favorite': True}}
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
'order': {'by': 'title'}
|
|
26
|
+
})
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from .dsl import ViewEvaluator, TransformedBook
|
|
30
|
+
from .service import ViewService
|
|
31
|
+
|
|
32
|
+
__all__ = ['ViewEvaluator', 'TransformedBook', 'ViewService']
|