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,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
|