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
|
@@ -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
|
+
}
|