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/skills/SKILL.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ebk
|
|
3
|
+
description: Use this skill when working with ebk - an eBook metadata management CLI with SQLite storage, full-text search, hierarchical tags, and a virtual filesystem shell. Invoke for library management, book operations, imports/exports, or ebk shell navigation.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ebk - eBook Metadata Management
|
|
7
|
+
|
|
8
|
+
A CLI tool for managing ebook libraries with SQLite storage, full-text search, and a virtual filesystem shell.
|
|
9
|
+
|
|
10
|
+
## Quick Reference
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# Library management
|
|
14
|
+
ebk lib init ~/my-library
|
|
15
|
+
ebk lib migrate
|
|
16
|
+
ebk lib backup -o backup.tar.gz
|
|
17
|
+
ebk lib check
|
|
18
|
+
|
|
19
|
+
# Query and discovery
|
|
20
|
+
ebk query search "python programming"
|
|
21
|
+
ebk query list --author "Knuth"
|
|
22
|
+
ebk query stats
|
|
23
|
+
ebk query sql "SELECT title FROM books WHERE language='en'"
|
|
24
|
+
|
|
25
|
+
# Import books
|
|
26
|
+
ebk import add book.pdf
|
|
27
|
+
ebk import folder ~/Downloads/books/
|
|
28
|
+
ebk import calibre ~/Calibre\ Library/
|
|
29
|
+
ebk import isbn 978-0134685991
|
|
30
|
+
|
|
31
|
+
# Export library
|
|
32
|
+
ebk export json -o library.json
|
|
33
|
+
ebk export csv -o library.csv
|
|
34
|
+
ebk export opds -o catalog.xml --copy-files
|
|
35
|
+
ebk export goodreads -o goodreads.csv
|
|
36
|
+
ebk export calibre -o calibre.csv
|
|
37
|
+
|
|
38
|
+
# Book operations
|
|
39
|
+
ebk book info 42
|
|
40
|
+
ebk book read 42 --text
|
|
41
|
+
ebk book rate 42 --rating 5
|
|
42
|
+
ebk book tag 42 --add "Work/Project-2024"
|
|
43
|
+
ebk book purge ~/library --no-files --execute
|
|
44
|
+
|
|
45
|
+
# Interactive
|
|
46
|
+
ebk shell # VFS shell
|
|
47
|
+
ebk serve # Web server
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Command Groups
|
|
51
|
+
|
|
52
|
+
| Command | Purpose |
|
|
53
|
+
|---------|---------|
|
|
54
|
+
| `ebk lib` | Library management (init, migrate, backup, restore, check) |
|
|
55
|
+
| `ebk query` | Query and discovery (search, list, stats, sql) |
|
|
56
|
+
| `ebk import` | Import books (add, folder, calibre, isbn, opds, url) |
|
|
57
|
+
| `ebk export` | Export library (json, csv, html, opds, goodreads, calibre) |
|
|
58
|
+
| `ebk book` | Book operations (info, read, rate, favorite, tag, purge, merge) |
|
|
59
|
+
| `ebk note` | Manage annotations (add, list, extract, export) |
|
|
60
|
+
| `ebk tag` | Manage hierarchical tags (list, tree, add, remove, rename) |
|
|
61
|
+
| `ebk queue` | Reading queue (list, add, remove, move, next) |
|
|
62
|
+
| `ebk view` | Named library subsets (create, list, show, delete, edit) |
|
|
63
|
+
| `ebk skill` | Claude Code skill management |
|
|
64
|
+
|
|
65
|
+
## Search Syntax
|
|
66
|
+
|
|
67
|
+
ebk supports advanced query syntax:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Field-specific search
|
|
71
|
+
ebk query search "title:Python author:Knuth"
|
|
72
|
+
|
|
73
|
+
# Boolean operators
|
|
74
|
+
ebk query search "python AND programming"
|
|
75
|
+
ebk query search "python OR ruby"
|
|
76
|
+
ebk query search "NOT java"
|
|
77
|
+
|
|
78
|
+
# Exact phrases
|
|
79
|
+
ebk query search '"machine learning"'
|
|
80
|
+
|
|
81
|
+
# Comparisons
|
|
82
|
+
ebk query search "rating:>=4"
|
|
83
|
+
ebk query search "pages:>500"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## VFS Shell
|
|
87
|
+
|
|
88
|
+
`ebk shell` provides filesystem-like navigation:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
/ # Root
|
|
92
|
+
/books/{id}/ # Book by ID
|
|
93
|
+
/books/{id}/title # Title as text
|
|
94
|
+
/books/{id}/authors # Authors list
|
|
95
|
+
/books/{id}/metadata # Full JSON metadata
|
|
96
|
+
/books/{id}/files/ # Ebook files
|
|
97
|
+
/books/{id}/similar/ # Similar books
|
|
98
|
+
/authors/{name}/ # Browse by author
|
|
99
|
+
/subjects/{subject}/ # Browse by subject
|
|
100
|
+
/tags/{hierarchy}/ # Hierarchical tags
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Shell commands: `ls`, `cd`, `cat`, `find`, `grep`, `ln`, `mv`, `rm`, `mkdir`
|
|
104
|
+
|
|
105
|
+
Supports piping: `ls /books/ | grep Python | head 10`
|
|
106
|
+
|
|
107
|
+
## Python API
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from ebk.library_db import Library
|
|
111
|
+
|
|
112
|
+
# Open library
|
|
113
|
+
lib = Library.open("~/my-library")
|
|
114
|
+
|
|
115
|
+
# Fluent query API
|
|
116
|
+
results = (lib.query()
|
|
117
|
+
.filter_by_author("Knuth")
|
|
118
|
+
.filter_by_language("en")
|
|
119
|
+
.order_by("title")
|
|
120
|
+
.limit(20)
|
|
121
|
+
.all())
|
|
122
|
+
|
|
123
|
+
# Search
|
|
124
|
+
books = lib.search("python programming", limit=10)
|
|
125
|
+
|
|
126
|
+
# Get book
|
|
127
|
+
book = lib.get_book(42)
|
|
128
|
+
print(book.title, book.authors)
|
|
129
|
+
|
|
130
|
+
lib.close()
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Configuration
|
|
134
|
+
|
|
135
|
+
Config file: `~/.config/ebk/config.json`
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
ebk config --library-path ~/my-library # Set default library
|
|
139
|
+
ebk config --llm-provider ollama # Set LLM provider
|
|
140
|
+
ebk config --show # Show current config
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Database Schema
|
|
144
|
+
|
|
145
|
+
Core tables: `books`, `authors`, `subjects`, `files`, `covers`, `tags`, `personal_metadata`, `annotations`, `books_fts` (FTS5)
|
|
146
|
+
|
|
147
|
+
Library directory structure:
|
|
148
|
+
```
|
|
149
|
+
library/
|
|
150
|
+
├── library.db # SQLite database
|
|
151
|
+
├── files/ # Hash-prefixed ebook storage
|
|
152
|
+
└── covers/
|
|
153
|
+
└── thumbnails/ # Cover images
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Views DSL
|
|
157
|
+
|
|
158
|
+
Views are named, composable library subsets:
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
name: unread-python
|
|
162
|
+
description: Unread Python books
|
|
163
|
+
filter:
|
|
164
|
+
reading_status: unread
|
|
165
|
+
subjects:
|
|
166
|
+
any: ["Python", "Programming"]
|
|
167
|
+
sort: added desc
|
|
168
|
+
limit: 50
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
ebk view create unread-python --filter "reading_status:unread" --filter "subject:Python"
|
|
173
|
+
ebk query list --view unread-python
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Tips
|
|
177
|
+
|
|
178
|
+
1. Use `ebk config --library-path` to set a default library
|
|
179
|
+
2. `ebk shell` for interactive exploration
|
|
180
|
+
3. `ebk query sql` for complex custom queries
|
|
181
|
+
4. `ebk export opds` for Android reader compatibility
|
|
182
|
+
5. Tags are hierarchical: `Work/Project-2024/Research`
|
ebk/skills/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Claude Code skill data for ebk
|
ebk/vfs/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Virtual File System for navigating the library database.
|
|
2
|
+
|
|
3
|
+
The VFS provides a filesystem-like interface for browsing and interacting
|
|
4
|
+
with the ebook library. It maps database entities to a hierarchical structure
|
|
5
|
+
that can be navigated with familiar shell commands.
|
|
6
|
+
|
|
7
|
+
Architecture:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
/ # Root (RootNode)
|
|
11
|
+
├── books/ # All books (BooksDirectoryNode)
|
|
12
|
+
│ ├── 1/ # Book 1 (BookNode)
|
|
13
|
+
│ │ ├── title # Metadata file (TitleFileNode)
|
|
14
|
+
│ │ ├── authors # Metadata file (AuthorsFileNode)
|
|
15
|
+
│ │ ├── description # Metadata file
|
|
16
|
+
│ │ ├── text # Extracted text (TextFileNode)
|
|
17
|
+
│ │ ├── files/ # Physical files (FilesDirectoryNode)
|
|
18
|
+
│ │ │ ├── book.pdf
|
|
19
|
+
│ │ │ └── book.epub
|
|
20
|
+
│ │ ├── similar/ # Similar books (SimilarDirectoryNode)
|
|
21
|
+
│ │ ├── annotations/ # User annotations
|
|
22
|
+
│ │ └── covers/ # Cover images
|
|
23
|
+
│ └── 2/
|
|
24
|
+
├── authors/ # Browse by author (AuthorsDirectoryNode)
|
|
25
|
+
│ └── knuth-donald/ # Books by this author
|
|
26
|
+
├── subjects/ # Browse by subject
|
|
27
|
+
└── series/ # Browse by series
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Node Types:
|
|
31
|
+
|
|
32
|
+
- Node: Base class for all VFS entries
|
|
33
|
+
- DirectoryNode: Can contain children (cd into them)
|
|
34
|
+
- FileNode: Leaf nodes with content (cat them)
|
|
35
|
+
- VirtualNode: Dynamically computed (e.g., /books/, /similar/)
|
|
36
|
+
- SymlinkNode: Links to other nodes
|
|
37
|
+
|
|
38
|
+
Path Resolution:
|
|
39
|
+
|
|
40
|
+
The PathResolver handles navigation:
|
|
41
|
+
- Absolute paths: /books/42/title
|
|
42
|
+
- Relative paths: ../other, ./files
|
|
43
|
+
- Special: ., .., ~ (home = /)
|
|
44
|
+
- Symlink following
|
|
45
|
+
- Tab completion support
|
|
46
|
+
|
|
47
|
+
Usage Example:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from ebk.library_db import Library
|
|
51
|
+
from ebk.vfs import LibraryVFS
|
|
52
|
+
|
|
53
|
+
# Create VFS for a library
|
|
54
|
+
lib = Library.open("/path/to/library")
|
|
55
|
+
vfs = LibraryVFS(lib)
|
|
56
|
+
|
|
57
|
+
# Navigate
|
|
58
|
+
root = vfs.root
|
|
59
|
+
books_dir = vfs.resolver.resolve("/books", root)
|
|
60
|
+
book_node = vfs.resolver.resolve("/books/42", root)
|
|
61
|
+
|
|
62
|
+
# List children
|
|
63
|
+
children = books_dir.list_children() # All books
|
|
64
|
+
for child in children:
|
|
65
|
+
print(child.name, child.get_info())
|
|
66
|
+
|
|
67
|
+
# Read file content
|
|
68
|
+
title_node = vfs.resolver.resolve("/books/42/title", root)
|
|
69
|
+
if isinstance(title_node, FileNode):
|
|
70
|
+
content = title_node.read_content()
|
|
71
|
+
print(content)
|
|
72
|
+
```
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
from ebk.vfs.base import (
|
|
76
|
+
Node,
|
|
77
|
+
DirectoryNode,
|
|
78
|
+
FileNode,
|
|
79
|
+
VirtualNode,
|
|
80
|
+
SymlinkNode,
|
|
81
|
+
NodeType,
|
|
82
|
+
)
|
|
83
|
+
from ebk.vfs.resolver import PathResolver, PathError, NotADirectoryError, NotFoundError
|
|
84
|
+
from ebk.vfs.library_vfs import LibraryVFS
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
# Main entry point
|
|
88
|
+
"LibraryVFS",
|
|
89
|
+
# Core classes
|
|
90
|
+
"Node",
|
|
91
|
+
"DirectoryNode",
|
|
92
|
+
"FileNode",
|
|
93
|
+
"VirtualNode",
|
|
94
|
+
"SymlinkNode",
|
|
95
|
+
"NodeType",
|
|
96
|
+
# Path resolution
|
|
97
|
+
"PathResolver",
|
|
98
|
+
"PathError",
|
|
99
|
+
"NotADirectoryError",
|
|
100
|
+
"NotFoundError",
|
|
101
|
+
]
|
ebk/vfs/base.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
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
|
+
|
|
17
|
+
|
|
18
|
+
class NodeType(Enum):
|
|
19
|
+
"""Type of VFS node."""
|
|
20
|
+
DIRECTORY = "directory"
|
|
21
|
+
FILE = "file"
|
|
22
|
+
VIRTUAL = "virtual"
|
|
23
|
+
SYMLINK = "symlink"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Node(ABC):
|
|
27
|
+
"""Base class for all VFS nodes.
|
|
28
|
+
|
|
29
|
+
A Node represents an entry in the virtual filesystem. It can be
|
|
30
|
+
a directory (navigable), a file (readable), or something virtual
|
|
31
|
+
(dynamically computed).
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
name: The name of this node (e.g., "title", "books", "42")
|
|
35
|
+
parent: Parent directory node (None for root)
|
|
36
|
+
node_type: Type of node (directory, file, virtual, symlink)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
name: str,
|
|
42
|
+
parent: Optional['DirectoryNode'] = None,
|
|
43
|
+
node_type: NodeType = NodeType.FILE,
|
|
44
|
+
):
|
|
45
|
+
"""Initialize a VFS node.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
name: Name of this node
|
|
49
|
+
parent: Parent directory (None for root)
|
|
50
|
+
node_type: Type of node
|
|
51
|
+
"""
|
|
52
|
+
self.name = name
|
|
53
|
+
self.parent = parent
|
|
54
|
+
self.node_type = node_type
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def get_info(self) -> Dict[str, Any]:
|
|
58
|
+
"""Get metadata about this node for display.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Dict with keys like: size, modified, type, description
|
|
62
|
+
"""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def get_path(self) -> str:
|
|
66
|
+
"""Get absolute path to this node.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Path like /books/42/title
|
|
70
|
+
"""
|
|
71
|
+
if self.parent is None:
|
|
72
|
+
return "/"
|
|
73
|
+
|
|
74
|
+
parts = []
|
|
75
|
+
node = self
|
|
76
|
+
while node.parent is not None:
|
|
77
|
+
parts.append(node.name)
|
|
78
|
+
node = node.parent
|
|
79
|
+
|
|
80
|
+
if not parts:
|
|
81
|
+
return "/"
|
|
82
|
+
|
|
83
|
+
return "/" + "/".join(reversed(parts))
|
|
84
|
+
|
|
85
|
+
def __repr__(self) -> str:
|
|
86
|
+
return f"{self.__class__.__name__}(name='{self.name}', path='{self.get_path()}')"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DirectoryNode(Node):
|
|
90
|
+
"""A directory node that can contain children.
|
|
91
|
+
|
|
92
|
+
Directory nodes can be navigated into with `cd` and their
|
|
93
|
+
children can be listed with `ls`.
|
|
94
|
+
|
|
95
|
+
Children can be:
|
|
96
|
+
- Static: Fixed set of children
|
|
97
|
+
- Dynamic: Children computed on-demand (e.g., book list from DB)
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, name: str, parent: Optional['DirectoryNode'] = None):
|
|
101
|
+
"""Initialize a directory node.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
name: Name of this directory
|
|
105
|
+
parent: Parent directory
|
|
106
|
+
"""
|
|
107
|
+
super().__init__(name, parent, NodeType.DIRECTORY)
|
|
108
|
+
self._children: Dict[str, Node] = {}
|
|
109
|
+
|
|
110
|
+
@abstractmethod
|
|
111
|
+
def list_children(self) -> List[Node]:
|
|
112
|
+
"""List all children of this directory.
|
|
113
|
+
|
|
114
|
+
This may compute children dynamically from the database.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
List of child nodes
|
|
118
|
+
"""
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def get_child(self, name: str) -> Optional[Node]:
|
|
123
|
+
"""Get a child node by name.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
name: Name of child node
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Child node or None if not found
|
|
130
|
+
"""
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
def get_info(self) -> Dict[str, Any]:
|
|
134
|
+
"""Get directory metadata.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Dict with directory information
|
|
138
|
+
"""
|
|
139
|
+
children = self.list_children()
|
|
140
|
+
return {
|
|
141
|
+
"type": "directory",
|
|
142
|
+
"name": self.name,
|
|
143
|
+
"children_count": len(children),
|
|
144
|
+
"path": self.get_path(),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class FileNode(Node):
|
|
149
|
+
"""A file node with readable content.
|
|
150
|
+
|
|
151
|
+
File nodes represent data that can be read with `cat`.
|
|
152
|
+
Examples: book title, description, full text, etc.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
name: str,
|
|
158
|
+
parent: Optional[DirectoryNode] = None,
|
|
159
|
+
size: Optional[int] = None,
|
|
160
|
+
):
|
|
161
|
+
"""Initialize a file node.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
name: Name of this file
|
|
165
|
+
parent: Parent directory
|
|
166
|
+
size: Size in bytes (if known)
|
|
167
|
+
"""
|
|
168
|
+
super().__init__(name, parent, NodeType.FILE)
|
|
169
|
+
self._size = size
|
|
170
|
+
|
|
171
|
+
@abstractmethod
|
|
172
|
+
def read_content(self) -> str:
|
|
173
|
+
"""Read the content of this file.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
File content as string
|
|
177
|
+
"""
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
def write_content(self, content: str) -> None:
|
|
181
|
+
"""Write content to this file.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
content: Content to write
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
NotImplementedError: If the file is read-only
|
|
188
|
+
"""
|
|
189
|
+
raise NotImplementedError(f"File '{self.name}' is read-only")
|
|
190
|
+
|
|
191
|
+
def is_writable(self) -> bool:
|
|
192
|
+
"""Check if this file is writable.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
True if file supports writing, False otherwise
|
|
196
|
+
"""
|
|
197
|
+
# By default, files are read-only unless they override write_content
|
|
198
|
+
try:
|
|
199
|
+
# Try calling write_content with empty string to see if it raises NotImplementedError
|
|
200
|
+
# This is a bit hacky but works
|
|
201
|
+
return hasattr(self.__class__, 'write_content') and \
|
|
202
|
+
self.__class__.write_content != FileNode.write_content
|
|
203
|
+
except Exception:
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
def get_info(self) -> Dict[str, Any]:
|
|
207
|
+
"""Get file metadata.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Dict with file information
|
|
211
|
+
"""
|
|
212
|
+
return {
|
|
213
|
+
"type": "file",
|
|
214
|
+
"name": self.name,
|
|
215
|
+
"size": self._size,
|
|
216
|
+
"path": self.get_path(),
|
|
217
|
+
"writable": self.is_writable(),
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class VirtualNode(DirectoryNode):
|
|
222
|
+
"""A virtual directory with dynamically computed children.
|
|
223
|
+
|
|
224
|
+
Virtual nodes don't have a fixed set of children - they compute
|
|
225
|
+
them on-demand from the database or other sources.
|
|
226
|
+
|
|
227
|
+
Examples:
|
|
228
|
+
- /books/ - Lists all books from DB
|
|
229
|
+
- /books/42/similar/ - Computes similar books on-demand
|
|
230
|
+
- /authors/ - Lists all authors from DB
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def __init__(self, name: str, parent: Optional[DirectoryNode] = None):
|
|
234
|
+
"""Initialize a virtual directory node.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
name: Name of this directory
|
|
238
|
+
parent: Parent directory
|
|
239
|
+
"""
|
|
240
|
+
super().__init__(name, parent)
|
|
241
|
+
self.node_type = NodeType.VIRTUAL
|
|
242
|
+
|
|
243
|
+
def get_info(self) -> Dict[str, Any]:
|
|
244
|
+
"""Get virtual directory metadata.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Dict with virtual directory information
|
|
248
|
+
"""
|
|
249
|
+
info = super().get_info()
|
|
250
|
+
info["type"] = "virtual"
|
|
251
|
+
return info
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class SymlinkNode(Node):
|
|
255
|
+
"""A symbolic link pointing to another node.
|
|
256
|
+
|
|
257
|
+
Used for creating convenient shortcuts, like similar books
|
|
258
|
+
appearing as links in /books/42/similar/.
|
|
259
|
+
|
|
260
|
+
Attributes:
|
|
261
|
+
target_path: Path to the target node
|
|
262
|
+
metadata: Optional metadata dict to include in get_info()
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
def __init__(
|
|
266
|
+
self,
|
|
267
|
+
name: str,
|
|
268
|
+
target_path: str,
|
|
269
|
+
parent: Optional[DirectoryNode] = None,
|
|
270
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
271
|
+
):
|
|
272
|
+
"""Initialize a symlink node.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
name: Name of this symlink
|
|
276
|
+
target_path: Path to target node
|
|
277
|
+
parent: Parent directory
|
|
278
|
+
metadata: Optional metadata to include in get_info()
|
|
279
|
+
"""
|
|
280
|
+
super().__init__(name, parent, NodeType.SYMLINK)
|
|
281
|
+
self.target_path = target_path
|
|
282
|
+
self.metadata = metadata or {}
|
|
283
|
+
|
|
284
|
+
def get_info(self) -> Dict[str, Any]:
|
|
285
|
+
"""Get symlink metadata.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Dict with symlink information plus any provided metadata
|
|
289
|
+
"""
|
|
290
|
+
info = {
|
|
291
|
+
"type": "symlink",
|
|
292
|
+
"name": self.name,
|
|
293
|
+
"target": self.target_path,
|
|
294
|
+
"path": self.get_path(),
|
|
295
|
+
}
|
|
296
|
+
# Merge in any provided metadata
|
|
297
|
+
info.update(self.metadata)
|
|
298
|
+
return info
|
ebk/vfs/library_vfs.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Main LibraryVFS class - entry point for VFS access."""
|
|
2
|
+
|
|
3
|
+
from ebk.library_db import Library
|
|
4
|
+
from ebk.vfs.base import DirectoryNode, Node
|
|
5
|
+
from ebk.vfs.resolver import PathResolver
|
|
6
|
+
from ebk.vfs.nodes import RootNode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LibraryVFS:
|
|
10
|
+
"""Virtual File System for a library.
|
|
11
|
+
|
|
12
|
+
This is the main entry point for accessing the VFS. It creates
|
|
13
|
+
the root node and provides a resolver for path navigation.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
>>> lib = Library.open("/path/to/library")
|
|
17
|
+
>>> vfs = LibraryVFS(lib)
|
|
18
|
+
>>>
|
|
19
|
+
>>> # Navigate using resolver
|
|
20
|
+
>>> books_dir = vfs.resolver.resolve("/books", vfs.root)
|
|
21
|
+
>>> book_node = vfs.resolver.resolve("/books/42", vfs.root)
|
|
22
|
+
>>>
|
|
23
|
+
>>> # List children
|
|
24
|
+
>>> children = books_dir.list_children()
|
|
25
|
+
>>>
|
|
26
|
+
>>> # Read file content
|
|
27
|
+
>>> title_node = vfs.resolver.resolve("/books/42/title", vfs.root)
|
|
28
|
+
>>> if isinstance(title_node, FileNode):
|
|
29
|
+
>>> print(title_node.read_content())
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, library: Library):
|
|
33
|
+
"""Initialize VFS for a library.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
library: Library instance
|
|
37
|
+
"""
|
|
38
|
+
self.library = library
|
|
39
|
+
self.root = RootNode(library)
|
|
40
|
+
self.resolver = PathResolver(self.root)
|
|
41
|
+
self.current = self.root # Current working directory
|
|
42
|
+
|
|
43
|
+
def cd(self, path: str) -> bool:
|
|
44
|
+
"""Change current directory.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
path: Path to navigate to
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
True if successful, False otherwise
|
|
51
|
+
"""
|
|
52
|
+
new_dir = self.resolver.resolve_directory(path, self.current)
|
|
53
|
+
if new_dir is None:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
self.current = new_dir
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
def pwd(self) -> str:
|
|
60
|
+
"""Get current working directory path.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Current path
|
|
64
|
+
"""
|
|
65
|
+
return self.current.get_path()
|
|
66
|
+
|
|
67
|
+
def ls(self, path: str = ".") -> list:
|
|
68
|
+
"""List children of a directory.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
path: Path to list (default: current directory)
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of nodes
|
|
75
|
+
"""
|
|
76
|
+
node = self.resolver.resolve(path, self.current)
|
|
77
|
+
if node is None or not isinstance(node, DirectoryNode):
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
return node.list_children()
|
|
81
|
+
|
|
82
|
+
def cat(self, path: str) -> str:
|
|
83
|
+
"""Read content of a file node.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
path: Path to file
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
File content or error message
|
|
90
|
+
"""
|
|
91
|
+
from ebk.vfs.base import FileNode
|
|
92
|
+
|
|
93
|
+
node = self.resolver.resolve(path, self.current)
|
|
94
|
+
if node is None:
|
|
95
|
+
return f"cat: {path}: No such file or directory"
|
|
96
|
+
|
|
97
|
+
if not isinstance(node, FileNode):
|
|
98
|
+
return f"cat: {path}: Is a directory"
|
|
99
|
+
|
|
100
|
+
return node.read_content()
|
|
101
|
+
|
|
102
|
+
def get_node(self, path: str) -> Node:
|
|
103
|
+
"""Resolve a path to a node.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
path: Path to resolve
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Resolved node or None
|
|
110
|
+
"""
|
|
111
|
+
return self.resolver.resolve(path, self.current)
|
|
112
|
+
|
|
113
|
+
def complete(self, partial: str) -> list:
|
|
114
|
+
"""Get tab completion candidates.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
partial: Partial path
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
List of completion candidates
|
|
121
|
+
"""
|
|
122
|
+
return self.resolver.complete_path(partial, self.current)
|