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
@@ -0,0 +1,1743 @@
1
+ """
2
+ Export library to a self-contained HTML5 file with embedded CSS and JavaScript.
3
+
4
+ Creates an interactive, searchable, filterable library catalog that works offline.
5
+ All metadata, including contributors, series, keywords, etc., is preserved.
6
+
7
+ Features:
8
+ - Dark/light mode toggle
9
+ - Grid/list/table view modes
10
+ - Advanced search syntax (field:value, boolean operators)
11
+ - Sidebar navigation (authors, subjects, series)
12
+ - Keyboard shortcuts (/, Escape, j/k navigation)
13
+ - Print-friendly styling
14
+ - Reading queue display
15
+ - Responsive mobile design
16
+ """
17
+
18
+ from pathlib import Path
19
+ from typing import List, Optional, Dict, Any
20
+ import json
21
+ from datetime import datetime
22
+
23
+
24
+ def export_to_html(
25
+ books: List,
26
+ output_path: Path,
27
+ include_stats: bool = True,
28
+ base_url: str = "",
29
+ views: Optional[List[Dict[str, Any]]] = None
30
+ ):
31
+ """
32
+ Export library to a single self-contained HTML file.
33
+
34
+ Args:
35
+ books: List of Book ORM objects
36
+ output_path: Path to write HTML file
37
+ include_stats: Include library statistics
38
+ base_url: Base URL for file links (e.g., '/library' or 'https://example.com/books')
39
+ If empty, uses relative paths from HTML file location
40
+ views: Optional list of view data dicts with 'name', 'description', 'book_ids'
41
+ """
42
+
43
+ # Serialize books to JSON-compatible format
44
+ books_data = []
45
+ authors_set = set()
46
+ subjects_set = set()
47
+ series_set = set()
48
+ languages_set = set()
49
+ formats_set = set()
50
+
51
+ for book in books:
52
+ # Get primary cover if available
53
+ cover_path = None
54
+ if book.covers:
55
+ primary_cover = next((c for c in book.covers if c.is_primary), book.covers[0])
56
+ cover_path = primary_cover.path
57
+
58
+ # Collect for sidebar navigation
59
+ for author in book.authors:
60
+ authors_set.add(author.name)
61
+ for subject in book.subjects:
62
+ subjects_set.add(subject.name)
63
+ if book.series:
64
+ series_set.add(book.series)
65
+ if book.language:
66
+ languages_set.add(book.language)
67
+ for file in book.files:
68
+ formats_set.add(file.format.upper())
69
+
70
+ # Get queue position if available
71
+ queue_position = None
72
+ if book.personal and hasattr(book.personal, 'queue_position'):
73
+ queue_position = book.personal.queue_position
74
+
75
+ book_data = {
76
+ 'id': book.id,
77
+ 'title': book.title,
78
+ 'subtitle': book.subtitle,
79
+ 'authors': [{'name': a.name, 'sort_name': a.sort_name} for a in book.authors],
80
+ 'contributors': [
81
+ {'name': c.name, 'role': c.role, 'file_as': c.file_as}
82
+ for c in book.contributors
83
+ ] if hasattr(book, 'contributors') else [],
84
+ 'subjects': [s.name for s in book.subjects],
85
+ 'language': book.language,
86
+ 'publisher': book.publisher,
87
+ 'publication_date': book.publication_date,
88
+ 'series': book.series,
89
+ 'series_index': book.series_index,
90
+ 'edition': book.edition,
91
+ 'description': book.description,
92
+ 'page_count': book.page_count,
93
+ 'word_count': book.word_count,
94
+ 'keywords': book.keywords or [],
95
+ 'rights': book.rights,
96
+ 'identifiers': [
97
+ {'scheme': i.scheme, 'value': i.value}
98
+ for i in book.identifiers
99
+ ],
100
+ 'files': [
101
+ {
102
+ 'format': f.format,
103
+ 'size_bytes': f.size_bytes,
104
+ 'mime_type': f.mime_type,
105
+ 'creator_application': f.creator_application,
106
+ 'created_date': f.created_date.isoformat() if f.created_date else None,
107
+ 'modified_date': f.modified_date.isoformat() if f.modified_date else None,
108
+ 'path': f.path,
109
+ }
110
+ for f in book.files
111
+ ],
112
+ 'cover_path': cover_path,
113
+ 'personal': {
114
+ 'rating': book.personal.rating if book.personal else None,
115
+ 'favorite': book.personal.favorite if book.personal else False,
116
+ 'reading_status': book.personal.reading_status if book.personal else 'unread',
117
+ 'reading_progress': book.personal.reading_progress if book.personal else 0,
118
+ 'queue_position': queue_position,
119
+ 'tags': book.personal.personal_tags or [] if book.personal else [],
120
+ },
121
+ 'created_at': book.created_at.isoformat(),
122
+ }
123
+ books_data.append(book_data)
124
+
125
+ # Generate statistics and navigation data
126
+ stats = {
127
+ 'total_books': len(books),
128
+ 'total_authors': len(authors_set),
129
+ 'total_subjects': len(subjects_set),
130
+ 'total_series': len(series_set),
131
+ 'languages': sorted(list(languages_set)),
132
+ 'formats': sorted(list(formats_set)),
133
+ }
134
+
135
+ nav_data = {
136
+ 'authors': sorted(list(authors_set)),
137
+ 'subjects': sorted(list(subjects_set)),
138
+ 'series': sorted(list(series_set)),
139
+ 'views': views or [],
140
+ }
141
+
142
+ # Create HTML content
143
+ html_content = _generate_html_template(books_data, stats, nav_data, base_url, views or [])
144
+
145
+ # Write to file
146
+ output_path.write_text(html_content, encoding='utf-8')
147
+
148
+
149
+ def _generate_html_template(
150
+ books_data: List[dict],
151
+ stats: dict,
152
+ nav_data: dict,
153
+ base_url: str = "",
154
+ views: Optional[List[Dict[str, Any]]] = None
155
+ ) -> str:
156
+ """Generate the complete HTML template with embedded CSS and JavaScript."""
157
+
158
+ books_json = json.dumps(books_data, indent=None, ensure_ascii=False)
159
+ stats_json = json.dumps(stats, indent=None, ensure_ascii=False)
160
+ nav_json = json.dumps(nav_data, indent=None, ensure_ascii=False)
161
+ base_url_json = json.dumps(base_url, ensure_ascii=False)
162
+ views_json = json.dumps(views or [], indent=None, ensure_ascii=False)
163
+ export_date = datetime.now().strftime('%Y-%m-%d %H:%M')
164
+
165
+ return f'''<!DOCTYPE html>
166
+ <html lang="en">
167
+ <head>
168
+ <meta charset="UTF-8">
169
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
170
+ <title>ebk Library</title>
171
+ <style>
172
+ *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
173
+
174
+ :root {{
175
+ --bg-primary: #f8fafc;
176
+ --bg-secondary: #ffffff;
177
+ --bg-tertiary: #f1f5f9;
178
+ --bg-hover: #e2e8f0;
179
+ --text-primary: #0f172a;
180
+ --text-secondary: #475569;
181
+ --text-muted: #94a3b8;
182
+ --border: #e2e8f0;
183
+ --accent: #6366f1;
184
+ --accent-hover: #4f46e5;
185
+ --accent-light: #eef2ff;
186
+ --success: #10b981;
187
+ --success-light: #d1fae5;
188
+ --warning: #f59e0b;
189
+ --warning-light: #fef3c7;
190
+ --danger: #ef4444;
191
+ --danger-light: #fee2e2;
192
+ --shadow: 0 1px 3px rgba(0,0,0,0.1);
193
+ --shadow-lg: 0 4px 12px rgba(0,0,0,0.1);
194
+ --radius: 8px;
195
+ --radius-lg: 12px;
196
+ --transition: 0.15s ease;
197
+ }}
198
+
199
+ [data-theme="dark"] {{
200
+ --bg-primary: #0f172a;
201
+ --bg-secondary: #1e293b;
202
+ --bg-tertiary: #334155;
203
+ --bg-hover: #475569;
204
+ --text-primary: #f1f5f9;
205
+ --text-secondary: #cbd5e1;
206
+ --text-muted: #64748b;
207
+ --border: #334155;
208
+ --accent-light: #312e81;
209
+ --success-light: #064e3b;
210
+ --warning-light: #78350f;
211
+ --danger-light: #7f1d1d;
212
+ --shadow: 0 1px 3px rgba(0,0,0,0.3);
213
+ --shadow-lg: 0 4px 12px rgba(0,0,0,0.3);
214
+ }}
215
+
216
+ body {{
217
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
218
+ background: var(--bg-primary);
219
+ color: var(--text-primary);
220
+ line-height: 1.6;
221
+ min-height: 100vh;
222
+ }}
223
+
224
+ /* Layout */
225
+ .app-container {{
226
+ display: flex;
227
+ min-height: 100vh;
228
+ }}
229
+
230
+ .sidebar {{
231
+ width: 280px;
232
+ background: var(--bg-secondary);
233
+ border-right: 1px solid var(--border);
234
+ display: flex;
235
+ flex-direction: column;
236
+ position: fixed;
237
+ top: 0;
238
+ left: 0;
239
+ bottom: 0;
240
+ z-index: 100;
241
+ transform: translateX(-100%);
242
+ transition: transform 0.3s ease;
243
+ }}
244
+
245
+ .sidebar.open {{
246
+ transform: translateX(0);
247
+ }}
248
+
249
+ @media (min-width: 1024px) {{
250
+ .sidebar {{
251
+ transform: translateX(0);
252
+ position: sticky;
253
+ height: 100vh;
254
+ }}
255
+ }}
256
+
257
+ .sidebar-header {{
258
+ padding: 20px;
259
+ border-bottom: 1px solid var(--border);
260
+ }}
261
+
262
+ .sidebar-logo {{
263
+ font-size: 1.25rem;
264
+ font-weight: 700;
265
+ color: var(--accent);
266
+ display: flex;
267
+ align-items: center;
268
+ gap: 12px;
269
+ }}
270
+
271
+ .logo-icon {{
272
+ width: 40px;
273
+ height: 40px;
274
+ background: linear-gradient(135deg, var(--accent), #8b5cf6);
275
+ border-radius: var(--radius);
276
+ display: flex;
277
+ align-items: center;
278
+ justify-content: center;
279
+ font-size: 1.25rem;
280
+ color: white;
281
+ }}
282
+
283
+ .sidebar-nav {{
284
+ flex: 1;
285
+ overflow-y: auto;
286
+ padding: 12px;
287
+ }}
288
+
289
+ .nav-section {{
290
+ margin-bottom: 16px;
291
+ }}
292
+
293
+ .nav-section-title {{
294
+ font-size: 0.75rem;
295
+ font-weight: 600;
296
+ text-transform: uppercase;
297
+ letter-spacing: 0.05em;
298
+ color: var(--text-muted);
299
+ padding: 8px 12px;
300
+ }}
301
+
302
+ .nav-item {{
303
+ display: flex;
304
+ align-items: center;
305
+ gap: 10px;
306
+ padding: 10px 12px;
307
+ border-radius: var(--radius);
308
+ color: var(--text-secondary);
309
+ cursor: pointer;
310
+ transition: all 0.15s ease;
311
+ font-size: 0.9rem;
312
+ }}
313
+
314
+ .nav-item:hover {{
315
+ background: var(--bg-tertiary);
316
+ color: var(--text-primary);
317
+ }}
318
+
319
+ .nav-item.active {{
320
+ background: var(--accent);
321
+ color: white;
322
+ }}
323
+
324
+ .nav-item-count {{
325
+ margin-left: auto;
326
+ font-size: 0.75rem;
327
+ background: var(--bg-tertiary);
328
+ padding: 2px 8px;
329
+ border-radius: 12px;
330
+ color: var(--text-muted);
331
+ }}
332
+
333
+ .nav-item.active .nav-item-count {{
334
+ background: rgba(255,255,255,0.2);
335
+ color: white;
336
+ }}
337
+
338
+ /* Main Content */
339
+ .main-content {{
340
+ flex: 1;
341
+ display: flex;
342
+ flex-direction: column;
343
+ min-width: 0;
344
+ }}
345
+
346
+ @media (min-width: 1024px) {{
347
+ .main-content {{
348
+ margin-left: 0;
349
+ }}
350
+ }}
351
+
352
+ /* Header */
353
+ .header {{
354
+ background: var(--bg-secondary);
355
+ border-bottom: 1px solid var(--border);
356
+ padding: 16px 24px;
357
+ display: flex;
358
+ align-items: center;
359
+ gap: 16px;
360
+ position: sticky;
361
+ top: 0;
362
+ z-index: 50;
363
+ }}
364
+
365
+ .menu-toggle {{
366
+ display: flex;
367
+ align-items: center;
368
+ justify-content: center;
369
+ width: 40px;
370
+ height: 40px;
371
+ border: none;
372
+ background: var(--bg-tertiary);
373
+ border-radius: var(--radius);
374
+ cursor: pointer;
375
+ color: var(--text-primary);
376
+ font-size: 1.25rem;
377
+ }}
378
+
379
+ @media (min-width: 1024px) {{
380
+ .menu-toggle {{
381
+ display: none;
382
+ }}
383
+ }}
384
+
385
+ .search-container {{
386
+ flex: 1;
387
+ max-width: 600px;
388
+ position: relative;
389
+ }}
390
+
391
+ .search-input {{
392
+ width: 100%;
393
+ padding: 10px 16px 10px 44px;
394
+ border: 2px solid var(--border);
395
+ border-radius: var(--radius);
396
+ font-size: 0.95rem;
397
+ background: var(--bg-primary);
398
+ color: var(--text-primary);
399
+ transition: border-color 0.2s;
400
+ }}
401
+
402
+ .search-input:focus {{
403
+ outline: none;
404
+ border-color: var(--accent);
405
+ }}
406
+
407
+ .search-input::placeholder {{
408
+ color: var(--text-muted);
409
+ }}
410
+
411
+ .search-icon {{
412
+ position: absolute;
413
+ left: 14px;
414
+ top: 50%;
415
+ transform: translateY(-50%);
416
+ color: var(--text-muted);
417
+ }}
418
+
419
+ .header-actions {{
420
+ display: flex;
421
+ align-items: center;
422
+ gap: 8px;
423
+ }}
424
+
425
+ .icon-btn {{
426
+ display: flex;
427
+ align-items: center;
428
+ justify-content: center;
429
+ width: 40px;
430
+ height: 40px;
431
+ border: none;
432
+ background: var(--bg-tertiary);
433
+ border-radius: var(--radius);
434
+ cursor: pointer;
435
+ color: var(--text-secondary);
436
+ font-size: 1.1rem;
437
+ transition: all 0.15s ease;
438
+ }}
439
+
440
+ .icon-btn:hover {{
441
+ background: var(--accent);
442
+ color: white;
443
+ }}
444
+
445
+ .icon-btn.active {{
446
+ background: var(--accent);
447
+ color: white;
448
+ }}
449
+
450
+ /* Toolbar */
451
+ .toolbar {{
452
+ background: var(--bg-secondary);
453
+ border-bottom: 1px solid var(--border);
454
+ padding: 12px 24px;
455
+ display: flex;
456
+ align-items: center;
457
+ gap: 16px;
458
+ flex-wrap: wrap;
459
+ }}
460
+
461
+ .filter-group {{
462
+ display: flex;
463
+ align-items: center;
464
+ gap: 8px;
465
+ }}
466
+
467
+ .filter-label {{
468
+ font-size: 0.8rem;
469
+ color: var(--text-muted);
470
+ font-weight: 500;
471
+ }}
472
+
473
+ .filter-select {{
474
+ padding: 6px 12px;
475
+ border: 1px solid var(--border);
476
+ border-radius: var(--radius);
477
+ font-size: 0.85rem;
478
+ background: var(--bg-primary);
479
+ color: var(--text-primary);
480
+ cursor: pointer;
481
+ }}
482
+
483
+ .results-info {{
484
+ margin-left: auto;
485
+ font-size: 0.85rem;
486
+ color: var(--text-muted);
487
+ }}
488
+
489
+ /* Stats Bar */
490
+ .stats-bar {{
491
+ display: flex;
492
+ gap: 16px;
493
+ padding: 16px 24px;
494
+ background: var(--bg-secondary);
495
+ border-bottom: 1px solid var(--border);
496
+ overflow-x: auto;
497
+ }}
498
+
499
+ .stat-item {{
500
+ display: flex;
501
+ align-items: center;
502
+ gap: 12px;
503
+ padding: 12px 16px;
504
+ background: var(--bg-tertiary);
505
+ border-radius: var(--radius);
506
+ min-width: fit-content;
507
+ }}
508
+
509
+ .stat-icon {{
510
+ width: 40px;
511
+ height: 40px;
512
+ border-radius: var(--radius);
513
+ display: flex;
514
+ align-items: center;
515
+ justify-content: center;
516
+ font-size: 1.2rem;
517
+ }}
518
+
519
+ .stat-icon.books {{ background: var(--accent-light); color: var(--accent); }}
520
+ .stat-icon.authors {{ background: var(--success-light); color: var(--success); }}
521
+ .stat-icon.subjects {{ background: var(--warning-light); color: var(--warning); }}
522
+ .stat-icon.series {{ background: var(--danger-light); color: var(--danger); }}
523
+
524
+ .stat-content {{ display: flex; flex-direction: column; }}
525
+ .stat-value {{ font-size: 1.25rem; font-weight: 700; color: var(--text-primary); line-height: 1.2; }}
526
+ .stat-label {{ font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }}
527
+
528
+ /* Content Area */
529
+ .content {{
530
+ flex: 1;
531
+ padding: 24px;
532
+ overflow-y: auto;
533
+ }}
534
+
535
+ /* Grid View */
536
+ .book-grid {{
537
+ display: grid;
538
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
539
+ gap: 20px;
540
+ }}
541
+
542
+ .book-card {{
543
+ background: var(--bg-secondary);
544
+ border-radius: var(--radius-lg);
545
+ overflow: hidden;
546
+ box-shadow: var(--shadow);
547
+ transition: transform 0.2s, box-shadow 0.2s;
548
+ cursor: pointer;
549
+ }}
550
+
551
+ .book-card:hover {{
552
+ transform: translateY(-4px);
553
+ box-shadow: var(--shadow-lg);
554
+ }}
555
+
556
+ .book-cover {{
557
+ aspect-ratio: 2/3;
558
+ background: var(--bg-tertiary);
559
+ display: flex;
560
+ align-items: center;
561
+ justify-content: center;
562
+ overflow: hidden;
563
+ }}
564
+
565
+ .book-cover img {{
566
+ width: 100%;
567
+ height: 100%;
568
+ object-fit: cover;
569
+ }}
570
+
571
+ .book-cover-placeholder {{
572
+ font-size: 3rem;
573
+ color: var(--text-muted);
574
+ }}
575
+
576
+ .book-info {{
577
+ padding: 12px;
578
+ }}
579
+
580
+ .book-title {{
581
+ font-size: 0.9rem;
582
+ font-weight: 600;
583
+ color: var(--text-primary);
584
+ line-height: 1.3;
585
+ display: -webkit-box;
586
+ -webkit-line-clamp: 2;
587
+ -webkit-box-orient: vertical;
588
+ overflow: hidden;
589
+ }}
590
+
591
+ .book-author {{
592
+ font-size: 0.8rem;
593
+ color: var(--text-secondary);
594
+ margin-top: 4px;
595
+ white-space: nowrap;
596
+ overflow: hidden;
597
+ text-overflow: ellipsis;
598
+ }}
599
+
600
+ .book-meta {{
601
+ display: flex;
602
+ align-items: center;
603
+ gap: 8px;
604
+ margin-top: 8px;
605
+ flex-wrap: wrap;
606
+ }}
607
+
608
+ .book-badge {{
609
+ font-size: 0.7rem;
610
+ padding: 2px 6px;
611
+ border-radius: 4px;
612
+ font-weight: 600;
613
+ text-transform: uppercase;
614
+ }}
615
+
616
+ .badge-format {{
617
+ background: var(--bg-tertiary);
618
+ color: var(--text-secondary);
619
+ }}
620
+
621
+ .badge-favorite {{
622
+ background: #fef3c7;
623
+ color: #92400e;
624
+ }}
625
+
626
+ .badge-queue {{
627
+ background: #dbeafe;
628
+ color: #1e40af;
629
+ }}
630
+
631
+ .book-rating {{
632
+ color: var(--warning);
633
+ font-size: 0.75rem;
634
+ }}
635
+
636
+ /* List View */
637
+ .book-list {{
638
+ display: flex;
639
+ flex-direction: column;
640
+ gap: 12px;
641
+ }}
642
+
643
+ .book-list-item {{
644
+ background: var(--bg-secondary);
645
+ border-radius: var(--radius);
646
+ padding: 16px;
647
+ display: flex;
648
+ gap: 16px;
649
+ align-items: flex-start;
650
+ box-shadow: var(--shadow);
651
+ cursor: pointer;
652
+ transition: box-shadow 0.2s;
653
+ }}
654
+
655
+ .book-list-item:hover {{
656
+ box-shadow: var(--shadow-lg);
657
+ }}
658
+
659
+ .book-list-cover {{
660
+ width: 60px;
661
+ height: 90px;
662
+ background: var(--bg-tertiary);
663
+ border-radius: 4px;
664
+ overflow: hidden;
665
+ flex-shrink: 0;
666
+ }}
667
+
668
+ .book-list-cover img {{
669
+ width: 100%;
670
+ height: 100%;
671
+ object-fit: cover;
672
+ }}
673
+
674
+ .book-list-info {{
675
+ flex: 1;
676
+ min-width: 0;
677
+ }}
678
+
679
+ .book-list-title {{
680
+ font-weight: 600;
681
+ color: var(--text-primary);
682
+ margin-bottom: 4px;
683
+ }}
684
+
685
+ .book-list-author {{
686
+ font-size: 0.9rem;
687
+ color: var(--text-secondary);
688
+ margin-bottom: 8px;
689
+ }}
690
+
691
+ .book-list-meta {{
692
+ display: flex;
693
+ gap: 16px;
694
+ font-size: 0.8rem;
695
+ color: var(--text-muted);
696
+ flex-wrap: wrap;
697
+ }}
698
+
699
+ /* Table View */
700
+ .book-table {{
701
+ width: 100%;
702
+ border-collapse: collapse;
703
+ font-size: 0.9rem;
704
+ }}
705
+
706
+ .book-table th {{
707
+ text-align: left;
708
+ padding: 12px 16px;
709
+ background: var(--bg-tertiary);
710
+ font-weight: 600;
711
+ color: var(--text-secondary);
712
+ border-bottom: 2px solid var(--border);
713
+ cursor: pointer;
714
+ user-select: none;
715
+ }}
716
+
717
+ .book-table th:hover {{
718
+ background: var(--border);
719
+ }}
720
+
721
+ .book-table td {{
722
+ padding: 12px 16px;
723
+ border-bottom: 1px solid var(--border);
724
+ color: var(--text-primary);
725
+ }}
726
+
727
+ .book-table tr:hover td {{
728
+ background: var(--bg-tertiary);
729
+ }}
730
+
731
+ .table-title {{
732
+ font-weight: 500;
733
+ cursor: pointer;
734
+ }}
735
+
736
+ .table-title:hover {{
737
+ color: var(--accent);
738
+ }}
739
+
740
+ /* Modal */
741
+ .modal-overlay {{
742
+ display: none;
743
+ position: fixed;
744
+ inset: 0;
745
+ background: rgba(0,0,0,0.5);
746
+ z-index: 200;
747
+ padding: 20px;
748
+ overflow-y: auto;
749
+ }}
750
+
751
+ .modal-overlay.active {{
752
+ display: flex;
753
+ align-items: flex-start;
754
+ justify-content: center;
755
+ }}
756
+
757
+ .modal {{
758
+ background: var(--bg-secondary);
759
+ border-radius: var(--radius-lg);
760
+ max-width: 800px;
761
+ width: 100%;
762
+ margin-top: 40px;
763
+ box-shadow: var(--shadow-lg);
764
+ }}
765
+
766
+ .modal-header {{
767
+ padding: 20px 24px;
768
+ border-bottom: 1px solid var(--border);
769
+ display: flex;
770
+ align-items: flex-start;
771
+ justify-content: space-between;
772
+ gap: 16px;
773
+ }}
774
+
775
+ .modal-title {{
776
+ font-size: 1.25rem;
777
+ font-weight: 600;
778
+ color: var(--text-primary);
779
+ }}
780
+
781
+ .modal-close {{
782
+ width: 32px;
783
+ height: 32px;
784
+ border: none;
785
+ background: var(--bg-tertiary);
786
+ border-radius: var(--radius);
787
+ cursor: pointer;
788
+ font-size: 1.25rem;
789
+ color: var(--text-secondary);
790
+ display: flex;
791
+ align-items: center;
792
+ justify-content: center;
793
+ }}
794
+
795
+ .modal-close:hover {{
796
+ background: var(--danger);
797
+ color: white;
798
+ }}
799
+
800
+ .modal-body {{
801
+ padding: 24px;
802
+ max-height: 70vh;
803
+ overflow-y: auto;
804
+ }}
805
+
806
+ .modal-cover {{
807
+ text-align: center;
808
+ margin-bottom: 24px;
809
+ }}
810
+
811
+ .modal-cover img {{
812
+ max-width: 200px;
813
+ max-height: 300px;
814
+ border-radius: var(--radius);
815
+ box-shadow: var(--shadow-lg);
816
+ }}
817
+
818
+ .detail-section {{
819
+ margin-bottom: 20px;
820
+ }}
821
+
822
+ .detail-label {{
823
+ font-size: 0.75rem;
824
+ font-weight: 600;
825
+ text-transform: uppercase;
826
+ letter-spacing: 0.05em;
827
+ color: var(--text-muted);
828
+ margin-bottom: 6px;
829
+ }}
830
+
831
+ .detail-value {{
832
+ color: var(--text-primary);
833
+ }}
834
+
835
+ .detail-tags {{
836
+ display: flex;
837
+ flex-wrap: wrap;
838
+ gap: 6px;
839
+ }}
840
+
841
+ .tag {{
842
+ background: var(--bg-tertiary);
843
+ color: var(--text-secondary);
844
+ padding: 4px 10px;
845
+ border-radius: 16px;
846
+ font-size: 0.8rem;
847
+ }}
848
+
849
+ .file-list {{
850
+ list-style: none;
851
+ }}
852
+
853
+ .file-item {{
854
+ display: flex;
855
+ align-items: center;
856
+ gap: 12px;
857
+ padding: 8px 0;
858
+ border-bottom: 1px solid var(--border);
859
+ }}
860
+
861
+ .file-item:last-child {{
862
+ border-bottom: none;
863
+ }}
864
+
865
+ .file-link {{
866
+ color: var(--accent);
867
+ text-decoration: none;
868
+ font-weight: 500;
869
+ }}
870
+
871
+ .file-link:hover {{
872
+ text-decoration: underline;
873
+ }}
874
+
875
+ .file-size {{
876
+ color: var(--text-muted);
877
+ font-size: 0.85rem;
878
+ }}
879
+
880
+ /* Empty State */
881
+ .empty-state {{
882
+ text-align: center;
883
+ padding: 60px 20px;
884
+ color: var(--text-muted);
885
+ }}
886
+
887
+ .empty-state-icon {{
888
+ font-size: 4rem;
889
+ margin-bottom: 16px;
890
+ }}
891
+
892
+ .empty-state h3 {{
893
+ color: var(--text-secondary);
894
+ margin-bottom: 8px;
895
+ }}
896
+
897
+ /* Pagination */
898
+ .pagination {{
899
+ display: flex;
900
+ justify-content: center;
901
+ align-items: center;
902
+ gap: 8px;
903
+ margin-top: 24px;
904
+ flex-wrap: wrap;
905
+ }}
906
+
907
+ .page-btn {{
908
+ padding: 8px 14px;
909
+ border: 1px solid var(--border);
910
+ background: var(--bg-secondary);
911
+ color: var(--text-primary);
912
+ border-radius: var(--radius);
913
+ cursor: pointer;
914
+ font-size: 0.9rem;
915
+ transition: all 0.15s ease;
916
+ }}
917
+
918
+ .page-btn:hover:not(:disabled) {{
919
+ border-color: var(--accent);
920
+ color: var(--accent);
921
+ }}
922
+
923
+ .page-btn.active {{
924
+ background: var(--accent);
925
+ border-color: var(--accent);
926
+ color: white;
927
+ }}
928
+
929
+ .page-btn:disabled {{
930
+ opacity: 0.5;
931
+ cursor: not-allowed;
932
+ }}
933
+
934
+ /* Keyboard shortcuts hint */
935
+ .keyboard-hint {{
936
+ position: fixed;
937
+ bottom: 20px;
938
+ right: 20px;
939
+ background: var(--bg-secondary);
940
+ border: 1px solid var(--border);
941
+ border-radius: var(--radius);
942
+ padding: 12px 16px;
943
+ font-size: 0.8rem;
944
+ color: var(--text-muted);
945
+ box-shadow: var(--shadow-lg);
946
+ z-index: 50;
947
+ }}
948
+
949
+ .kbd {{
950
+ display: inline-block;
951
+ background: var(--bg-tertiary);
952
+ border: 1px solid var(--border);
953
+ border-radius: 4px;
954
+ padding: 2px 6px;
955
+ font-family: monospace;
956
+ font-size: 0.75rem;
957
+ margin: 0 2px;
958
+ }}
959
+
960
+ /* Sidebar overlay for mobile */
961
+ .sidebar-overlay {{
962
+ display: none;
963
+ position: fixed;
964
+ inset: 0;
965
+ background: rgba(0,0,0,0.5);
966
+ z-index: 99;
967
+ }}
968
+
969
+ .sidebar-overlay.active {{
970
+ display: block;
971
+ }}
972
+
973
+ /* Print styles */
974
+ @media print {{
975
+ .sidebar, .header, .toolbar, .stats-bar, .pagination, .keyboard-hint {{
976
+ display: none !important;
977
+ }}
978
+ .main-content {{
979
+ margin-left: 0 !important;
980
+ }}
981
+ .book-card, .book-list-item {{
982
+ break-inside: avoid;
983
+ }}
984
+ }}
985
+
986
+ /* Scrollbar styling */
987
+ ::-webkit-scrollbar {{
988
+ width: 8px;
989
+ height: 8px;
990
+ }}
991
+
992
+ ::-webkit-scrollbar-track {{
993
+ background: var(--bg-primary);
994
+ }}
995
+
996
+ ::-webkit-scrollbar-thumb {{
997
+ background: var(--border);
998
+ border-radius: 4px;
999
+ }}
1000
+
1001
+ ::-webkit-scrollbar-thumb:hover {{
1002
+ background: var(--text-muted);
1003
+ }}
1004
+ </style>
1005
+ </head>
1006
+ <body>
1007
+ <div class="sidebar-overlay" onclick="toggleSidebar()"></div>
1008
+
1009
+ <div class="app-container">
1010
+ <aside class="sidebar" id="sidebar">
1011
+ <div class="sidebar-header">
1012
+ <div class="sidebar-logo">
1013
+ <div class="logo-icon">📚</div>
1014
+ <span>ebk Library</span>
1015
+ </div>
1016
+ </div>
1017
+ <nav class="sidebar-nav">
1018
+ <div class="nav-section">
1019
+ <div class="nav-section-title">Library</div>
1020
+ <div class="nav-item active" data-filter="all" onclick="setFilter('all')">
1021
+ <span>📖</span> All Books
1022
+ <span class="nav-item-count" id="count-all">0</span>
1023
+ </div>
1024
+ <div class="nav-item" data-filter="favorites" onclick="setFilter('favorites')">
1025
+ <span>⭐</span> Favorites
1026
+ <span class="nav-item-count" id="count-favorites">0</span>
1027
+ </div>
1028
+ <div class="nav-item" data-filter="queue" onclick="setFilter('queue')">
1029
+ <span>📋</span> Reading Queue
1030
+ <span class="nav-item-count" id="count-queue">0</span>
1031
+ </div>
1032
+ <div class="nav-item" data-filter="reading" onclick="setFilter('reading')">
1033
+ <span>📖</span> Currently Reading
1034
+ <span class="nav-item-count" id="count-reading">0</span>
1035
+ </div>
1036
+ </div>
1037
+ <div class="nav-section">
1038
+ <div class="nav-section-title">Browse</div>
1039
+ <div class="nav-item" data-filter="authors" onclick="toggleSubnav('authors')">
1040
+ <span>👤</span> Authors
1041
+ <span class="nav-item-count" id="count-authors">0</span>
1042
+ </div>
1043
+ <div id="subnav-authors" class="subnav" style="display:none; max-height: 200px; overflow-y: auto; margin-left: 20px;"></div>
1044
+ <div class="nav-item" data-filter="subjects" onclick="toggleSubnav('subjects')">
1045
+ <span>🏷️</span> Subjects
1046
+ <span class="nav-item-count" id="count-subjects">0</span>
1047
+ </div>
1048
+ <div id="subnav-subjects" class="subnav" style="display:none; max-height: 200px; overflow-y: auto; margin-left: 20px;"></div>
1049
+ <div class="nav-item" data-filter="series" onclick="toggleSubnav('series')">
1050
+ <span>📚</span> Series
1051
+ <span class="nav-item-count" id="count-series">0</span>
1052
+ </div>
1053
+ <div id="subnav-series" class="subnav" style="display:none; max-height: 200px; overflow-y: auto; margin-left: 20px;"></div>
1054
+ </div>
1055
+ <div class="nav-section" id="views-section" style="display:none;">
1056
+ <div class="nav-section-title">Views</div>
1057
+ <div id="views-list"></div>
1058
+ </div>
1059
+ </nav>
1060
+ <div style="padding: 16px; border-top: 1px solid var(--border); font-size: 0.75rem; color: var(--text-muted);">
1061
+ Exported: {export_date}
1062
+ </div>
1063
+ </aside>
1064
+
1065
+ <main class="main-content">
1066
+ <header class="header">
1067
+ <button class="menu-toggle" onclick="toggleSidebar()">☰</button>
1068
+ <div class="search-container">
1069
+ <span class="search-icon">🔍</span>
1070
+ <input type="text" class="search-input" id="search" placeholder="Search... (try title:python or author:knuth)" autocomplete="off">
1071
+ </div>
1072
+ <div class="header-actions">
1073
+ <button class="icon-btn active" id="view-grid" onclick="setView('grid')" title="Grid View">▦</button>
1074
+ <button class="icon-btn" id="view-list" onclick="setView('list')" title="List View">☰</button>
1075
+ <button class="icon-btn" id="view-table" onclick="setView('table')" title="Table View">▤</button>
1076
+ <button class="icon-btn" id="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">🌓</button>
1077
+ </div>
1078
+ </header>
1079
+
1080
+ <div class="toolbar">
1081
+ <div class="filter-group">
1082
+ <span class="filter-label">Sort:</span>
1083
+ <select class="filter-select" id="sort-field" onchange="applyFilters()">
1084
+ <option value="title">Title</option>
1085
+ <option value="author">Author</option>
1086
+ <option value="date_added">Date Added</option>
1087
+ <option value="publication_date">Publication Date</option>
1088
+ <option value="rating">Rating</option>
1089
+ </select>
1090
+ <select class="filter-select" id="sort-order" onchange="applyFilters()">
1091
+ <option value="asc">A-Z</option>
1092
+ <option value="desc">Z-A</option>
1093
+ </select>
1094
+ </div>
1095
+ <div class="filter-group">
1096
+ <span class="filter-label">Language:</span>
1097
+ <select class="filter-select" id="filter-language" onchange="applyFilters()">
1098
+ <option value="">All</option>
1099
+ </select>
1100
+ </div>
1101
+ <div class="filter-group">
1102
+ <span class="filter-label">Format:</span>
1103
+ <select class="filter-select" id="filter-format" onchange="applyFilters()">
1104
+ <option value="">All</option>
1105
+ </select>
1106
+ </div>
1107
+ <div class="results-info" id="results-info"></div>
1108
+ </div>
1109
+
1110
+ <div class="stats-bar">
1111
+ <div class="stat-item">
1112
+ <div class="stat-icon books">📚</div>
1113
+ <div class="stat-content">
1114
+ <span class="stat-value" id="stat-books">0</span>
1115
+ <span class="stat-label">Books</span>
1116
+ </div>
1117
+ </div>
1118
+ <div class="stat-item">
1119
+ <div class="stat-icon authors">👤</div>
1120
+ <div class="stat-content">
1121
+ <span class="stat-value" id="stat-authors">0</span>
1122
+ <span class="stat-label">Authors</span>
1123
+ </div>
1124
+ </div>
1125
+ <div class="stat-item">
1126
+ <div class="stat-icon subjects">🏷️</div>
1127
+ <div class="stat-content">
1128
+ <span class="stat-value" id="stat-subjects">0</span>
1129
+ <span class="stat-label">Subjects</span>
1130
+ </div>
1131
+ </div>
1132
+ <div class="stat-item">
1133
+ <div class="stat-icon series">📖</div>
1134
+ <div class="stat-content">
1135
+ <span class="stat-value" id="stat-series">0</span>
1136
+ <span class="stat-label">Series</span>
1137
+ </div>
1138
+ </div>
1139
+ </div>
1140
+
1141
+ <div class="content">
1142
+ <div id="book-container"></div>
1143
+ <div class="empty-state" id="empty-state" style="display:none;">
1144
+ <div class="empty-state-icon">📭</div>
1145
+ <h3>No books found</h3>
1146
+ <p>Try adjusting your search or filters</p>
1147
+ </div>
1148
+ <div class="pagination" id="pagination"></div>
1149
+ </div>
1150
+ </main>
1151
+ </div>
1152
+
1153
+ <div class="modal-overlay" id="modal" onclick="if(event.target===this)closeModal()">
1154
+ <div class="modal">
1155
+ <div class="modal-header">
1156
+ <h2 class="modal-title" id="modal-title"></h2>
1157
+ <button class="modal-close" onclick="closeModal()">×</button>
1158
+ </div>
1159
+ <div class="modal-body" id="modal-body"></div>
1160
+ </div>
1161
+ </div>
1162
+
1163
+ <div class="keyboard-hint" id="keyboard-hint">
1164
+ <kbd>/</kbd> Search &nbsp; <kbd>Esc</kbd> Close &nbsp; <kbd>j</kbd><kbd>k</kbd> Navigate
1165
+ </div>
1166
+
1167
+ <script>
1168
+ // Data
1169
+ const BOOKS = {books_json};
1170
+ const STATS = {stats_json};
1171
+ const NAV = {nav_json};
1172
+ const BASE_URL = {base_url_json};
1173
+ const VIEWS = {views_json};
1174
+
1175
+ // State
1176
+ let currentView = 'grid';
1177
+ let currentFilter = 'all';
1178
+ let currentSubfilter = null;
1179
+ let currentViewName = null;
1180
+ let filteredBooks = [...BOOKS];
1181
+ let currentPage = 1;
1182
+ const perPage = 48;
1183
+
1184
+ // Initialize
1185
+ document.addEventListener('DOMContentLoaded', () => {{
1186
+ initTheme();
1187
+ populateFilters();
1188
+ updateCounts();
1189
+ applyFilters();
1190
+ setupKeyboardShortcuts();
1191
+ }});
1192
+
1193
+ function initTheme() {{
1194
+ const saved = localStorage.getItem('ebk_theme');
1195
+ if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {{
1196
+ document.documentElement.setAttribute('data-theme', 'dark');
1197
+ }}
1198
+ }}
1199
+
1200
+ function toggleTheme() {{
1201
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
1202
+ document.documentElement.setAttribute('data-theme', isDark ? 'light' : 'dark');
1203
+ localStorage.setItem('ebk_theme', isDark ? 'light' : 'dark');
1204
+ }}
1205
+
1206
+ function toggleSidebar() {{
1207
+ document.getElementById('sidebar').classList.toggle('open');
1208
+ document.querySelector('.sidebar-overlay').classList.toggle('active');
1209
+ }}
1210
+
1211
+ function populateFilters() {{
1212
+ const langSelect = document.getElementById('filter-language');
1213
+ STATS.languages.forEach(l => {{
1214
+ const opt = document.createElement('option');
1215
+ opt.value = l;
1216
+ opt.textContent = l.toUpperCase();
1217
+ langSelect.appendChild(opt);
1218
+ }});
1219
+
1220
+ const formatSelect = document.getElementById('filter-format');
1221
+ STATS.formats.forEach(f => {{
1222
+ const opt = document.createElement('option');
1223
+ opt.value = f;
1224
+ opt.textContent = f;
1225
+ formatSelect.appendChild(opt);
1226
+ }});
1227
+
1228
+ // Populate views list
1229
+ if (VIEWS && VIEWS.length > 0) {{
1230
+ const viewsSection = document.getElementById('views-section');
1231
+ const viewsList = document.getElementById('views-list');
1232
+ viewsSection.style.display = 'block';
1233
+ viewsList.innerHTML = VIEWS.map(v => {{
1234
+ const bookCount = v.book_ids ? v.book_ids.length : 0;
1235
+ const escapedName = v.name.replace(/'/g, "\\\\'");
1236
+ return `<div class="nav-item" data-view="${{escapedName}}" onclick="setViewFilter('${{escapedName}}')">
1237
+ <span>📋</span> ${{v.name}}
1238
+ <span class="nav-item-count">${{bookCount}}</span>
1239
+ </div>`;
1240
+ }}).join('');
1241
+ }}
1242
+ }}
1243
+
1244
+ function updateCounts() {{
1245
+ // Update sidebar counts
1246
+ document.getElementById('count-all').textContent = BOOKS.length;
1247
+ document.getElementById('count-favorites').textContent = BOOKS.filter(b => b.personal?.favorite).length;
1248
+ document.getElementById('count-queue').textContent = BOOKS.filter(b => b.personal?.queue_position).length;
1249
+ document.getElementById('count-reading').textContent = BOOKS.filter(b => b.personal?.reading_status === 'reading').length;
1250
+ document.getElementById('count-authors').textContent = NAV.authors.length;
1251
+ document.getElementById('count-subjects').textContent = NAV.subjects.length;
1252
+ document.getElementById('count-series').textContent = NAV.series.length;
1253
+
1254
+ // Update stats bar
1255
+ document.getElementById('stat-books').textContent = STATS.total_books.toLocaleString();
1256
+ document.getElementById('stat-authors').textContent = STATS.total_authors.toLocaleString();
1257
+ document.getElementById('stat-subjects').textContent = STATS.total_subjects.toLocaleString();
1258
+ document.getElementById('stat-series').textContent = STATS.total_series.toLocaleString();
1259
+ }}
1260
+
1261
+ function toggleSubnav(type) {{
1262
+ const el = document.getElementById('subnav-' + type);
1263
+ if (el.style.display === 'none') {{
1264
+ el.style.display = 'block';
1265
+ const items = NAV[type].slice(0, 50);
1266
+ el.innerHTML = items.map(item =>
1267
+ `<div class="nav-item" style="padding: 6px 12px; font-size: 0.8rem;" onclick="setSubfilter('${{type}}', '${{item.replace(/'/g, "\\\\'")}}')">
1268
+ ${{item.length > 30 ? item.substring(0, 30) + '...' : item}}
1269
+ </div>`
1270
+ ).join('');
1271
+ if (NAV[type].length > 50) {{
1272
+ el.innerHTML += `<div style="padding: 6px 12px; font-size: 0.75rem; color: var(--text-muted);">+ ${{NAV[type].length - 50}} more</div>`;
1273
+ }}
1274
+ }} else {{
1275
+ el.style.display = 'none';
1276
+ }}
1277
+ }}
1278
+
1279
+ function setFilter(filter) {{
1280
+ currentFilter = filter;
1281
+ currentSubfilter = null;
1282
+ currentViewName = null;
1283
+ currentPage = 1;
1284
+ document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
1285
+ document.querySelector(`.nav-item[data-filter="${{filter}}"]`)?.classList.add('active');
1286
+ applyFilters();
1287
+ if (window.innerWidth < 1024) toggleSidebar();
1288
+ }}
1289
+
1290
+ function setSubfilter(type, value) {{
1291
+ currentFilter = type;
1292
+ currentSubfilter = value;
1293
+ currentViewName = null;
1294
+ currentPage = 1;
1295
+ applyFilters();
1296
+ if (window.innerWidth < 1024) toggleSidebar();
1297
+ }}
1298
+
1299
+ function setViewFilter(viewName) {{
1300
+ currentFilter = 'view';
1301
+ currentViewName = viewName;
1302
+ currentSubfilter = null;
1303
+ currentPage = 1;
1304
+ document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
1305
+ document.querySelector(`.nav-item[data-view="${{viewName}}"]`)?.classList.add('active');
1306
+ applyFilters();
1307
+ if (window.innerWidth < 1024) toggleSidebar();
1308
+ }}
1309
+
1310
+ function setView(view) {{
1311
+ currentView = view;
1312
+ document.querySelectorAll('.header-actions .icon-btn').forEach(b => b.classList.remove('active'));
1313
+ document.getElementById('view-' + view).classList.add('active');
1314
+ renderBooks();
1315
+ }}
1316
+
1317
+ function parseSearch(query) {{
1318
+ const terms = {{}};
1319
+ const general = [];
1320
+
1321
+ // Match field:value or field:"quoted value"
1322
+ const regex = /(\\w+):(?:"([^"]+)"|([^\\s]+))|"([^"]+)"|(\\S+)/g;
1323
+ let match;
1324
+
1325
+ while ((match = regex.exec(query)) !== null) {{
1326
+ if (match[1]) {{
1327
+ // field:value
1328
+ const field = match[1].toLowerCase();
1329
+ const value = (match[2] || match[3]).toLowerCase();
1330
+ terms[field] = value;
1331
+ }} else {{
1332
+ // general term
1333
+ const term = (match[4] || match[5]).toLowerCase();
1334
+ if (term !== 'or' && term !== 'and') {{
1335
+ general.push(term);
1336
+ }}
1337
+ }}
1338
+ }}
1339
+
1340
+ return {{ terms, general }};
1341
+ }}
1342
+
1343
+ function bookMatchesSearch(book, parsed) {{
1344
+ // Check field-specific terms
1345
+ for (const [field, value] of Object.entries(parsed.terms)) {{
1346
+ switch (field) {{
1347
+ case 'title':
1348
+ if (!book.title?.toLowerCase().includes(value)) return false;
1349
+ break;
1350
+ case 'author':
1351
+ if (!book.authors.some(a => a.name.toLowerCase().includes(value))) return false;
1352
+ break;
1353
+ case 'tag':
1354
+ case 'subject':
1355
+ if (!book.subjects.some(s => s.toLowerCase().includes(value))) return false;
1356
+ break;
1357
+ case 'series':
1358
+ if (!book.series?.toLowerCase().includes(value)) return false;
1359
+ break;
1360
+ case 'language':
1361
+ case 'lang':
1362
+ if (book.language?.toLowerCase() !== value) return false;
1363
+ break;
1364
+ case 'format':
1365
+ if (!book.files.some(f => f.format.toLowerCase() === value)) return false;
1366
+ break;
1367
+ case 'rating':
1368
+ const rating = parseFloat(value);
1369
+ if (value.startsWith('>=')) {{
1370
+ if ((book.personal?.rating || 0) < parseFloat(value.slice(2))) return false;
1371
+ }} else if (value.startsWith('>')) {{
1372
+ if ((book.personal?.rating || 0) <= parseFloat(value.slice(1))) return false;
1373
+ }} else {{
1374
+ if ((book.personal?.rating || 0) < rating) return false;
1375
+ }}
1376
+ break;
1377
+ case 'favorite':
1378
+ if (value === 'true' && !book.personal?.favorite) return false;
1379
+ if (value === 'false' && book.personal?.favorite) return false;
1380
+ break;
1381
+ case 'status':
1382
+ if (book.personal?.reading_status !== value) return false;
1383
+ break;
1384
+ }}
1385
+ }}
1386
+
1387
+ // Check general terms
1388
+ if (parsed.general.length > 0) {{
1389
+ const searchable = [
1390
+ book.title,
1391
+ book.subtitle,
1392
+ ...book.authors.map(a => a.name),
1393
+ ...book.subjects,
1394
+ ...(book.keywords || []),
1395
+ book.description,
1396
+ book.publisher
1397
+ ].filter(Boolean).join(' ').toLowerCase();
1398
+
1399
+ for (const term of parsed.general) {{
1400
+ if (term.startsWith('-')) {{
1401
+ if (searchable.includes(term.slice(1))) return false;
1402
+ }} else {{
1403
+ if (!searchable.includes(term)) return false;
1404
+ }}
1405
+ }}
1406
+ }}
1407
+
1408
+ return true;
1409
+ }}
1410
+
1411
+ function applyFilters() {{
1412
+ const searchQuery = document.getElementById('search').value;
1413
+ const langFilter = document.getElementById('filter-language').value;
1414
+ const formatFilter = document.getElementById('filter-format').value;
1415
+ const sortField = document.getElementById('sort-field').value;
1416
+ const sortOrder = document.getElementById('sort-order').value;
1417
+
1418
+ const parsed = parseSearch(searchQuery);
1419
+
1420
+ filteredBooks = BOOKS.filter(book => {{
1421
+ // Category filter
1422
+ if (currentFilter === 'favorites' && !book.personal?.favorite) return false;
1423
+ if (currentFilter === 'queue' && !book.personal?.queue_position) return false;
1424
+ if (currentFilter === 'reading' && book.personal?.reading_status !== 'reading') return false;
1425
+
1426
+ // View filter
1427
+ if (currentFilter === 'view' && currentViewName) {{
1428
+ const view = VIEWS.find(v => v.name === currentViewName);
1429
+ if (view && view.book_ids) {{
1430
+ if (!view.book_ids.includes(book.id)) return false;
1431
+ }}
1432
+ }}
1433
+
1434
+ // Subfilter
1435
+ if (currentSubfilter) {{
1436
+ if (currentFilter === 'authors' && !book.authors.some(a => a.name === currentSubfilter)) return false;
1437
+ if (currentFilter === 'subjects' && !book.subjects.includes(currentSubfilter)) return false;
1438
+ if (currentFilter === 'series' && book.series !== currentSubfilter) return false;
1439
+ }}
1440
+
1441
+ // Search
1442
+ if (searchQuery && !bookMatchesSearch(book, parsed)) return false;
1443
+
1444
+ // Language
1445
+ if (langFilter && book.language !== langFilter) return false;
1446
+
1447
+ // Format
1448
+ if (formatFilter && !book.files.some(f => f.format.toUpperCase() === formatFilter)) return false;
1449
+
1450
+ return true;
1451
+ }});
1452
+
1453
+ // Sort
1454
+ filteredBooks.sort((a, b) => {{
1455
+ let aVal, bVal;
1456
+ switch (sortField) {{
1457
+ case 'title':
1458
+ aVal = a.title?.toLowerCase() || '';
1459
+ bVal = b.title?.toLowerCase() || '';
1460
+ break;
1461
+ case 'author':
1462
+ aVal = a.authors[0]?.name.toLowerCase() || '';
1463
+ bVal = b.authors[0]?.name.toLowerCase() || '';
1464
+ break;
1465
+ case 'date_added':
1466
+ aVal = new Date(a.created_at);
1467
+ bVal = new Date(b.created_at);
1468
+ break;
1469
+ case 'publication_date':
1470
+ aVal = a.publication_date || '';
1471
+ bVal = b.publication_date || '';
1472
+ break;
1473
+ case 'rating':
1474
+ aVal = a.personal?.rating || 0;
1475
+ bVal = b.personal?.rating || 0;
1476
+ break;
1477
+ default:
1478
+ return 0;
1479
+ }}
1480
+ const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
1481
+ return sortOrder === 'asc' ? cmp : -cmp;
1482
+ }});
1483
+
1484
+ renderBooks();
1485
+ }}
1486
+
1487
+ function renderBooks() {{
1488
+ const container = document.getElementById('book-container');
1489
+ const emptyState = document.getElementById('empty-state');
1490
+ const resultsInfo = document.getElementById('results-info');
1491
+ const pagination = document.getElementById('pagination');
1492
+
1493
+ if (filteredBooks.length === 0) {{
1494
+ container.innerHTML = '';
1495
+ emptyState.style.display = 'block';
1496
+ pagination.innerHTML = '';
1497
+ resultsInfo.textContent = 'No books found';
1498
+ return;
1499
+ }}
1500
+
1501
+ emptyState.style.display = 'none';
1502
+
1503
+ // Pagination
1504
+ const totalPages = Math.ceil(filteredBooks.length / perPage);
1505
+ const start = (currentPage - 1) * perPage;
1506
+ const pageBooks = filteredBooks.slice(start, start + perPage);
1507
+
1508
+ resultsInfo.textContent = `Showing ${{start + 1}}-${{Math.min(start + perPage, filteredBooks.length)}} of ${{filteredBooks.length}} books`;
1509
+
1510
+ if (currentView === 'grid') {{
1511
+ container.innerHTML = `<div class="book-grid">${{pageBooks.map(renderGridCard).join('')}}</div>`;
1512
+ }} else if (currentView === 'list') {{
1513
+ container.innerHTML = `<div class="book-list">${{pageBooks.map(renderListItem).join('')}}</div>`;
1514
+ }} else {{
1515
+ container.innerHTML = renderTable(pageBooks);
1516
+ }}
1517
+
1518
+ renderPagination(totalPages);
1519
+ }}
1520
+
1521
+ function renderGridCard(book) {{
1522
+ const coverUrl = book.cover_path ? (BASE_URL ? BASE_URL + '/' + book.cover_path : book.cover_path) : null;
1523
+ const author = book.authors.map(a => a.name).join(', ') || 'Unknown';
1524
+ const rating = book.personal?.rating ? '★'.repeat(Math.round(book.personal.rating)) : '';
1525
+
1526
+ return `
1527
+ <div class="book-card" onclick="showDetails(${{book.id}})">
1528
+ <div class="book-cover">
1529
+ ${{coverUrl ? `<img src="${{coverUrl}}" alt="" loading="lazy" onerror="this.parentElement.innerHTML='<div class=\\'book-cover-placeholder\\'>📖</div>'">` : '<div class="book-cover-placeholder">📖</div>'}}
1530
+ </div>
1531
+ <div class="book-info">
1532
+ <div class="book-title">${{escapeHtml(book.title)}}</div>
1533
+ <div class="book-author">${{escapeHtml(author)}}</div>
1534
+ <div class="book-meta">
1535
+ ${{book.personal?.favorite ? '<span class="book-badge badge-favorite">⭐</span>' : ''}}
1536
+ ${{book.personal?.queue_position ? `<span class="book-badge badge-queue">#${{book.personal.queue_position}}</span>` : ''}}
1537
+ ${{book.files.map(f => `<span class="book-badge badge-format">${{f.format}}</span>`).join('')}}
1538
+ </div>
1539
+ ${{rating ? `<div class="book-rating">${{rating}}</div>` : ''}}
1540
+ </div>
1541
+ </div>
1542
+ `;
1543
+ }}
1544
+
1545
+ function renderListItem(book) {{
1546
+ const coverUrl = book.cover_path ? (BASE_URL ? BASE_URL + '/' + book.cover_path : book.cover_path) : null;
1547
+ const author = book.authors.map(a => a.name).join(', ') || 'Unknown';
1548
+
1549
+ return `
1550
+ <div class="book-list-item" onclick="showDetails(${{book.id}})">
1551
+ <div class="book-list-cover">
1552
+ ${{coverUrl ? `<img src="${{coverUrl}}" alt="" loading="lazy">` : ''}}
1553
+ </div>
1554
+ <div class="book-list-info">
1555
+ <div class="book-list-title">
1556
+ ${{book.personal?.favorite ? '⭐ ' : ''}}${{escapeHtml(book.title)}}
1557
+ </div>
1558
+ <div class="book-list-author">${{escapeHtml(author)}}</div>
1559
+ <div class="book-list-meta">
1560
+ ${{book.publication_date ? `<span>📅 ${{book.publication_date}}</span>` : ''}}
1561
+ ${{book.language ? `<span>🌐 ${{book.language.toUpperCase()}}</span>` : ''}}
1562
+ ${{book.files.map(f => `<span>📄 ${{f.format.toUpperCase()}}</span>`).join('')}}
1563
+ ${{book.personal?.rating ? `<span>⭐ ${{book.personal.rating}}</span>` : ''}}
1564
+ </div>
1565
+ </div>
1566
+ </div>
1567
+ `;
1568
+ }}
1569
+
1570
+ function renderTable(books) {{
1571
+ return `
1572
+ <table class="book-table">
1573
+ <thead>
1574
+ <tr>
1575
+ <th onclick="sortBy('title')">Title</th>
1576
+ <th onclick="sortBy('author')">Author</th>
1577
+ <th onclick="sortBy('publication_date')">Year</th>
1578
+ <th>Format</th>
1579
+ <th onclick="sortBy('rating')">Rating</th>
1580
+ </tr>
1581
+ </thead>
1582
+ <tbody>
1583
+ ${{books.map(book => `
1584
+ <tr>
1585
+ <td><span class="table-title" onclick="showDetails(${{book.id}})">${{book.personal?.favorite ? '⭐ ' : ''}}${{escapeHtml(book.title)}}</span></td>
1586
+ <td>${{escapeHtml(book.authors.map(a => a.name).join(', ') || '-')}}</td>
1587
+ <td>${{book.publication_date?.substring(0, 4) || '-'}}</td>
1588
+ <td>${{book.files.map(f => f.format.toUpperCase()).join(', ')}}</td>
1589
+ <td>${{book.personal?.rating ? '★'.repeat(Math.round(book.personal.rating)) : '-'}}</td>
1590
+ </tr>
1591
+ `).join('')}}
1592
+ </tbody>
1593
+ </table>
1594
+ `;
1595
+ }}
1596
+
1597
+ function renderPagination(totalPages) {{
1598
+ const pagination = document.getElementById('pagination');
1599
+ if (totalPages <= 1) {{
1600
+ pagination.innerHTML = '';
1601
+ return;
1602
+ }}
1603
+
1604
+ let html = `<button class="page-btn" onclick="goToPage(${{currentPage - 1}})" ${{currentPage === 1 ? 'disabled' : ''}}>← Prev</button>`;
1605
+
1606
+ const start = Math.max(1, currentPage - 2);
1607
+ const end = Math.min(totalPages, currentPage + 2);
1608
+
1609
+ if (start > 1) html += `<button class="page-btn" onclick="goToPage(1)">1</button>`;
1610
+ if (start > 2) html += `<span style="color:var(--text-muted)">...</span>`;
1611
+
1612
+ for (let i = start; i <= end; i++) {{
1613
+ html += `<button class="page-btn ${{i === currentPage ? 'active' : ''}}" onclick="goToPage(${{i}})">${{i}}</button>`;
1614
+ }}
1615
+
1616
+ if (end < totalPages - 1) html += `<span style="color:var(--text-muted)">...</span>`;
1617
+ if (end < totalPages) html += `<button class="page-btn" onclick="goToPage(${{totalPages}})">${{totalPages}}</button>`;
1618
+
1619
+ html += `<button class="page-btn" onclick="goToPage(${{currentPage + 1}})" ${{currentPage === totalPages ? 'disabled' : ''}}>Next →</button>`;
1620
+
1621
+ pagination.innerHTML = html;
1622
+ }}
1623
+
1624
+ function goToPage(page) {{
1625
+ const totalPages = Math.ceil(filteredBooks.length / perPage);
1626
+ if (page < 1 || page > totalPages) return;
1627
+ currentPage = page;
1628
+ renderBooks();
1629
+ window.scrollTo({{ top: 0, behavior: 'smooth' }});
1630
+ }}
1631
+
1632
+ function showDetails(id) {{
1633
+ const book = BOOKS.find(b => b.id === id);
1634
+ if (!book) return;
1635
+
1636
+ document.getElementById('modal-title').textContent = book.title;
1637
+
1638
+ const coverUrl = book.cover_path ? (BASE_URL ? BASE_URL + '/' + book.cover_path : book.cover_path) : null;
1639
+
1640
+ let html = '';
1641
+
1642
+ if (coverUrl) {{
1643
+ html += `<div class="modal-cover"><img src="${{coverUrl}}" alt="" onerror="this.style.display='none'"></div>`;
1644
+ }}
1645
+
1646
+ if (book.subtitle) {{
1647
+ html += `<div class="detail-section"><div class="detail-label">Subtitle</div><div class="detail-value">${{escapeHtml(book.subtitle)}}</div></div>`;
1648
+ }}
1649
+
1650
+ if (book.authors.length) {{
1651
+ html += `<div class="detail-section"><div class="detail-label">Authors</div><div class="detail-value">${{book.authors.map(a => escapeHtml(a.name)).join(', ')}}</div></div>`;
1652
+ }}
1653
+
1654
+ if (book.series) {{
1655
+ html += `<div class="detail-section"><div class="detail-label">Series</div><div class="detail-value">${{escapeHtml(book.series)}} #${{book.series_index || '?'}}</div></div>`;
1656
+ }}
1657
+
1658
+ if (book.description) {{
1659
+ html += `<div class="detail-section"><div class="detail-label">Description</div><div class="detail-value">${{book.description}}</div></div>`;
1660
+ }}
1661
+
1662
+ const meta = [];
1663
+ if (book.publisher) meta.push(`Publisher: ${{escapeHtml(book.publisher)}}`);
1664
+ if (book.publication_date) meta.push(`Published: ${{book.publication_date}}`);
1665
+ if (book.language) meta.push(`Language: ${{book.language.toUpperCase()}}`);
1666
+ if (book.page_count) meta.push(`Pages: ${{book.page_count}}`);
1667
+ if (meta.length) {{
1668
+ html += `<div class="detail-section"><div class="detail-label">Details</div><div class="detail-value">${{meta.join(' • ')}}</div></div>`;
1669
+ }}
1670
+
1671
+ if (book.subjects.length) {{
1672
+ html += `<div class="detail-section"><div class="detail-label">Subjects</div><div class="detail-tags">${{book.subjects.map(s => `<span class="tag">${{escapeHtml(s)}}</span>`).join('')}}</div></div>`;
1673
+ }}
1674
+
1675
+ if (book.files.length) {{
1676
+ html += `<div class="detail-section"><div class="detail-label">Files</div><ul class="file-list">${{book.files.map(f => {{
1677
+ const url = BASE_URL ? BASE_URL + '/' + f.path : f.path;
1678
+ return `<li class="file-item"><a href="${{url}}" class="file-link" target="_blank">${{f.format.toUpperCase()}}</a><span class="file-size">${{formatBytes(f.size_bytes)}}</span></li>`;
1679
+ }}).join('')}}</ul></div>`;
1680
+ }}
1681
+
1682
+ if (book.identifiers.length) {{
1683
+ html += `<div class="detail-section"><div class="detail-label">Identifiers</div><div class="detail-value">${{book.identifiers.map(i => `${{i.scheme.toUpperCase()}}: ${{i.value}}`).join('<br>')}}</div></div>`;
1684
+ }}
1685
+
1686
+ document.getElementById('modal-body').innerHTML = html;
1687
+ document.getElementById('modal').classList.add('active');
1688
+ }}
1689
+
1690
+ function closeModal() {{
1691
+ document.getElementById('modal').classList.remove('active');
1692
+ }}
1693
+
1694
+ function setupKeyboardShortcuts() {{
1695
+ document.addEventListener('keydown', e => {{
1696
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {{
1697
+ if (e.key === 'Escape') {{
1698
+ e.target.blur();
1699
+ }}
1700
+ return;
1701
+ }}
1702
+
1703
+ if (e.key === '/') {{
1704
+ e.preventDefault();
1705
+ document.getElementById('search').focus();
1706
+ }} else if (e.key === 'Escape') {{
1707
+ closeModal();
1708
+ }} else if (e.key === 'j') {{
1709
+ goToPage(currentPage + 1);
1710
+ }} else if (e.key === 'k') {{
1711
+ goToPage(currentPage - 1);
1712
+ }} else if (e.key === 'g') {{
1713
+ setView('grid');
1714
+ }} else if (e.key === 'l') {{
1715
+ setView('list');
1716
+ }} else if (e.key === 't') {{
1717
+ setView('table');
1718
+ }}
1719
+ }});
1720
+
1721
+ document.getElementById('search').addEventListener('input', () => {{
1722
+ currentPage = 1;
1723
+ applyFilters();
1724
+ }});
1725
+ }}
1726
+
1727
+ function escapeHtml(text) {{
1728
+ if (!text) return '';
1729
+ const div = document.createElement('div');
1730
+ div.textContent = text;
1731
+ return div.innerHTML;
1732
+ }}
1733
+
1734
+ function formatBytes(bytes) {{
1735
+ if (!bytes) return '0 B';
1736
+ const k = 1024;
1737
+ const sizes = ['B', 'KB', 'MB', 'GB'];
1738
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1739
+ return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
1740
+ }}
1741
+ </script>
1742
+ </body>
1743
+ </html>'''