ebk 0.1.0__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/__init__.py +35 -0
- 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 +2828 -680
- ebk/config.py +260 -22
- 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/decorators.py +132 -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 +7 -3
- ebk/exports/jinja_export.py +287 -0
- ebk/exports/multi_facet_export.py +164 -0
- ebk/exports/symlink_dag.py +479 -0
- ebk/extract_metadata.py +76 -7
- ebk/library_db.py +899 -0
- ebk/plugins/__init__.py +42 -0
- ebk/plugins/base.py +502 -0
- ebk/plugins/hooks.py +444 -0
- ebk/plugins/registry.py +500 -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/search_parser.py +413 -0
- ebk/server.py +1633 -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.2.dist-info/METADATA +755 -0
- ebk-0.3.2.dist-info/RECORD +69 -0
- {ebk-0.1.0.dist-info → ebk-0.3.2.dist-info}/WHEEL +1 -1
- ebk-0.3.2.dist-info/licenses/LICENSE +21 -0
- ebk/imports/__init__.py +0 -0
- ebk/imports/calibre.py +0 -144
- ebk/imports/ebooks.py +0 -116
- ebk/llm.py +0 -58
- ebk/manager.py +0 -44
- ebk/merge.py +0 -308
- ebk/streamlit/__init__.py +0 -0
- ebk/streamlit/__pycache__/__init__.cpython-310.pyc +0 -0
- ebk/streamlit/__pycache__/display.cpython-310.pyc +0 -0
- ebk/streamlit/__pycache__/filters.cpython-310.pyc +0 -0
- ebk/streamlit/__pycache__/utils.cpython-310.pyc +0 -0
- ebk/streamlit/app.py +0 -185
- ebk/streamlit/display.py +0 -168
- ebk/streamlit/filters.py +0 -151
- ebk/streamlit/utils.py +0 -58
- ebk/utils.py +0 -311
- ebk-0.1.0.dist-info/METADATA +0 -457
- ebk-0.1.0.dist-info/RECORD +0 -29
- {ebk-0.1.0.dist-info → ebk-0.3.2.dist-info}/entry_points.txt +0 -0
- {ebk-0.1.0.dist-info → ebk-0.3.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Subject-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 Subject
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SubjectsDirectoryNode(VirtualNode):
|
|
11
|
+
"""/subjects/ - Virtual directory listing all subjects/tags.
|
|
12
|
+
|
|
13
|
+
Each child is a SubjectNode representing books with that subject.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, library: Library, parent: Optional[DirectoryNode] = None):
|
|
17
|
+
"""Initialize subjects directory.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
library: Library instance
|
|
21
|
+
parent: Parent node (usually root)
|
|
22
|
+
"""
|
|
23
|
+
super().__init__(name="subjects", parent=parent)
|
|
24
|
+
self.library = library
|
|
25
|
+
|
|
26
|
+
def list_children(self) -> List[Node]:
|
|
27
|
+
"""List all subjects.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
List of SubjectNode instances
|
|
31
|
+
"""
|
|
32
|
+
# Query all subjects from database
|
|
33
|
+
subjects_query = self.library.session.query(Subject).all()
|
|
34
|
+
|
|
35
|
+
subject_nodes = []
|
|
36
|
+
for subject in subjects_query:
|
|
37
|
+
# Create a slug from subject name
|
|
38
|
+
slug = self._make_slug(subject.name)
|
|
39
|
+
node = SubjectNode(subject, slug, self.library, parent=self)
|
|
40
|
+
subject_nodes.append(node)
|
|
41
|
+
|
|
42
|
+
return subject_nodes
|
|
43
|
+
|
|
44
|
+
def get_child(self, name: str) -> Optional[Node]:
|
|
45
|
+
"""Get a subject by slug.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
name: Subject slug (e.g., "python", "machine-learning")
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
SubjectNode or None
|
|
52
|
+
"""
|
|
53
|
+
# Try to find subject by matching slug
|
|
54
|
+
subjects = self.library.session.query(Subject).all()
|
|
55
|
+
|
|
56
|
+
for subject in subjects:
|
|
57
|
+
slug = self._make_slug(subject.name)
|
|
58
|
+
if slug == name:
|
|
59
|
+
return SubjectNode(subject, slug, self.library, parent=self)
|
|
60
|
+
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def _make_slug(self, name: str) -> str:
|
|
64
|
+
"""Convert subject name to filesystem-safe slug.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
name: Subject name
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Slugified name (e.g., "Machine Learning" -> "machine-learning")
|
|
71
|
+
"""
|
|
72
|
+
# Simple slugification: lowercase, replace spaces with hyphens
|
|
73
|
+
slug = name.lower().replace(" ", "-")
|
|
74
|
+
|
|
75
|
+
# Remove special characters
|
|
76
|
+
slug = "".join(c for c in slug if c.isalnum() or c == "-")
|
|
77
|
+
return slug
|
|
78
|
+
|
|
79
|
+
def get_info(self) -> Dict[str, Any]:
|
|
80
|
+
"""Get subjects directory info.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Dict with directory information
|
|
84
|
+
"""
|
|
85
|
+
total = self.library.session.query(Subject).count()
|
|
86
|
+
return {
|
|
87
|
+
"type": "virtual",
|
|
88
|
+
"name": "subjects",
|
|
89
|
+
"total_subjects": total,
|
|
90
|
+
"path": self.get_path(),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class SubjectNode(VirtualNode):
|
|
95
|
+
"""/subjects/python/ - Books with a specific subject/tag.
|
|
96
|
+
|
|
97
|
+
Contains symlinks to books tagged with this subject.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
subject: Subject,
|
|
103
|
+
slug: str,
|
|
104
|
+
library: Library,
|
|
105
|
+
parent: Optional[DirectoryNode] = None,
|
|
106
|
+
):
|
|
107
|
+
"""Initialize subject node.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
subject: Subject database model
|
|
111
|
+
slug: Subject slug for URL
|
|
112
|
+
library: Library instance
|
|
113
|
+
parent: Parent node (usually SubjectsDirectoryNode)
|
|
114
|
+
"""
|
|
115
|
+
super().__init__(name=slug, parent=parent)
|
|
116
|
+
self.subject = subject
|
|
117
|
+
self.library = library
|
|
118
|
+
|
|
119
|
+
def list_children(self) -> List[Node]:
|
|
120
|
+
"""List books with this subject as symlinks.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
List of SymlinkNode instances
|
|
124
|
+
"""
|
|
125
|
+
symlinks = []
|
|
126
|
+
for book in self.subject.books:
|
|
127
|
+
target_path = f"/books/{book.id}"
|
|
128
|
+
name = str(book.id)
|
|
129
|
+
|
|
130
|
+
# Include book metadata for display
|
|
131
|
+
metadata = {
|
|
132
|
+
"title": book.title or "Untitled",
|
|
133
|
+
}
|
|
134
|
+
if book.authors:
|
|
135
|
+
metadata["author"] = ", ".join([a.name for a in book.authors])
|
|
136
|
+
|
|
137
|
+
symlink = SymlinkNode(name, target_path, parent=self, metadata=metadata)
|
|
138
|
+
symlinks.append(symlink)
|
|
139
|
+
|
|
140
|
+
return symlinks
|
|
141
|
+
|
|
142
|
+
def get_child(self, name: str) -> Optional[Node]:
|
|
143
|
+
"""Get a book symlink by ID.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
name: Book ID as string
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
SymlinkNode or None
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
book_id = int(name)
|
|
153
|
+
except ValueError:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
# Check if this book has this subject
|
|
157
|
+
for book in self.subject.books:
|
|
158
|
+
if book.id == book_id:
|
|
159
|
+
target_path = f"/books/{book.id}"
|
|
160
|
+
|
|
161
|
+
# Include book metadata for display
|
|
162
|
+
metadata = {
|
|
163
|
+
"title": book.title or "Untitled",
|
|
164
|
+
}
|
|
165
|
+
if book.authors:
|
|
166
|
+
metadata["author"] = ", ".join([a.name for a in book.authors])
|
|
167
|
+
|
|
168
|
+
return SymlinkNode(name, target_path, parent=self, metadata=metadata)
|
|
169
|
+
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def get_info(self) -> Dict[str, Any]:
|
|
173
|
+
"""Get subject node info.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Dict with subject information
|
|
177
|
+
"""
|
|
178
|
+
return {
|
|
179
|
+
"type": "virtual",
|
|
180
|
+
"name": self.name,
|
|
181
|
+
"subject": self.subject.name,
|
|
182
|
+
"book_count": len(self.subject.books),
|
|
183
|
+
"path": self.get_path(),
|
|
184
|
+
}
|
ebk/vfs/nodes/tags.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Tag-related VFS nodes for hierarchical user-defined organization."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Dict, Any
|
|
4
|
+
|
|
5
|
+
from ebk.vfs.base import VirtualNode, DirectoryNode, SymlinkNode, Node, FileNode
|
|
6
|
+
from ebk.library_db import Library
|
|
7
|
+
from ebk.db.models import Tag
|
|
8
|
+
from ebk.services.tag_service import TagService
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TagsDirectoryNode(VirtualNode):
|
|
12
|
+
"""/tags/ - Virtual directory with hierarchical tag structure.
|
|
13
|
+
|
|
14
|
+
This is the entry point for browsing user-defined tags.
|
|
15
|
+
Shows root-level tags as children.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, library: Library, parent: Optional[DirectoryNode] = None):
|
|
19
|
+
"""Initialize tags directory.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
library: Library instance
|
|
23
|
+
parent: Parent node (usually root)
|
|
24
|
+
"""
|
|
25
|
+
super().__init__(name="tags", parent=parent)
|
|
26
|
+
self.library = library
|
|
27
|
+
self.tag_service = TagService(library.session)
|
|
28
|
+
|
|
29
|
+
def list_children(self) -> List[Node]:
|
|
30
|
+
"""List root-level tags.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
List of TagNode instances for tags with no parent
|
|
34
|
+
"""
|
|
35
|
+
root_tags = self.tag_service.get_root_tags()
|
|
36
|
+
|
|
37
|
+
tag_nodes = []
|
|
38
|
+
for tag in root_tags:
|
|
39
|
+
node = TagNode(tag, self.library, parent=self)
|
|
40
|
+
tag_nodes.append(node)
|
|
41
|
+
|
|
42
|
+
return tag_nodes
|
|
43
|
+
|
|
44
|
+
def get_child(self, name: str) -> Optional[Node]:
|
|
45
|
+
"""Get a root-level tag by name.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
name: Tag name (e.g., "Work", "Reading-List")
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
TagNode or None
|
|
52
|
+
"""
|
|
53
|
+
# Try to find tag by name at root level
|
|
54
|
+
tag = self.tag_service.get_tag(name)
|
|
55
|
+
|
|
56
|
+
if tag and tag.parent_id is None:
|
|
57
|
+
return TagNode(tag, self.library, parent=self)
|
|
58
|
+
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def get_info(self) -> Dict[str, Any]:
|
|
62
|
+
"""Get tags directory info.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Dict with directory information
|
|
66
|
+
"""
|
|
67
|
+
total = self.library.session.query(Tag).count()
|
|
68
|
+
root_count = len(self.tag_service.get_root_tags())
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
"type": "virtual",
|
|
72
|
+
"name": "tags",
|
|
73
|
+
"total_tags": total,
|
|
74
|
+
"root_tags": root_count,
|
|
75
|
+
"path": self.get_path(),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TagNode(VirtualNode):
|
|
80
|
+
"""/tags/Work/ or /tags/Work/Project-2024/ - A tag directory.
|
|
81
|
+
|
|
82
|
+
Represents a tag in the hierarchy. Contains:
|
|
83
|
+
- Child tags (subdirectories)
|
|
84
|
+
- Books with this tag (symlinks to /books/ID)
|
|
85
|
+
- Metadata files (description, color, stats)
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
tag: Tag,
|
|
91
|
+
library: Library,
|
|
92
|
+
parent: Optional[DirectoryNode] = None,
|
|
93
|
+
):
|
|
94
|
+
"""Initialize tag node.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
tag: Tag database model
|
|
98
|
+
library: Library instance
|
|
99
|
+
parent: Parent node (TagsDirectoryNode or another TagNode)
|
|
100
|
+
"""
|
|
101
|
+
super().__init__(name=tag.name, parent=parent)
|
|
102
|
+
self.tag = tag
|
|
103
|
+
self.library = library
|
|
104
|
+
self.tag_service = TagService(library.session)
|
|
105
|
+
|
|
106
|
+
def list_children(self) -> List[Node]:
|
|
107
|
+
"""List child tags and books.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
List of TagNode instances (for child tags) and
|
|
111
|
+
SymlinkNode instances (for books)
|
|
112
|
+
"""
|
|
113
|
+
children = []
|
|
114
|
+
|
|
115
|
+
# Add child tags as subdirectories
|
|
116
|
+
child_tags = self.tag_service.get_children(self.tag)
|
|
117
|
+
for child_tag in child_tags:
|
|
118
|
+
node = TagNode(child_tag, self.library, parent=self)
|
|
119
|
+
children.append(node)
|
|
120
|
+
|
|
121
|
+
# Add books as symlinks to /books/ID
|
|
122
|
+
for book in self.tag.books:
|
|
123
|
+
target_path = f"/books/{book.id}"
|
|
124
|
+
name = str(book.id)
|
|
125
|
+
|
|
126
|
+
# Include book metadata for display
|
|
127
|
+
metadata = {
|
|
128
|
+
"title": book.title or "Untitled",
|
|
129
|
+
}
|
|
130
|
+
if book.authors:
|
|
131
|
+
metadata["author"] = ", ".join([a.name for a in book.authors])
|
|
132
|
+
|
|
133
|
+
symlink = SymlinkNode(name, target_path, parent=self, metadata=metadata)
|
|
134
|
+
children.append(symlink)
|
|
135
|
+
|
|
136
|
+
# Add metadata files (always show, even if empty - they're writable)
|
|
137
|
+
children.append(TagDescriptionFile(self.tag, self.library, parent=self))
|
|
138
|
+
children.append(TagColorFile(self.tag, self.library, parent=self))
|
|
139
|
+
children.append(TagStatsFile(self.tag, self.tag_service, parent=self))
|
|
140
|
+
|
|
141
|
+
return children
|
|
142
|
+
|
|
143
|
+
def get_child(self, name: str) -> Optional[Node]:
|
|
144
|
+
"""Get a child tag or book by name.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
name: Child tag name, book ID, or metadata file name
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Node or None
|
|
151
|
+
"""
|
|
152
|
+
# Check for metadata files first
|
|
153
|
+
if name == "description":
|
|
154
|
+
return TagDescriptionFile(self.tag, self.library, parent=self)
|
|
155
|
+
|
|
156
|
+
if name == "color":
|
|
157
|
+
return TagColorFile(self.tag, self.library, parent=self)
|
|
158
|
+
|
|
159
|
+
if name == "stats":
|
|
160
|
+
return TagStatsFile(self.tag, self.tag_service, parent=self)
|
|
161
|
+
|
|
162
|
+
# Try to find child tag by name
|
|
163
|
+
child_tags = self.tag_service.get_children(self.tag)
|
|
164
|
+
for child_tag in child_tags:
|
|
165
|
+
if child_tag.name == name:
|
|
166
|
+
return TagNode(child_tag, self.library, parent=self)
|
|
167
|
+
|
|
168
|
+
# Try to find book by ID
|
|
169
|
+
try:
|
|
170
|
+
book_id = int(name)
|
|
171
|
+
except ValueError:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
# Check if this book has this tag
|
|
175
|
+
for book in self.tag.books:
|
|
176
|
+
if book.id == book_id:
|
|
177
|
+
target_path = f"/books/{book.id}"
|
|
178
|
+
|
|
179
|
+
# Include book metadata for display
|
|
180
|
+
metadata = {
|
|
181
|
+
"title": book.title or "Untitled",
|
|
182
|
+
}
|
|
183
|
+
if book.authors:
|
|
184
|
+
metadata["author"] = ", ".join([a.name for a in book.authors])
|
|
185
|
+
|
|
186
|
+
return SymlinkNode(name, target_path, parent=self, metadata=metadata)
|
|
187
|
+
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def get_info(self) -> Dict[str, Any]:
|
|
191
|
+
"""Get tag node info.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Dict with tag information
|
|
195
|
+
"""
|
|
196
|
+
child_tags = self.tag_service.get_children(self.tag)
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
"type": "virtual",
|
|
200
|
+
"name": self.name,
|
|
201
|
+
"path": self.tag.path,
|
|
202
|
+
"full_path": self.get_path(),
|
|
203
|
+
"depth": self.tag.depth,
|
|
204
|
+
"book_count": len(self.tag.books),
|
|
205
|
+
"child_tags": len(child_tags),
|
|
206
|
+
"description": self.tag.description,
|
|
207
|
+
"color": self.tag.color,
|
|
208
|
+
"created_at": self.tag.created_at.isoformat() if self.tag.created_at else None,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class TagDescriptionFile(FileNode):
|
|
213
|
+
"""Metadata file showing tag description."""
|
|
214
|
+
|
|
215
|
+
def __init__(self, tag: Tag, library: Library, parent: Optional[DirectoryNode] = None):
|
|
216
|
+
"""Initialize tag description file.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
tag: Tag database model
|
|
220
|
+
library: Library instance for database access
|
|
221
|
+
parent: Parent TagNode
|
|
222
|
+
"""
|
|
223
|
+
super().__init__(name="description", parent=parent)
|
|
224
|
+
self.tag = tag
|
|
225
|
+
self.library = library
|
|
226
|
+
|
|
227
|
+
def read_content(self) -> str:
|
|
228
|
+
"""Read tag description.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Tag description text
|
|
232
|
+
"""
|
|
233
|
+
return self.tag.description or ""
|
|
234
|
+
|
|
235
|
+
def write_content(self, content: str) -> None:
|
|
236
|
+
"""Write tag description.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
content: New description text
|
|
240
|
+
"""
|
|
241
|
+
self.tag.description = content.strip()
|
|
242
|
+
self.library.session.commit()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class TagColorFile(FileNode):
|
|
246
|
+
"""Metadata file showing tag color."""
|
|
247
|
+
|
|
248
|
+
def __init__(self, tag: Tag, library: Library, parent: Optional[DirectoryNode] = None):
|
|
249
|
+
"""Initialize tag color file.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
tag: Tag database model
|
|
253
|
+
library: Library instance for database access
|
|
254
|
+
parent: Parent TagNode
|
|
255
|
+
"""
|
|
256
|
+
super().__init__(name="color", parent=parent)
|
|
257
|
+
self.tag = tag
|
|
258
|
+
self.library = library
|
|
259
|
+
|
|
260
|
+
def read_content(self) -> str:
|
|
261
|
+
"""Read tag color.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Hex color code
|
|
265
|
+
"""
|
|
266
|
+
return self.tag.color or ""
|
|
267
|
+
|
|
268
|
+
def write_content(self, content: str) -> None:
|
|
269
|
+
"""Write tag color.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
content: Hex color code (e.g., "#FF5733" or "FF5733") or named color
|
|
273
|
+
"""
|
|
274
|
+
import re
|
|
275
|
+
|
|
276
|
+
color = content.strip()
|
|
277
|
+
|
|
278
|
+
if not color:
|
|
279
|
+
# Empty string clears the color
|
|
280
|
+
self.tag.color = None
|
|
281
|
+
self.library.session.commit()
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
# Support common named colors
|
|
285
|
+
named_colors = {
|
|
286
|
+
'red': '#FF0000',
|
|
287
|
+
'green': '#00FF00',
|
|
288
|
+
'blue': '#0000FF',
|
|
289
|
+
'yellow': '#FFFF00',
|
|
290
|
+
'orange': '#FFA500',
|
|
291
|
+
'purple': '#800080',
|
|
292
|
+
'pink': '#FFC0CB',
|
|
293
|
+
'cyan': '#00FFFF',
|
|
294
|
+
'magenta': '#FF00FF',
|
|
295
|
+
'lime': '#00FF00',
|
|
296
|
+
'navy': '#000080',
|
|
297
|
+
'teal': '#008080',
|
|
298
|
+
'gray': '#808080',
|
|
299
|
+
'grey': '#808080',
|
|
300
|
+
'black': '#000000',
|
|
301
|
+
'white': '#FFFFFF',
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
# Check if it's a named color first
|
|
305
|
+
color_lower = color.lower()
|
|
306
|
+
if color_lower in named_colors:
|
|
307
|
+
color = named_colors[color_lower]
|
|
308
|
+
else:
|
|
309
|
+
# Add # prefix if not present for hex codes
|
|
310
|
+
if not color.startswith('#'):
|
|
311
|
+
color = '#' + color
|
|
312
|
+
|
|
313
|
+
# Validate hex color format (#RGB or #RRGGBB)
|
|
314
|
+
hex_pattern = r'^#[0-9A-Fa-f]{3}$|^#[0-9A-Fa-f]{6}$'
|
|
315
|
+
if not re.match(hex_pattern, color):
|
|
316
|
+
raise ValueError(
|
|
317
|
+
f"Invalid color format: '{content}'. "
|
|
318
|
+
f"Use hex codes (#FF5733 or #F73) or named colors "
|
|
319
|
+
f"({', '.join(sorted(named_colors.keys()))})"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
self.tag.color = color
|
|
323
|
+
self.library.session.commit()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class TagStatsFile(FileNode):
|
|
327
|
+
"""Metadata file showing tag statistics."""
|
|
328
|
+
|
|
329
|
+
def __init__(
|
|
330
|
+
self,
|
|
331
|
+
tag: Tag,
|
|
332
|
+
tag_service: TagService,
|
|
333
|
+
parent: Optional[DirectoryNode] = None
|
|
334
|
+
):
|
|
335
|
+
"""Initialize tag stats file.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
tag: Tag database model
|
|
339
|
+
tag_service: TagService instance
|
|
340
|
+
parent: Parent TagNode
|
|
341
|
+
"""
|
|
342
|
+
super().__init__(name="stats", parent=parent)
|
|
343
|
+
self.tag = tag
|
|
344
|
+
self.tag_service = tag_service
|
|
345
|
+
|
|
346
|
+
def read_content(self) -> str:
|
|
347
|
+
"""Read tag statistics.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Formatted statistics text
|
|
351
|
+
"""
|
|
352
|
+
stats = self.tag_service.get_tag_stats(self.tag.path)
|
|
353
|
+
|
|
354
|
+
lines = [
|
|
355
|
+
f"Tag: {self.tag.path}",
|
|
356
|
+
f"Name: {self.tag.name}",
|
|
357
|
+
f"Depth: {stats.get('depth', 0)}",
|
|
358
|
+
f"Books: {stats.get('book_count', 0)}",
|
|
359
|
+
f"Subtags: {stats.get('subtag_count', 0)}",
|
|
360
|
+
]
|
|
361
|
+
|
|
362
|
+
if self.tag.description:
|
|
363
|
+
lines.append(f"Description: {self.tag.description}")
|
|
364
|
+
|
|
365
|
+
if self.tag.color:
|
|
366
|
+
lines.append(f"Color: {self.tag.color}")
|
|
367
|
+
|
|
368
|
+
if stats.get('created_at'):
|
|
369
|
+
lines.append(f"Created: {stats['created_at']}")
|
|
370
|
+
|
|
371
|
+
return "\n".join(lines)
|