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.
Files changed (87) hide show
  1. ebk/__init__.py +35 -0
  2. ebk/ai/__init__.py +23 -0
  3. ebk/ai/knowledge_graph.py +450 -0
  4. ebk/ai/llm_providers/__init__.py +26 -0
  5. ebk/ai/llm_providers/anthropic.py +209 -0
  6. ebk/ai/llm_providers/base.py +295 -0
  7. ebk/ai/llm_providers/gemini.py +285 -0
  8. ebk/ai/llm_providers/ollama.py +294 -0
  9. ebk/ai/metadata_enrichment.py +394 -0
  10. ebk/ai/question_generator.py +328 -0
  11. ebk/ai/reading_companion.py +224 -0
  12. ebk/ai/semantic_search.py +433 -0
  13. ebk/ai/text_extractor.py +393 -0
  14. ebk/calibre_import.py +66 -0
  15. ebk/cli.py +6433 -0
  16. ebk/config.py +230 -0
  17. ebk/db/__init__.py +37 -0
  18. ebk/db/migrations.py +507 -0
  19. ebk/db/models.py +725 -0
  20. ebk/db/session.py +144 -0
  21. ebk/decorators.py +1 -0
  22. ebk/exports/__init__.py +0 -0
  23. ebk/exports/base_exporter.py +218 -0
  24. ebk/exports/echo_export.py +279 -0
  25. ebk/exports/html_library.py +1743 -0
  26. ebk/exports/html_utils.py +87 -0
  27. ebk/exports/hugo.py +59 -0
  28. ebk/exports/jinja_export.py +286 -0
  29. ebk/exports/multi_facet_export.py +159 -0
  30. ebk/exports/opds_export.py +232 -0
  31. ebk/exports/symlink_dag.py +479 -0
  32. ebk/exports/zip.py +25 -0
  33. ebk/extract_metadata.py +341 -0
  34. ebk/ident.py +89 -0
  35. ebk/library_db.py +1440 -0
  36. ebk/opds.py +748 -0
  37. ebk/plugins/__init__.py +42 -0
  38. ebk/plugins/base.py +502 -0
  39. ebk/plugins/hooks.py +442 -0
  40. ebk/plugins/registry.py +499 -0
  41. ebk/repl/__init__.py +9 -0
  42. ebk/repl/find.py +126 -0
  43. ebk/repl/grep.py +173 -0
  44. ebk/repl/shell.py +1677 -0
  45. ebk/repl/text_utils.py +320 -0
  46. ebk/search_parser.py +413 -0
  47. ebk/server.py +3608 -0
  48. ebk/services/__init__.py +28 -0
  49. ebk/services/annotation_extraction.py +351 -0
  50. ebk/services/annotation_service.py +380 -0
  51. ebk/services/export_service.py +577 -0
  52. ebk/services/import_service.py +447 -0
  53. ebk/services/personal_metadata_service.py +347 -0
  54. ebk/services/queue_service.py +253 -0
  55. ebk/services/tag_service.py +281 -0
  56. ebk/services/text_extraction.py +317 -0
  57. ebk/services/view_service.py +12 -0
  58. ebk/similarity/__init__.py +77 -0
  59. ebk/similarity/base.py +154 -0
  60. ebk/similarity/core.py +471 -0
  61. ebk/similarity/extractors.py +168 -0
  62. ebk/similarity/metrics.py +376 -0
  63. ebk/skills/SKILL.md +182 -0
  64. ebk/skills/__init__.py +1 -0
  65. ebk/vfs/__init__.py +101 -0
  66. ebk/vfs/base.py +298 -0
  67. ebk/vfs/library_vfs.py +122 -0
  68. ebk/vfs/nodes/__init__.py +54 -0
  69. ebk/vfs/nodes/authors.py +196 -0
  70. ebk/vfs/nodes/books.py +480 -0
  71. ebk/vfs/nodes/files.py +155 -0
  72. ebk/vfs/nodes/metadata.py +385 -0
  73. ebk/vfs/nodes/root.py +100 -0
  74. ebk/vfs/nodes/similar.py +165 -0
  75. ebk/vfs/nodes/subjects.py +184 -0
  76. ebk/vfs/nodes/tags.py +371 -0
  77. ebk/vfs/resolver.py +228 -0
  78. ebk/vfs_router.py +275 -0
  79. ebk/views/__init__.py +32 -0
  80. ebk/views/dsl.py +668 -0
  81. ebk/views/service.py +619 -0
  82. ebk-0.4.4.dist-info/METADATA +755 -0
  83. ebk-0.4.4.dist-info/RECORD +87 -0
  84. ebk-0.4.4.dist-info/WHEEL +5 -0
  85. ebk-0.4.4.dist-info/entry_points.txt +2 -0
  86. ebk-0.4.4.dist-info/licenses/LICENSE +21 -0
  87. ebk-0.4.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,380 @@
1
+ """
2
+ Annotation service for managing book annotations.
3
+
4
+ Provides CRUD operations for annotations (notes, highlights, bookmarks)
5
+ and extraction from ebook files.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import List, Optional, Dict, Any
11
+ from datetime import datetime
12
+ import logging
13
+
14
+ from sqlalchemy.orm import Session
15
+
16
+ from ..db.models import Book, Annotation
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class AnnotationService:
22
+ """Service for managing book annotations."""
23
+
24
+ def __init__(self, session: Session, library_path: Optional[Path] = None):
25
+ """
26
+ Initialize the annotation service.
27
+
28
+ Args:
29
+ session: SQLAlchemy database session
30
+ library_path: Path to the library root (needed for extraction)
31
+ """
32
+ self.session = session
33
+ self.library_path = Path(library_path) if library_path else None
34
+
35
+ def create(
36
+ self,
37
+ book_id: int,
38
+ content: str,
39
+ annotation_type: str = 'note',
40
+ page_number: Optional[int] = None,
41
+ position: Optional[Dict[str, Any]] = None,
42
+ color: Optional[str] = None,
43
+ ) -> Annotation:
44
+ """
45
+ Create a new annotation for a book.
46
+
47
+ Args:
48
+ book_id: Book ID
49
+ content: Annotation text content
50
+ annotation_type: Type (note, highlight, bookmark)
51
+ page_number: Optional page number
52
+ position: Optional position info
53
+ color: Optional color for highlights
54
+
55
+ Returns:
56
+ Created Annotation instance
57
+ """
58
+ annotation = Annotation(
59
+ book_id=book_id,
60
+ content=content,
61
+ annotation_type=annotation_type,
62
+ page_number=page_number,
63
+ position=position,
64
+ color=color,
65
+ created_at=datetime.now()
66
+ )
67
+ self.session.add(annotation)
68
+ self.session.commit()
69
+
70
+ logger.info(f"Created {annotation_type} annotation for book {book_id}")
71
+ return annotation
72
+
73
+ def get(self, annotation_id: int) -> Optional[Annotation]:
74
+ """
75
+ Get an annotation by ID.
76
+
77
+ Args:
78
+ annotation_id: Annotation ID
79
+
80
+ Returns:
81
+ Annotation instance or None
82
+ """
83
+ return self.session.query(Annotation).filter_by(id=annotation_id).first()
84
+
85
+ def list(
86
+ self,
87
+ book_id: int,
88
+ type_filter: Optional[str] = None,
89
+ page_filter: Optional[int] = None,
90
+ ) -> List[Annotation]:
91
+ """
92
+ List annotations for a book.
93
+
94
+ Args:
95
+ book_id: Book ID
96
+ type_filter: Optional annotation type filter
97
+ page_filter: Optional page number filter
98
+
99
+ Returns:
100
+ List of Annotation instances
101
+ """
102
+ query = self.session.query(Annotation).filter_by(book_id=book_id)
103
+
104
+ if type_filter:
105
+ query = query.filter_by(annotation_type=type_filter)
106
+
107
+ if page_filter is not None:
108
+ query = query.filter_by(page_number=page_filter)
109
+
110
+ return query.order_by(Annotation.page_number.asc().nulls_last(),
111
+ Annotation.created_at.desc()).all()
112
+
113
+ def update(
114
+ self,
115
+ annotation_id: int,
116
+ content: Optional[str] = None,
117
+ page_number: Optional[int] = None,
118
+ color: Optional[str] = None,
119
+ ) -> Optional[Annotation]:
120
+ """
121
+ Update an existing annotation.
122
+
123
+ Args:
124
+ annotation_id: Annotation ID
125
+ content: New content (if changing)
126
+ page_number: New page number (if changing)
127
+ color: New color (if changing)
128
+
129
+ Returns:
130
+ Updated Annotation or None if not found
131
+ """
132
+ annotation = self.get(annotation_id)
133
+ if not annotation:
134
+ return None
135
+
136
+ if content is not None:
137
+ annotation.content = content
138
+ if page_number is not None:
139
+ annotation.page_number = page_number
140
+ if color is not None:
141
+ annotation.color = color
142
+
143
+ self.session.commit()
144
+ logger.info(f"Updated annotation {annotation_id}")
145
+ return annotation
146
+
147
+ def delete(self, annotation_id: int) -> bool:
148
+ """
149
+ Delete an annotation.
150
+
151
+ Args:
152
+ annotation_id: Annotation ID
153
+
154
+ Returns:
155
+ True if deleted, False if not found
156
+ """
157
+ annotation = self.get(annotation_id)
158
+ if not annotation:
159
+ return False
160
+
161
+ self.session.delete(annotation)
162
+ self.session.commit()
163
+ logger.info(f"Deleted annotation {annotation_id}")
164
+ return True
165
+
166
+ def delete_all_for_book(self, book_id: int) -> int:
167
+ """
168
+ Delete all annotations for a book.
169
+
170
+ Args:
171
+ book_id: Book ID
172
+
173
+ Returns:
174
+ Number of annotations deleted
175
+ """
176
+ count = self.session.query(Annotation).filter_by(book_id=book_id).count()
177
+ self.session.query(Annotation).filter_by(book_id=book_id).delete()
178
+ self.session.commit()
179
+ logger.info(f"Deleted {count} annotations for book {book_id}")
180
+ return count
181
+
182
+ def extract_from_file(
183
+ self,
184
+ book_id: int,
185
+ file_format: Optional[str] = None,
186
+ ) -> int:
187
+ """
188
+ Extract annotations from book files and save to database.
189
+
190
+ Args:
191
+ book_id: Book ID
192
+ file_format: Optional specific format to extract from
193
+
194
+ Returns:
195
+ Number of annotations extracted and saved
196
+ """
197
+ if not self.library_path:
198
+ raise ValueError("library_path required for annotation extraction")
199
+
200
+ from .annotation_extraction import extract_annotations_from_book
201
+
202
+ book = self.session.query(Book).filter_by(id=book_id).first()
203
+ if not book:
204
+ logger.error(f"Book {book_id} not found")
205
+ return 0
206
+
207
+ annotations = extract_annotations_from_book(book, self.library_path, file_format)
208
+ total_saved = 0
209
+
210
+ for annot in annotations:
211
+ # Skip duplicates (same content, same page, same type)
212
+ existing = self.session.query(Annotation).filter_by(
213
+ book_id=book_id,
214
+ content=annot.content,
215
+ page_number=annot.page_number,
216
+ annotation_type=annot.annotation_type
217
+ ).first()
218
+
219
+ if existing:
220
+ continue
221
+
222
+ self.create(
223
+ book_id=book_id,
224
+ content=annot.content,
225
+ annotation_type=annot.annotation_type,
226
+ page_number=annot.page_number,
227
+ position=annot.position,
228
+ color=annot.color
229
+ )
230
+ total_saved += 1
231
+
232
+ return total_saved
233
+
234
+ def export(
235
+ self,
236
+ book_id: int,
237
+ format_type: str = 'json',
238
+ type_filter: Optional[str] = None,
239
+ ) -> str:
240
+ """
241
+ Export annotations for a book in a specific format.
242
+
243
+ Args:
244
+ book_id: Book ID
245
+ format_type: Export format (json, markdown, txt)
246
+ type_filter: Optional annotation type filter
247
+
248
+ Returns:
249
+ Formatted string of annotations
250
+ """
251
+ annotations = self.list(book_id, type_filter=type_filter)
252
+ book = self.session.query(Book).filter_by(id=book_id).first()
253
+
254
+ if format_type == 'json':
255
+ return self._export_json(book, annotations)
256
+ elif format_type == 'markdown':
257
+ return self._export_markdown(book, annotations)
258
+ else: # txt
259
+ return self._export_txt(book, annotations)
260
+
261
+ def _export_json(self, book: Book, annotations: List[Annotation]) -> str:
262
+ """Export annotations as JSON."""
263
+ data = {
264
+ "book_id": book.id if book else None,
265
+ "book_title": book.title if book else "Unknown",
266
+ "exported_at": datetime.now().isoformat(),
267
+ "annotations": [
268
+ {
269
+ "id": a.id,
270
+ "type": a.annotation_type,
271
+ "content": a.content,
272
+ "page": a.page_number,
273
+ "color": a.color,
274
+ "created_at": a.created_at.isoformat() if a.created_at else None,
275
+ }
276
+ for a in annotations
277
+ ]
278
+ }
279
+ return json.dumps(data, indent=2, ensure_ascii=False)
280
+
281
+ def _export_markdown(self, book: Book, annotations: List[Annotation]) -> str:
282
+ """Export annotations as Markdown."""
283
+ lines = []
284
+ title = book.title if book else "Unknown Book"
285
+ lines.append(f"# Annotations: {title}\n")
286
+ lines.append(f"*Exported: {datetime.now().strftime('%Y-%m-%d %H:%M')}*\n")
287
+
288
+ # Group by type
289
+ by_type: Dict[str, List[Annotation]] = {}
290
+ for a in annotations:
291
+ t = a.annotation_type or 'note'
292
+ if t not in by_type:
293
+ by_type[t] = []
294
+ by_type[t].append(a)
295
+
296
+ for ann_type, items in by_type.items():
297
+ lines.append(f"\n## {ann_type.title()}s\n")
298
+
299
+ for a in items:
300
+ page_info = f" (p. {a.page_number})" if a.page_number else ""
301
+ lines.append(f"- **{page_info}**: {a.content}")
302
+
303
+ return "\n".join(lines)
304
+
305
+ def _export_txt(self, book: Book, annotations: List[Annotation]) -> str:
306
+ """Export annotations as plain text."""
307
+ lines = []
308
+ title = book.title if book else "Unknown Book"
309
+ lines.append(f"Annotations for: {title}")
310
+ lines.append(f"Exported: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
311
+ lines.append("-" * 40)
312
+
313
+ for a in annotations:
314
+ type_info = f"[{a.annotation_type}]" if a.annotation_type else "[note]"
315
+ page_info = f" Page {a.page_number}" if a.page_number else ""
316
+ lines.append(f"\n{type_info}{page_info}")
317
+ lines.append(a.content)
318
+
319
+ return "\n".join(lines)
320
+
321
+ def count(self, book_id: Optional[int] = None) -> int:
322
+ """
323
+ Count annotations.
324
+
325
+ Args:
326
+ book_id: Optional book ID to filter by
327
+
328
+ Returns:
329
+ Number of annotations
330
+ """
331
+ query = self.session.query(Annotation)
332
+ if book_id is not None:
333
+ query = query.filter_by(book_id=book_id)
334
+ return query.count()
335
+
336
+ def count_by_type(self, book_id: Optional[int] = None) -> Dict[str, int]:
337
+ """
338
+ Count annotations grouped by type.
339
+
340
+ Args:
341
+ book_id: Optional book ID to filter by
342
+
343
+ Returns:
344
+ Dictionary mapping annotation type to count
345
+ """
346
+ from sqlalchemy import func
347
+
348
+ query = self.session.query(
349
+ Annotation.annotation_type,
350
+ func.count(Annotation.id)
351
+ )
352
+
353
+ if book_id is not None:
354
+ query = query.filter_by(book_id=book_id)
355
+
356
+ query = query.group_by(Annotation.annotation_type)
357
+ result = query.all()
358
+
359
+ return {t or 'note': c for t, c in result}
360
+
361
+ def to_dict(self, annotation: Annotation) -> Dict[str, Any]:
362
+ """
363
+ Convert an annotation to a dictionary.
364
+
365
+ Args:
366
+ annotation: Annotation instance
367
+
368
+ Returns:
369
+ Dictionary representation
370
+ """
371
+ return {
372
+ "id": annotation.id,
373
+ "book_id": annotation.book_id,
374
+ "type": annotation.annotation_type,
375
+ "content": annotation.content,
376
+ "page": annotation.page_number,
377
+ "position": annotation.position,
378
+ "color": annotation.color,
379
+ "created_at": annotation.created_at.isoformat() if annotation.created_at else None,
380
+ }