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
ebk/views/dsl.py
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Views DSL Evaluator.
|
|
3
|
+
|
|
4
|
+
Implements a composable DSL for selecting, transforming, and ordering books.
|
|
5
|
+
The DSL follows SICP principles: primitives, combination, abstraction, closure.
|
|
6
|
+
|
|
7
|
+
Grammar:
|
|
8
|
+
view := {select?: selector, transform?: transform, order?: ordering}
|
|
9
|
+
|
|
10
|
+
selector := 'all' | 'none'
|
|
11
|
+
| {filter: predicate}
|
|
12
|
+
| {ids: [int, ...]}
|
|
13
|
+
| {view: string}
|
|
14
|
+
| {union: [selector, ...]}
|
|
15
|
+
| {intersect: [selector, ...]}
|
|
16
|
+
| {difference: [selector, selector]}
|
|
17
|
+
|
|
18
|
+
predicate := {field: value}
|
|
19
|
+
| {field: {op: value}}
|
|
20
|
+
| {and: [predicate, ...]}
|
|
21
|
+
| {or: [predicate, ...]}
|
|
22
|
+
| {not: predicate}
|
|
23
|
+
|
|
24
|
+
transform := 'identity'
|
|
25
|
+
| {override: {book_id: {field: value, ...}, ...}}
|
|
26
|
+
| {compose: [transform, ...]}
|
|
27
|
+
|
|
28
|
+
ordering := {by: field}
|
|
29
|
+
| {by: field, desc: bool}
|
|
30
|
+
| {custom: [int, ...]}
|
|
31
|
+
| {then: [ordering, ...]}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Any, Dict, List, Optional, Set, Union
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
import logging
|
|
38
|
+
|
|
39
|
+
from sqlalchemy.orm import Session
|
|
40
|
+
from sqlalchemy import and_, or_
|
|
41
|
+
|
|
42
|
+
from ..db.models import Book, Author, Subject, Tag, File, PersonalMetadata, View
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class TransformedBook:
|
|
49
|
+
"""
|
|
50
|
+
A book with optional view-specific overrides applied.
|
|
51
|
+
|
|
52
|
+
The original book is preserved; overrides provide a view-specific lens.
|
|
53
|
+
"""
|
|
54
|
+
book: Book
|
|
55
|
+
title_override: Optional[str] = None
|
|
56
|
+
description_override: Optional[str] = None
|
|
57
|
+
position: Optional[int] = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def id(self) -> int:
|
|
61
|
+
return self.book.id
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def title(self) -> str:
|
|
65
|
+
return self.title_override if self.title_override else self.book.title
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def description(self) -> Optional[str]:
|
|
69
|
+
return self.description_override if self.description_override else self.book.description
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def authors(self):
|
|
73
|
+
return self.book.authors
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def subjects(self):
|
|
77
|
+
return self.book.subjects
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def language(self):
|
|
81
|
+
return self.book.language
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def files(self):
|
|
85
|
+
return self.book.files
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def covers(self):
|
|
89
|
+
return self.book.covers
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def personal(self):
|
|
93
|
+
return self.book.personal
|
|
94
|
+
|
|
95
|
+
def __repr__(self):
|
|
96
|
+
override_marker = '*' if self.title_override or self.description_override else ''
|
|
97
|
+
return f"<TransformedBook{override_marker}(id={self.id}, title='{self.title[:50]}')>"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ViewEvaluator:
|
|
101
|
+
"""
|
|
102
|
+
Evaluates view definitions against a library.
|
|
103
|
+
|
|
104
|
+
The evaluator implements a small interpreter for the Views DSL,
|
|
105
|
+
following the structure:
|
|
106
|
+
evaluate(view) = order(transform(select(library)))
|
|
107
|
+
|
|
108
|
+
Each stage is a pure function operating on sets of books.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, session: Session):
|
|
112
|
+
self.session = session
|
|
113
|
+
self._view_cache: Dict[str, View] = {}
|
|
114
|
+
|
|
115
|
+
def evaluate(
|
|
116
|
+
self,
|
|
117
|
+
definition: Dict[str, Any],
|
|
118
|
+
view_name: Optional[str] = None
|
|
119
|
+
) -> List[TransformedBook]:
|
|
120
|
+
"""
|
|
121
|
+
Evaluate a view definition and return transformed books.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
definition: View definition dict with select/transform/order
|
|
125
|
+
view_name: Optional name for error messages
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
List of TransformedBook in order
|
|
129
|
+
"""
|
|
130
|
+
context = view_name or '<anonymous>'
|
|
131
|
+
|
|
132
|
+
# Stage 1: Select - determine which books
|
|
133
|
+
selector = definition.get('select', 'all')
|
|
134
|
+
book_set = self._evaluate_selector(selector, context)
|
|
135
|
+
|
|
136
|
+
# Stage 2: Transform - apply overrides
|
|
137
|
+
transform = definition.get('transform', 'identity')
|
|
138
|
+
transformed = self._evaluate_transform(transform, book_set, context)
|
|
139
|
+
|
|
140
|
+
# Stage 3: Order - sort the results
|
|
141
|
+
ordering = definition.get('order', {'by': 'title'})
|
|
142
|
+
ordered = self._evaluate_ordering(ordering, transformed, context)
|
|
143
|
+
|
|
144
|
+
return ordered
|
|
145
|
+
|
|
146
|
+
def evaluate_view(self, view_name: str) -> List[TransformedBook]:
|
|
147
|
+
"""
|
|
148
|
+
Evaluate a named view from the database.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
view_name: Name of the view
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
List of TransformedBook in order
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
ValueError: If view not found
|
|
158
|
+
"""
|
|
159
|
+
view = self._get_view(view_name)
|
|
160
|
+
if not view:
|
|
161
|
+
raise ValueError(f"View '{view_name}' not found")
|
|
162
|
+
|
|
163
|
+
result = self.evaluate(view.definition, view_name)
|
|
164
|
+
|
|
165
|
+
# Update cached count
|
|
166
|
+
view.cached_count = len(result)
|
|
167
|
+
view.cached_at = datetime.now(timezone.utc)
|
|
168
|
+
self.session.commit()
|
|
169
|
+
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
def count(self, definition: Dict[str, Any]) -> int:
|
|
173
|
+
"""Count books matching a view definition without full evaluation."""
|
|
174
|
+
selector = definition.get('select', 'all')
|
|
175
|
+
book_set = self._evaluate_selector(selector, '<count>')
|
|
176
|
+
return len(book_set)
|
|
177
|
+
|
|
178
|
+
# =========================================================================
|
|
179
|
+
# Selector Evaluation
|
|
180
|
+
# =========================================================================
|
|
181
|
+
|
|
182
|
+
def _evaluate_selector(
|
|
183
|
+
self,
|
|
184
|
+
selector: Union[str, Dict[str, Any]],
|
|
185
|
+
context: str
|
|
186
|
+
) -> Set[Book]:
|
|
187
|
+
"""Evaluate a selector and return a set of books."""
|
|
188
|
+
|
|
189
|
+
# Primitive: all
|
|
190
|
+
if selector == 'all':
|
|
191
|
+
return set(self.session.query(Book).all())
|
|
192
|
+
|
|
193
|
+
# Primitive: none
|
|
194
|
+
if selector == 'none':
|
|
195
|
+
return set()
|
|
196
|
+
|
|
197
|
+
if not isinstance(selector, dict):
|
|
198
|
+
raise ValueError(f"Invalid selector in {context}: {selector}")
|
|
199
|
+
|
|
200
|
+
# Primitive: filter
|
|
201
|
+
if 'filter' in selector:
|
|
202
|
+
return self._evaluate_filter(selector['filter'], context)
|
|
203
|
+
|
|
204
|
+
# Primitive: ids
|
|
205
|
+
if 'ids' in selector:
|
|
206
|
+
ids = selector['ids']
|
|
207
|
+
if not isinstance(ids, list):
|
|
208
|
+
raise ValueError(f"'ids' must be a list in {context}")
|
|
209
|
+
books = self.session.query(Book).filter(Book.id.in_(ids)).all()
|
|
210
|
+
return set(books)
|
|
211
|
+
|
|
212
|
+
# Primitive: single id
|
|
213
|
+
if 'id' in selector:
|
|
214
|
+
book = self.session.get(Book, selector['id'])
|
|
215
|
+
return {book} if book else set()
|
|
216
|
+
|
|
217
|
+
# Abstraction: view reference
|
|
218
|
+
if 'view' in selector:
|
|
219
|
+
view_name = selector['view']
|
|
220
|
+
return self._evaluate_view_reference(view_name, context)
|
|
221
|
+
|
|
222
|
+
# Combination: union
|
|
223
|
+
if 'union' in selector:
|
|
224
|
+
selectors = selector['union']
|
|
225
|
+
if not isinstance(selectors, list):
|
|
226
|
+
raise ValueError(f"'union' must be a list in {context}")
|
|
227
|
+
result = set()
|
|
228
|
+
for sel in selectors:
|
|
229
|
+
result = result.union(self._evaluate_selector(sel, context))
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
# Combination: intersect
|
|
233
|
+
if 'intersect' in selector:
|
|
234
|
+
selectors = selector['intersect']
|
|
235
|
+
if not isinstance(selectors, list) or len(selectors) < 2:
|
|
236
|
+
raise ValueError(f"'intersect' must be a list of 2+ selectors in {context}")
|
|
237
|
+
result = self._evaluate_selector(selectors[0], context)
|
|
238
|
+
for sel in selectors[1:]:
|
|
239
|
+
result = result.intersection(self._evaluate_selector(sel, context))
|
|
240
|
+
return result
|
|
241
|
+
|
|
242
|
+
# Combination: difference
|
|
243
|
+
if 'difference' in selector:
|
|
244
|
+
selectors = selector['difference']
|
|
245
|
+
if not isinstance(selectors, list) or len(selectors) != 2:
|
|
246
|
+
raise ValueError(f"'difference' must be a list of exactly 2 selectors in {context}")
|
|
247
|
+
a = self._evaluate_selector(selectors[0], context)
|
|
248
|
+
b = self._evaluate_selector(selectors[1], context)
|
|
249
|
+
return a - b
|
|
250
|
+
|
|
251
|
+
# Primitive: sql - execute raw SQL to get book IDs
|
|
252
|
+
if 'sql' in selector:
|
|
253
|
+
return self._evaluate_sql_selector(selector['sql'], context)
|
|
254
|
+
|
|
255
|
+
raise ValueError(f"Unknown selector type in {context}: {list(selector.keys())}")
|
|
256
|
+
|
|
257
|
+
def _evaluate_sql_selector(self, sql_query: str, context: str) -> Set[Book]:
|
|
258
|
+
"""
|
|
259
|
+
Evaluate a raw SQL selector to get book IDs.
|
|
260
|
+
|
|
261
|
+
The SQL query must return book IDs in the first column. Only SELECT
|
|
262
|
+
queries are allowed for security.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
sql_query: SQL query that returns book IDs
|
|
266
|
+
context: Context for error messages
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Set of Book objects matching the IDs
|
|
270
|
+
|
|
271
|
+
Examples:
|
|
272
|
+
{sql: "SELECT id FROM books WHERE language = 'en'"}
|
|
273
|
+
{sql: "SELECT book_id FROM book_subjects WHERE subject_id = 1"}
|
|
274
|
+
"""
|
|
275
|
+
import sqlite3
|
|
276
|
+
|
|
277
|
+
# Security check: only allow SELECT queries
|
|
278
|
+
query_stripped = sql_query.strip().upper()
|
|
279
|
+
if not query_stripped.startswith('SELECT'):
|
|
280
|
+
raise ValueError(f"SQL selector must be a SELECT query in {context}")
|
|
281
|
+
|
|
282
|
+
# Check for dangerous operations
|
|
283
|
+
dangerous = ['DROP', 'DELETE', 'INSERT', 'UPDATE', 'CREATE', 'ALTER', 'ATTACH', 'DETACH']
|
|
284
|
+
for pattern in dangerous:
|
|
285
|
+
if pattern in query_stripped:
|
|
286
|
+
raise ValueError(f"SQL selector contains disallowed keyword '{pattern}' in {context}")
|
|
287
|
+
|
|
288
|
+
# Get the database path from the session
|
|
289
|
+
# SQLAlchemy 2.x uses engine.url.database
|
|
290
|
+
engine = self.session.get_bind()
|
|
291
|
+
db_path = engine.url.database
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
conn = sqlite3.connect(db_path)
|
|
295
|
+
cursor = conn.cursor()
|
|
296
|
+
cursor.execute(sql_query)
|
|
297
|
+
rows = cursor.fetchall()
|
|
298
|
+
conn.close()
|
|
299
|
+
|
|
300
|
+
# Extract book IDs from the first column
|
|
301
|
+
book_ids = [row[0] for row in rows if row[0] is not None]
|
|
302
|
+
|
|
303
|
+
if not book_ids:
|
|
304
|
+
return set()
|
|
305
|
+
|
|
306
|
+
# Fetch books by ID
|
|
307
|
+
books = self.session.query(Book).filter(Book.id.in_(book_ids)).all()
|
|
308
|
+
return set(books)
|
|
309
|
+
|
|
310
|
+
except sqlite3.Error as e:
|
|
311
|
+
raise ValueError(f"SQL error in {context}: {e}")
|
|
312
|
+
|
|
313
|
+
def _evaluate_filter(
|
|
314
|
+
self,
|
|
315
|
+
predicate: Dict[str, Any],
|
|
316
|
+
context: str
|
|
317
|
+
) -> Set[Book]:
|
|
318
|
+
"""Evaluate a filter predicate and return matching books."""
|
|
319
|
+
|
|
320
|
+
# Boolean combinators
|
|
321
|
+
if 'and' in predicate:
|
|
322
|
+
predicates = predicate['and']
|
|
323
|
+
result = self._evaluate_filter(predicates[0], context)
|
|
324
|
+
for pred in predicates[1:]:
|
|
325
|
+
result = result.intersection(self._evaluate_filter(pred, context))
|
|
326
|
+
return result
|
|
327
|
+
|
|
328
|
+
if 'or' in predicate:
|
|
329
|
+
predicates = predicate['or']
|
|
330
|
+
result = set()
|
|
331
|
+
for pred in predicates:
|
|
332
|
+
result = result.union(self._evaluate_filter(pred, context))
|
|
333
|
+
return result
|
|
334
|
+
|
|
335
|
+
if 'not' in predicate:
|
|
336
|
+
all_books = set(self.session.query(Book).all())
|
|
337
|
+
excluded = self._evaluate_filter(predicate['not'], context)
|
|
338
|
+
return all_books - excluded
|
|
339
|
+
|
|
340
|
+
# Field predicates
|
|
341
|
+
query = self.session.query(Book)
|
|
342
|
+
query = self._apply_field_predicates(query, predicate, context)
|
|
343
|
+
return set(query.all())
|
|
344
|
+
|
|
345
|
+
def _apply_field_predicates(
|
|
346
|
+
self,
|
|
347
|
+
query,
|
|
348
|
+
predicate: Dict[str, Any],
|
|
349
|
+
context: str
|
|
350
|
+
):
|
|
351
|
+
"""Apply field predicates to a query."""
|
|
352
|
+
|
|
353
|
+
for field, value in predicate.items():
|
|
354
|
+
query = self._apply_single_predicate(query, field, value, context)
|
|
355
|
+
|
|
356
|
+
return query
|
|
357
|
+
|
|
358
|
+
def _apply_single_predicate(self, query, field: str, value: Any, context: str):
|
|
359
|
+
"""Apply a single field predicate to a query."""
|
|
360
|
+
|
|
361
|
+
# Handle comparison operators
|
|
362
|
+
if isinstance(value, dict):
|
|
363
|
+
return self._apply_comparison(query, field, value, context)
|
|
364
|
+
|
|
365
|
+
# Simple equality checks
|
|
366
|
+
if field == 'subject':
|
|
367
|
+
query = query.join(Book.subjects).filter(Subject.name.ilike(f"%{value}%"))
|
|
368
|
+
elif field == 'author':
|
|
369
|
+
query = query.join(Book.authors).filter(Author.name.ilike(f"%{value}%"))
|
|
370
|
+
elif field == 'tag':
|
|
371
|
+
query = query.join(Book.tags).filter(Tag.path.ilike(f"%{value}%"))
|
|
372
|
+
elif field == 'language':
|
|
373
|
+
query = query.filter(Book.language == value)
|
|
374
|
+
elif field == 'title':
|
|
375
|
+
query = query.filter(Book.title.ilike(f"%{value}%"))
|
|
376
|
+
elif field == 'publisher':
|
|
377
|
+
query = query.filter(Book.publisher.ilike(f"%{value}%"))
|
|
378
|
+
elif field == 'series':
|
|
379
|
+
query = query.filter(Book.series.ilike(f"%{value}%"))
|
|
380
|
+
elif field == 'format':
|
|
381
|
+
query = query.join(Book.files).filter(File.format.ilike(f"%{value}%"))
|
|
382
|
+
elif field == 'favorite':
|
|
383
|
+
if value:
|
|
384
|
+
query = query.join(Book.personal).filter(PersonalMetadata.favorite == True)
|
|
385
|
+
else:
|
|
386
|
+
query = query.outerjoin(Book.personal).filter(
|
|
387
|
+
or_(PersonalMetadata.favorite == False, PersonalMetadata.favorite.is_(None))
|
|
388
|
+
)
|
|
389
|
+
elif field == 'reading_status' or field == 'status':
|
|
390
|
+
query = query.join(Book.personal).filter(PersonalMetadata.reading_status == value)
|
|
391
|
+
elif field == 'rating':
|
|
392
|
+
query = query.join(Book.personal).filter(PersonalMetadata.rating == value)
|
|
393
|
+
elif field == 'year':
|
|
394
|
+
year_str = str(value)
|
|
395
|
+
query = query.filter(Book.publication_date.like(f"{year_str}%"))
|
|
396
|
+
else:
|
|
397
|
+
logger.warning(f"Unknown filter field '{field}' in {context}")
|
|
398
|
+
|
|
399
|
+
return query
|
|
400
|
+
|
|
401
|
+
def _apply_comparison(self, query, field: str, comparison: Dict[str, Any], context: str):
|
|
402
|
+
"""Apply a comparison operator to a query."""
|
|
403
|
+
|
|
404
|
+
# Get the comparison operator and value
|
|
405
|
+
if 'gte' in comparison:
|
|
406
|
+
op, val = '>=', comparison['gte']
|
|
407
|
+
elif 'gt' in comparison:
|
|
408
|
+
op, val = '>', comparison['gt']
|
|
409
|
+
elif 'lte' in comparison:
|
|
410
|
+
op, val = '<=', comparison['lte']
|
|
411
|
+
elif 'lt' in comparison:
|
|
412
|
+
op, val = '<', comparison['lt']
|
|
413
|
+
elif 'eq' in comparison:
|
|
414
|
+
op, val = '==', comparison['eq']
|
|
415
|
+
elif 'ne' in comparison:
|
|
416
|
+
op, val = '!=', comparison['ne']
|
|
417
|
+
elif 'contains' in comparison:
|
|
418
|
+
return self._apply_single_predicate(query, field, comparison['contains'], context)
|
|
419
|
+
elif 'in' in comparison:
|
|
420
|
+
vals = comparison['in']
|
|
421
|
+
if field == 'language':
|
|
422
|
+
query = query.filter(Book.language.in_(vals))
|
|
423
|
+
elif field == 'id':
|
|
424
|
+
query = query.filter(Book.id.in_(vals))
|
|
425
|
+
return query
|
|
426
|
+
elif 'between' in comparison:
|
|
427
|
+
low, high = comparison['between']
|
|
428
|
+
if field == 'rating':
|
|
429
|
+
query = query.join(Book.personal).filter(
|
|
430
|
+
and_(PersonalMetadata.rating >= low, PersonalMetadata.rating <= high)
|
|
431
|
+
)
|
|
432
|
+
elif field == 'year':
|
|
433
|
+
query = query.filter(
|
|
434
|
+
and_(Book.publication_date >= str(low), Book.publication_date <= str(high))
|
|
435
|
+
)
|
|
436
|
+
return query
|
|
437
|
+
else:
|
|
438
|
+
raise ValueError(f"Unknown comparison operator in {context}: {comparison}")
|
|
439
|
+
|
|
440
|
+
# Apply the comparison
|
|
441
|
+
if field == 'rating':
|
|
442
|
+
query = query.join(Book.personal)
|
|
443
|
+
if op == '>=':
|
|
444
|
+
query = query.filter(PersonalMetadata.rating >= val)
|
|
445
|
+
elif op == '>':
|
|
446
|
+
query = query.filter(PersonalMetadata.rating > val)
|
|
447
|
+
elif op == '<=':
|
|
448
|
+
query = query.filter(PersonalMetadata.rating <= val)
|
|
449
|
+
elif op == '<':
|
|
450
|
+
query = query.filter(PersonalMetadata.rating < val)
|
|
451
|
+
elif op == '==':
|
|
452
|
+
query = query.filter(PersonalMetadata.rating == val)
|
|
453
|
+
elif op == '!=':
|
|
454
|
+
query = query.filter(PersonalMetadata.rating != val)
|
|
455
|
+
elif field == 'year':
|
|
456
|
+
year_str = str(val)
|
|
457
|
+
if op == '>=':
|
|
458
|
+
query = query.filter(Book.publication_date >= year_str)
|
|
459
|
+
elif op == '>':
|
|
460
|
+
query = query.filter(Book.publication_date > year_str)
|
|
461
|
+
elif op == '<=':
|
|
462
|
+
query = query.filter(Book.publication_date <= year_str)
|
|
463
|
+
elif op == '<':
|
|
464
|
+
query = query.filter(Book.publication_date < year_str)
|
|
465
|
+
elif field == 'pages':
|
|
466
|
+
if op == '>=':
|
|
467
|
+
query = query.filter(Book.page_count >= val)
|
|
468
|
+
elif op == '>':
|
|
469
|
+
query = query.filter(Book.page_count > val)
|
|
470
|
+
elif op == '<=':
|
|
471
|
+
query = query.filter(Book.page_count <= val)
|
|
472
|
+
elif op == '<':
|
|
473
|
+
query = query.filter(Book.page_count < val)
|
|
474
|
+
|
|
475
|
+
return query
|
|
476
|
+
|
|
477
|
+
def _evaluate_view_reference(self, view_name: str, context: str) -> Set[Book]:
|
|
478
|
+
"""Evaluate a view reference by name."""
|
|
479
|
+
view = self._get_view(view_name)
|
|
480
|
+
if not view:
|
|
481
|
+
raise ValueError(f"Referenced view '{view_name}' not found in {context}")
|
|
482
|
+
|
|
483
|
+
# Recursively evaluate the referenced view's selector
|
|
484
|
+
selector = view.definition.get('select', 'all')
|
|
485
|
+
return self._evaluate_selector(selector, f"{context}→{view_name}")
|
|
486
|
+
|
|
487
|
+
def _get_view(self, name: str) -> Optional[View]:
|
|
488
|
+
"""Get a view by name with caching."""
|
|
489
|
+
if name not in self._view_cache:
|
|
490
|
+
view = self.session.query(View).filter_by(name=name).first()
|
|
491
|
+
self._view_cache[name] = view
|
|
492
|
+
return self._view_cache[name]
|
|
493
|
+
|
|
494
|
+
# =========================================================================
|
|
495
|
+
# Transform Evaluation
|
|
496
|
+
# =========================================================================
|
|
497
|
+
|
|
498
|
+
def _evaluate_transform(
|
|
499
|
+
self,
|
|
500
|
+
transform: Union[str, Dict[str, Any]],
|
|
501
|
+
books: Set[Book],
|
|
502
|
+
context: str
|
|
503
|
+
) -> List[TransformedBook]:
|
|
504
|
+
"""Apply transforms to books."""
|
|
505
|
+
|
|
506
|
+
# Start with identity transform (no overrides)
|
|
507
|
+
if transform == 'identity':
|
|
508
|
+
return [TransformedBook(book=book) for book in books]
|
|
509
|
+
|
|
510
|
+
if not isinstance(transform, dict):
|
|
511
|
+
raise ValueError(f"Invalid transform in {context}: {transform}")
|
|
512
|
+
|
|
513
|
+
# Build initial transformed books
|
|
514
|
+
transformed = {book.id: TransformedBook(book=book) for book in books}
|
|
515
|
+
|
|
516
|
+
# Apply overrides
|
|
517
|
+
if 'override' in transform:
|
|
518
|
+
overrides = transform['override']
|
|
519
|
+
for book_id_str, fields in overrides.items():
|
|
520
|
+
book_id = int(book_id_str) if isinstance(book_id_str, str) else book_id_str
|
|
521
|
+
if book_id in transformed:
|
|
522
|
+
tb = transformed[book_id]
|
|
523
|
+
if 'title' in fields:
|
|
524
|
+
tb.title_override = fields['title']
|
|
525
|
+
if 'description' in fields:
|
|
526
|
+
tb.description_override = fields['description']
|
|
527
|
+
if 'position' in fields:
|
|
528
|
+
tb.position = fields['position']
|
|
529
|
+
|
|
530
|
+
# Handle compose (chain of transforms)
|
|
531
|
+
if 'compose' in transform:
|
|
532
|
+
transforms = transform['compose']
|
|
533
|
+
result = list(transformed.values())
|
|
534
|
+
for t in transforms:
|
|
535
|
+
# Reapply each transform
|
|
536
|
+
book_set = {tb.book for tb in result}
|
|
537
|
+
result = self._evaluate_transform(t, book_set, context)
|
|
538
|
+
return result
|
|
539
|
+
|
|
540
|
+
# Handle view reference (inherit transforms from another view)
|
|
541
|
+
if 'view' in transform:
|
|
542
|
+
view_name = transform['view']
|
|
543
|
+
view = self._get_view(view_name)
|
|
544
|
+
if view and 'transform' in view.definition:
|
|
545
|
+
book_set = {tb.book for tb in transformed.values()}
|
|
546
|
+
return self._evaluate_transform(
|
|
547
|
+
view.definition['transform'], book_set, f"{context}→{view_name}"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
return list(transformed.values())
|
|
551
|
+
|
|
552
|
+
# =========================================================================
|
|
553
|
+
# Ordering Evaluation
|
|
554
|
+
# =========================================================================
|
|
555
|
+
|
|
556
|
+
def _evaluate_ordering(
|
|
557
|
+
self,
|
|
558
|
+
ordering: Union[str, Dict[str, Any]],
|
|
559
|
+
books: List[TransformedBook],
|
|
560
|
+
context: str
|
|
561
|
+
) -> List[TransformedBook]:
|
|
562
|
+
"""Apply ordering to transformed books."""
|
|
563
|
+
|
|
564
|
+
if isinstance(ordering, str):
|
|
565
|
+
ordering = {'by': ordering}
|
|
566
|
+
|
|
567
|
+
if not isinstance(ordering, dict):
|
|
568
|
+
raise ValueError(f"Invalid ordering in {context}: {ordering}")
|
|
569
|
+
|
|
570
|
+
# Custom order by IDs
|
|
571
|
+
if 'custom' in ordering:
|
|
572
|
+
custom_order = ordering['custom']
|
|
573
|
+
id_to_book = {tb.id: tb for tb in books}
|
|
574
|
+
ordered = []
|
|
575
|
+
for book_id in custom_order:
|
|
576
|
+
if book_id in id_to_book:
|
|
577
|
+
ordered.append(id_to_book.pop(book_id))
|
|
578
|
+
# Append remaining books not in custom order
|
|
579
|
+
ordered.extend(id_to_book.values())
|
|
580
|
+
return ordered
|
|
581
|
+
|
|
582
|
+
# Compound ordering (then)
|
|
583
|
+
if 'then' in ordering:
|
|
584
|
+
orderings = ordering['then']
|
|
585
|
+
result = books
|
|
586
|
+
# Apply orderings in reverse (most significant last)
|
|
587
|
+
for ord_spec in reversed(orderings):
|
|
588
|
+
result = self._evaluate_ordering(ord_spec, result, context)
|
|
589
|
+
return result
|
|
590
|
+
|
|
591
|
+
# Simple field ordering
|
|
592
|
+
field = ordering.get('by', 'title')
|
|
593
|
+
desc = ordering.get('desc', False)
|
|
594
|
+
|
|
595
|
+
def get_sort_key(tb: TransformedBook):
|
|
596
|
+
if field == 'title':
|
|
597
|
+
return (tb.title or '').lower()
|
|
598
|
+
elif field == 'author':
|
|
599
|
+
authors = tb.book.authors
|
|
600
|
+
return authors[0].name.lower() if authors else ''
|
|
601
|
+
elif field == 'date' or field == 'publication_date':
|
|
602
|
+
return tb.book.publication_date or ''
|
|
603
|
+
elif field == 'rating':
|
|
604
|
+
pm = tb.book.personal
|
|
605
|
+
return pm.rating if pm and pm.rating else 0
|
|
606
|
+
elif field == 'created_at':
|
|
607
|
+
return tb.book.created_at or datetime.min
|
|
608
|
+
elif field == 'position':
|
|
609
|
+
return tb.position if tb.position is not None else float('inf')
|
|
610
|
+
elif field == 'id':
|
|
611
|
+
return tb.id
|
|
612
|
+
else:
|
|
613
|
+
return (tb.title or '').lower()
|
|
614
|
+
|
|
615
|
+
return sorted(books, key=get_sort_key, reverse=desc)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# ============================================================================
|
|
619
|
+
# Built-in Virtual Views
|
|
620
|
+
# ============================================================================
|
|
621
|
+
|
|
622
|
+
BUILTIN_VIEWS = {
|
|
623
|
+
'all': {
|
|
624
|
+
'description': 'All books in the library',
|
|
625
|
+
'select': 'all',
|
|
626
|
+
'order': {'by': 'title'}
|
|
627
|
+
},
|
|
628
|
+
'favorites': {
|
|
629
|
+
'description': 'Books marked as favorites',
|
|
630
|
+
'select': {'filter': {'favorite': True}},
|
|
631
|
+
'order': {'by': 'title'}
|
|
632
|
+
},
|
|
633
|
+
'reading': {
|
|
634
|
+
'description': 'Books currently being read',
|
|
635
|
+
'select': {'filter': {'reading_status': 'reading'}},
|
|
636
|
+
'order': {'by': 'title'}
|
|
637
|
+
},
|
|
638
|
+
'completed': {
|
|
639
|
+
'description': 'Books that have been read',
|
|
640
|
+
'select': {'filter': {'reading_status': 'read'}},
|
|
641
|
+
'order': {'by': 'title'}
|
|
642
|
+
},
|
|
643
|
+
'unread': {
|
|
644
|
+
'description': 'Books not yet started',
|
|
645
|
+
'select': {'filter': {'reading_status': 'unread'}},
|
|
646
|
+
'order': {'by': 'title'}
|
|
647
|
+
},
|
|
648
|
+
'recent': {
|
|
649
|
+
'description': 'Recently added books',
|
|
650
|
+
'select': 'all',
|
|
651
|
+
'order': {'by': 'created_at', 'desc': True}
|
|
652
|
+
},
|
|
653
|
+
'top-rated': {
|
|
654
|
+
'description': 'Highest rated books',
|
|
655
|
+
'select': {'filter': {'rating': {'gte': 4}}},
|
|
656
|
+
'order': {'by': 'rating', 'desc': True}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def get_builtin_view(name: str) -> Optional[Dict[str, Any]]:
|
|
662
|
+
"""Get a built-in virtual view definition."""
|
|
663
|
+
return BUILTIN_VIEWS.get(name)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def is_builtin_view(name: str) -> bool:
|
|
667
|
+
"""Check if a view name is a built-in."""
|
|
668
|
+
return name in BUILTIN_VIEWS
|