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

ebk/server.py ADDED
@@ -0,0 +1,1633 @@
1
+ """
2
+ Web server for ebk library management.
3
+
4
+ Provides a REST API and web interface for managing ebook libraries.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Optional, List
9
+ import tempfile
10
+ import shutil
11
+
12
+ from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Query
13
+ from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
14
+ from fastapi.staticfiles import StaticFiles
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from pydantic import BaseModel
17
+
18
+ from .library_db import Library
19
+ from .extract_metadata import extract_metadata
20
+
21
+
22
+ # Pydantic models for API
23
+ class BookResponse(BaseModel):
24
+ id: int
25
+ title: str
26
+ subtitle: Optional[str]
27
+ authors: List[str]
28
+ language: Optional[str]
29
+ publisher: Optional[str]
30
+ publication_date: Optional[str]
31
+ series: Optional[str]
32
+ series_index: Optional[float]
33
+ description: Optional[str]
34
+ subjects: List[str]
35
+ files: List[dict]
36
+ rating: Optional[float]
37
+ favorite: bool
38
+ reading_status: str
39
+ tags: List[str]
40
+ cover_path: Optional[str]
41
+
42
+
43
+ class BookUpdateRequest(BaseModel):
44
+ title: Optional[str] = None
45
+ subtitle: Optional[str] = None
46
+ language: Optional[str] = None
47
+ publisher: Optional[str] = None
48
+ publication_date: Optional[str] = None
49
+ description: Optional[str] = None
50
+ rating: Optional[float] = None
51
+ favorite: Optional[bool] = None
52
+ reading_status: Optional[str] = None
53
+ tags: Optional[List[str]] = None
54
+
55
+
56
+ class LibraryStats(BaseModel):
57
+ total_books: int
58
+ total_authors: int
59
+ total_subjects: int
60
+ total_files: int
61
+ total_size_mb: float
62
+ languages: List[str]
63
+ formats: List[str]
64
+
65
+
66
+ # Global library instance
67
+ _library: Optional[Library] = None
68
+ _library_path: Optional[Path] = None
69
+
70
+
71
+ def get_library() -> Library:
72
+ """Get the current library instance."""
73
+ if _library is None:
74
+ raise HTTPException(status_code=500, detail="Library not initialized")
75
+ return _library
76
+
77
+
78
+ def init_library(library_path: Path):
79
+ """Initialize the library."""
80
+ global _library, _library_path
81
+ _library_path = library_path
82
+ _library = Library.open(library_path)
83
+
84
+
85
+ def create_app(library_path: Path) -> FastAPI:
86
+ """Create FastAPI application with initialized library."""
87
+ # Initialize library
88
+ init_library(library_path)
89
+
90
+ # Return the pre-configured app with all routes
91
+ return app
92
+
93
+
94
+ # Create FastAPI app
95
+ app = FastAPI(
96
+ title="ebk Library Manager",
97
+ description="Web interface for managing ebook libraries",
98
+ version="1.0.0"
99
+ )
100
+
101
+ # Enable CORS
102
+ app.add_middleware(
103
+ CORSMiddleware,
104
+ allow_origins=["*"],
105
+ allow_credentials=True,
106
+ allow_methods=["*"],
107
+ allow_headers=["*"],
108
+ )
109
+
110
+
111
+ @app.get("/", response_class=HTMLResponse)
112
+ async def root():
113
+ """Serve the main web interface."""
114
+ return get_web_interface()
115
+
116
+
117
+ @app.get("/api/books", response_model=List[BookResponse])
118
+ async def list_books(
119
+ limit: int = Query(50, ge=1, le=1000),
120
+ offset: int = Query(0, ge=0),
121
+ search: Optional[str] = None,
122
+ author: Optional[str] = None,
123
+ subject: Optional[str] = None,
124
+ language: Optional[str] = None,
125
+ favorite: Optional[bool] = None,
126
+ format_filter: Optional[str] = None,
127
+ sort_by: Optional[str] = Query(None, alias="sort"),
128
+ sort_order: Optional[str] = Query("asc", alias="order"),
129
+ min_rating: Optional[float] = Query(None, alias="rating")
130
+ ):
131
+ """List books with filtering, sorting, and pagination."""
132
+ lib = get_library()
133
+
134
+ query = lib.query()
135
+
136
+ # Apply filters
137
+ if author:
138
+ query = query.filter_by_author(author)
139
+ if subject:
140
+ query = query.filter_by_subject(subject)
141
+ if language:
142
+ query = query.filter_by_language(language)
143
+ if favorite is not None:
144
+ query = query.filter_by_favorite(favorite)
145
+ if format_filter:
146
+ query = query.filter_by_format(format_filter)
147
+
148
+ # Apply sorting before pagination
149
+ if sort_by:
150
+ desc = (sort_order == "desc")
151
+ query = query.order_by(sort_by, desc=desc)
152
+ else:
153
+ # Default sort by title
154
+ query = query.order_by("title", desc=False)
155
+
156
+ # Apply pagination after sorting
157
+ query = query.limit(limit).offset(offset)
158
+ books = query.all()
159
+
160
+ # Search if provided (in-memory after fetching)
161
+ if search:
162
+ search_lower = search.lower()
163
+ books = [
164
+ b for b in books
165
+ if search_lower in b.title.lower() or
166
+ any(search_lower in a.name.lower() for a in b.authors) or
167
+ (b.description and search_lower in b.description.lower())
168
+ ]
169
+
170
+ # Apply rating filter (in-memory for now)
171
+ if min_rating is not None:
172
+ books = [
173
+ b for b in books
174
+ if b.personal and b.personal.rating and b.personal.rating >= min_rating
175
+ ]
176
+
177
+ # Convert to response format
178
+ return [_book_to_response(book) for book in books]
179
+
180
+
181
+ @app.get("/api/books/{book_id}", response_model=BookResponse)
182
+ async def get_book(book_id: int):
183
+ """Get a specific book by ID."""
184
+ lib = get_library()
185
+ book = lib.get_book(book_id)
186
+
187
+ if not book:
188
+ raise HTTPException(status_code=404, detail="Book not found")
189
+
190
+ return _book_to_response(book)
191
+
192
+
193
+ @app.patch("/api/books/{book_id}")
194
+ async def update_book(book_id: int, update: BookUpdateRequest):
195
+ """Update book metadata."""
196
+ lib = get_library()
197
+ book = lib.get_book(book_id)
198
+
199
+ if not book:
200
+ raise HTTPException(status_code=404, detail="Book not found")
201
+
202
+ # Update fields
203
+ if update.title is not None:
204
+ book.title = update.title
205
+ if update.subtitle is not None:
206
+ book.subtitle = update.subtitle
207
+ if update.language is not None:
208
+ book.language = update.language
209
+ if update.publisher is not None:
210
+ book.publisher = update.publisher
211
+ if update.publication_date is not None:
212
+ book.publication_date = update.publication_date
213
+ if update.description is not None:
214
+ book.description = update.description
215
+
216
+ # Update personal metadata
217
+ # Handle reading status and rating together to avoid multiple calls
218
+ if update.reading_status is not None or update.rating is not None:
219
+ current_status = book.personal.reading_status if book.personal else 'unread'
220
+ new_status = update.reading_status if update.reading_status is not None else current_status
221
+ lib.update_reading_status(book_id, status=new_status, rating=update.rating)
222
+
223
+ if update.favorite is not None:
224
+ lib.set_favorite(book_id, update.favorite)
225
+
226
+ if update.tags is not None:
227
+ # Clear existing tags and add new ones
228
+ if book.personal and book.personal.personal_tags:
229
+ lib.remove_tags(book_id, book.personal.personal_tags)
230
+ if update.tags:
231
+ lib.add_tags(book_id, update.tags)
232
+
233
+ lib.session.commit()
234
+
235
+ # Refresh and return
236
+ lib.session.refresh(book)
237
+ return _book_to_response(book)
238
+
239
+
240
+ @app.delete("/api/books/{book_id}")
241
+ async def delete_book(book_id: int, delete_files: bool = Query(False)):
242
+ """Delete a book from the library."""
243
+ lib = get_library()
244
+ book = lib.get_book(book_id)
245
+
246
+ if not book:
247
+ raise HTTPException(status_code=404, detail="Book not found")
248
+
249
+ # Delete files if requested
250
+ if delete_files and _library_path:
251
+ for file in book.files:
252
+ file_path = _library_path / file.path
253
+ if file_path.exists():
254
+ file_path.unlink()
255
+
256
+ # Delete from database
257
+ lib.session.delete(book)
258
+ lib.session.commit()
259
+
260
+ return {"message": "Book deleted successfully"}
261
+
262
+
263
+ @app.post("/api/books/import")
264
+ async def import_book(
265
+ file: UploadFile = File(...),
266
+ extract_text: bool = Form(True),
267
+ extract_cover: bool = Form(True)
268
+ ):
269
+ """Import a new book file."""
270
+ lib = get_library()
271
+
272
+ # Save uploaded file to temp location
273
+ with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp:
274
+ shutil.copyfileobj(file.file, tmp)
275
+ tmp_path = Path(tmp.name)
276
+
277
+ try:
278
+ # Extract metadata
279
+ metadata = extract_metadata(str(tmp_path))
280
+
281
+ # Import to library
282
+ book = lib.add_book(
283
+ tmp_path,
284
+ metadata=metadata,
285
+ extract_text=extract_text,
286
+ extract_cover=extract_cover
287
+ )
288
+
289
+ if not book:
290
+ raise HTTPException(status_code=400, detail="Failed to import book")
291
+
292
+ return _book_to_response(book)
293
+
294
+ finally:
295
+ # Clean up temp file
296
+ tmp_path.unlink()
297
+
298
+
299
+ @app.get("/api/books/{book_id}/files/{file_format}")
300
+ async def download_file(book_id: int, file_format: str):
301
+ """Download a book file."""
302
+ lib = get_library()
303
+ book = lib.get_book(book_id)
304
+
305
+ if not book:
306
+ raise HTTPException(status_code=404, detail="Book not found")
307
+
308
+ # Find file with matching format
309
+ book_file = next((f for f in book.files if f.format.lower() == file_format.lower()), None)
310
+
311
+ if not book_file or not _library_path:
312
+ raise HTTPException(status_code=404, detail="File not found")
313
+
314
+ file_path = _library_path / book_file.path
315
+
316
+ if not file_path.exists():
317
+ raise HTTPException(status_code=404, detail="File not found on disk")
318
+
319
+ return FileResponse(
320
+ file_path,
321
+ media_type="application/octet-stream",
322
+ filename=f"{book.title}.{file_format}"
323
+ )
324
+
325
+
326
+ @app.get("/api/books/{book_id}/cover")
327
+ async def get_cover(book_id: int):
328
+ """Get the cover image for a book."""
329
+ lib = get_library()
330
+ book = lib.get_book(book_id)
331
+
332
+ if not book:
333
+ raise HTTPException(status_code=404, detail="Book not found")
334
+
335
+ # Find primary cover
336
+ if not book.covers:
337
+ raise HTTPException(status_code=404, detail="No cover available")
338
+
339
+ primary_cover = next((c for c in book.covers if c.is_primary), book.covers[0])
340
+
341
+ if not _library_path:
342
+ raise HTTPException(status_code=500, detail="Library path not initialized")
343
+
344
+ cover_path = _library_path / primary_cover.path
345
+
346
+ if not cover_path.exists():
347
+ raise HTTPException(status_code=404, detail="Cover file not found on disk")
348
+
349
+ return FileResponse(
350
+ cover_path,
351
+ media_type="image/png",
352
+ filename=f"cover_{book_id}.png"
353
+ )
354
+
355
+
356
+ @app.get("/api/stats", response_model=LibraryStats)
357
+ async def get_stats():
358
+ """Get library statistics."""
359
+ lib = get_library()
360
+ stats = lib.stats()
361
+
362
+ # Calculate total size from all files
363
+ from .db.models import File
364
+ from sqlalchemy import func
365
+ total_size = lib.session.query(func.sum(File.size_bytes)).scalar() or 0
366
+
367
+ # Convert language/format dicts to lists
368
+ languages = list(stats['languages'].keys()) if isinstance(stats['languages'], dict) else stats['languages']
369
+ formats = list(stats['formats'].keys()) if isinstance(stats['formats'], dict) else stats['formats']
370
+
371
+ return LibraryStats(
372
+ total_books=stats['total_books'],
373
+ total_authors=stats['total_authors'],
374
+ total_subjects=stats['total_subjects'],
375
+ total_files=stats['total_files'],
376
+ total_size_mb=total_size / (1024 ** 2),
377
+ languages=languages,
378
+ formats=formats
379
+ )
380
+
381
+
382
+ @app.get("/api/search")
383
+ async def search_books(q: str, limit: int = Query(50, ge=1, le=1000)):
384
+ """Full-text search across books."""
385
+ lib = get_library()
386
+ results = lib.search(q, limit=limit)
387
+ return [_book_to_response(book) for book in results]
388
+
389
+
390
+ def _book_to_response(book) -> dict:
391
+ """Convert Book ORM object to API response."""
392
+ # Get primary cover if available
393
+ cover_path = None
394
+ if book.covers:
395
+ primary_cover = next((c for c in book.covers if c.is_primary), book.covers[0])
396
+ cover_path = primary_cover.path
397
+
398
+ return {
399
+ "id": book.id,
400
+ "title": book.title,
401
+ "subtitle": book.subtitle,
402
+ "authors": [a.name for a in book.authors],
403
+ "language": book.language,
404
+ "publisher": book.publisher,
405
+ "publication_date": book.publication_date,
406
+ "series": book.series,
407
+ "series_index": book.series_index,
408
+ "description": book.description,
409
+ "subjects": [s.name for s in book.subjects],
410
+ "files": [
411
+ {
412
+ "format": f.format,
413
+ "size_bytes": f.size_bytes,
414
+ "path": f.path
415
+ }
416
+ for f in book.files
417
+ ],
418
+ "rating": book.personal.rating if book.personal else None,
419
+ "favorite": book.personal.favorite if book.personal else False,
420
+ "reading_status": book.personal.reading_status if book.personal else "unread",
421
+ "tags": book.personal.personal_tags if (book.personal and book.personal.personal_tags) else [],
422
+ "cover_path": cover_path
423
+ }
424
+
425
+
426
+ def get_web_interface() -> str:
427
+ """Generate the web interface HTML."""
428
+ return """
429
+ <!DOCTYPE html>
430
+ <html lang="en">
431
+ <head>
432
+ <meta charset="UTF-8">
433
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
434
+ <title>ebk Library Manager</title>
435
+ <style>
436
+ * {
437
+ margin: 0;
438
+ padding: 0;
439
+ box-sizing: border-box;
440
+ }
441
+
442
+ :root {
443
+ --primary: #2563eb;
444
+ --primary-dark: #1e40af;
445
+ --secondary: #64748b;
446
+ --background: #f8fafc;
447
+ --surface: #ffffff;
448
+ --text: #1e293b;
449
+ --text-light: #64748b;
450
+ --border: #e2e8f0;
451
+ --success: #10b981;
452
+ --warning: #f59e0b;
453
+ --danger: #ef4444;
454
+ }
455
+
456
+ body {
457
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
458
+ background: var(--background);
459
+ color: var(--text);
460
+ line-height: 1.6;
461
+ }
462
+
463
+ .container {
464
+ max-width: 1400px;
465
+ margin: 0 auto;
466
+ padding: 20px;
467
+ }
468
+
469
+ header {
470
+ background: var(--surface);
471
+ border-bottom: 2px solid var(--border);
472
+ padding: 20px 0;
473
+ margin-bottom: 30px;
474
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
475
+ }
476
+
477
+ h1 {
478
+ font-size: 2rem;
479
+ font-weight: 700;
480
+ color: var(--primary);
481
+ margin-bottom: 10px;
482
+ }
483
+
484
+ .toolbar {
485
+ display: flex;
486
+ gap: 15px;
487
+ flex-wrap: wrap;
488
+ align-items: center;
489
+ background: var(--surface);
490
+ padding: 15px;
491
+ border-radius: 8px;
492
+ margin-bottom: 20px;
493
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
494
+ }
495
+
496
+ .search-bar {
497
+ flex: 1;
498
+ min-width: 300px;
499
+ padding: 10px 15px;
500
+ border: 2px solid var(--border);
501
+ border-radius: 6px;
502
+ font-size: 1rem;
503
+ }
504
+
505
+ .search-bar:focus {
506
+ outline: none;
507
+ border-color: var(--primary);
508
+ }
509
+
510
+ .btn {
511
+ padding: 10px 20px;
512
+ border: none;
513
+ border-radius: 6px;
514
+ font-size: 0.9rem;
515
+ font-weight: 600;
516
+ cursor: pointer;
517
+ transition: all 0.2s;
518
+ }
519
+
520
+ .btn-primary {
521
+ background: var(--primary);
522
+ color: white;
523
+ }
524
+
525
+ .btn-primary:hover {
526
+ background: var(--primary-dark);
527
+ }
528
+
529
+ .btn-secondary {
530
+ background: var(--secondary);
531
+ color: white;
532
+ }
533
+
534
+ .btn-danger {
535
+ background: var(--danger);
536
+ color: white;
537
+ }
538
+
539
+ .stats {
540
+ display: grid;
541
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
542
+ gap: 15px;
543
+ margin-bottom: 20px;
544
+ }
545
+
546
+ .stat-card {
547
+ background: var(--surface);
548
+ padding: 20px;
549
+ border-radius: 8px;
550
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
551
+ }
552
+
553
+ .stat-label {
554
+ color: var(--text-light);
555
+ font-size: 0.875rem;
556
+ margin-bottom: 5px;
557
+ }
558
+
559
+ .stat-value {
560
+ font-size: 2rem;
561
+ font-weight: 700;
562
+ color: var(--primary);
563
+ }
564
+
565
+ .book-grid {
566
+ display: grid;
567
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
568
+ gap: 20px;
569
+ }
570
+
571
+ .book-card {
572
+ background: var(--surface);
573
+ border-radius: 8px;
574
+ padding: 20px;
575
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
576
+ transition: transform 0.2s, box-shadow 0.2s;
577
+ cursor: pointer;
578
+ }
579
+
580
+ .book-card:hover {
581
+ transform: translateY(-2px);
582
+ box-shadow: 0 4px 6px rgba(0,0,0,0.15);
583
+ }
584
+
585
+ .book-title {
586
+ font-size: 1.1rem;
587
+ font-weight: 600;
588
+ margin-bottom: 8px;
589
+ color: var(--text);
590
+ }
591
+
592
+ .book-authors {
593
+ color: var(--text-light);
594
+ font-size: 0.9rem;
595
+ margin-bottom: 8px;
596
+ }
597
+
598
+ .book-meta {
599
+ display: flex;
600
+ flex-wrap: wrap;
601
+ gap: 8px;
602
+ margin-top: 12px;
603
+ }
604
+
605
+ .badge {
606
+ display: inline-block;
607
+ padding: 3px 8px;
608
+ border-radius: 4px;
609
+ font-size: 0.75rem;
610
+ font-weight: 600;
611
+ }
612
+
613
+ .badge-format {
614
+ background: #f3f4f6;
615
+ color: var(--secondary);
616
+ }
617
+
618
+ .badge-language {
619
+ background: #fef3c7;
620
+ color: #92400e;
621
+ }
622
+
623
+ .favorite-star {
624
+ color: var(--warning);
625
+ font-size: 1.2rem;
626
+ }
627
+
628
+ .rating {
629
+ color: var(--warning);
630
+ font-size: 0.9rem;
631
+ }
632
+
633
+ .modal {
634
+ display: none;
635
+ position: fixed;
636
+ top: 0;
637
+ left: 0;
638
+ right: 0;
639
+ bottom: 0;
640
+ background: rgba(0,0,0,0.6);
641
+ z-index: 1000;
642
+ padding: 20px;
643
+ overflow-y: auto;
644
+ }
645
+
646
+ .modal.active {
647
+ display: flex;
648
+ align-items: center;
649
+ justify-content: center;
650
+ }
651
+
652
+ .modal-content {
653
+ background: var(--surface);
654
+ border-radius: 12px;
655
+ max-width: 800px;
656
+ width: 100%;
657
+ max-height: 90vh;
658
+ overflow-y: auto;
659
+ box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
660
+ }
661
+
662
+ .modal-header {
663
+ padding: 24px;
664
+ border-bottom: 1px solid var(--border);
665
+ display: flex;
666
+ justify-content: space-between;
667
+ align-items: center;
668
+ }
669
+
670
+ .modal-title {
671
+ font-size: 1.5rem;
672
+ font-weight: 700;
673
+ }
674
+
675
+ .close-btn {
676
+ background: none;
677
+ border: none;
678
+ font-size: 2rem;
679
+ color: var(--text-light);
680
+ cursor: pointer;
681
+ padding: 0;
682
+ width: 30px;
683
+ height: 30px;
684
+ line-height: 1;
685
+ }
686
+
687
+ .modal-body {
688
+ padding: 24px;
689
+ }
690
+
691
+ .form-group {
692
+ margin-bottom: 20px;
693
+ }
694
+
695
+ .form-label {
696
+ display: block;
697
+ font-weight: 600;
698
+ margin-bottom: 8px;
699
+ color: var(--text);
700
+ }
701
+
702
+ .form-control {
703
+ width: 100%;
704
+ padding: 10px;
705
+ border: 1px solid var(--border);
706
+ border-radius: 6px;
707
+ font-size: 1rem;
708
+ }
709
+
710
+ textarea.form-control {
711
+ min-height: 100px;
712
+ resize: vertical;
713
+ }
714
+
715
+ .loading {
716
+ text-align: center;
717
+ padding: 40px;
718
+ color: var(--text-light);
719
+ }
720
+
721
+ .spinner {
722
+ border: 3px solid var(--border);
723
+ border-top: 3px solid var(--primary);
724
+ border-radius: 50%;
725
+ width: 40px;
726
+ height: 40px;
727
+ animation: spin 1s linear infinite;
728
+ margin: 0 auto;
729
+ }
730
+
731
+ @keyframes spin {
732
+ 0% { transform: rotate(0deg); }
733
+ 100% { transform: rotate(360deg); }
734
+ }
735
+
736
+ .error {
737
+ background: #fee2e2;
738
+ color: #991b1b;
739
+ padding: 15px;
740
+ border-radius: 6px;
741
+ margin-bottom: 20px;
742
+ }
743
+
744
+ .success {
745
+ background: #d1fae5;
746
+ color: #065f46;
747
+ padding: 15px;
748
+ border-radius: 6px;
749
+ margin-bottom: 20px;
750
+ }
751
+ </style>
752
+ </head>
753
+ <body>
754
+ <header>
755
+ <div class="container">
756
+ <h1>📚 ebk Library Manager</h1>
757
+ <div id="stats-container" class="stats"></div>
758
+ </div>
759
+ </header>
760
+
761
+ <div class="container">
762
+ <div class="toolbar">
763
+ <input
764
+ type="text"
765
+ id="search-input"
766
+ class="search-bar"
767
+ placeholder="Search books by title, author, or description..."
768
+ >
769
+ <button class="btn btn-primary" onclick="showImportModal()">
770
+ ➕ Import Book
771
+ </button>
772
+ <button class="btn btn-secondary" onclick="refreshBooks()">
773
+ 🔄 Refresh
774
+ </button>
775
+ </div>
776
+
777
+ <!-- Filters and Sorting -->
778
+ <div style="background: var(--surface); padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
779
+ <div style="display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 10px;">
780
+ <div style="flex: 1; min-width: 150px;">
781
+ <label style="display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 5px; color: var(--text-light);">Sort By</label>
782
+ <select id="sort-field" class="form-control" onchange="applyFiltersAndSort()">
783
+ <option value="title">Title</option>
784
+ <option value="created_at">Date Added</option>
785
+ <option value="publication_date">Publication Date</option>
786
+ <option value="rating">Rating</option>
787
+ </select>
788
+ </div>
789
+
790
+ <div style="flex: 1; min-width: 150px;">
791
+ <label style="display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 5px; color: var(--text-light);">Order</label>
792
+ <select id="sort-order" class="form-control" onchange="applyFiltersAndSort()">
793
+ <option value="asc">Ascending</option>
794
+ <option value="desc">Descending</option>
795
+ </select>
796
+ </div>
797
+
798
+ <div style="flex: 1; min-width: 150px;">
799
+ <label style="display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 5px; color: var(--text-light);">Language</label>
800
+ <select id="filter-language" class="form-control" onchange="applyFiltersAndSort()">
801
+ <option value="">All Languages</option>
802
+ </select>
803
+ </div>
804
+
805
+ <div style="flex: 1; min-width: 150px;">
806
+ <label style="display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 5px; color: var(--text-light);">Format</label>
807
+ <select id="filter-format" class="form-control" onchange="applyFiltersAndSort()">
808
+ <option value="">All Formats</option>
809
+ </select>
810
+ </div>
811
+
812
+ <div style="flex: 1; min-width: 150px;">
813
+ <label style="display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 5px; color: var(--text-light);">Favorites</label>
814
+ <select id="filter-favorite" class="form-control" onchange="applyFiltersAndSort()">
815
+ <option value="">All Books</option>
816
+ <option value="true">Favorites Only</option>
817
+ <option value="false">Non-Favorites</option>
818
+ </select>
819
+ </div>
820
+
821
+ <div style="flex: 1; min-width: 150px;">
822
+ <label style="display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 5px; color: var(--text-light);">Min Rating</label>
823
+ <select id="filter-rating" class="form-control" onchange="applyFiltersAndSort()">
824
+ <option value="">Any Rating</option>
825
+ <option value="1">1+ Stars</option>
826
+ <option value="2">2+ Stars</option>
827
+ <option value="3">3+ Stars</option>
828
+ <option value="4">4+ Stars</option>
829
+ <option value="5">5 Stars</option>
830
+ </select>
831
+ </div>
832
+ </div>
833
+
834
+ <div style="display: flex; gap: 10px; align-items: center;">
835
+ <button class="btn btn-secondary" onclick="clearFilters()" style="font-size: 0.875rem; padding: 8px 16px;">
836
+ Clear Filters
837
+ </button>
838
+ <span id="filter-count" style="color: var(--text-light); font-size: 0.875rem;"></span>
839
+ </div>
840
+ </div>
841
+
842
+ <div id="message-container"></div>
843
+
844
+ <div id="loading" class="loading" style="display: none;">
845
+ <div class="spinner"></div>
846
+ <p>Loading books...</p>
847
+ </div>
848
+
849
+ <div id="book-grid" class="book-grid"></div>
850
+
851
+ <div id="pagination" style="display: flex; justify-content: center; align-items: center; gap: 15px; margin-top: 30px; padding: 20px;">
852
+ <button class="btn btn-secondary" onclick="previousPage()" id="prev-btn">
853
+ ← Previous
854
+ </button>
855
+ <span id="page-info" style="color: var(--text-light);">Page 1</span>
856
+ <button class="btn btn-secondary" onclick="nextPage()" id="next-btn">
857
+ Next →
858
+ </button>
859
+ </div>
860
+ </div>
861
+
862
+ <!-- Edit Book Modal -->
863
+ <div id="edit-modal" class="modal">
864
+ <div class="modal-content">
865
+ <div class="modal-header">
866
+ <h2 class="modal-title">Edit Book</h2>
867
+ <button class="close-btn" onclick="closeModal('edit-modal')">&times;</button>
868
+ </div>
869
+ <div class="modal-body">
870
+ <form id="edit-form" onsubmit="saveBook(event)">
871
+ <input type="hidden" id="edit-book-id">
872
+
873
+ <div class="form-group">
874
+ <label class="form-label">Title</label>
875
+ <input type="text" id="edit-title" class="form-control">
876
+ </div>
877
+
878
+ <div class="form-group">
879
+ <label class="form-label">Subtitle</label>
880
+ <input type="text" id="edit-subtitle" class="form-control">
881
+ </div>
882
+
883
+ <div class="form-group">
884
+ <label class="form-label">Language</label>
885
+ <input type="text" id="edit-language" class="form-control">
886
+ </div>
887
+
888
+ <div class="form-group">
889
+ <label class="form-label">Publisher</label>
890
+ <input type="text" id="edit-publisher" class="form-control">
891
+ </div>
892
+
893
+ <div class="form-group">
894
+ <label class="form-label">Publication Date</label>
895
+ <input type="text" id="edit-publication-date" class="form-control" placeholder="YYYY-MM-DD">
896
+ </div>
897
+
898
+ <div class="form-group">
899
+ <label class="form-label">Description</label>
900
+ <textarea id="edit-description" class="form-control"></textarea>
901
+ </div>
902
+
903
+ <div class="form-group">
904
+ <label class="form-label">Rating (1-5)</label>
905
+ <input type="number" id="edit-rating" class="form-control" min="1" max="5" step="0.5">
906
+ </div>
907
+
908
+ <div class="form-group">
909
+ <label class="form-label">Reading Status</label>
910
+ <select id="edit-status" class="form-control">
911
+ <option value="unread">Unread</option>
912
+ <option value="reading">Reading</option>
913
+ <option value="completed">Completed</option>
914
+ </select>
915
+ </div>
916
+
917
+ <div class="form-group">
918
+ <label class="form-label">
919
+ <input type="checkbox" id="edit-favorite">
920
+ Favorite
921
+ </label>
922
+ </div>
923
+
924
+ <div class="form-group">
925
+ <label class="form-label">Tags (comma-separated)</label>
926
+ <input type="text" id="edit-tags" class="form-control">
927
+ </div>
928
+
929
+ <div style="display: flex; gap: 10px;">
930
+ <button type="submit" class="btn btn-primary">Save Changes</button>
931
+ <button type="button" class="btn btn-danger" onclick="deleteBook()">Delete Book</button>
932
+ <button type="button" class="btn btn-secondary" onclick="closeModal('edit-modal')">Cancel</button>
933
+ </div>
934
+ </form>
935
+ </div>
936
+ </div>
937
+ </div>
938
+
939
+ <!-- Import Book Modal -->
940
+ <div id="import-modal" class="modal">
941
+ <div class="modal-content">
942
+ <div class="modal-header">
943
+ <h2 class="modal-title">Import Book</h2>
944
+ <button class="close-btn" onclick="closeModal('import-modal')">&times;</button>
945
+ </div>
946
+ <div class="modal-body">
947
+ <form id="import-form" onsubmit="importBook(event)">
948
+ <div class="form-group">
949
+ <label class="form-label">Select File (PDF, EPUB, MOBI, etc.)</label>
950
+ <input type="file" id="import-file" class="form-control" accept=".pdf,.epub,.mobi,.azw,.azw3,.txt" required>
951
+ </div>
952
+
953
+ <div class="form-group">
954
+ <label>
955
+ <input type="checkbox" id="import-extract-text" checked>
956
+ Extract full text for search
957
+ </label>
958
+ </div>
959
+
960
+ <div class="form-group">
961
+ <label>
962
+ <input type="checkbox" id="import-extract-cover" checked>
963
+ Extract cover image
964
+ </label>
965
+ </div>
966
+
967
+ <div style="display: flex; gap: 10px;">
968
+ <button type="submit" class="btn btn-primary">Import</button>
969
+ <button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">Cancel</button>
970
+ </div>
971
+ </form>
972
+ </div>
973
+ </div>
974
+ </div>
975
+
976
+ <!-- View Book Details Modal -->
977
+ <div id="details-modal" class="modal">
978
+ <div class="modal-content">
979
+ <div class="modal-header">
980
+ <h2 class="modal-title" id="details-title"></h2>
981
+ <button class="close-btn" onclick="closeModal('details-modal')">&times;</button>
982
+ </div>
983
+ <div class="modal-body" id="details-body"></div>
984
+ </div>
985
+ </div>
986
+
987
+ <script>
988
+ let books = [];
989
+ let currentBookId = null;
990
+ let currentPage = 1;
991
+ let booksPerPage = 100;
992
+ let totalBooks = 0;
993
+ let isSearching = false;
994
+
995
+ // Initialize
996
+ document.addEventListener('DOMContentLoaded', () => {
997
+ loadStats();
998
+
999
+ // Restore state from URL
1000
+ restoreStateFromURL();
1001
+ loadBooks();
1002
+
1003
+ // Search debouncing
1004
+ let searchTimeout;
1005
+ document.getElementById('search-input').addEventListener('input', (e) => {
1006
+ clearTimeout(searchTimeout);
1007
+ searchTimeout = setTimeout(() => {
1008
+ if (e.target.value.length >= 3) {
1009
+ isSearching = true;
1010
+ currentPage = 1;
1011
+ updateURL();
1012
+ searchBooks(e.target.value);
1013
+ } else if (e.target.value.length === 0) {
1014
+ isSearching = false;
1015
+ currentPage = 1;
1016
+ updateURL();
1017
+ loadBooks();
1018
+ }
1019
+ }, 300);
1020
+ });
1021
+
1022
+ // Handle browser back/forward
1023
+ window.addEventListener('popstate', () => {
1024
+ restoreStateFromURL();
1025
+ if (isSearching) {
1026
+ searchBooks(document.getElementById('search-input').value);
1027
+ } else {
1028
+ loadBooks();
1029
+ }
1030
+ });
1031
+ });
1032
+
1033
+ function updateURL() {
1034
+ const params = new URLSearchParams();
1035
+
1036
+ // Page
1037
+ if (currentPage > 1) params.set('page', currentPage);
1038
+
1039
+ // Search
1040
+ const searchQuery = document.getElementById('search-input').value;
1041
+ if (searchQuery) params.set('q', searchQuery);
1042
+
1043
+ // Filters
1044
+ const language = document.getElementById('filter-language').value;
1045
+ const format = document.getElementById('filter-format').value;
1046
+ const favorite = document.getElementById('filter-favorite').value;
1047
+ const minRating = document.getElementById('filter-rating').value;
1048
+
1049
+ if (language) params.set('language', language);
1050
+ if (format) params.set('format', format);
1051
+ if (favorite) params.set('favorite', favorite);
1052
+ if (minRating) params.set('rating', minRating);
1053
+
1054
+ // Sorting
1055
+ const sortField = document.getElementById('sort-field').value;
1056
+ const sortOrder = document.getElementById('sort-order').value;
1057
+
1058
+ if (sortField !== 'title') params.set('sort', sortField);
1059
+ if (sortOrder !== 'asc') params.set('order', sortOrder);
1060
+
1061
+ // Update URL without reloading
1062
+ const newURL = params.toString() ? `?${params.toString()}` : window.location.pathname;
1063
+ window.history.pushState({}, '', newURL);
1064
+ }
1065
+
1066
+ function restoreStateFromURL() {
1067
+ const params = new URLSearchParams(window.location.search);
1068
+
1069
+ // Restore page
1070
+ currentPage = parseInt(params.get('page')) || 1;
1071
+
1072
+ // Restore search
1073
+ const searchQuery = params.get('q') || '';
1074
+ document.getElementById('search-input').value = searchQuery;
1075
+ isSearching = searchQuery.length >= 3;
1076
+
1077
+ // Restore filters
1078
+ document.getElementById('filter-language').value = params.get('language') || '';
1079
+ document.getElementById('filter-format').value = params.get('format') || '';
1080
+ document.getElementById('filter-favorite').value = params.get('favorite') || '';
1081
+ document.getElementById('filter-rating').value = params.get('rating') || '';
1082
+
1083
+ // Restore sorting
1084
+ document.getElementById('sort-field').value = params.get('sort') || 'title';
1085
+ document.getElementById('sort-order').value = params.get('order') || 'asc';
1086
+ }
1087
+
1088
+ async function loadStats() {
1089
+ try {
1090
+ const response = await fetch('/api/stats');
1091
+ const stats = await response.json();
1092
+
1093
+ // Populate filter dropdowns
1094
+ const languageSelect = document.getElementById('filter-language');
1095
+ const formatSelect = document.getElementById('filter-format');
1096
+
1097
+ stats.languages.forEach(lang => {
1098
+ const option = document.createElement('option');
1099
+ option.value = lang;
1100
+ option.textContent = lang.toUpperCase();
1101
+ languageSelect.appendChild(option);
1102
+ });
1103
+
1104
+ stats.formats.forEach(fmt => {
1105
+ const option = document.createElement('option');
1106
+ option.value = fmt;
1107
+ option.textContent = fmt.toUpperCase();
1108
+ formatSelect.appendChild(option);
1109
+ });
1110
+
1111
+ document.getElementById('stats-container').innerHTML = `
1112
+ <div class="stat-card">
1113
+ <div class="stat-label">Total Books</div>
1114
+ <div class="stat-value">${stats.total_books}</div>
1115
+ </div>
1116
+ <div class="stat-card">
1117
+ <div class="stat-label">Authors</div>
1118
+ <div class="stat-value">${stats.total_authors}</div>
1119
+ </div>
1120
+ <div class="stat-card">
1121
+ <div class="stat-label">Files</div>
1122
+ <div class="stat-value">${stats.total_files}</div>
1123
+ </div>
1124
+ <div class="stat-card">
1125
+ <div class="stat-label">Storage</div>
1126
+ <div class="stat-value">${stats.total_size_mb.toFixed(1)} MB</div>
1127
+ </div>
1128
+ `;
1129
+ } catch (error) {
1130
+ console.error('Error loading stats:', error);
1131
+ }
1132
+ }
1133
+
1134
+ function buildQueryParams() {
1135
+ const params = new URLSearchParams();
1136
+ const offset = (currentPage - 1) * booksPerPage;
1137
+
1138
+ params.append('limit', booksPerPage);
1139
+ params.append('offset', offset);
1140
+
1141
+ // Add filters
1142
+ const language = document.getElementById('filter-language').value;
1143
+ const format = document.getElementById('filter-format').value;
1144
+ const favorite = document.getElementById('filter-favorite').value;
1145
+ const minRating = document.getElementById('filter-rating').value;
1146
+
1147
+ if (language) params.append('language', language);
1148
+ if (format) params.append('format_filter', format);
1149
+ if (favorite) params.append('favorite', favorite);
1150
+ if (minRating) params.append('rating', minRating);
1151
+
1152
+ // Add sorting
1153
+ const sortField = document.getElementById('sort-field').value;
1154
+ const sortOrder = document.getElementById('sort-order').value;
1155
+
1156
+ if (sortField) params.append('sort', sortField);
1157
+ if (sortOrder) params.append('order', sortOrder);
1158
+
1159
+ return params.toString();
1160
+ }
1161
+
1162
+ async function loadBooks() {
1163
+ const loading = document.getElementById('loading');
1164
+ const grid = document.getElementById('book-grid');
1165
+
1166
+ loading.style.display = 'block';
1167
+ grid.innerHTML = '';
1168
+
1169
+ try {
1170
+ const queryParams = buildQueryParams();
1171
+ const response = await fetch(`/api/books?${queryParams}`);
1172
+ books = await response.json();
1173
+
1174
+ // Get total count from stats (approximate for filtered results)
1175
+ const statsResponse = await fetch('/api/stats');
1176
+ const stats = await statsResponse.json();
1177
+ totalBooks = stats.total_books;
1178
+
1179
+ renderBooks(books);
1180
+ updatePagination();
1181
+ updateFilterCount();
1182
+ } catch (error) {
1183
+ showError('Failed to load books: ' + error.message);
1184
+ } finally {
1185
+ loading.style.display = 'none';
1186
+ }
1187
+ }
1188
+
1189
+ async function searchBooks(query) {
1190
+ const loading = document.getElementById('loading');
1191
+ const grid = document.getElementById('book-grid');
1192
+
1193
+ loading.style.display = 'block';
1194
+ grid.innerHTML = '';
1195
+
1196
+ try {
1197
+ const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
1198
+ books = await response.json();
1199
+ renderBooks(books);
1200
+ } catch (error) {
1201
+ showError('Search failed: ' + error.message);
1202
+ } finally {
1203
+ loading.style.display = 'none';
1204
+ }
1205
+ }
1206
+
1207
+ function renderBooks(books) {
1208
+ const grid = document.getElementById('book-grid');
1209
+
1210
+ if (books.length === 0) {
1211
+ grid.innerHTML = '<p class="loading">No books found</p>';
1212
+ return;
1213
+ }
1214
+
1215
+ grid.innerHTML = books.map(book => {
1216
+ // Find preferred format (pdf > epub > mobi > others)
1217
+ const preferredFormat = book.files.find(f => f.format.toLowerCase() === 'pdf') ||
1218
+ book.files.find(f => f.format.toLowerCase() === 'epub') ||
1219
+ book.files.find(f => f.format.toLowerCase() === 'mobi') ||
1220
+ book.files[0];
1221
+
1222
+ return `
1223
+ <div class="book-card">
1224
+ ${book.cover_path ? `
1225
+ <div style="text-align: center; margin-bottom: 10px; cursor: pointer;"
1226
+ onclick="openBookFile(${book.id}, '${preferredFormat?.format || ''}'); event.stopPropagation();"
1227
+ title="Click to open ${preferredFormat?.format.toUpperCase() || 'book'}">
1228
+ <img src="/api/books/${book.id}/cover" alt="Cover" style="max-width: 100%; max-height: 200px; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
1229
+ </div>
1230
+ ` : ''}
1231
+ <div class="book-title" onclick="showBookDetails(${book.id})" style="cursor: pointer;">
1232
+ ${escapeHtml(book.title)}
1233
+ ${book.favorite ? '<span class="favorite-star">⭐</span>' : ''}
1234
+ </div>
1235
+ <div class="book-authors" onclick="showBookDetails(${book.id})" style="cursor: pointer;">
1236
+ ${book.authors.join(', ') || 'Unknown Author'}
1237
+ </div>
1238
+ ${book.publication_date ? `<div style="color: var(--text-light); font-size: 0.85rem; margin-top: 4px;" onclick="showBookDetails(${book.id})">📅 ${book.publication_date}</div>` : ''}
1239
+ ${book.rating ? `<div class="rating" onclick="showBookDetails(${book.id})">${'★'.repeat(Math.round(book.rating))} ${book.rating}</div>` : ''}
1240
+ <div class="book-meta">
1241
+ ${book.files.map(f => `<span class="badge badge-format" style="cursor: pointer;" onclick="openBookFile(${book.id}, '${f.format}'); event.stopPropagation();" title="Click to open ${f.format.toUpperCase()}">${f.format.toUpperCase()}</span>`).join('')}
1242
+ ${book.language ? `<span class="badge badge-language" onclick="showBookDetails(${book.id})">${book.language.toUpperCase()}</span>` : ''}
1243
+ </div>
1244
+ </div>
1245
+ `;
1246
+ }).join('');
1247
+ }
1248
+
1249
+ function openBookFile(bookId, format) {
1250
+ if (!format) {
1251
+ showError('No file format available');
1252
+ return;
1253
+ }
1254
+ // Open in new tab
1255
+ window.open(`/api/books/${bookId}/files/${format.toLowerCase()}`, '_blank');
1256
+ }
1257
+
1258
+ async function showBookDetails(bookId) {
1259
+ try {
1260
+ const response = await fetch(`/api/books/${bookId}`);
1261
+ const book = await response.json();
1262
+
1263
+ document.getElementById('details-title').textContent = book.title;
1264
+
1265
+ let html = '';
1266
+
1267
+ // Cover image
1268
+ if (book.cover_path) {
1269
+ html += `
1270
+ <div style="text-align: center; margin-bottom: 20px;">
1271
+ <img src="/api/books/${book.id}/cover" alt="Cover"
1272
+ style="max-width: 300px; max-height: 400px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.2);">
1273
+ </div>
1274
+ `;
1275
+ }
1276
+
1277
+ // Available formats with clickable links
1278
+ if (book.files && book.files.length > 0) {
1279
+ html += `
1280
+ <div class="detail-section">
1281
+ <div class="detail-label">Available Formats</div>
1282
+ <div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px;">
1283
+ ${book.files.map(f => `
1284
+ <a href="/api/books/${book.id}/files/${f.format.toLowerCase()}"
1285
+ target="_blank"
1286
+ class="btn btn-primary"
1287
+ style="text-decoration: none; display: inline-flex; align-items: center; gap: 8px;">
1288
+ <span>📄</span>
1289
+ <span>${f.format.toUpperCase()}</span>
1290
+ <span style="font-size: 0.875rem; opacity: 0.8;">(${formatBytes(f.size_bytes)})</span>
1291
+ </a>
1292
+ `).join('')}
1293
+ </div>
1294
+ </div>
1295
+ `;
1296
+ }
1297
+
1298
+ // Authors
1299
+ if (book.authors && book.authors.length > 0) {
1300
+ html += `
1301
+ <div class="detail-section">
1302
+ <div class="detail-label">Authors</div>
1303
+ <div class="detail-value">${book.authors.join(', ')}</div>
1304
+ </div>
1305
+ `;
1306
+ }
1307
+
1308
+ // Subtitle
1309
+ if (book.subtitle) {
1310
+ html += `
1311
+ <div class="detail-section">
1312
+ <div class="detail-label">Subtitle</div>
1313
+ <div class="detail-value">${escapeHtml(book.subtitle)}</div>
1314
+ </div>
1315
+ `;
1316
+ }
1317
+
1318
+ // Description
1319
+ if (book.description) {
1320
+ html += `
1321
+ <div class="detail-section">
1322
+ <div class="detail-label">Description</div>
1323
+ <div class="detail-value">${book.description}</div>
1324
+ </div>
1325
+ `;
1326
+ }
1327
+
1328
+ // Metadata
1329
+ const metadata = [];
1330
+ if (book.publisher) metadata.push(`Publisher: ${book.publisher}`);
1331
+ if (book.publication_date) metadata.push(`Published: ${book.publication_date}`);
1332
+ if (book.language) metadata.push(`Language: ${book.language.toUpperCase()}`);
1333
+ if (book.rating) metadata.push(`Rating: ${'★'.repeat(Math.round(book.rating))} (${book.rating}/5)`);
1334
+ if (book.reading_status) metadata.push(`Status: ${book.reading_status}`);
1335
+
1336
+ if (metadata.length > 0) {
1337
+ html += `
1338
+ <div class="detail-section">
1339
+ <div class="detail-label">Metadata</div>
1340
+ <div class="detail-value">${metadata.join(' • ')}</div>
1341
+ </div>
1342
+ `;
1343
+ }
1344
+
1345
+ // Subjects/Tags
1346
+ if (book.subjects && book.subjects.length > 0) {
1347
+ html += `
1348
+ <div class="detail-section">
1349
+ <div class="detail-label">Subjects</div>
1350
+ <div class="detail-value">${book.subjects.join(', ')}</div>
1351
+ </div>
1352
+ `;
1353
+ }
1354
+
1355
+ // Personal tags
1356
+ if (book.tags && book.tags.length > 0) {
1357
+ html += `
1358
+ <div class="detail-section">
1359
+ <div class="detail-label">Personal Tags</div>
1360
+ <div class="detail-value">${book.tags.join(', ')}</div>
1361
+ </div>
1362
+ `;
1363
+ }
1364
+
1365
+ // Edit button
1366
+ html += `
1367
+ <div style="margin-top: 20px;">
1368
+ <button class="btn btn-primary" onclick="closeModal('details-modal'); editBook(${book.id});">
1369
+ ✏️ Edit Metadata
1370
+ </button>
1371
+ </div>
1372
+ `;
1373
+
1374
+ document.getElementById('details-body').innerHTML = html;
1375
+ document.getElementById('details-modal').classList.add('active');
1376
+ } catch (error) {
1377
+ showError('Failed to load book details: ' + error.message);
1378
+ }
1379
+ }
1380
+
1381
+ async function editBook(bookId) {
1382
+ currentBookId = bookId;
1383
+
1384
+ try {
1385
+ const response = await fetch(`/api/books/${bookId}`);
1386
+ const book = await response.json();
1387
+
1388
+ document.getElementById('edit-book-id').value = book.id;
1389
+ document.getElementById('edit-title').value = book.title || '';
1390
+ document.getElementById('edit-subtitle').value = book.subtitle || '';
1391
+ document.getElementById('edit-language').value = book.language || '';
1392
+ document.getElementById('edit-publisher').value = book.publisher || '';
1393
+ document.getElementById('edit-publication-date').value = book.publication_date || '';
1394
+ document.getElementById('edit-description').value = book.description || '';
1395
+ document.getElementById('edit-rating').value = book.rating || '';
1396
+ document.getElementById('edit-status').value = book.reading_status || 'unread';
1397
+ document.getElementById('edit-favorite').checked = book.favorite || false;
1398
+ document.getElementById('edit-tags').value = (book.tags || []).join(', ');
1399
+
1400
+ document.getElementById('edit-modal').classList.add('active');
1401
+ } catch (error) {
1402
+ showError('Failed to load book: ' + error.message);
1403
+ }
1404
+ }
1405
+
1406
+ async function saveBook(event) {
1407
+ event.preventDefault();
1408
+
1409
+ const bookId = document.getElementById('edit-book-id').value;
1410
+ const tags = document.getElementById('edit-tags').value
1411
+ .split(',')
1412
+ .map(t => t.trim())
1413
+ .filter(t => t);
1414
+
1415
+ const update = {
1416
+ title: document.getElementById('edit-title').value,
1417
+ subtitle: document.getElementById('edit-subtitle').value,
1418
+ language: document.getElementById('edit-language').value,
1419
+ publisher: document.getElementById('edit-publisher').value,
1420
+ publication_date: document.getElementById('edit-publication-date').value,
1421
+ description: document.getElementById('edit-description').value,
1422
+ rating: parseFloat(document.getElementById('edit-rating').value) || null,
1423
+ reading_status: document.getElementById('edit-status').value,
1424
+ favorite: document.getElementById('edit-favorite').checked,
1425
+ tags: tags
1426
+ };
1427
+
1428
+ try {
1429
+ const response = await fetch(`/api/books/${bookId}`, {
1430
+ method: 'PATCH',
1431
+ headers: { 'Content-Type': 'application/json' },
1432
+ body: JSON.stringify(update)
1433
+ });
1434
+
1435
+ if (!response.ok) throw new Error('Failed to update book');
1436
+
1437
+ closeModal('edit-modal');
1438
+ showSuccess('Book updated successfully');
1439
+ refreshBooks();
1440
+ } catch (error) {
1441
+ showError('Failed to save changes: ' + error.message);
1442
+ }
1443
+ }
1444
+
1445
+ async function deleteBook() {
1446
+ if (!confirm('Are you sure you want to delete this book?')) {
1447
+ return;
1448
+ }
1449
+
1450
+ const bookId = currentBookId;
1451
+ const deleteFiles = confirm('Also delete files from disk?');
1452
+
1453
+ try {
1454
+ const response = await fetch(`/api/books/${bookId}?delete_files=${deleteFiles}`, {
1455
+ method: 'DELETE'
1456
+ });
1457
+
1458
+ if (!response.ok) throw new Error('Failed to delete book');
1459
+
1460
+ closeModal('edit-modal');
1461
+ showSuccess('Book deleted successfully');
1462
+ refreshBooks();
1463
+ } catch (error) {
1464
+ showError('Failed to delete book: ' + error.message);
1465
+ }
1466
+ }
1467
+
1468
+ function showImportModal() {
1469
+ document.getElementById('import-modal').classList.add('active');
1470
+ }
1471
+
1472
+ async function importBook(event) {
1473
+ event.preventDefault();
1474
+
1475
+ const fileInput = document.getElementById('import-file');
1476
+ const file = fileInput.files[0];
1477
+
1478
+ if (!file) {
1479
+ showError('Please select a file');
1480
+ return;
1481
+ }
1482
+
1483
+ const formData = new FormData();
1484
+ formData.append('file', file);
1485
+ formData.append('extract_text', document.getElementById('import-extract-text').checked);
1486
+ formData.append('extract_cover', document.getElementById('import-extract-cover').checked);
1487
+
1488
+ try {
1489
+ const response = await fetch('/api/books/import', {
1490
+ method: 'POST',
1491
+ body: formData
1492
+ });
1493
+
1494
+ if (!response.ok) throw new Error('Import failed');
1495
+
1496
+ closeModal('import-modal');
1497
+ showSuccess('Book imported successfully');
1498
+ document.getElementById('import-form').reset();
1499
+ refreshBooks();
1500
+ loadStats();
1501
+ } catch (error) {
1502
+ showError('Import failed: ' + error.message);
1503
+ }
1504
+ }
1505
+
1506
+ function closeModal(modalId) {
1507
+ document.getElementById(modalId).classList.remove('active');
1508
+ }
1509
+
1510
+ function refreshBooks() {
1511
+ loadBooks();
1512
+ loadStats();
1513
+ }
1514
+
1515
+ function showError(message) {
1516
+ const container = document.getElementById('message-container');
1517
+ container.innerHTML = `<div class="error">${escapeHtml(message)}</div>`;
1518
+ setTimeout(() => container.innerHTML = '', 5000);
1519
+ }
1520
+
1521
+ function showSuccess(message) {
1522
+ const container = document.getElementById('message-container');
1523
+ container.innerHTML = `<div class="success">${escapeHtml(message)}</div>`;
1524
+ setTimeout(() => container.innerHTML = '', 3000);
1525
+ }
1526
+
1527
+ function escapeHtml(text) {
1528
+ const div = document.createElement('div');
1529
+ div.textContent = text;
1530
+ return div.innerHTML;
1531
+ }
1532
+
1533
+ function formatBytes(bytes) {
1534
+ if (bytes === 0) return '0 Bytes';
1535
+ const k = 1024;
1536
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
1537
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1538
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
1539
+ }
1540
+
1541
+ function updatePagination() {
1542
+ const totalPages = Math.ceil(totalBooks / booksPerPage);
1543
+ const pageInfo = document.getElementById('page-info');
1544
+ const prevBtn = document.getElementById('prev-btn');
1545
+ const nextBtn = document.getElementById('next-btn');
1546
+
1547
+ pageInfo.textContent = `Page ${currentPage} of ${totalPages} (${totalBooks} total books)`;
1548
+
1549
+ prevBtn.disabled = currentPage <= 1;
1550
+ nextBtn.disabled = currentPage >= totalPages;
1551
+
1552
+ prevBtn.style.opacity = currentPage <= 1 ? '0.5' : '1';
1553
+ nextBtn.style.opacity = currentPage >= totalPages ? '0.5' : '1';
1554
+ }
1555
+
1556
+ function nextPage() {
1557
+ const totalPages = Math.ceil(totalBooks / booksPerPage);
1558
+ if (currentPage < totalPages) {
1559
+ currentPage++;
1560
+ updateURL();
1561
+ if (isSearching) {
1562
+ const query = document.getElementById('search-input').value;
1563
+ searchBooks(query);
1564
+ } else {
1565
+ loadBooks();
1566
+ }
1567
+ window.scrollTo({ top: 0, behavior: 'smooth' });
1568
+ }
1569
+ }
1570
+
1571
+ function previousPage() {
1572
+ if (currentPage > 1) {
1573
+ currentPage--;
1574
+ updateURL();
1575
+ if (isSearching) {
1576
+ const query = document.getElementById('search-input').value;
1577
+ searchBooks(query);
1578
+ } else {
1579
+ loadBooks();
1580
+ }
1581
+ window.scrollTo({ top: 0, behavior: 'smooth' });
1582
+ }
1583
+ }
1584
+
1585
+ function applyFiltersAndSort() {
1586
+ currentPage = 1;
1587
+ updateURL();
1588
+ if (isSearching) {
1589
+ const query = document.getElementById('search-input').value;
1590
+ searchBooks(query);
1591
+ } else {
1592
+ loadBooks();
1593
+ }
1594
+ }
1595
+
1596
+ function clearFilters() {
1597
+ document.getElementById('sort-field').value = 'title';
1598
+ document.getElementById('sort-order').value = 'asc';
1599
+ document.getElementById('filter-language').value = '';
1600
+ document.getElementById('filter-format').value = '';
1601
+ document.getElementById('filter-favorite').value = '';
1602
+ document.getElementById('filter-rating').value = '';
1603
+ document.getElementById('search-input').value = '';
1604
+ isSearching = false;
1605
+ currentPage = 1;
1606
+ updateURL();
1607
+ loadBooks();
1608
+ }
1609
+
1610
+ function updateFilterCount() {
1611
+ const language = document.getElementById('filter-language').value;
1612
+ const format = document.getElementById('filter-format').value;
1613
+ const favorite = document.getElementById('filter-favorite').value;
1614
+ const rating = document.getElementById('filter-rating').value;
1615
+
1616
+ let activeFilters = 0;
1617
+ if (language) activeFilters++;
1618
+ if (format) activeFilters++;
1619
+ if (favorite) activeFilters++;
1620
+ if (rating) activeFilters++;
1621
+
1622
+ const filterCount = document.getElementById('filter-count');
1623
+ if (activeFilters > 0) {
1624
+ filterCount.textContent = `${activeFilters} filter${activeFilters > 1 ? 's' : ''} active`;
1625
+ filterCount.style.fontWeight = '600';
1626
+ } else {
1627
+ filterCount.textContent = '';
1628
+ }
1629
+ }
1630
+ </script>
1631
+ </body>
1632
+ </html>
1633
+ """