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,347 @@
1
+ """
2
+ Personal metadata service for managing user-specific book data.
3
+
4
+ Handles ratings, favorites, reading status, progress tracking, and personal tags.
5
+ """
6
+
7
+ from typing import List, Optional, Dict, Any
8
+ from datetime import datetime
9
+ import logging
10
+
11
+ from sqlalchemy.orm import Session
12
+
13
+ from ..db.models import Book, PersonalMetadata
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class PersonalMetadataService:
19
+ """Service for managing personal metadata for books."""
20
+
21
+ def __init__(self, session: Session):
22
+ """
23
+ Initialize the personal metadata service.
24
+
25
+ Args:
26
+ session: SQLAlchemy database session
27
+ """
28
+ self.session = session
29
+
30
+ def get(self, book_id: int) -> Optional[PersonalMetadata]:
31
+ """
32
+ Get personal metadata for a book.
33
+
34
+ Args:
35
+ book_id: Book ID
36
+
37
+ Returns:
38
+ PersonalMetadata instance or None if not found
39
+ """
40
+ return self.session.query(PersonalMetadata).filter_by(
41
+ book_id=book_id
42
+ ).first()
43
+
44
+ def get_or_create(self, book_id: int) -> PersonalMetadata:
45
+ """
46
+ Get or create personal metadata for a book.
47
+
48
+ Args:
49
+ book_id: Book ID
50
+
51
+ Returns:
52
+ PersonalMetadata instance (created if didn't exist)
53
+ """
54
+ personal = self.get(book_id)
55
+ if not personal:
56
+ personal = PersonalMetadata(book_id=book_id)
57
+ self.session.add(personal)
58
+ self.session.flush()
59
+ return personal
60
+
61
+ def set_rating(self, book_id: int, rating: Optional[float]) -> PersonalMetadata:
62
+ """
63
+ Set rating for a book.
64
+
65
+ Args:
66
+ book_id: Book ID
67
+ rating: Rating value (0-5, or None to clear)
68
+
69
+ Returns:
70
+ Updated PersonalMetadata instance
71
+ """
72
+ if rating is not None and (rating < 0 or rating > 5):
73
+ raise ValueError("Rating must be between 0 and 5")
74
+
75
+ personal = self.get_or_create(book_id)
76
+ personal.rating = rating
77
+ self.session.commit()
78
+ logger.debug(f"Set rating for book {book_id}: {rating}")
79
+ return personal
80
+
81
+ def set_favorite(self, book_id: int, is_favorite: bool = True) -> PersonalMetadata:
82
+ """
83
+ Mark or unmark a book as favorite.
84
+
85
+ Args:
86
+ book_id: Book ID
87
+ is_favorite: True to mark as favorite, False to unmark
88
+
89
+ Returns:
90
+ Updated PersonalMetadata instance
91
+ """
92
+ personal = self.get_or_create(book_id)
93
+ personal.favorite = is_favorite
94
+ self.session.commit()
95
+ logger.debug(f"Set favorite for book {book_id}: {is_favorite}")
96
+ return personal
97
+
98
+ def set_reading_status(
99
+ self,
100
+ book_id: int,
101
+ status: str,
102
+ progress: Optional[int] = None,
103
+ ) -> PersonalMetadata:
104
+ """
105
+ Set reading status for a book.
106
+
107
+ Args:
108
+ book_id: Book ID
109
+ status: Reading status (unread, reading, read, abandoned)
110
+ progress: Optional reading progress (0-100)
111
+
112
+ Returns:
113
+ Updated PersonalMetadata instance
114
+ """
115
+ valid_statuses = {'unread', 'reading', 'read', 'abandoned'}
116
+ if status not in valid_statuses:
117
+ raise ValueError(f"Status must be one of: {valid_statuses}")
118
+
119
+ if progress is not None and (progress < 0 or progress > 100):
120
+ raise ValueError("Progress must be between 0 and 100")
121
+
122
+ personal = self.get_or_create(book_id)
123
+ personal.reading_status = status
124
+
125
+ if progress is not None:
126
+ personal.reading_progress = progress
127
+
128
+ # Update dates based on status
129
+ if status == 'reading' and not personal.date_started:
130
+ personal.date_started = datetime.now()
131
+ elif status == 'read':
132
+ personal.date_finished = datetime.now()
133
+ personal.reading_progress = 100
134
+
135
+ self.session.commit()
136
+ logger.debug(f"Set reading status for book {book_id}: {status}")
137
+ return personal
138
+
139
+ def update_progress(self, book_id: int, progress: int) -> PersonalMetadata:
140
+ """
141
+ Update reading progress for a book.
142
+
143
+ Args:
144
+ book_id: Book ID
145
+ progress: Reading progress percentage (0-100)
146
+
147
+ Returns:
148
+ Updated PersonalMetadata instance
149
+ """
150
+ if progress < 0 or progress > 100:
151
+ raise ValueError("Progress must be between 0 and 100")
152
+
153
+ personal = self.get_or_create(book_id)
154
+ personal.reading_progress = progress
155
+
156
+ # Auto-update status based on progress
157
+ if progress > 0 and personal.reading_status == 'unread':
158
+ personal.reading_status = 'reading'
159
+ if not personal.date_started:
160
+ personal.date_started = datetime.now()
161
+ elif progress == 100:
162
+ personal.reading_status = 'read'
163
+ personal.date_finished = datetime.now()
164
+
165
+ self.session.commit()
166
+ logger.debug(f"Updated progress for book {book_id}: {progress}%")
167
+ return personal
168
+
169
+ def set_owned(self, book_id: int, owned: bool = True) -> PersonalMetadata:
170
+ """
171
+ Set whether a book is owned.
172
+
173
+ Args:
174
+ book_id: Book ID
175
+ owned: True if owned, False if borrowed/library
176
+
177
+ Returns:
178
+ Updated PersonalMetadata instance
179
+ """
180
+ personal = self.get_or_create(book_id)
181
+ personal.owned = owned
182
+ self.session.commit()
183
+ logger.debug(f"Set owned for book {book_id}: {owned}")
184
+ return personal
185
+
186
+ def add_personal_tags(self, book_id: int, tags: List[str]) -> PersonalMetadata:
187
+ """
188
+ Add personal tags to a book.
189
+
190
+ Args:
191
+ book_id: Book ID
192
+ tags: List of tag strings to add
193
+
194
+ Returns:
195
+ Updated PersonalMetadata instance
196
+ """
197
+ personal = self.get_or_create(book_id)
198
+ existing_tags = personal.personal_tags or []
199
+
200
+ # Add new tags (avoiding duplicates)
201
+ for tag in tags:
202
+ if tag and tag not in existing_tags:
203
+ existing_tags.append(tag)
204
+
205
+ personal.personal_tags = existing_tags
206
+ self.session.commit()
207
+ logger.debug(f"Added personal tags to book {book_id}: {tags}")
208
+ return personal
209
+
210
+ def remove_personal_tags(self, book_id: int, tags: List[str]) -> PersonalMetadata:
211
+ """
212
+ Remove personal tags from a book.
213
+
214
+ Args:
215
+ book_id: Book ID
216
+ tags: List of tag strings to remove
217
+
218
+ Returns:
219
+ Updated PersonalMetadata instance
220
+ """
221
+ personal = self.get(book_id)
222
+ if not personal or not personal.personal_tags:
223
+ return personal
224
+
225
+ personal.personal_tags = [t for t in personal.personal_tags if t not in tags]
226
+ self.session.commit()
227
+ logger.debug(f"Removed personal tags from book {book_id}: {tags}")
228
+ return personal
229
+
230
+ def get_favorites(self) -> List[Book]:
231
+ """
232
+ Get all favorite books.
233
+
234
+ Returns:
235
+ List of favorite books
236
+ """
237
+ return self.session.query(Book).join(Book.personal).filter(
238
+ PersonalMetadata.favorite == True
239
+ ).all()
240
+
241
+ def get_by_status(self, status: str) -> List[Book]:
242
+ """
243
+ Get books by reading status.
244
+
245
+ Args:
246
+ status: Reading status to filter by
247
+
248
+ Returns:
249
+ List of books with the specified status
250
+ """
251
+ return self.session.query(Book).join(Book.personal).filter(
252
+ PersonalMetadata.reading_status == status
253
+ ).all()
254
+
255
+ def get_by_rating(self, min_rating: float, max_rating: float = 5.0) -> List[Book]:
256
+ """
257
+ Get books within a rating range.
258
+
259
+ Args:
260
+ min_rating: Minimum rating (inclusive)
261
+ max_rating: Maximum rating (inclusive)
262
+
263
+ Returns:
264
+ List of books within the rating range
265
+ """
266
+ return self.session.query(Book).join(Book.personal).filter(
267
+ PersonalMetadata.rating >= min_rating,
268
+ PersonalMetadata.rating <= max_rating
269
+ ).order_by(PersonalMetadata.rating.desc()).all()
270
+
271
+ def get_stats(self) -> Dict[str, Any]:
272
+ """
273
+ Get personal metadata statistics.
274
+
275
+ Returns:
276
+ Dictionary with statistics about personal metadata
277
+ """
278
+ from sqlalchemy import func
279
+
280
+ stats = {
281
+ "total_with_metadata": self.session.query(PersonalMetadata).count(),
282
+ "favorites_count": self.session.query(PersonalMetadata).filter(
283
+ PersonalMetadata.favorite == True
284
+ ).count(),
285
+ "by_status": {},
286
+ "by_rating": {},
287
+ "in_queue": self.session.query(PersonalMetadata).filter(
288
+ PersonalMetadata.queue_position.isnot(None)
289
+ ).count(),
290
+ }
291
+
292
+ # Count by status
293
+ status_counts = self.session.query(
294
+ PersonalMetadata.reading_status,
295
+ func.count(PersonalMetadata.id)
296
+ ).group_by(PersonalMetadata.reading_status).all()
297
+
298
+ for status, count in status_counts:
299
+ if status:
300
+ stats["by_status"][status] = count
301
+
302
+ # Count by rating
303
+ rating_counts = self.session.query(
304
+ func.round(PersonalMetadata.rating),
305
+ func.count(PersonalMetadata.id)
306
+ ).filter(
307
+ PersonalMetadata.rating.isnot(None)
308
+ ).group_by(func.round(PersonalMetadata.rating)).all()
309
+
310
+ for rating, count in rating_counts:
311
+ if rating is not None:
312
+ stats["by_rating"][int(rating)] = count
313
+
314
+ # Average rating
315
+ avg_rating = self.session.query(
316
+ func.avg(PersonalMetadata.rating)
317
+ ).filter(PersonalMetadata.rating.isnot(None)).scalar()
318
+ stats["average_rating"] = round(avg_rating, 2) if avg_rating else None
319
+
320
+ return stats
321
+
322
+ def to_dict(self, book_id: int) -> Optional[Dict[str, Any]]:
323
+ """
324
+ Get personal metadata as a dictionary.
325
+
326
+ Args:
327
+ book_id: Book ID
328
+
329
+ Returns:
330
+ Dictionary representation or None if no metadata
331
+ """
332
+ personal = self.get(book_id)
333
+ if not personal:
334
+ return None
335
+
336
+ return {
337
+ "rating": personal.rating,
338
+ "is_favorite": personal.favorite,
339
+ "reading_status": personal.reading_status,
340
+ "reading_progress": personal.reading_progress,
341
+ "owned": personal.owned,
342
+ "queue_position": personal.queue_position,
343
+ "personal_tags": personal.personal_tags or [],
344
+ "date_added": personal.date_added.isoformat() if personal.date_added else None,
345
+ "date_started": personal.date_started.isoformat() if personal.date_started else None,
346
+ "date_finished": personal.date_finished.isoformat() if personal.date_finished else None,
347
+ }
@@ -0,0 +1,253 @@
1
+ """
2
+ Reading queue service for managing the reading queue.
3
+
4
+ Provides operations for adding, removing, reordering, and querying
5
+ books in the reading queue.
6
+ """
7
+
8
+ from typing import List, Optional
9
+ import logging
10
+
11
+ from sqlalchemy import func
12
+ from sqlalchemy.orm import Session
13
+
14
+ from ..db.models import Book, PersonalMetadata
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ReadingQueueService:
20
+ """Service for managing the reading queue."""
21
+
22
+ def __init__(self, session: Session):
23
+ """
24
+ Initialize the reading queue service.
25
+
26
+ Args:
27
+ session: SQLAlchemy database session
28
+ """
29
+ self.session = session
30
+
31
+ def get_queue(self) -> List[Book]:
32
+ """
33
+ Get all books in the reading queue, ordered by position.
34
+
35
+ Returns:
36
+ List of books in queue order
37
+ """
38
+ return self.session.query(Book).join(Book.personal).filter(
39
+ PersonalMetadata.queue_position.isnot(None)
40
+ ).order_by(PersonalMetadata.queue_position).all()
41
+
42
+ def get_next(self) -> Optional[Book]:
43
+ """
44
+ Get the next book in the reading queue (position 1).
45
+
46
+ Returns:
47
+ The book at the top of the queue, or None if queue is empty
48
+ """
49
+ return self.session.query(Book).join(Book.personal).filter(
50
+ PersonalMetadata.queue_position == 1
51
+ ).first()
52
+
53
+ def get_position(self, book_id: int) -> Optional[int]:
54
+ """
55
+ Get the position of a book in the queue.
56
+
57
+ Args:
58
+ book_id: Book ID to check
59
+
60
+ Returns:
61
+ Queue position (1-based) or None if not in queue
62
+ """
63
+ personal = self.session.query(PersonalMetadata).filter_by(
64
+ book_id=book_id
65
+ ).first()
66
+
67
+ if personal:
68
+ return personal.queue_position
69
+ return None
70
+
71
+ def is_in_queue(self, book_id: int) -> bool:
72
+ """
73
+ Check if a book is in the queue.
74
+
75
+ Args:
76
+ book_id: Book ID to check
77
+
78
+ Returns:
79
+ True if book is in queue
80
+ """
81
+ return self.get_position(book_id) is not None
82
+
83
+ def add(self, book_id: int, position: Optional[int] = None) -> int:
84
+ """
85
+ Add a book to the reading queue.
86
+
87
+ Args:
88
+ book_id: Book ID to add
89
+ position: Position in queue (1-based). If None, adds to end.
90
+
91
+ Returns:
92
+ The position where the book was added
93
+ """
94
+ personal = self.session.query(PersonalMetadata).filter_by(
95
+ book_id=book_id
96
+ ).first()
97
+
98
+ if not personal:
99
+ personal = PersonalMetadata(book_id=book_id)
100
+ self.session.add(personal)
101
+ self.session.flush()
102
+
103
+ # If already in queue, just reorder
104
+ if personal.queue_position is not None:
105
+ if position is not None:
106
+ self.reorder(book_id, position)
107
+ return personal.queue_position
108
+
109
+ # Get current max position
110
+ max_pos = self.session.query(
111
+ func.max(PersonalMetadata.queue_position)
112
+ ).scalar() or 0
113
+
114
+ if position is None:
115
+ # Add to end
116
+ personal.queue_position = max_pos + 1
117
+ else:
118
+ # Insert at specific position, shift others down
119
+ position = max(1, position)
120
+ self.session.query(PersonalMetadata).filter(
121
+ PersonalMetadata.queue_position >= position,
122
+ PersonalMetadata.queue_position.isnot(None)
123
+ ).update({
124
+ PersonalMetadata.queue_position: PersonalMetadata.queue_position + 1
125
+ })
126
+ personal.queue_position = position
127
+
128
+ self.session.commit()
129
+ logger.info(f"Added book {book_id} to queue at position {personal.queue_position}")
130
+ return personal.queue_position
131
+
132
+ def remove(self, book_id: int) -> bool:
133
+ """
134
+ Remove a book from the reading queue.
135
+
136
+ Args:
137
+ book_id: Book ID to remove
138
+
139
+ Returns:
140
+ True if book was removed, False if it wasn't in queue
141
+ """
142
+ personal = self.session.query(PersonalMetadata).filter_by(
143
+ book_id=book_id
144
+ ).first()
145
+
146
+ if not personal or personal.queue_position is None:
147
+ return False
148
+
149
+ old_position = personal.queue_position
150
+ personal.queue_position = None
151
+
152
+ # Shift other items up to fill gap
153
+ self.session.query(PersonalMetadata).filter(
154
+ PersonalMetadata.queue_position > old_position
155
+ ).update({
156
+ PersonalMetadata.queue_position: PersonalMetadata.queue_position - 1
157
+ })
158
+
159
+ self.session.commit()
160
+ logger.info(f"Removed book {book_id} from queue")
161
+ return True
162
+
163
+ def reorder(self, book_id: int, new_position: int) -> bool:
164
+ """
165
+ Move a book to a new position in the queue.
166
+
167
+ Args:
168
+ book_id: Book ID to move
169
+ new_position: New position (1-based)
170
+
171
+ Returns:
172
+ True if book was reordered, False if it wasn't in queue
173
+ """
174
+ personal = self.session.query(PersonalMetadata).filter_by(
175
+ book_id=book_id
176
+ ).first()
177
+
178
+ if not personal or personal.queue_position is None:
179
+ # Not in queue, add it
180
+ self.add(book_id, new_position)
181
+ return True
182
+
183
+ old_position = personal.queue_position
184
+ new_position = max(1, new_position)
185
+
186
+ if old_position == new_position:
187
+ return True
188
+
189
+ if old_position < new_position:
190
+ # Moving down: shift items between old and new up
191
+ self.session.query(PersonalMetadata).filter(
192
+ PersonalMetadata.queue_position > old_position,
193
+ PersonalMetadata.queue_position <= new_position,
194
+ PersonalMetadata.queue_position.isnot(None)
195
+ ).update({
196
+ PersonalMetadata.queue_position: PersonalMetadata.queue_position - 1
197
+ })
198
+ else:
199
+ # Moving up: shift items between new and old down
200
+ self.session.query(PersonalMetadata).filter(
201
+ PersonalMetadata.queue_position >= new_position,
202
+ PersonalMetadata.queue_position < old_position,
203
+ PersonalMetadata.queue_position.isnot(None)
204
+ ).update({
205
+ PersonalMetadata.queue_position: PersonalMetadata.queue_position + 1
206
+ })
207
+
208
+ personal.queue_position = new_position
209
+ self.session.commit()
210
+ logger.info(f"Moved book {book_id} from position {old_position} to {new_position}")
211
+ return True
212
+
213
+ def clear(self) -> int:
214
+ """
215
+ Clear all books from the reading queue.
216
+
217
+ Returns:
218
+ Number of books removed from queue
219
+ """
220
+ count = self.session.query(PersonalMetadata).filter(
221
+ PersonalMetadata.queue_position.isnot(None)
222
+ ).count()
223
+
224
+ self.session.query(PersonalMetadata).filter(
225
+ PersonalMetadata.queue_position.isnot(None)
226
+ ).update({PersonalMetadata.queue_position: None})
227
+
228
+ self.session.commit()
229
+ logger.info(f"Cleared reading queue ({count} items)")
230
+ return count
231
+
232
+ def count(self) -> int:
233
+ """
234
+ Get the number of books in the queue.
235
+
236
+ Returns:
237
+ Number of books in queue
238
+ """
239
+ return self.session.query(PersonalMetadata).filter(
240
+ PersonalMetadata.queue_position.isnot(None)
241
+ ).count()
242
+
243
+ def pop_next(self) -> Optional[Book]:
244
+ """
245
+ Remove and return the next book from the queue.
246
+
247
+ Returns:
248
+ The book that was at position 1, or None if queue is empty
249
+ """
250
+ book = self.get_next()
251
+ if book:
252
+ self.remove(book.id)
253
+ return book