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.

Files changed (84) hide show
  1. ebk/__init__.py +35 -0
  2. ebk/ai/__init__.py +23 -0
  3. ebk/ai/knowledge_graph.py +443 -0
  4. ebk/ai/llm_providers/__init__.py +21 -0
  5. ebk/ai/llm_providers/base.py +230 -0
  6. ebk/ai/llm_providers/ollama.py +362 -0
  7. ebk/ai/metadata_enrichment.py +396 -0
  8. ebk/ai/question_generator.py +328 -0
  9. ebk/ai/reading_companion.py +224 -0
  10. ebk/ai/semantic_search.py +434 -0
  11. ebk/ai/text_extractor.py +394 -0
  12. ebk/cli.py +2828 -680
  13. ebk/config.py +260 -22
  14. ebk/db/__init__.py +37 -0
  15. ebk/db/migrations.py +180 -0
  16. ebk/db/models.py +526 -0
  17. ebk/db/session.py +144 -0
  18. ebk/decorators.py +132 -0
  19. ebk/exports/base_exporter.py +218 -0
  20. ebk/exports/html_library.py +1390 -0
  21. ebk/exports/html_utils.py +117 -0
  22. ebk/exports/hugo.py +7 -3
  23. ebk/exports/jinja_export.py +287 -0
  24. ebk/exports/multi_facet_export.py +164 -0
  25. ebk/exports/symlink_dag.py +479 -0
  26. ebk/extract_metadata.py +76 -7
  27. ebk/library_db.py +899 -0
  28. ebk/plugins/__init__.py +42 -0
  29. ebk/plugins/base.py +502 -0
  30. ebk/plugins/hooks.py +444 -0
  31. ebk/plugins/registry.py +500 -0
  32. ebk/repl/__init__.py +9 -0
  33. ebk/repl/find.py +126 -0
  34. ebk/repl/grep.py +174 -0
  35. ebk/repl/shell.py +1677 -0
  36. ebk/repl/text_utils.py +320 -0
  37. ebk/search_parser.py +413 -0
  38. ebk/server.py +1633 -0
  39. ebk/services/__init__.py +11 -0
  40. ebk/services/import_service.py +442 -0
  41. ebk/services/tag_service.py +282 -0
  42. ebk/services/text_extraction.py +317 -0
  43. ebk/similarity/__init__.py +77 -0
  44. ebk/similarity/base.py +154 -0
  45. ebk/similarity/core.py +445 -0
  46. ebk/similarity/extractors.py +168 -0
  47. ebk/similarity/metrics.py +376 -0
  48. ebk/vfs/__init__.py +101 -0
  49. ebk/vfs/base.py +301 -0
  50. ebk/vfs/library_vfs.py +124 -0
  51. ebk/vfs/nodes/__init__.py +54 -0
  52. ebk/vfs/nodes/authors.py +196 -0
  53. ebk/vfs/nodes/books.py +480 -0
  54. ebk/vfs/nodes/files.py +155 -0
  55. ebk/vfs/nodes/metadata.py +385 -0
  56. ebk/vfs/nodes/root.py +100 -0
  57. ebk/vfs/nodes/similar.py +165 -0
  58. ebk/vfs/nodes/subjects.py +184 -0
  59. ebk/vfs/nodes/tags.py +371 -0
  60. ebk/vfs/resolver.py +228 -0
  61. ebk-0.3.2.dist-info/METADATA +755 -0
  62. ebk-0.3.2.dist-info/RECORD +69 -0
  63. {ebk-0.1.0.dist-info → ebk-0.3.2.dist-info}/WHEEL +1 -1
  64. ebk-0.3.2.dist-info/licenses/LICENSE +21 -0
  65. ebk/imports/__init__.py +0 -0
  66. ebk/imports/calibre.py +0 -144
  67. ebk/imports/ebooks.py +0 -116
  68. ebk/llm.py +0 -58
  69. ebk/manager.py +0 -44
  70. ebk/merge.py +0 -308
  71. ebk/streamlit/__init__.py +0 -0
  72. ebk/streamlit/__pycache__/__init__.cpython-310.pyc +0 -0
  73. ebk/streamlit/__pycache__/display.cpython-310.pyc +0 -0
  74. ebk/streamlit/__pycache__/filters.cpython-310.pyc +0 -0
  75. ebk/streamlit/__pycache__/utils.cpython-310.pyc +0 -0
  76. ebk/streamlit/app.py +0 -185
  77. ebk/streamlit/display.py +0 -168
  78. ebk/streamlit/filters.py +0 -151
  79. ebk/streamlit/utils.py +0 -58
  80. ebk/utils.py +0 -311
  81. ebk-0.1.0.dist-info/METADATA +0 -457
  82. ebk-0.1.0.dist-info/RECORD +0 -29
  83. {ebk-0.1.0.dist-info → ebk-0.3.2.dist-info}/entry_points.txt +0 -0
  84. {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)