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