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