ebk 0.3.1__py3-none-any.whl → 0.3.2__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.

Potentially problematic release.


This version of ebk might be problematic. Click here for more details.

Files changed (61) hide show
  1. ebk/ai/__init__.py +23 -0
  2. ebk/ai/knowledge_graph.py +443 -0
  3. ebk/ai/llm_providers/__init__.py +21 -0
  4. ebk/ai/llm_providers/base.py +230 -0
  5. ebk/ai/llm_providers/ollama.py +362 -0
  6. ebk/ai/metadata_enrichment.py +396 -0
  7. ebk/ai/question_generator.py +328 -0
  8. ebk/ai/reading_companion.py +224 -0
  9. ebk/ai/semantic_search.py +434 -0
  10. ebk/ai/text_extractor.py +394 -0
  11. ebk/cli.py +1097 -9
  12. ebk/db/__init__.py +37 -0
  13. ebk/db/migrations.py +180 -0
  14. ebk/db/models.py +526 -0
  15. ebk/db/session.py +144 -0
  16. ebk/exports/__init__.py +0 -0
  17. ebk/exports/base_exporter.py +218 -0
  18. ebk/exports/html_library.py +1390 -0
  19. ebk/exports/html_utils.py +117 -0
  20. ebk/exports/hugo.py +59 -0
  21. ebk/exports/jinja_export.py +287 -0
  22. ebk/exports/multi_facet_export.py +164 -0
  23. ebk/exports/symlink_dag.py +479 -0
  24. ebk/exports/zip.py +25 -0
  25. ebk/library_db.py +155 -0
  26. ebk/repl/__init__.py +9 -0
  27. ebk/repl/find.py +126 -0
  28. ebk/repl/grep.py +174 -0
  29. ebk/repl/shell.py +1677 -0
  30. ebk/repl/text_utils.py +320 -0
  31. ebk/services/__init__.py +11 -0
  32. ebk/services/import_service.py +442 -0
  33. ebk/services/tag_service.py +282 -0
  34. ebk/services/text_extraction.py +317 -0
  35. ebk/similarity/__init__.py +77 -0
  36. ebk/similarity/base.py +154 -0
  37. ebk/similarity/core.py +445 -0
  38. ebk/similarity/extractors.py +168 -0
  39. ebk/similarity/metrics.py +376 -0
  40. ebk/vfs/__init__.py +101 -0
  41. ebk/vfs/base.py +301 -0
  42. ebk/vfs/library_vfs.py +124 -0
  43. ebk/vfs/nodes/__init__.py +54 -0
  44. ebk/vfs/nodes/authors.py +196 -0
  45. ebk/vfs/nodes/books.py +480 -0
  46. ebk/vfs/nodes/files.py +155 -0
  47. ebk/vfs/nodes/metadata.py +385 -0
  48. ebk/vfs/nodes/root.py +100 -0
  49. ebk/vfs/nodes/similar.py +165 -0
  50. ebk/vfs/nodes/subjects.py +184 -0
  51. ebk/vfs/nodes/tags.py +371 -0
  52. ebk/vfs/resolver.py +228 -0
  53. {ebk-0.3.1.dist-info → ebk-0.3.2.dist-info}/METADATA +1 -1
  54. ebk-0.3.2.dist-info/RECORD +69 -0
  55. ebk-0.3.2.dist-info/entry_points.txt +2 -0
  56. ebk-0.3.2.dist-info/top_level.txt +1 -0
  57. ebk-0.3.1.dist-info/RECORD +0 -19
  58. ebk-0.3.1.dist-info/entry_points.txt +0 -6
  59. ebk-0.3.1.dist-info/top_level.txt +0 -2
  60. {ebk-0.3.1.dist-info → ebk-0.3.2.dist-info}/WHEEL +0 -0
  61. {ebk-0.3.1.dist-info → ebk-0.3.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1390 @@
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
+
8
+ from pathlib import Path
9
+ from typing import List, Optional
10
+ import json
11
+ from datetime import datetime
12
+
13
+
14
+ def export_to_html(books: List, output_path: Path, include_stats: bool = True, base_url: str = ""):
15
+ """
16
+ Export library to a single self-contained HTML file.
17
+
18
+ Args:
19
+ books: List of Book ORM objects
20
+ output_path: Path to write HTML file
21
+ include_stats: Include library statistics
22
+ base_url: Base URL for file links (e.g., '/library' or 'https://example.com/books')
23
+ If empty, uses relative paths from HTML file location
24
+ """
25
+
26
+ # Serialize books to JSON-compatible format
27
+ books_data = []
28
+ for book in books:
29
+ # Get primary cover if available
30
+ cover_path = None
31
+ if book.covers:
32
+ primary_cover = next((c for c in book.covers if c.is_primary), book.covers[0])
33
+ cover_path = primary_cover.path
34
+
35
+ book_data = {
36
+ 'id': book.id,
37
+ 'title': book.title,
38
+ 'subtitle': book.subtitle,
39
+ 'authors': [{'name': a.name, 'sort_name': a.sort_name} for a in book.authors],
40
+ 'contributors': [
41
+ {'name': c.name, 'role': c.role, 'file_as': c.file_as}
42
+ for c in book.contributors
43
+ ] if hasattr(book, 'contributors') else [],
44
+ 'subjects': [s.name for s in book.subjects],
45
+ 'language': book.language,
46
+ 'publisher': book.publisher,
47
+ 'publication_date': book.publication_date,
48
+ 'series': book.series,
49
+ 'series_index': book.series_index,
50
+ 'edition': book.edition,
51
+ 'description': book.description,
52
+ 'page_count': book.page_count,
53
+ 'word_count': book.word_count,
54
+ 'keywords': book.keywords or [],
55
+ 'rights': book.rights,
56
+ 'identifiers': [
57
+ {'scheme': i.scheme, 'value': i.value}
58
+ for i in book.identifiers
59
+ ],
60
+ 'files': [
61
+ {
62
+ 'format': f.format,
63
+ 'size_bytes': f.size_bytes,
64
+ 'mime_type': f.mime_type,
65
+ 'creator_application': f.creator_application,
66
+ 'created_date': f.created_date.isoformat() if f.created_date else None,
67
+ 'modified_date': f.modified_date.isoformat() if f.modified_date else None,
68
+ 'path': f.path, # Store relative path from library root
69
+ }
70
+ for f in book.files
71
+ ],
72
+ 'cover_path': cover_path,
73
+ 'personal': {
74
+ 'rating': book.personal.rating if book.personal else None,
75
+ 'favorite': book.personal.favorite if book.personal else False,
76
+ 'reading_status': book.personal.reading_status if book.personal else 'unread',
77
+ 'tags': book.personal.personal_tags or [] if book.personal else [],
78
+ },
79
+ 'created_at': book.created_at.isoformat(),
80
+ }
81
+ books_data.append(book_data)
82
+
83
+ # Generate statistics
84
+ stats = {}
85
+ if include_stats:
86
+ all_authors = set()
87
+ all_subjects = set()
88
+ all_languages = set()
89
+ all_formats = set()
90
+ series_count = 0
91
+
92
+ for book in books:
93
+ for author in book.authors:
94
+ all_authors.add(author.name)
95
+ for subject in book.subjects:
96
+ all_subjects.add(subject.name)
97
+ if book.language:
98
+ all_languages.add(book.language)
99
+ for file in book.files:
100
+ all_formats.add(file.format)
101
+ if book.series:
102
+ series_count += 1
103
+
104
+ stats = {
105
+ 'total_books': len(books),
106
+ 'total_authors': len(all_authors),
107
+ 'total_subjects': len(all_subjects),
108
+ 'languages': list(all_languages),
109
+ 'formats': list(all_formats),
110
+ 'books_in_series': series_count,
111
+ }
112
+
113
+ # Create HTML content
114
+ html_content = _generate_html_template(books_data, stats, base_url)
115
+
116
+ # Write to file
117
+ output_path.write_text(html_content, encoding='utf-8')
118
+
119
+
120
+ def _generate_html_template(books_data: List[dict], stats: dict, base_url: str = "") -> str:
121
+ """Generate the complete HTML template with embedded CSS and JavaScript."""
122
+
123
+ books_json = json.dumps(books_data, indent=2, ensure_ascii=False)
124
+ stats_json = json.dumps(stats, indent=2, ensure_ascii=False)
125
+ base_url_json = json.dumps(base_url, ensure_ascii=False)
126
+ export_date = datetime.now().isoformat()
127
+
128
+ return f'''<!DOCTYPE html>
129
+ <html lang="en">
130
+ <head>
131
+ <meta charset="UTF-8">
132
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
133
+ <title>eBook Library Catalog</title>
134
+ <style>
135
+ * {{
136
+ margin: 0;
137
+ padding: 0;
138
+ box-sizing: border-box;
139
+ }}
140
+
141
+ :root {{
142
+ --primary: #2563eb;
143
+ --primary-dark: #1e40af;
144
+ --secondary: #64748b;
145
+ --background: #f8fafc;
146
+ --surface: #ffffff;
147
+ --text: #1e293b;
148
+ --text-light: #64748b;
149
+ --border: #e2e8f0;
150
+ --success: #10b981;
151
+ --warning: #f59e0b;
152
+ --danger: #ef4444;
153
+ }}
154
+
155
+ body {{
156
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
157
+ background: var(--background);
158
+ color: var(--text);
159
+ line-height: 1.6;
160
+ }}
161
+
162
+ .container {{
163
+ max-width: 1400px;
164
+ margin: 0 auto;
165
+ padding: 20px;
166
+ }}
167
+
168
+ header {{
169
+ background: var(--surface);
170
+ border-bottom: 2px solid var(--border);
171
+ padding: 20px 0;
172
+ margin-bottom: 30px;
173
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
174
+ }}
175
+
176
+ h1 {{
177
+ font-size: 2rem;
178
+ font-weight: 700;
179
+ color: var(--primary);
180
+ margin-bottom: 10px;
181
+ }}
182
+
183
+ .stats {{
184
+ display: flex;
185
+ gap: 30px;
186
+ flex-wrap: wrap;
187
+ margin-top: 15px;
188
+ color: var(--text-light);
189
+ font-size: 0.9rem;
190
+ }}
191
+
192
+ .stat-item {{
193
+ display: flex;
194
+ align-items: center;
195
+ gap: 5px;
196
+ }}
197
+
198
+ .stat-value {{
199
+ font-weight: 600;
200
+ color: var(--text);
201
+ }}
202
+
203
+ .controls {{
204
+ background: var(--surface);
205
+ padding: 20px;
206
+ border-radius: 8px;
207
+ margin-bottom: 20px;
208
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
209
+ }}
210
+
211
+ .search-bar {{
212
+ width: 100%;
213
+ padding: 12px 16px;
214
+ font-size: 1rem;
215
+ border: 2px solid var(--border);
216
+ border-radius: 6px;
217
+ margin-bottom: 15px;
218
+ transition: border-color 0.2s;
219
+ }}
220
+
221
+ .search-bar:focus {{
222
+ outline: none;
223
+ border-color: var(--primary);
224
+ }}
225
+
226
+ .filters {{
227
+ display: flex;
228
+ gap: 15px;
229
+ flex-wrap: wrap;
230
+ }}
231
+
232
+ .filter-group {{
233
+ flex: 1;
234
+ min-width: 200px;
235
+ }}
236
+
237
+ .filter-group label {{
238
+ display: block;
239
+ font-size: 0.875rem;
240
+ font-weight: 600;
241
+ margin-bottom: 5px;
242
+ color: var(--text-light);
243
+ }}
244
+
245
+ .filter-group select {{
246
+ width: 100%;
247
+ padding: 8px 12px;
248
+ border: 1px solid var(--border);
249
+ border-radius: 6px;
250
+ font-size: 0.9rem;
251
+ background: white;
252
+ cursor: pointer;
253
+ }}
254
+
255
+ .book-grid {{
256
+ display: grid;
257
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
258
+ gap: 20px;
259
+ }}
260
+
261
+ .book-card {{
262
+ background: var(--surface);
263
+ border-radius: 8px;
264
+ padding: 20px;
265
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
266
+ transition: transform 0.2s, box-shadow 0.2s;
267
+ cursor: pointer;
268
+ }}
269
+
270
+ .book-card:hover {{
271
+ transform: translateY(-2px);
272
+ box-shadow: 0 4px 6px rgba(0,0,0,0.15);
273
+ }}
274
+
275
+ .book-header {{
276
+ display: flex;
277
+ justify-content: space-between;
278
+ align-items: flex-start;
279
+ margin-bottom: 12px;
280
+ }}
281
+
282
+ .book-title {{
283
+ font-size: 1.1rem;
284
+ font-weight: 600;
285
+ color: var(--text);
286
+ line-height: 1.3;
287
+ flex: 1;
288
+ }}
289
+
290
+ .favorite-badge {{
291
+ color: var(--warning);
292
+ font-size: 1.2rem;
293
+ margin-left: 8px;
294
+ }}
295
+
296
+ .book-subtitle {{
297
+ font-size: 0.9rem;
298
+ color: var(--text-light);
299
+ margin-bottom: 8px;
300
+ }}
301
+
302
+ .book-authors {{
303
+ font-size: 0.95rem;
304
+ color: var(--text-light);
305
+ margin-bottom: 8px;
306
+ }}
307
+
308
+ .book-meta {{
309
+ display: flex;
310
+ flex-wrap: wrap;
311
+ gap: 8px;
312
+ margin-top: 12px;
313
+ }}
314
+
315
+ .badge {{
316
+ display: inline-block;
317
+ padding: 3px 8px;
318
+ border-radius: 4px;
319
+ font-size: 0.75rem;
320
+ font-weight: 600;
321
+ text-transform: uppercase;
322
+ }}
323
+
324
+ .badge-series {{
325
+ background: #dbeafe;
326
+ color: var(--primary);
327
+ }}
328
+
329
+ .badge-format {{
330
+ background: #f3f4f6;
331
+ color: var(--secondary);
332
+ }}
333
+
334
+ .badge-language {{
335
+ background: #fef3c7;
336
+ color: #92400e;
337
+ }}
338
+
339
+ .rating {{
340
+ color: var(--warning);
341
+ font-size: 0.9rem;
342
+ }}
343
+
344
+ .modal {{
345
+ display: none;
346
+ position: fixed;
347
+ top: 0;
348
+ left: 0;
349
+ right: 0;
350
+ bottom: 0;
351
+ background: rgba(0,0,0,0.6);
352
+ z-index: 1000;
353
+ padding: 20px;
354
+ overflow-y: auto;
355
+ }}
356
+
357
+ .modal.active {{
358
+ display: flex;
359
+ align-items: center;
360
+ justify-content: center;
361
+ }}
362
+
363
+ .modal-content {{
364
+ background: var(--surface);
365
+ border-radius: 12px;
366
+ max-width: 800px;
367
+ width: 100%;
368
+ max-height: 90vh;
369
+ overflow-y: auto;
370
+ box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
371
+ }}
372
+
373
+ .modal-header {{
374
+ padding: 24px;
375
+ border-bottom: 1px solid var(--border);
376
+ display: flex;
377
+ justify-content: space-between;
378
+ align-items: flex-start;
379
+ }}
380
+
381
+ .modal-title {{
382
+ font-size: 1.5rem;
383
+ font-weight: 700;
384
+ color: var(--text);
385
+ flex: 1;
386
+ }}
387
+
388
+ .close-btn {{
389
+ background: none;
390
+ border: none;
391
+ font-size: 2rem;
392
+ color: var(--text-light);
393
+ cursor: pointer;
394
+ padding: 0;
395
+ width: 30px;
396
+ height: 30px;
397
+ line-height: 1;
398
+ }}
399
+
400
+ .close-btn:hover {{
401
+ color: var(--text);
402
+ }}
403
+
404
+ .modal-body {{
405
+ padding: 24px;
406
+ }}
407
+
408
+ .detail-section {{
409
+ margin-bottom: 24px;
410
+ }}
411
+
412
+ .detail-label {{
413
+ font-weight: 600;
414
+ color: var(--text-light);
415
+ font-size: 0.875rem;
416
+ text-transform: uppercase;
417
+ letter-spacing: 0.05em;
418
+ margin-bottom: 8px;
419
+ }}
420
+
421
+ .detail-value {{
422
+ color: var(--text);
423
+ margin-bottom: 8px;
424
+ }}
425
+
426
+ .contributors-list, .identifiers-list {{
427
+ list-style: none;
428
+ padding: 0;
429
+ }}
430
+
431
+ .contributors-list li, .identifiers-list li {{
432
+ padding: 4px 0;
433
+ color: var(--text);
434
+ }}
435
+
436
+ .contributor-role {{
437
+ color: var(--text-light);
438
+ font-size: 0.875rem;
439
+ margin-left: 8px;
440
+ }}
441
+
442
+ .file-link {{
443
+ color: var(--primary);
444
+ text-decoration: none;
445
+ font-weight: 600;
446
+ transition: color 0.2s;
447
+ }}
448
+
449
+ .file-link:hover {{
450
+ color: var(--primary-dark);
451
+ text-decoration: underline;
452
+ }}
453
+
454
+ .tags {{
455
+ display: flex;
456
+ flex-wrap: wrap;
457
+ gap: 6px;
458
+ }}
459
+
460
+ .tag {{
461
+ background: #e0e7ff;
462
+ color: var(--primary);
463
+ padding: 4px 10px;
464
+ border-radius: 12px;
465
+ font-size: 0.8rem;
466
+ }}
467
+
468
+ .no-results {{
469
+ text-align: center;
470
+ padding: 60px 20px;
471
+ color: var(--text-light);
472
+ }}
473
+
474
+ .no-results-icon {{
475
+ font-size: 4rem;
476
+ margin-bottom: 16px;
477
+ }}
478
+
479
+ @media (max-width: 768px) {{
480
+ .book-grid {{
481
+ grid-template-columns: 1fr;
482
+ }}
483
+
484
+ .filters {{
485
+ flex-direction: column;
486
+ }}
487
+
488
+ .stats {{
489
+ flex-direction: column;
490
+ gap: 8px;
491
+ }}
492
+ }}
493
+
494
+ code {{
495
+ background: rgba(37, 99, 235, 0.1);
496
+ padding: 2px 6px;
497
+ border-radius: 4px;
498
+ font-family: 'Courier New', monospace;
499
+ font-size: 0.9em;
500
+ color: var(--primary);
501
+ }}
502
+
503
+ #search-help ul {{
504
+ list-style-type: none;
505
+ }}
506
+
507
+ #search-help li {{
508
+ padding: 3px 0;
509
+ }}
510
+ </style>
511
+ </head>
512
+ <body>
513
+ <header>
514
+ <div class="container">
515
+ <h1>📚 eBook Library</h1>
516
+ <div class="stats">
517
+ <div class="stat-item">
518
+ <span>Total Books:</span>
519
+ <span class="stat-value" id="total-books">0</span>
520
+ </div>
521
+ <div class="stat-item">
522
+ <span>Authors:</span>
523
+ <span class="stat-value" id="total-authors">0</span>
524
+ </div>
525
+ <div class="stat-item">
526
+ <span>Subjects:</span>
527
+ <span class="stat-value" id="total-subjects">0</span>
528
+ </div>
529
+ <div class="stat-item">
530
+ <span>Exported:</span>
531
+ <span class="stat-value">{export_date[:10]}</span>
532
+ </div>
533
+ </div>
534
+ </div>
535
+ </header>
536
+
537
+ <div class="container">
538
+ <div class="controls">
539
+ <div style="position: relative; display: flex; align-items: center; gap: 8px;">
540
+ <input
541
+ type="text"
542
+ class="search-bar"
543
+ id="search-input"
544
+ placeholder="Search books... (try: title:Python rating:>=4)"
545
+ style="flex: 1;"
546
+ >
547
+ <button
548
+ onclick="toggleSearchHelp()"
549
+ style="background: var(--secondary); color: white; border: none; border-radius: 6px; width: 36px; height: 36px; cursor: pointer; font-size: 1.2rem; display: flex; align-items: center; justify-content: center;"
550
+ title="Search Help"
551
+ >
552
+ ?
553
+ </button>
554
+ </div>
555
+
556
+ <div id="search-help" style="display: none; margin-top: 10px; padding: 15px; background: var(--card-bg); border-radius: 8px; border: 2px solid var(--primary); font-size: 0.875rem;">
557
+ <h4 style="margin-top: 0; color: var(--primary);">Advanced Search Syntax</h4>
558
+
559
+ <p style="margin: 5px 0;"><strong>Field Searches:</strong></p>
560
+ <ul style="margin: 5px 0 10px 20px; padding: 0;">
561
+ <li><code>title:Python</code> - Search in title only</li>
562
+ <li><code>author:Knuth</code> - Search author name</li>
563
+ <li><code>tag:programming</code> - Search subjects/tags</li>
564
+ <li><code>description:algorithms</code> - Search description</li>
565
+ <li><code>series:TAOCP</code> - Search series name</li>
566
+ </ul>
567
+
568
+ <p style="margin: 5px 0;"><strong>Filters:</strong></p>
569
+ <ul style="margin: 5px 0 10px 20px; padding: 0;">
570
+ <li><code>language:en</code> - Language code</li>
571
+ <li><code>format:pdf</code> - File format</li>
572
+ <li><code>rating:5</code> or <code>rating:>=4</code> - Rating filter</li>
573
+ <li><code>favorite:true</code> - Favorites only</li>
574
+ <li><code>status:reading</code> - Reading status</li>
575
+ </ul>
576
+
577
+ <p style="margin: 5px 0;"><strong>Boolean Logic:</strong></p>
578
+ <ul style="margin: 5px 0 10px 20px; padding: 0;">
579
+ <li><code>python java</code> - Both terms (implicit AND)</li>
580
+ <li><code>python OR java</code> - Either term</li>
581
+ <li><code>NOT java</code> or <code>-java</code> - Exclude term</li>
582
+ </ul>
583
+
584
+ <p style="margin: 5px 0;"><strong>Quotes:</strong></p>
585
+ <ul style="margin: 5px 0 10px 20px; padding: 0;">
586
+ <li><code>"machine learning"</code> - Exact phrase</li>
587
+ <li><code>author:"Donald Knuth"</code> - Field with phrase</li>
588
+ </ul>
589
+
590
+ <p style="margin: 5px 0;"><strong>Examples:</strong></p>
591
+ <ul style="margin: 5px 0 0 20px; padding: 0;">
592
+ <li><code>title:Python rating:>=4 format:pdf</code></li>
593
+ <li><code>author:"Donald Knuth" series:TAOCP</code></li>
594
+ <li><code>tag:programming favorite:true NOT java</code></li>
595
+ </ul>
596
+ </div>
597
+
598
+ <div class="filters">
599
+ <div class="filter-group">
600
+ <label for="sort-field">Sort By</label>
601
+ <select id="sort-field">
602
+ <option value="title">Title</option>
603
+ <option value="created_at">Date Added</option>
604
+ <option value="publication_date">Publication Date</option>
605
+ <option value="rating">Rating</option>
606
+ </select>
607
+ </div>
608
+
609
+ <div class="filter-group">
610
+ <label for="sort-order">Order</label>
611
+ <select id="sort-order">
612
+ <option value="asc">Ascending</option>
613
+ <option value="desc">Descending</option>
614
+ </select>
615
+ </div>
616
+
617
+ <div class="filter-group">
618
+ <label for="language-filter">Language</label>
619
+ <select id="language-filter">
620
+ <option value="">All Languages</option>
621
+ </select>
622
+ </div>
623
+
624
+ <div class="filter-group">
625
+ <label for="format-filter">Format</label>
626
+ <select id="format-filter">
627
+ <option value="">All Formats</option>
628
+ </select>
629
+ </div>
630
+
631
+ <div class="filter-group">
632
+ <label for="series-filter">Series</label>
633
+ <select id="series-filter">
634
+ <option value="">All Books</option>
635
+ <option value="has-series">In Series</option>
636
+ <option value="no-series">Not in Series</option>
637
+ </select>
638
+ </div>
639
+
640
+ <div class="filter-group">
641
+ <label for="favorite-filter">Favorites</label>
642
+ <select id="favorite-filter">
643
+ <option value="">All Books</option>
644
+ <option value="true">Favorites Only</option>
645
+ </select>
646
+ </div>
647
+
648
+ <div class="filter-group">
649
+ <label for="rating-filter">Min Rating</label>
650
+ <select id="rating-filter">
651
+ <option value="">Any Rating</option>
652
+ <option value="1">1+ Stars</option>
653
+ <option value="2">2+ Stars</option>
654
+ <option value="3">3+ Stars</option>
655
+ <option value="4">4+ Stars</option>
656
+ <option value="5">5 Stars</option>
657
+ </select>
658
+ </div>
659
+ </div>
660
+ <button onclick="clearFilters()" style="margin-top: 10px; padding: 8px 16px; background: var(--secondary); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;">Clear Filters</button>
661
+ </div>
662
+
663
+ <div id="results-info" style="margin-bottom: 15px; color: var(--text-light); font-size: 0.9rem;"></div>
664
+
665
+ <div class="book-grid" id="book-grid"></div>
666
+
667
+ <div class="no-results" id="no-results" style="display: none;">
668
+ <div class="no-results-icon">🔍</div>
669
+ <h2>No books found</h2>
670
+ <p>Try adjusting your search or filters</p>
671
+ </div>
672
+
673
+ <div id="pagination" style="display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 30px; flex-wrap: wrap;"></div>
674
+ </div>
675
+
676
+ <div class="modal" id="book-modal">
677
+ <div class="modal-content">
678
+ <div class="modal-header">
679
+ <h2 class="modal-title" id="modal-title"></h2>
680
+ <div style="display: flex; gap: 10px; align-items: center;">
681
+ <button class="btn-primary" onclick="toggleEditMode()" id="edit-mode-btn" style="padding: 6px 12px; font-size: 0.875rem;">Edit Metadata</button>
682
+ <button class="close-btn" onclick="closeModal()">&times;</button>
683
+ </div>
684
+ </div>
685
+ <div class="modal-body" id="modal-body"></div>
686
+ </div>
687
+ </div>
688
+
689
+ <script>
690
+ // Embedded data
691
+ const BOOKS = {books_json};
692
+ const STATS = {stats_json};
693
+ const BASE_URL = {base_url_json};
694
+
695
+ // State
696
+ let filteredBooks = [...BOOKS];
697
+ let editMode = false;
698
+ let currentBookId = null;
699
+ let currentPage = 1;
700
+ const booksPerPage = 50;
701
+
702
+ // Initialize
703
+ document.addEventListener('DOMContentLoaded', () => {{
704
+ populateStats();
705
+ populateFilters();
706
+ restoreStateFromURL();
707
+ applyFilters();
708
+ setupEventListeners();
709
+
710
+ // Handle browser back/forward
711
+ window.addEventListener('popstate', () => {{
712
+ restoreStateFromURL();
713
+ applyFilters();
714
+ }});
715
+ }});
716
+
717
+ function populateStats() {{
718
+ document.getElementById('total-books').textContent = STATS.total_books;
719
+ document.getElementById('total-authors').textContent = STATS.total_authors;
720
+ document.getElementById('total-subjects').textContent = STATS.total_subjects;
721
+ }}
722
+
723
+ function populateFilters() {{
724
+ // Languages
725
+ const languageSelect = document.getElementById('language-filter');
726
+ STATS.languages.forEach(lang => {{
727
+ const option = document.createElement('option');
728
+ option.value = lang;
729
+ option.textContent = lang.toUpperCase();
730
+ languageSelect.appendChild(option);
731
+ }});
732
+
733
+ // Formats
734
+ const formatSelect = document.getElementById('format-filter');
735
+ STATS.formats.forEach(fmt => {{
736
+ const option = document.createElement('option');
737
+ option.value = fmt;
738
+ option.textContent = fmt.toUpperCase();
739
+ formatSelect.appendChild(option);
740
+ }});
741
+ }}
742
+
743
+ // localStorage helpers
744
+ function loadMetadataOverrides() {{
745
+ const overrides = localStorage.getItem('ebk_metadata_overrides');
746
+ return overrides ? JSON.parse(overrides) : {{}};
747
+ }}
748
+
749
+ function saveMetadataOverride(bookId, field, value) {{
750
+ const overrides = loadMetadataOverrides();
751
+ if (!overrides[bookId]) overrides[bookId] = {{}};
752
+ overrides[bookId][field] = value;
753
+ localStorage.setItem('ebk_metadata_overrides', JSON.stringify(overrides));
754
+ }}
755
+
756
+ function applyMetadataOverrides(book) {{
757
+ const overrides = loadMetadataOverrides();
758
+ if (overrides[book.id]) {{
759
+ return {{ ...book, ...overrides[book.id] }};
760
+ }}
761
+ return book;
762
+ }}
763
+
764
+ // URL State Management
765
+ function updateURL() {{
766
+ const params = new URLSearchParams();
767
+
768
+ if (currentPage > 1) params.set('page', currentPage);
769
+
770
+ const searchQuery = document.getElementById('search-input').value;
771
+ if (searchQuery) params.set('q', searchQuery);
772
+
773
+ const language = document.getElementById('language-filter').value;
774
+ if (language) params.set('language', language);
775
+
776
+ const format = document.getElementById('format-filter').value;
777
+ if (format) params.set('format', format);
778
+
779
+ const series = document.getElementById('series-filter').value;
780
+ if (series) params.set('series', series);
781
+
782
+ const favorite = document.getElementById('favorite-filter').value;
783
+ if (favorite) params.set('favorite', favorite);
784
+
785
+ const rating = document.getElementById('rating-filter').value;
786
+ if (rating) params.set('rating', rating);
787
+
788
+ const sortField = document.getElementById('sort-field').value;
789
+ if (sortField !== 'title') params.set('sort', sortField);
790
+
791
+ const sortOrder = document.getElementById('sort-order').value;
792
+ if (sortOrder !== 'asc') params.set('order', sortOrder);
793
+
794
+ const newURL = params.toString() ? `?${{params.toString()}}` : window.location.pathname;
795
+ window.history.pushState({{}}, '', newURL);
796
+ }}
797
+
798
+ function restoreStateFromURL() {{
799
+ const params = new URLSearchParams(window.location.search);
800
+
801
+ currentPage = parseInt(params.get('page')) || 1;
802
+
803
+ document.getElementById('search-input').value = params.get('q') || '';
804
+ document.getElementById('language-filter').value = params.get('language') || '';
805
+ document.getElementById('format-filter').value = params.get('format') || '';
806
+ document.getElementById('series-filter').value = params.get('series') || '';
807
+ document.getElementById('favorite-filter').value = params.get('favorite') || '';
808
+ document.getElementById('rating-filter').value = params.get('rating') || '';
809
+ document.getElementById('sort-field').value = params.get('sort') || 'title';
810
+ document.getElementById('sort-order').value = params.get('order') || 'asc';
811
+ }}
812
+
813
+ function applyFilters() {{
814
+ filterBooks();
815
+ updateURL();
816
+ }}
817
+
818
+ function setupEventListeners() {{
819
+ document.getElementById('search-input').addEventListener('input', () => {{
820
+ currentPage = 1; // Reset to page 1 on new search
821
+ applyFilters();
822
+ }});
823
+ document.getElementById('language-filter').addEventListener('change', () => {{
824
+ currentPage = 1;
825
+ applyFilters();
826
+ }});
827
+ document.getElementById('format-filter').addEventListener('change', () => {{
828
+ currentPage = 1;
829
+ applyFilters();
830
+ }});
831
+ document.getElementById('series-filter').addEventListener('change', () => {{
832
+ currentPage = 1;
833
+ applyFilters();
834
+ }});
835
+ document.getElementById('favorite-filter').addEventListener('change', () => {{
836
+ currentPage = 1;
837
+ applyFilters();
838
+ }});
839
+ document.getElementById('rating-filter').addEventListener('change', () => {{
840
+ currentPage = 1;
841
+ applyFilters();
842
+ }});
843
+ document.getElementById('sort-field').addEventListener('change', applyFilters);
844
+ document.getElementById('sort-order').addEventListener('change', applyFilters);
845
+
846
+ // Close modal on outside click
847
+ document.getElementById('book-modal').addEventListener('click', (e) => {{
848
+ if (e.target.id === 'book-modal') {{
849
+ closeModal();
850
+ }}
851
+ }});
852
+ }}
853
+
854
+ function filterBooks() {{
855
+ const searchTerm = document.getElementById('search-input').value.toLowerCase();
856
+ const languageFilter = document.getElementById('language-filter').value;
857
+ const formatFilter = document.getElementById('format-filter').value;
858
+ const seriesFilter = document.getElementById('series-filter').value;
859
+ const favoriteFilter = document.getElementById('favorite-filter').value;
860
+ const ratingFilter = document.getElementById('rating-filter').value;
861
+ const sortField = document.getElementById('sort-field').value;
862
+ const sortOrder = document.getElementById('sort-order').value;
863
+
864
+ // Apply metadata overrides and filter
865
+ filteredBooks = BOOKS.map(applyMetadataOverrides).filter(book => {{
866
+ // Search
867
+ if (searchTerm) {{
868
+ const searchable = [
869
+ book.title,
870
+ book.subtitle,
871
+ ...book.authors.map(a => a.name),
872
+ ...book.subjects,
873
+ ...book.keywords,
874
+ book.description
875
+ ].filter(x => x).join(' ').toLowerCase();
876
+
877
+ if (!searchable.includes(searchTerm)) return false;
878
+ }}
879
+
880
+ // Language
881
+ if (languageFilter && book.language !== languageFilter) return false;
882
+
883
+ // Format
884
+ if (formatFilter && !book.files.some(f => f.format === formatFilter)) return false;
885
+
886
+ // Series
887
+ if (seriesFilter === 'has-series' && !book.series) return false;
888
+ if (seriesFilter === 'no-series' && book.series) return false;
889
+
890
+ // Favorite
891
+ if (favoriteFilter === 'true' && !book.personal.favorite) return false;
892
+
893
+ // Rating
894
+ if (ratingFilter && (!book.personal.rating || book.personal.rating < parseFloat(ratingFilter))) return false;
895
+
896
+ return true;
897
+ }});
898
+
899
+ // Sort
900
+ filteredBooks.sort((a, b) => {{
901
+ let aVal, bVal;
902
+
903
+ switch(sortField) {{
904
+ case 'title':
905
+ aVal = (a.title || '').toLowerCase();
906
+ bVal = (b.title || '').toLowerCase();
907
+ break;
908
+ case 'created_at':
909
+ aVal = new Date(a.created_at || 0);
910
+ bVal = new Date(b.created_at || 0);
911
+ break;
912
+ case 'publication_date':
913
+ aVal = new Date(a.publication_date || 0);
914
+ bVal = new Date(b.publication_date || 0);
915
+ break;
916
+ case 'rating':
917
+ aVal = a.personal?.rating || 0;
918
+ bVal = b.personal?.rating || 0;
919
+ break;
920
+ default:
921
+ return 0;
922
+ }}
923
+
924
+ if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
925
+ if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
926
+ return 0;
927
+ }});
928
+
929
+ renderBooks();
930
+ }}
931
+
932
+ function clearFilters() {{
933
+ document.getElementById('search-input').value = '';
934
+ document.getElementById('language-filter').value = '';
935
+ document.getElementById('format-filter').value = '';
936
+ document.getElementById('series-filter').value = '';
937
+ document.getElementById('favorite-filter').value = '';
938
+ document.getElementById('rating-filter').value = '';
939
+ document.getElementById('sort-field').value = 'title';
940
+ document.getElementById('sort-order').value = 'asc';
941
+ filterBooks();
942
+ }}
943
+
944
+ function renderBooks() {{
945
+ const grid = document.getElementById('book-grid');
946
+ const noResults = document.getElementById('no-results');
947
+ const resultsInfo = document.getElementById('results-info');
948
+ const pagination = document.getElementById('pagination');
949
+
950
+ if (filteredBooks.length === 0) {{
951
+ grid.style.display = 'none';
952
+ noResults.style.display = 'block';
953
+ resultsInfo.style.display = 'none';
954
+ pagination.style.display = 'none';
955
+ return;
956
+ }}
957
+
958
+ grid.style.display = 'grid';
959
+ noResults.style.display = 'none';
960
+ resultsInfo.style.display = 'block';
961
+
962
+ // Pagination
963
+ const totalPages = Math.ceil(filteredBooks.length / booksPerPage);
964
+ const startIdx = (currentPage - 1) * booksPerPage;
965
+ const endIdx = Math.min(startIdx + booksPerPage, filteredBooks.length);
966
+ const booksToShow = filteredBooks.slice(startIdx, endIdx);
967
+
968
+ // Update results info
969
+ resultsInfo.textContent = `Showing ${{startIdx + 1}}-${{endIdx}} of ${{filteredBooks.length}} books`;
970
+
971
+ grid.innerHTML = booksToShow.map(book => `
972
+ <div class="book-card" onclick="showBookDetails(${{book.id}})">
973
+ ${{book.cover_path ? `
974
+ <div style="text-align: center; margin-bottom: 12px;">
975
+ <img src="${{BASE_URL ? BASE_URL + '/' + book.cover_path : book.cover_path}}"
976
+ alt="Cover"
977
+ style="max-width: 100%; max-height: 220px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15);"
978
+ onerror="this.style.display='none'">
979
+ </div>
980
+ ` : ''}}
981
+ <div class="book-header">
982
+ <div class="book-title">
983
+ ${{escapeHtml(book.title)}}
984
+ ${{book.personal.favorite ? '<span class="favorite-badge">⭐</span>' : ''}}
985
+ </div>
986
+ </div>
987
+ ${{book.subtitle ? `<div class="book-subtitle">${{escapeHtml(book.subtitle)}}</div>` : ''}}
988
+ <div class="book-authors">
989
+ ${{book.authors.map(a => a.name).join(', ') || 'Unknown Author'}}
990
+ </div>
991
+ ${{book.publication_date ? `<div style="color: #6b7280; font-size: 0.875rem; margin-top: 4px;">📅 ${{book.publication_date}}</div>` : ''}}
992
+ ${{book.personal.rating ? `<div class="rating">${{'★'.repeat(Math.round(book.personal.rating))}} ${{book.personal.rating}}</div>` : ''}}
993
+ <div class="book-meta">
994
+ ${{book.series ? `<span class="badge badge-series">${{escapeHtml(book.series)}} #${{book.series_index}}</span>` : ''}}
995
+ ${{book.files.map(f => `<span class="badge badge-format">${{f.format.toUpperCase()}}</span>`).join('')}}
996
+ ${{book.language ? `<span class="badge badge-language">${{book.language.toUpperCase()}}</span>` : ''}}
997
+ </div>
998
+ </div>
999
+ `).join('');
1000
+
1001
+ // Render pagination controls
1002
+ renderPagination(totalPages);
1003
+ }}
1004
+
1005
+ function renderPagination(totalPages) {{
1006
+ const pagination = document.getElementById('pagination');
1007
+
1008
+ if (totalPages <= 1) {{
1009
+ pagination.style.display = 'none';
1010
+ return;
1011
+ }}
1012
+
1013
+ pagination.style.display = 'flex';
1014
+
1015
+ let html = '';
1016
+
1017
+ // Previous button
1018
+ html += `<button onclick="goToPage(${{currentPage - 1}})" ${{currentPage === 1 ? 'disabled' : ''}}
1019
+ style="padding: 8px 16px; background: var(--primary); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem; ${{currentPage === 1 ? 'opacity: 0.5; cursor: not-allowed;' : ''}}">
1020
+ ← Previous
1021
+ </button>`;
1022
+
1023
+ // Page numbers
1024
+ const maxButtons = 7;
1025
+ let startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2));
1026
+ let endPage = Math.min(totalPages, startPage + maxButtons - 1);
1027
+
1028
+ if (endPage - startPage < maxButtons - 1) {{
1029
+ startPage = Math.max(1, endPage - maxButtons + 1);
1030
+ }}
1031
+
1032
+ if (startPage > 1) {{
1033
+ html += `<button onclick="goToPage(1)" style="padding: 8px 12px; background: white; color: var(--text); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 0.875rem;">1</button>`;
1034
+ if (startPage > 2) {{
1035
+ html += `<span style="padding: 8px;">...</span>`;
1036
+ }}
1037
+ }}
1038
+
1039
+ for (let i = startPage; i <= endPage; i++) {{
1040
+ const isActive = i === currentPage;
1041
+ html += `<button onclick="goToPage(${{i}})"
1042
+ style="padding: 8px 12px; background: ${{isActive ? 'var(--primary)' : 'white'}}; color: ${{isActive ? 'white' : 'var(--text)'}}; border: 1px solid ${{isActive ? 'var(--primary)' : 'var(--border)'}}; border-radius: 6px; cursor: pointer; font-size: 0.875rem; font-weight: ${{isActive ? '600' : '400'}};">
1043
+ ${{i}}
1044
+ </button>`;
1045
+ }}
1046
+
1047
+ if (endPage < totalPages) {{
1048
+ if (endPage < totalPages - 1) {{
1049
+ html += `<span style="padding: 8px;">...</span>`;
1050
+ }}
1051
+ html += `<button onclick="goToPage(${{totalPages}})" style="padding: 8px 12px; background: white; color: var(--text); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 0.875rem;">${{totalPages}}</button>`;
1052
+ }}
1053
+
1054
+ // Next button
1055
+ html += `<button onclick="goToPage(${{currentPage + 1}})" ${{currentPage === totalPages ? 'disabled' : ''}}
1056
+ style="padding: 8px 16px; background: var(--primary); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem; ${{currentPage === totalPages ? 'opacity: 0.5; cursor: not-allowed;' : ''}}">
1057
+ Next →
1058
+ </button>`;
1059
+
1060
+ pagination.innerHTML = html;
1061
+ }}
1062
+
1063
+ function goToPage(page) {{
1064
+ const totalPages = Math.ceil(filteredBooks.length / booksPerPage);
1065
+ if (page < 1 || page > totalPages) return;
1066
+ currentPage = page;
1067
+ renderBooks();
1068
+ updateURL();
1069
+ window.scrollTo({{ top: 0, behavior: 'smooth' }});
1070
+ }}
1071
+
1072
+ function showBookDetails(bookId) {{
1073
+ currentBookId = bookId;
1074
+ editMode = false;
1075
+ document.getElementById('edit-mode-btn').textContent = 'Edit Metadata';
1076
+
1077
+ const book = BOOKS.find(b => b.id === bookId);
1078
+ if (!book) return;
1079
+
1080
+ const modal = document.getElementById('book-modal');
1081
+ const modalTitle = document.getElementById('modal-title');
1082
+ const modalBody = document.getElementById('modal-body');
1083
+
1084
+ modalTitle.textContent = book.title;
1085
+
1086
+ let html = '';
1087
+
1088
+ // Cover image
1089
+ if (book.cover_path) {{
1090
+ html += `
1091
+ <div style="text-align: center; margin-bottom: 20px;">
1092
+ <img src="${{BASE_URL ? BASE_URL + '/' + book.cover_path : book.cover_path}}"
1093
+ alt="Cover"
1094
+ style="max-width: 300px; max-height: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.2);"
1095
+ onerror="this.style.display='none'">
1096
+ </div>
1097
+ `;
1098
+ }}
1099
+
1100
+ // Authors
1101
+ if (book.authors.length > 0) {{
1102
+ html += `
1103
+ <div class="detail-section">
1104
+ <div class="detail-label">Authors</div>
1105
+ <div class="detail-value">${{book.authors.map(a => a.name).join(', ')}}</div>
1106
+ </div>
1107
+ `;
1108
+ }}
1109
+
1110
+ // Contributors
1111
+ if (book.contributors && book.contributors.length > 0) {{
1112
+ html += `
1113
+ <div class="detail-section">
1114
+ <div class="detail-label">Contributors</div>
1115
+ <ul class="contributors-list">
1116
+ ${{book.contributors.map(c => `<li>${{c.name}} <span class="contributor-role">(${{c.role}})</span></li>`).join('')}}
1117
+ </ul>
1118
+ </div>
1119
+ `;
1120
+ }}
1121
+
1122
+ // Series
1123
+ if (book.series) {{
1124
+ html += `
1125
+ <div class="detail-section">
1126
+ <div class="detail-label">Series</div>
1127
+ <div class="detail-value">${{book.series}} #${{book.series_index}}</div>
1128
+ </div>
1129
+ `;
1130
+ }}
1131
+
1132
+ // Description (rendered as HTML)
1133
+ if (book.description) {{
1134
+ html += `
1135
+ <div class="detail-section">
1136
+ <div class="detail-label">Description</div>
1137
+ <div class="detail-value">${{book.description}}</div>
1138
+ </div>
1139
+ `;
1140
+ }}
1141
+
1142
+ // Metadata
1143
+ const metadata = [];
1144
+ if (book.publisher) metadata.push(`Publisher: ${{book.publisher}}`);
1145
+ if (book.publication_date) metadata.push(`Published: ${{book.publication_date}}`);
1146
+ if (book.edition) metadata.push(`Edition: ${{book.edition}}`);
1147
+ if (book.language) metadata.push(`Language: ${{book.language.toUpperCase()}}`);
1148
+ if (book.page_count) metadata.push(`Pages: ${{book.page_count}}`);
1149
+
1150
+ if (metadata.length > 0) {{
1151
+ html += `
1152
+ <div class="detail-section">
1153
+ <div class="detail-label">Metadata</div>
1154
+ <div class="detail-value">${{metadata.join(' • ')}}</div>
1155
+ </div>
1156
+ `;
1157
+ }}
1158
+
1159
+ // Subjects
1160
+ if (book.subjects.length > 0) {{
1161
+ html += `
1162
+ <div class="detail-section">
1163
+ <div class="detail-label">Subjects</div>
1164
+ <div class="tags">
1165
+ ${{book.subjects.map(s => `<span class="tag">${{s}}</span>`).join('')}}
1166
+ </div>
1167
+ </div>
1168
+ `;
1169
+ }}
1170
+
1171
+ // Keywords
1172
+ if (book.keywords && book.keywords.length > 0) {{
1173
+ html += `
1174
+ <div class="detail-section">
1175
+ <div class="detail-label">Keywords</div>
1176
+ <div class="tags">
1177
+ ${{book.keywords.map(k => `<span class="tag">${{k}}</span>`).join('')}}
1178
+ </div>
1179
+ </div>
1180
+ `;
1181
+ }}
1182
+
1183
+ // Files
1184
+ if (book.files.length > 0) {{
1185
+ html += `
1186
+ <div class="detail-section">
1187
+ <div class="detail-label">Files</div>
1188
+ <ul class="contributors-list">
1189
+ ${{book.files.map(f => {{
1190
+ const fileUrl = BASE_URL ? `${{BASE_URL}}/${{f.path}}` : f.path;
1191
+ return `
1192
+ <li>
1193
+ <a href="${{fileUrl}}" class="file-link" target="_blank">
1194
+ ${{f.format.toUpperCase()}}
1195
+ </a>
1196
+ • ${{formatBytes(f.size_bytes)}}
1197
+ ${{f.creator_application ? ` • Created with ${{f.creator_application}}` : ''}}
1198
+ </li>
1199
+ `;
1200
+ }}).join('')}}
1201
+ </ul>
1202
+ </div>
1203
+ `;
1204
+ }}
1205
+
1206
+ // Identifiers
1207
+ if (book.identifiers.length > 0) {{
1208
+ html += `
1209
+ <div class="detail-section">
1210
+ <div class="detail-label">Identifiers</div>
1211
+ <ul class="identifiers-list">
1212
+ ${{book.identifiers.map(i => `<li>${{i.scheme.toUpperCase()}}: ${{i.value}}</li>`).join('')}}
1213
+ </ul>
1214
+ </div>
1215
+ `;
1216
+ }}
1217
+
1218
+ // Personal tags
1219
+ if (book.personal.tags && book.personal.tags.length > 0) {{
1220
+ html += `
1221
+ <div class="detail-section">
1222
+ <div class="detail-label">Personal Tags</div>
1223
+ <div class="tags">
1224
+ ${{book.personal.tags.map(t => `<span class="tag">${{t}}</span>`).join('')}}
1225
+ </div>
1226
+ </div>
1227
+ `;
1228
+ }}
1229
+
1230
+ // Rights
1231
+ if (book.rights) {{
1232
+ html += `
1233
+ <div class="detail-section">
1234
+ <div class="detail-label">Rights</div>
1235
+ <div class="detail-value">${{escapeHtml(book.rights)}}</div>
1236
+ </div>
1237
+ `;
1238
+ }}
1239
+
1240
+ modalBody.innerHTML = html;
1241
+ modal.classList.add('active');
1242
+ }}
1243
+
1244
+ function closeModal() {{
1245
+ document.getElementById('book-modal').classList.remove('active');
1246
+ editMode = false;
1247
+ currentBookId = null;
1248
+ }}
1249
+
1250
+ function toggleSearchHelp() {{
1251
+ const helpDiv = document.getElementById('search-help');
1252
+ if (helpDiv.style.display === 'none') {{
1253
+ helpDiv.style.display = 'block';
1254
+ }} else {{
1255
+ helpDiv.style.display = 'none';
1256
+ }}
1257
+ }}
1258
+
1259
+ function toggleEditMode() {{
1260
+ editMode = !editMode;
1261
+ const btn = document.getElementById('edit-mode-btn');
1262
+
1263
+ if (editMode) {{
1264
+ btn.textContent = 'Save & Close';
1265
+ renderEditMode();
1266
+ }} else {{
1267
+ btn.textContent = 'Edit Metadata';
1268
+ saveMetadataOverrides();
1269
+ showBookDetails(currentBookId);
1270
+ }}
1271
+ }}
1272
+
1273
+ function renderEditMode() {{
1274
+ const book = BOOKS.find(b => b.id === currentBookId);
1275
+ if (!book) return;
1276
+
1277
+ const overrides = loadMetadataOverrides()[currentBookId] || {{}};
1278
+ const modalBody = document.getElementById('modal-body');
1279
+
1280
+ modalBody.innerHTML = `
1281
+ <div class="detail-section">
1282
+ <p style="color: #6b7280; margin-bottom: 20px;">
1283
+ ℹ️ Changes are saved locally in your browser and won't affect the original database.
1284
+ </p>
1285
+ </div>
1286
+
1287
+ <div class="detail-section">
1288
+ <label class="detail-label">Title</label>
1289
+ <input type="text" id="edit-title" class="form-control" value="${{escapeHtml(overrides.title || book.title)}}" style="width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px;">
1290
+ </div>
1291
+
1292
+ <div class="detail-section">
1293
+ <label class="detail-label">Subtitle</label>
1294
+ <input type="text" id="edit-subtitle" class="form-control" value="${{escapeHtml(overrides.subtitle || book.subtitle || '')}}" style="width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px;">
1295
+ </div>
1296
+
1297
+ <div class="detail-section">
1298
+ <label class="detail-label">Publisher</label>
1299
+ <input type="text" id="edit-publisher" class="form-control" value="${{escapeHtml(overrides.publisher || book.publisher || '')}}" style="width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px;">
1300
+ </div>
1301
+
1302
+ <div class="detail-section">
1303
+ <label class="detail-label">Publication Date</label>
1304
+ <input type="text" id="edit-publication-date" class="form-control" value="${{escapeHtml(overrides.publication_date || book.publication_date || '')}}" style="width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px;" placeholder="YYYY-MM-DD">
1305
+ </div>
1306
+
1307
+ <div class="detail-section">
1308
+ <label class="detail-label">Rating (1-5)</label>
1309
+ <input type="number" id="edit-rating" class="form-control" value="${{overrides.rating !== undefined ? overrides.rating : (book.personal.rating || '')}}" min="1" max="5" step="0.5" style="width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px;">
1310
+ </div>
1311
+
1312
+ <div class="detail-section">
1313
+ <label class="detail-label">
1314
+ <input type="checkbox" id="edit-favorite" ${{(overrides.favorite !== undefined ? overrides.favorite : book.personal.favorite) ? 'checked' : ''}}>
1315
+ Favorite
1316
+ </label>
1317
+ </div>
1318
+
1319
+ <div class="detail-section">
1320
+ <label class="detail-label">Description</label>
1321
+ <textarea id="edit-description" class="form-control" rows="6" style="width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px;">${{escapeHtml(overrides.description || book.description || '')}}</textarea>
1322
+ </div>
1323
+
1324
+ <div class="detail-section">
1325
+ <button onclick="clearOverrides()" class="btn-secondary" style="padding: 8px 16px; background: #ef4444; color: white; border: none; border-radius: 4px; cursor: pointer;">
1326
+ Clear Local Overrides
1327
+ </button>
1328
+ </div>
1329
+ `;
1330
+ }}
1331
+
1332
+ function saveMetadataOverrides() {{
1333
+ const overrides = loadMetadataOverrides();
1334
+ if (!overrides[currentBookId]) overrides[currentBookId] = {{}};
1335
+
1336
+ const title = document.getElementById('edit-title').value;
1337
+ const subtitle = document.getElementById('edit-subtitle').value;
1338
+ const publisher = document.getElementById('edit-publisher').value;
1339
+ const publication_date = document.getElementById('edit-publication-date').value;
1340
+ const rating = document.getElementById('edit-rating').value;
1341
+ const favorite = document.getElementById('edit-favorite').checked;
1342
+ const description = document.getElementById('edit-description').value;
1343
+
1344
+ const book = BOOKS.find(b => b.id === currentBookId);
1345
+
1346
+ // Only save if different from original
1347
+ if (title !== book.title) overrides[currentBookId].title = title;
1348
+ if (subtitle !== (book.subtitle || '')) overrides[currentBookId].subtitle = subtitle;
1349
+ if (publisher !== (book.publisher || '')) overrides[currentBookId].publisher = publisher;
1350
+ if (publication_date !== (book.publication_date || '')) overrides[currentBookId].publication_date = publication_date;
1351
+ if (rating !== (book.personal.rating || '')) overrides[currentBookId].rating = parseFloat(rating) || null;
1352
+ if (favorite !== book.personal.favorite) overrides[currentBookId].favorite = favorite;
1353
+ if (description !== (book.description || '')) overrides[currentBookId].description = description;
1354
+
1355
+ localStorage.setItem('ebk_metadata_overrides', JSON.stringify(overrides));
1356
+
1357
+ // Refresh the filtered books with overrides
1358
+ filteredBooks = BOOKS.map(applyMetadataOverrides);
1359
+ filterBooks();
1360
+ }}
1361
+
1362
+ function clearOverrides() {{
1363
+ if (!confirm('Clear all local metadata overrides for this book?')) return;
1364
+
1365
+ const overrides = loadMetadataOverrides();
1366
+ delete overrides[currentBookId];
1367
+ localStorage.setItem('ebk_metadata_overrides', JSON.stringify(overrides));
1368
+
1369
+ editMode = false;
1370
+ showBookDetails(currentBookId);
1371
+ filterBooks();
1372
+ }}
1373
+
1374
+ function escapeHtml(text) {{
1375
+ if (!text) return '';
1376
+ const div = document.createElement('div');
1377
+ div.textContent = text;
1378
+ return div.innerHTML;
1379
+ }}
1380
+
1381
+ function formatBytes(bytes) {{
1382
+ if (bytes === 0) return '0 Bytes';
1383
+ const k = 1024;
1384
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
1385
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1386
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
1387
+ }}
1388
+ </script>
1389
+ </body>
1390
+ </html>'''