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/service.py ADDED
@@ -0,0 +1,619 @@
1
+ """
2
+ Views Service - High-level API for managing views.
3
+
4
+ Provides CRUD operations and convenience methods for working with views.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+ import logging
10
+ import yaml
11
+
12
+ from sqlalchemy.orm import Session
13
+
14
+ from ..db.models import Book, View, ViewOverride
15
+ from .dsl import ViewEvaluator, TransformedBook, BUILTIN_VIEWS, is_builtin_view, get_builtin_view
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class ViewService:
21
+ """
22
+ Service for managing views.
23
+
24
+ Provides:
25
+ - CRUD operations for views
26
+ - View evaluation
27
+ - Import/export of view definitions
28
+ - Membership management (add/remove books)
29
+ - Override management (set/unset metadata)
30
+ """
31
+
32
+ def __init__(self, session: Session):
33
+ self.session = session
34
+ self.evaluator = ViewEvaluator(session)
35
+
36
+ # =========================================================================
37
+ # CRUD Operations
38
+ # =========================================================================
39
+
40
+ def create(
41
+ self,
42
+ name: str,
43
+ definition: Optional[Dict[str, Any]] = None,
44
+ description: Optional[str] = None,
45
+ **filter_kwargs
46
+ ) -> View:
47
+ """
48
+ Create a new view.
49
+
50
+ Can be created with a full definition dict or with filter kwargs.
51
+
52
+ Args:
53
+ name: View name (unique)
54
+ definition: Full view definition {select, transform, order}
55
+ description: Human-readable description
56
+ **filter_kwargs: Shorthand filters (subject, author, favorite, etc.)
57
+
58
+ Returns:
59
+ Created View instance
60
+
61
+ Examples:
62
+ # Full definition
63
+ svc.create('programming', definition={
64
+ 'select': {'filter': {'subject': 'programming'}},
65
+ 'order': {'by': 'title'}
66
+ })
67
+
68
+ # Shorthand filters
69
+ svc.create('favorites', favorite=True, description='My favorites')
70
+ """
71
+ if self.get(name):
72
+ raise ValueError(f"View '{name}' already exists")
73
+
74
+ if is_builtin_view(name):
75
+ raise ValueError(f"Cannot create view with reserved name '{name}'")
76
+
77
+ # Build definition from kwargs if not provided
78
+ if definition is None:
79
+ definition = self._build_definition_from_kwargs(filter_kwargs)
80
+
81
+ view = View(
82
+ name=name,
83
+ description=description,
84
+ definition=definition
85
+ )
86
+ self.session.add(view)
87
+ self.session.commit()
88
+
89
+ logger.debug(f"Created view '{name}'")
90
+ return view
91
+
92
+ def get(self, name: str) -> Optional[View]:
93
+ """Get a view by name."""
94
+ return self.session.query(View).filter_by(name=name).first()
95
+
96
+ def list(self, include_builtin: bool = True) -> List[Dict[str, Any]]:
97
+ """
98
+ List all views with metadata.
99
+
100
+ Args:
101
+ include_builtin: Whether to include built-in virtual views
102
+
103
+ Returns:
104
+ List of view info dicts
105
+ """
106
+ views = []
107
+
108
+ # Built-in views
109
+ if include_builtin:
110
+ for name, defn in BUILTIN_VIEWS.items():
111
+ views.append({
112
+ 'name': name,
113
+ 'description': defn.get('description', ''),
114
+ 'builtin': True,
115
+ 'count': None # Computed on demand
116
+ })
117
+
118
+ # User-defined views
119
+ for view in self.session.query(View).order_by(View.name).all():
120
+ views.append({
121
+ 'name': view.name,
122
+ 'description': view.description or '',
123
+ 'builtin': False,
124
+ 'count': view.cached_count,
125
+ 'created_at': view.created_at,
126
+ 'updated_at': view.updated_at
127
+ })
128
+
129
+ return views
130
+
131
+ def update(
132
+ self,
133
+ name: str,
134
+ definition: Optional[Dict[str, Any]] = None,
135
+ description: Optional[str] = None
136
+ ) -> View:
137
+ """
138
+ Update an existing view.
139
+
140
+ Args:
141
+ name: View name
142
+ definition: New definition (replaces existing)
143
+ description: New description
144
+
145
+ Returns:
146
+ Updated View instance
147
+ """
148
+ view = self.get(name)
149
+ if not view:
150
+ raise ValueError(f"View '{name}' not found")
151
+
152
+ if definition is not None:
153
+ view.definition = definition
154
+ view.cached_count = None # Invalidate cache
155
+ view.cached_at = None
156
+
157
+ if description is not None:
158
+ view.description = description
159
+
160
+ self.session.commit()
161
+ logger.debug(f"Updated view '{name}'")
162
+ return view
163
+
164
+ def delete(self, name: str) -> bool:
165
+ """
166
+ Delete a view.
167
+
168
+ Args:
169
+ name: View name
170
+
171
+ Returns:
172
+ True if deleted, False if not found
173
+ """
174
+ if is_builtin_view(name):
175
+ raise ValueError(f"Cannot delete built-in view '{name}'")
176
+
177
+ view = self.get(name)
178
+ if not view:
179
+ return False
180
+
181
+ self.session.delete(view)
182
+ self.session.commit()
183
+ logger.debug(f"Deleted view '{name}'")
184
+ return True
185
+
186
+ def rename(self, old_name: str, new_name: str) -> View:
187
+ """Rename a view."""
188
+ if is_builtin_view(old_name):
189
+ raise ValueError(f"Cannot rename built-in view '{old_name}'")
190
+
191
+ view = self.get(old_name)
192
+ if not view:
193
+ raise ValueError(f"View '{old_name}' not found")
194
+
195
+ if self.get(new_name):
196
+ raise ValueError(f"View '{new_name}' already exists")
197
+
198
+ view.name = new_name
199
+ self.session.commit()
200
+ logger.debug(f"Renamed view '{old_name}' to '{new_name}'")
201
+ return view
202
+
203
+ # =========================================================================
204
+ # Evaluation
205
+ # =========================================================================
206
+
207
+ def evaluate(self, name: str) -> List[TransformedBook]:
208
+ """
209
+ Evaluate a view and return transformed books.
210
+
211
+ Handles both user-defined and built-in views.
212
+
213
+ Args:
214
+ name: View name
215
+
216
+ Returns:
217
+ List of TransformedBook instances
218
+ """
219
+ # Check for built-in view first
220
+ if is_builtin_view(name):
221
+ defn = get_builtin_view(name)
222
+ return self.evaluator.evaluate(defn, name)
223
+
224
+ # User-defined view
225
+ return self.evaluator.evaluate_view(name)
226
+
227
+ def count(self, name: str) -> int:
228
+ """Get the book count for a view."""
229
+ if is_builtin_view(name):
230
+ defn = get_builtin_view(name)
231
+ return self.evaluator.count(defn)
232
+
233
+ view = self.get(name)
234
+ if not view:
235
+ raise ValueError(f"View '{name}' not found")
236
+
237
+ return self.evaluator.count(view.definition)
238
+
239
+ def books(self, name: str) -> List[Book]:
240
+ """Get raw books for a view (without transforms)."""
241
+ transformed = self.evaluate(name)
242
+ return [tb.book for tb in transformed]
243
+
244
+ # =========================================================================
245
+ # Membership Management
246
+ # =========================================================================
247
+
248
+ def add_book(self, view_name: str, book_id: int) -> None:
249
+ """
250
+ Add a book to a view's include list.
251
+
252
+ Args:
253
+ view_name: View name
254
+ book_id: Book ID to add
255
+ """
256
+ view = self.get(view_name)
257
+ if not view:
258
+ raise ValueError(f"View '{view_name}' not found")
259
+
260
+ defn = view.definition.copy()
261
+ selector = defn.get('select', 'all')
262
+
263
+ # If selector is a simple type, wrap it
264
+ if selector == 'all' or selector == 'none':
265
+ # Convert to union with explicit include
266
+ defn['select'] = {
267
+ 'union': [
268
+ selector if selector != 'none' else {'ids': []},
269
+ {'ids': [book_id]}
270
+ ]
271
+ }
272
+ elif isinstance(selector, dict):
273
+ if 'ids' in selector:
274
+ # Add to existing ids list
275
+ if book_id not in selector['ids']:
276
+ selector['ids'].append(book_id)
277
+ elif 'union' in selector:
278
+ # Add to existing union
279
+ # Look for an ids selector to add to
280
+ ids_sel = None
281
+ for sel in selector['union']:
282
+ if isinstance(sel, dict) and 'ids' in sel:
283
+ ids_sel = sel
284
+ break
285
+ if ids_sel:
286
+ if book_id not in ids_sel['ids']:
287
+ ids_sel['ids'].append(book_id)
288
+ else:
289
+ selector['union'].append({'ids': [book_id]})
290
+ else:
291
+ # Wrap existing selector in union with ids
292
+ defn['select'] = {
293
+ 'union': [selector, {'ids': [book_id]}]
294
+ }
295
+
296
+ view.definition = defn
297
+ view.cached_count = None
298
+ self.session.commit()
299
+ logger.debug(f"Added book {book_id} to view '{view_name}'")
300
+
301
+ def remove_book(self, view_name: str, book_id: int) -> None:
302
+ """
303
+ Remove a book from a view by adding it to exclusions.
304
+
305
+ Args:
306
+ view_name: View name
307
+ book_id: Book ID to remove
308
+ """
309
+ view = self.get(view_name)
310
+ if not view:
311
+ raise ValueError(f"View '{view_name}' not found")
312
+
313
+ defn = view.definition.copy()
314
+ selector = defn.get('select', 'all')
315
+
316
+ # Wrap in difference to exclude the book
317
+ defn['select'] = {
318
+ 'difference': [selector, {'ids': [book_id]}]
319
+ }
320
+
321
+ view.definition = defn
322
+ view.cached_count = None
323
+ self.session.commit()
324
+ logger.debug(f"Removed book {book_id} from view '{view_name}'")
325
+
326
+ # =========================================================================
327
+ # Override Management
328
+ # =========================================================================
329
+
330
+ def set_override(
331
+ self,
332
+ view_name: str,
333
+ book_id: int,
334
+ title: Optional[str] = None,
335
+ description: Optional[str] = None,
336
+ position: Optional[int] = None
337
+ ) -> ViewOverride:
338
+ """
339
+ Set metadata overrides for a book within a view.
340
+
341
+ Args:
342
+ view_name: View name
343
+ book_id: Book ID
344
+ title: Override title
345
+ description: Override description
346
+ position: Custom position for ordering
347
+
348
+ Returns:
349
+ ViewOverride instance
350
+ """
351
+ view = self.get(view_name)
352
+ if not view:
353
+ raise ValueError(f"View '{view_name}' not found")
354
+
355
+ # Check book exists
356
+ book = self.session.get(Book, book_id)
357
+ if not book:
358
+ raise ValueError(f"Book {book_id} not found")
359
+
360
+ # Get or create override
361
+ override = self.session.query(ViewOverride).filter_by(
362
+ view_id=view.id, book_id=book_id
363
+ ).first()
364
+
365
+ if not override:
366
+ override = ViewOverride(view_id=view.id, book_id=book_id)
367
+ self.session.add(override)
368
+
369
+ if title is not None:
370
+ override.title = title
371
+ if description is not None:
372
+ override.description = description
373
+ if position is not None:
374
+ override.position = position
375
+
376
+ self.session.commit()
377
+ logger.debug(f"Set override for book {book_id} in view '{view_name}'")
378
+ return override
379
+
380
+ def unset_override(
381
+ self,
382
+ view_name: str,
383
+ book_id: int,
384
+ field: Optional[str] = None
385
+ ) -> bool:
386
+ """
387
+ Remove overrides for a book within a view.
388
+
389
+ Args:
390
+ view_name: View name
391
+ book_id: Book ID
392
+ field: Specific field to unset (title, description, position)
393
+ If None, removes all overrides for the book
394
+
395
+ Returns:
396
+ True if override existed, False otherwise
397
+ """
398
+ view = self.get(view_name)
399
+ if not view:
400
+ raise ValueError(f"View '{view_name}' not found")
401
+
402
+ override = self.session.query(ViewOverride).filter_by(
403
+ view_id=view.id, book_id=book_id
404
+ ).first()
405
+
406
+ if not override:
407
+ return False
408
+
409
+ if field is None:
410
+ self.session.delete(override)
411
+ elif field == 'title':
412
+ override.title = None
413
+ elif field == 'description':
414
+ override.description = None
415
+ elif field == 'position':
416
+ override.position = None
417
+ else:
418
+ raise ValueError(f"Unknown field '{field}'")
419
+
420
+ self.session.commit()
421
+ logger.debug(f"Unset override for book {book_id} in view '{view_name}'")
422
+ return True
423
+
424
+ def get_overrides(self, view_name: str) -> List[ViewOverride]:
425
+ """Get all overrides for a view."""
426
+ view = self.get(view_name)
427
+ if not view:
428
+ raise ValueError(f"View '{view_name}' not found")
429
+ return view.overrides
430
+
431
+ # =========================================================================
432
+ # Import/Export
433
+ # =========================================================================
434
+
435
+ def export_yaml(self, name: str) -> str:
436
+ """
437
+ Export a view definition as YAML.
438
+
439
+ Args:
440
+ name: View name
441
+
442
+ Returns:
443
+ YAML string
444
+ """
445
+ if is_builtin_view(name):
446
+ defn = get_builtin_view(name)
447
+ data = {
448
+ 'name': name,
449
+ 'builtin': True,
450
+ **defn
451
+ }
452
+ else:
453
+ view = self.get(name)
454
+ if not view:
455
+ raise ValueError(f"View '{name}' not found")
456
+
457
+ data = {
458
+ 'name': view.name,
459
+ 'description': view.description,
460
+ **view.definition
461
+ }
462
+
463
+ # Include overrides if any
464
+ if view.overrides:
465
+ data['overrides'] = {
466
+ ov.book_id: {
467
+ k: v for k, v in [
468
+ ('title', ov.title),
469
+ ('description', ov.description),
470
+ ('position', ov.position)
471
+ ] if v is not None
472
+ }
473
+ for ov in view.overrides
474
+ }
475
+
476
+ return yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
477
+
478
+ def import_yaml(self, yaml_content: str, overwrite: bool = False) -> View:
479
+ """
480
+ Import a view from YAML.
481
+
482
+ Args:
483
+ yaml_content: YAML string
484
+ overwrite: If True, overwrite existing view
485
+
486
+ Returns:
487
+ Created or updated View instance
488
+ """
489
+ data = yaml.safe_load(yaml_content)
490
+
491
+ name = data.get('name')
492
+ if not name:
493
+ raise ValueError("View YAML must include 'name' field")
494
+
495
+ if is_builtin_view(name):
496
+ raise ValueError(f"Cannot import view with reserved name '{name}'")
497
+
498
+ description = data.get('description')
499
+
500
+ # Build definition from remaining fields
501
+ definition = {}
502
+ for key in ['select', 'transform', 'order']:
503
+ if key in data:
504
+ definition[key] = data[key]
505
+
506
+ existing = self.get(name)
507
+ if existing:
508
+ if not overwrite:
509
+ raise ValueError(f"View '{name}' already exists. Use overwrite=True to replace.")
510
+ view = self.update(name, definition=definition, description=description)
511
+ else:
512
+ view = self.create(name, definition=definition, description=description)
513
+
514
+ # Import overrides if present
515
+ if 'overrides' in data:
516
+ for book_id_str, fields in data['overrides'].items():
517
+ book_id = int(book_id_str)
518
+ self.set_override(
519
+ name, book_id,
520
+ title=fields.get('title'),
521
+ description=fields.get('description'),
522
+ position=fields.get('position')
523
+ )
524
+
525
+ return view
526
+
527
+ def import_file(self, path: Path, overwrite: bool = False) -> View:
528
+ """Import a view from a YAML file."""
529
+ with open(path) as f:
530
+ return self.import_yaml(f.read(), overwrite=overwrite)
531
+
532
+ def export_file(self, name: str, path: Path) -> None:
533
+ """Export a view to a YAML file."""
534
+ yaml_content = self.export_yaml(name)
535
+ with open(path, 'w') as f:
536
+ f.write(yaml_content)
537
+
538
+ # =========================================================================
539
+ # Utility Methods
540
+ # =========================================================================
541
+
542
+ def _build_definition_from_kwargs(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
543
+ """Build a view definition from filter keyword arguments."""
544
+ if not kwargs:
545
+ return {'select': 'all'}
546
+
547
+ # Build filter predicate from kwargs
548
+ filter_pred = {}
549
+ for key, value in kwargs.items():
550
+ filter_pred[key] = value
551
+
552
+ return {
553
+ 'select': {'filter': filter_pred},
554
+ 'order': {'by': 'title'}
555
+ }
556
+
557
+ def dependencies(self, name: str) -> List[str]:
558
+ """
559
+ Get views that this view depends on (references).
560
+
561
+ Args:
562
+ name: View name
563
+
564
+ Returns:
565
+ List of referenced view names
566
+ """
567
+ view = self.get(name)
568
+ if not view:
569
+ return []
570
+
571
+ deps = []
572
+ self._collect_dependencies(view.definition, deps)
573
+ return deps
574
+
575
+ def _collect_dependencies(self, obj: Any, deps: List[str]) -> None:
576
+ """Recursively collect view references from a definition."""
577
+ if isinstance(obj, dict):
578
+ if 'view' in obj:
579
+ view_name = obj['view']
580
+ if view_name not in deps:
581
+ deps.append(view_name)
582
+ for value in obj.values():
583
+ self._collect_dependencies(value, deps)
584
+ elif isinstance(obj, list):
585
+ for item in obj:
586
+ self._collect_dependencies(item, deps)
587
+
588
+ def dependents(self, name: str) -> List[str]:
589
+ """
590
+ Get views that depend on (reference) this view.
591
+
592
+ Args:
593
+ name: View name
594
+
595
+ Returns:
596
+ List of dependent view names
597
+ """
598
+ dependents = []
599
+ for view in self.session.query(View).all():
600
+ if name in self.dependencies(view.name):
601
+ dependents.append(view.name)
602
+ return dependents
603
+
604
+ def validate(self, definition: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
605
+ """
606
+ Validate a view definition without saving.
607
+
608
+ Args:
609
+ definition: View definition to validate
610
+
611
+ Returns:
612
+ Tuple of (is_valid, error_message)
613
+ """
614
+ try:
615
+ # Try to evaluate it
616
+ self.evaluator.evaluate(definition, '<validation>')
617
+ return True, None
618
+ except Exception as e:
619
+ return False, str(e)