ebk 0.4.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. ebk/__init__.py +35 -0
  2. ebk/ai/__init__.py +23 -0
  3. ebk/ai/knowledge_graph.py +450 -0
  4. ebk/ai/llm_providers/__init__.py +26 -0
  5. ebk/ai/llm_providers/anthropic.py +209 -0
  6. ebk/ai/llm_providers/base.py +295 -0
  7. ebk/ai/llm_providers/gemini.py +285 -0
  8. ebk/ai/llm_providers/ollama.py +294 -0
  9. ebk/ai/metadata_enrichment.py +394 -0
  10. ebk/ai/question_generator.py +328 -0
  11. ebk/ai/reading_companion.py +224 -0
  12. ebk/ai/semantic_search.py +433 -0
  13. ebk/ai/text_extractor.py +393 -0
  14. ebk/calibre_import.py +66 -0
  15. ebk/cli.py +6433 -0
  16. ebk/config.py +230 -0
  17. ebk/db/__init__.py +37 -0
  18. ebk/db/migrations.py +507 -0
  19. ebk/db/models.py +725 -0
  20. ebk/db/session.py +144 -0
  21. ebk/decorators.py +1 -0
  22. ebk/exports/__init__.py +0 -0
  23. ebk/exports/base_exporter.py +218 -0
  24. ebk/exports/echo_export.py +279 -0
  25. ebk/exports/html_library.py +1743 -0
  26. ebk/exports/html_utils.py +87 -0
  27. ebk/exports/hugo.py +59 -0
  28. ebk/exports/jinja_export.py +286 -0
  29. ebk/exports/multi_facet_export.py +159 -0
  30. ebk/exports/opds_export.py +232 -0
  31. ebk/exports/symlink_dag.py +479 -0
  32. ebk/exports/zip.py +25 -0
  33. ebk/extract_metadata.py +341 -0
  34. ebk/ident.py +89 -0
  35. ebk/library_db.py +1440 -0
  36. ebk/opds.py +748 -0
  37. ebk/plugins/__init__.py +42 -0
  38. ebk/plugins/base.py +502 -0
  39. ebk/plugins/hooks.py +442 -0
  40. ebk/plugins/registry.py +499 -0
  41. ebk/repl/__init__.py +9 -0
  42. ebk/repl/find.py +126 -0
  43. ebk/repl/grep.py +173 -0
  44. ebk/repl/shell.py +1677 -0
  45. ebk/repl/text_utils.py +320 -0
  46. ebk/search_parser.py +413 -0
  47. ebk/server.py +3608 -0
  48. ebk/services/__init__.py +28 -0
  49. ebk/services/annotation_extraction.py +351 -0
  50. ebk/services/annotation_service.py +380 -0
  51. ebk/services/export_service.py +577 -0
  52. ebk/services/import_service.py +447 -0
  53. ebk/services/personal_metadata_service.py +347 -0
  54. ebk/services/queue_service.py +253 -0
  55. ebk/services/tag_service.py +281 -0
  56. ebk/services/text_extraction.py +317 -0
  57. ebk/services/view_service.py +12 -0
  58. ebk/similarity/__init__.py +77 -0
  59. ebk/similarity/base.py +154 -0
  60. ebk/similarity/core.py +471 -0
  61. ebk/similarity/extractors.py +168 -0
  62. ebk/similarity/metrics.py +376 -0
  63. ebk/skills/SKILL.md +182 -0
  64. ebk/skills/__init__.py +1 -0
  65. ebk/vfs/__init__.py +101 -0
  66. ebk/vfs/base.py +298 -0
  67. ebk/vfs/library_vfs.py +122 -0
  68. ebk/vfs/nodes/__init__.py +54 -0
  69. ebk/vfs/nodes/authors.py +196 -0
  70. ebk/vfs/nodes/books.py +480 -0
  71. ebk/vfs/nodes/files.py +155 -0
  72. ebk/vfs/nodes/metadata.py +385 -0
  73. ebk/vfs/nodes/root.py +100 -0
  74. ebk/vfs/nodes/similar.py +165 -0
  75. ebk/vfs/nodes/subjects.py +184 -0
  76. ebk/vfs/nodes/tags.py +371 -0
  77. ebk/vfs/resolver.py +228 -0
  78. ebk/vfs_router.py +275 -0
  79. ebk/views/__init__.py +32 -0
  80. ebk/views/dsl.py +668 -0
  81. ebk/views/service.py +619 -0
  82. ebk-0.4.4.dist-info/METADATA +755 -0
  83. ebk-0.4.4.dist-info/RECORD +87 -0
  84. ebk-0.4.4.dist-info/WHEEL +5 -0
  85. ebk-0.4.4.dist-info/entry_points.txt +2 -0
  86. ebk-0.4.4.dist-info/licenses/LICENSE +21 -0
  87. ebk-0.4.4.dist-info/top_level.txt +1 -0
ebk/server.py ADDED
@@ -0,0 +1,3608 @@
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
+ from . import opds
21
+ from . import vfs_router
22
+
23
+
24
+ # Pydantic models for API
25
+ class BookResponse(BaseModel):
26
+ id: int
27
+ title: str
28
+ subtitle: Optional[str]
29
+ authors: List[str]
30
+ language: Optional[str]
31
+ publisher: Optional[str]
32
+ publication_date: Optional[str]
33
+ series: Optional[str]
34
+ series_index: Optional[float]
35
+ description: Optional[str]
36
+ subjects: List[str]
37
+ files: List[dict]
38
+ rating: Optional[float]
39
+ favorite: bool
40
+ reading_status: str
41
+ tags: List[str]
42
+ cover_path: Optional[str]
43
+
44
+
45
+ class BookUpdateRequest(BaseModel):
46
+ title: Optional[str] = None
47
+ subtitle: Optional[str] = None
48
+ language: Optional[str] = None
49
+ publisher: Optional[str] = None
50
+ publication_date: Optional[str] = None
51
+ description: Optional[str] = None
52
+ rating: Optional[float] = None
53
+ favorite: Optional[bool] = None
54
+ reading_status: Optional[str] = None
55
+ tags: Optional[List[str]] = None
56
+
57
+
58
+ class LibraryStats(BaseModel):
59
+ total_books: int
60
+ total_authors: int
61
+ total_subjects: int
62
+ total_files: int
63
+ total_size_mb: float
64
+ languages: List[str]
65
+ formats: List[str]
66
+ favorites_count: int = 0
67
+ reading_count: int = 0
68
+ completed_count: int = 0
69
+
70
+
71
+ class PaginatedBooksResponse(BaseModel):
72
+ items: List[BookResponse]
73
+ total: int
74
+ offset: int
75
+ limit: int
76
+
77
+
78
+ class FolderImportRequest(BaseModel):
79
+ folder_path: str
80
+ recursive: bool = True
81
+ extensions: str = "pdf,epub,mobi,azw3,txt"
82
+ limit: Optional[int] = None
83
+ extract_text: bool = True
84
+ extract_cover: bool = True
85
+
86
+
87
+ class CalibreImportRequest(BaseModel):
88
+ calibre_path: str
89
+ limit: Optional[int] = None
90
+
91
+
92
+ class ImportProgress(BaseModel):
93
+ total: int
94
+ imported: int
95
+ failed: int
96
+ current_file: Optional[str] = None
97
+ status: str # "running", "completed", "failed"
98
+ errors: List[str] = []
99
+
100
+
101
+ class URLImportRequest(BaseModel):
102
+ url: str
103
+ extract_text: bool = True
104
+ extract_cover: bool = True
105
+
106
+
107
+ class OPDSImportRequest(BaseModel):
108
+ opds_url: str
109
+ limit: Optional[int] = None
110
+ extract_text: bool = True
111
+ extract_cover: bool = True
112
+
113
+
114
+ class ISBNImportRequest(BaseModel):
115
+ isbn: str
116
+
117
+
118
+ # View-related models
119
+ class ViewResponse(BaseModel):
120
+ name: str
121
+ description: Optional[str]
122
+ builtin: bool
123
+ count: Optional[int]
124
+ created_at: Optional[str] = None
125
+ updated_at: Optional[str] = None
126
+
127
+
128
+ class ViewDetailResponse(BaseModel):
129
+ name: str
130
+ description: Optional[str]
131
+ builtin: bool
132
+ definition: dict
133
+ count: Optional[int]
134
+
135
+
136
+ class ViewCreateRequest(BaseModel):
137
+ name: str
138
+ description: Optional[str] = None
139
+ definition: Optional[dict] = None
140
+ # Shorthand filters if definition not provided
141
+ subject: Optional[str] = None
142
+ author: Optional[str] = None
143
+ favorite: Optional[bool] = None
144
+ reading_status: Optional[str] = None
145
+
146
+
147
+ class ViewUpdateRequest(BaseModel):
148
+ description: Optional[str] = None
149
+ definition: Optional[dict] = None
150
+
151
+
152
+ class ViewOverrideRequest(BaseModel):
153
+ title: Optional[str] = None
154
+ description: Optional[str] = None
155
+ position: Optional[int] = None
156
+
157
+
158
+ # Global library instance
159
+ _library: Optional[Library] = None
160
+ _library_path: Optional[Path] = None
161
+
162
+
163
+ def get_library() -> Library:
164
+ """Get the current library instance."""
165
+ if _library is None:
166
+ raise HTTPException(status_code=500, detail="Library not initialized")
167
+ return _library
168
+
169
+
170
+ def init_library(library_path: Path):
171
+ """Initialize the library."""
172
+ global _library, _library_path
173
+ _library_path = library_path
174
+ _library = Library.open(library_path)
175
+ # Initialize VFS for the library
176
+ vfs_router.init_vfs_from_library(_library)
177
+
178
+
179
+ def set_library(library: Library):
180
+ """Set the library instance directly (for testing)."""
181
+ global _library, _library_path
182
+ _library = library
183
+ # Initialize VFS for the library
184
+ vfs_router.init_vfs_from_library(_library)
185
+ _library_path = library.library_path
186
+
187
+
188
+ def create_app(library_path: Path) -> FastAPI:
189
+ """Create FastAPI application with initialized library."""
190
+ # Initialize library
191
+ init_library(library_path)
192
+
193
+ # Initialize OPDS with the same library
194
+ opds.set_library(_library)
195
+
196
+ # Return the pre-configured app with all routes
197
+ return app
198
+
199
+
200
+ # Create FastAPI app
201
+ app = FastAPI(
202
+ title="ebk Library Manager",
203
+ description="Web interface for managing ebook libraries",
204
+ version="1.0.0"
205
+ )
206
+
207
+ # Enable CORS
208
+ app.add_middleware(
209
+ CORSMiddleware,
210
+ allow_origins=["*"],
211
+ allow_credentials=True,
212
+ allow_methods=["*"],
213
+ allow_headers=["*"],
214
+ )
215
+
216
+ # Include OPDS router
217
+ app.include_router(opds.router)
218
+
219
+ # Include VFS router
220
+ app.include_router(vfs_router.router)
221
+
222
+
223
+ @app.get("/", response_class=HTMLResponse)
224
+ async def root():
225
+ """Serve the main web interface."""
226
+ return get_web_interface()
227
+
228
+
229
+ @app.get("/api/books", response_model=PaginatedBooksResponse)
230
+ async def list_books(
231
+ limit: int = Query(50, ge=1, le=1000),
232
+ offset: int = Query(0, ge=0),
233
+ search: Optional[str] = None,
234
+ author: Optional[str] = None,
235
+ subject: Optional[str] = None,
236
+ language: Optional[str] = None,
237
+ favorite: Optional[bool] = None,
238
+ reading_status: Optional[str] = None,
239
+ format_filter: Optional[str] = None,
240
+ sort_by: Optional[str] = Query(None, alias="sort"),
241
+ sort_order: Optional[str] = Query("asc", alias="order"),
242
+ min_rating: Optional[float] = Query(None, alias="rating")
243
+ ):
244
+ """List books with filtering, sorting, and pagination."""
245
+ lib = get_library()
246
+
247
+ query = lib.query()
248
+
249
+ # Apply filters BEFORE pagination
250
+ if author:
251
+ query = query.filter_by_author(author)
252
+ if subject:
253
+ query = query.filter_by_subject(subject)
254
+ if language:
255
+ query = query.filter_by_language(language)
256
+ if favorite is not None:
257
+ query = query.filter_by_favorite(favorite)
258
+ if reading_status:
259
+ query = query.filter_by_reading_status(reading_status)
260
+ if format_filter:
261
+ query = query.filter_by_format(format_filter)
262
+ if min_rating is not None:
263
+ query = query.filter_by_rating(int(min_rating))
264
+ if search:
265
+ query = query.filter_by_text(search)
266
+
267
+ # Get total count BEFORE pagination
268
+ total = query.count()
269
+
270
+ # Apply sorting before pagination
271
+ if sort_by:
272
+ desc = (sort_order == "desc")
273
+ query = query.order_by(sort_by, desc=desc)
274
+ else:
275
+ # Default sort by title
276
+ query = query.order_by("title", desc=False)
277
+
278
+ # Apply pagination AFTER all filters and sorting
279
+ query = query.limit(limit).offset(offset)
280
+ books = query.all()
281
+
282
+ # Convert to paginated response format
283
+ return PaginatedBooksResponse(
284
+ items=[_book_to_response(book) for book in books],
285
+ total=total,
286
+ offset=offset,
287
+ limit=limit
288
+ )
289
+
290
+
291
+ @app.get("/api/books/{book_id}", response_model=BookResponse)
292
+ async def get_book(book_id: int):
293
+ """Get a specific book by ID."""
294
+ lib = get_library()
295
+ book = lib.get_book(book_id)
296
+
297
+ if not book:
298
+ raise HTTPException(status_code=404, detail="Book not found")
299
+
300
+ return _book_to_response(book)
301
+
302
+
303
+ @app.patch("/api/books/{book_id}")
304
+ async def update_book(book_id: int, update: BookUpdateRequest):
305
+ """Update book metadata."""
306
+ lib = get_library()
307
+ book = lib.get_book(book_id)
308
+
309
+ if not book:
310
+ raise HTTPException(status_code=404, detail="Book not found")
311
+
312
+ # Update fields
313
+ if update.title is not None:
314
+ book.title = update.title
315
+ if update.subtitle is not None:
316
+ book.subtitle = update.subtitle
317
+ if update.language is not None:
318
+ book.language = update.language
319
+ if update.publisher is not None:
320
+ book.publisher = update.publisher
321
+ if update.publication_date is not None:
322
+ book.publication_date = update.publication_date
323
+ if update.description is not None:
324
+ book.description = update.description
325
+
326
+ # Update personal metadata
327
+ # Handle reading status and rating together to avoid multiple calls
328
+ if update.reading_status is not None or update.rating is not None:
329
+ current_status = book.personal.reading_status if book.personal else 'unread'
330
+ new_status = update.reading_status if update.reading_status is not None else current_status
331
+ lib.update_reading_status(book_id, status=new_status, rating=update.rating)
332
+
333
+ if update.favorite is not None:
334
+ lib.set_favorite(book_id, update.favorite)
335
+
336
+ if update.tags is not None:
337
+ # Clear existing tags and add new ones
338
+ if book.personal and book.personal.personal_tags:
339
+ lib.remove_tags(book_id, book.personal.personal_tags)
340
+ if update.tags:
341
+ lib.add_tags(book_id, update.tags)
342
+
343
+ lib.session.commit()
344
+
345
+ # Refresh and return
346
+ lib.session.refresh(book)
347
+ return _book_to_response(book)
348
+
349
+
350
+ @app.delete("/api/books/{book_id}")
351
+ async def delete_book(book_id: int, delete_files: bool = Query(False)):
352
+ """Delete a book from the library."""
353
+ lib = get_library()
354
+ book = lib.get_book(book_id)
355
+
356
+ if not book:
357
+ raise HTTPException(status_code=404, detail="Book not found")
358
+
359
+ # Delete files if requested
360
+ if delete_files and _library_path:
361
+ for file in book.files:
362
+ file_path = _library_path / file.path
363
+ if file_path.exists():
364
+ file_path.unlink()
365
+
366
+ # Delete from database
367
+ lib.session.delete(book)
368
+ lib.session.commit()
369
+
370
+ return {"message": "Book deleted successfully"}
371
+
372
+
373
+ @app.post("/api/books/import")
374
+ async def import_book(
375
+ file: UploadFile = File(...),
376
+ extract_text: bool = Form(True),
377
+ extract_cover: bool = Form(True)
378
+ ):
379
+ """Import a new book file."""
380
+ lib = get_library()
381
+
382
+ # Save uploaded file to temp location
383
+ with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp:
384
+ shutil.copyfileobj(file.file, tmp)
385
+ tmp_path = Path(tmp.name)
386
+
387
+ try:
388
+ # Extract metadata
389
+ metadata = extract_metadata(str(tmp_path))
390
+
391
+ # Import to library
392
+ book = lib.add_book(
393
+ tmp_path,
394
+ metadata=metadata,
395
+ extract_text=extract_text,
396
+ extract_cover=extract_cover
397
+ )
398
+
399
+ if not book:
400
+ raise HTTPException(status_code=400, detail="Failed to import book")
401
+
402
+ return _book_to_response(book)
403
+
404
+ finally:
405
+ # Clean up temp file
406
+ tmp_path.unlink()
407
+
408
+
409
+ @app.post("/api/books/import/folder")
410
+ async def import_folder(request: FolderImportRequest):
411
+ """Import books from a folder."""
412
+ lib = get_library()
413
+ folder_path = Path(request.folder_path)
414
+
415
+ if not folder_path.exists():
416
+ raise HTTPException(status_code=400, detail=f"Folder not found: {folder_path}")
417
+
418
+ if not folder_path.is_dir():
419
+ raise HTTPException(status_code=400, detail=f"Not a directory: {folder_path}")
420
+
421
+ # Parse extensions
422
+ extensions = [ext.strip().lower() for ext in request.extensions.split(",")]
423
+
424
+ # Find all matching files
425
+ files = []
426
+ if request.recursive:
427
+ for ext in extensions:
428
+ files.extend(folder_path.rglob(f"*.{ext}"))
429
+ else:
430
+ for ext in extensions:
431
+ files.extend(folder_path.glob(f"*.{ext}"))
432
+
433
+ # Apply limit if specified
434
+ if request.limit:
435
+ files = files[:request.limit]
436
+
437
+ # Import books
438
+ results = {
439
+ "total": len(files),
440
+ "imported": 0,
441
+ "failed": 0,
442
+ "errors": [],
443
+ "books": []
444
+ }
445
+
446
+ for file_path in files:
447
+ try:
448
+ metadata = extract_metadata(str(file_path))
449
+ book = lib.add_book(
450
+ file_path,
451
+ metadata=metadata,
452
+ extract_text=request.extract_text,
453
+ extract_cover=request.extract_cover
454
+ )
455
+ if book:
456
+ results["imported"] += 1
457
+ results["books"].append(_book_to_response(book))
458
+ else:
459
+ results["failed"] += 1
460
+ results["errors"].append(f"Failed to import: {file_path.name}")
461
+ except Exception as e:
462
+ results["failed"] += 1
463
+ results["errors"].append(f"{file_path.name}: {str(e)}")
464
+
465
+ return results
466
+
467
+
468
+ @app.post("/api/books/import/calibre")
469
+ async def import_calibre(request: CalibreImportRequest):
470
+ """Import books from a Calibre library."""
471
+ from .calibre_import import import_calibre_library
472
+
473
+ lib = get_library()
474
+ calibre_path = Path(request.calibre_path)
475
+
476
+ if not calibre_path.exists():
477
+ raise HTTPException(status_code=400, detail=f"Calibre library not found: {calibre_path}")
478
+
479
+ # Check for Calibre metadata database
480
+ metadata_db = calibre_path / "metadata.db"
481
+ if not metadata_db.exists():
482
+ raise HTTPException(status_code=400, detail="Not a valid Calibre library (metadata.db not found)")
483
+
484
+ try:
485
+ results = import_calibre_library(
486
+ calibre_path,
487
+ lib,
488
+ limit=request.limit
489
+ )
490
+ return {
491
+ "total": results.get("total", 0),
492
+ "imported": results.get("imported", 0),
493
+ "failed": results.get("failed", 0),
494
+ "errors": results.get("errors", [])
495
+ }
496
+ except Exception as e:
497
+ raise HTTPException(status_code=500, detail=f"Calibre import failed: {str(e)}")
498
+
499
+
500
+ @app.post("/api/books/import/url")
501
+ async def import_from_url(request: URLImportRequest):
502
+ """Import an ebook from a URL."""
503
+ import httpx
504
+ import re
505
+
506
+ lib = get_library()
507
+ url = request.url.strip()
508
+
509
+ # Validate URL
510
+ if not url.startswith(('http://', 'https://')):
511
+ raise HTTPException(status_code=400, detail="Invalid URL. Must start with http:// or https://")
512
+
513
+ # Supported extensions
514
+ supported_extensions = {'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.txt'}
515
+
516
+ try:
517
+ # Download the file
518
+ async with httpx.AsyncClient(follow_redirects=True, timeout=60.0) as client:
519
+ response = await client.get(url)
520
+ response.raise_for_status()
521
+
522
+ # Try to determine filename from Content-Disposition header or URL
523
+ filename = None
524
+ content_disposition = response.headers.get('content-disposition', '')
525
+ if 'filename=' in content_disposition:
526
+ match = re.search(r'filename[*]?=["\']?([^"\';]+)', content_disposition)
527
+ if match:
528
+ filename = match.group(1)
529
+
530
+ if not filename:
531
+ # Extract from URL path
532
+ from urllib.parse import urlparse, unquote
533
+ parsed = urlparse(url)
534
+ filename = unquote(parsed.path.split('/')[-1])
535
+
536
+ if not filename:
537
+ filename = 'downloaded_book'
538
+
539
+ # Check extension
540
+ ext = Path(filename).suffix.lower()
541
+ if ext not in supported_extensions:
542
+ # Try to guess from content-type
543
+ content_type = response.headers.get('content-type', '')
544
+ if 'pdf' in content_type:
545
+ ext = '.pdf'
546
+ elif 'epub' in content_type:
547
+ ext = '.epub'
548
+ else:
549
+ raise HTTPException(
550
+ status_code=400,
551
+ detail=f"Unsupported file type. Supported: {', '.join(supported_extensions)}"
552
+ )
553
+ filename = Path(filename).stem + ext
554
+
555
+ # Save to temp file
556
+ with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
557
+ tmp.write(response.content)
558
+ tmp_path = Path(tmp.name)
559
+
560
+ # Extract metadata and import
561
+ metadata = extract_metadata(str(tmp_path))
562
+ book = lib.add_book(
563
+ tmp_path,
564
+ metadata=metadata,
565
+ extract_text=request.extract_text,
566
+ extract_cover=request.extract_cover
567
+ )
568
+
569
+ # Clean up temp file
570
+ tmp_path.unlink()
571
+
572
+ if not book:
573
+ raise HTTPException(status_code=400, detail="Failed to import book from URL")
574
+
575
+ return _book_to_response(book)
576
+
577
+ except httpx.HTTPError as e:
578
+ raise HTTPException(status_code=400, detail=f"Failed to download file: {str(e)}")
579
+ except Exception as e:
580
+ raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
581
+
582
+
583
+ @app.post("/api/books/import/opds")
584
+ async def import_from_opds(request: OPDSImportRequest):
585
+ """Import books from an OPDS catalog feed."""
586
+ import httpx
587
+ import xml.etree.ElementTree as ET
588
+
589
+ lib = get_library()
590
+ opds_url = request.opds_url.strip()
591
+
592
+ if not opds_url.startswith(('http://', 'https://')):
593
+ raise HTTPException(status_code=400, detail="Invalid URL. Must start with http:// or https://")
594
+
595
+ results = {
596
+ "total": 0,
597
+ "imported": 0,
598
+ "failed": 0,
599
+ "errors": [],
600
+ "books": []
601
+ }
602
+
603
+ try:
604
+ async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
605
+ # Fetch OPDS feed
606
+ response = await client.get(opds_url)
607
+ response.raise_for_status()
608
+
609
+ # Parse OPDS (Atom) feed
610
+ root = ET.fromstring(response.content)
611
+
612
+ # OPDS uses Atom namespace
613
+ ns = {
614
+ 'atom': 'http://www.w3.org/2005/Atom',
615
+ 'opds': 'http://opds-spec.org/2010/catalog',
616
+ 'dc': 'http://purl.org/dc/elements/1.1/'
617
+ }
618
+
619
+ entries = root.findall('.//atom:entry', ns)
620
+ if not entries:
621
+ # Try without namespace (some feeds don't use it properly)
622
+ entries = root.findall('.//entry')
623
+
624
+ if request.limit:
625
+ entries = entries[:request.limit]
626
+
627
+ results["total"] = len(entries)
628
+
629
+ for entry in entries:
630
+ try:
631
+ # Find acquisition link (the actual book file)
632
+ acquisition_link = None
633
+ for link in entry.findall('atom:link', ns) or entry.findall('link'):
634
+ rel = link.get('rel', '')
635
+ href = link.get('href', '')
636
+ link_type = link.get('type', '')
637
+
638
+ # Look for acquisition links
639
+ if 'acquisition' in rel and href:
640
+ # Prefer epub, then pdf
641
+ if 'epub' in link_type:
642
+ acquisition_link = href
643
+ break
644
+ elif 'pdf' in link_type and not acquisition_link:
645
+ acquisition_link = href
646
+
647
+ if not acquisition_link:
648
+ results["failed"] += 1
649
+ title_el = entry.find('atom:title', ns) or entry.find('title')
650
+ title = title_el.text if title_el is not None else 'Unknown'
651
+ results["errors"].append(f"No download link found for: {title}")
652
+ continue
653
+
654
+ # Make URL absolute if needed
655
+ if not acquisition_link.startswith(('http://', 'https://')):
656
+ from urllib.parse import urljoin
657
+ acquisition_link = urljoin(opds_url, acquisition_link)
658
+
659
+ # Download and import
660
+ file_response = await client.get(acquisition_link, timeout=60.0)
661
+ file_response.raise_for_status()
662
+
663
+ # Determine extension
664
+ content_type = file_response.headers.get('content-type', '')
665
+ if 'epub' in content_type:
666
+ ext = '.epub'
667
+ elif 'pdf' in content_type:
668
+ ext = '.pdf'
669
+ elif 'mobi' in content_type:
670
+ ext = '.mobi'
671
+ else:
672
+ ext = '.epub' # Default guess
673
+
674
+ with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
675
+ tmp.write(file_response.content)
676
+ tmp_path = Path(tmp.name)
677
+
678
+ metadata = extract_metadata(str(tmp_path))
679
+ book = lib.add_book(
680
+ tmp_path,
681
+ metadata=metadata,
682
+ extract_text=request.extract_text,
683
+ extract_cover=request.extract_cover
684
+ )
685
+
686
+ tmp_path.unlink()
687
+
688
+ if book:
689
+ results["imported"] += 1
690
+ results["books"].append(_book_to_response(book))
691
+ else:
692
+ results["failed"] += 1
693
+
694
+ except Exception as e:
695
+ results["failed"] += 1
696
+ results["errors"].append(str(e))
697
+
698
+ return results
699
+
700
+ except httpx.HTTPError as e:
701
+ raise HTTPException(status_code=400, detail=f"Failed to fetch OPDS feed: {str(e)}")
702
+ except ET.ParseError as e:
703
+ raise HTTPException(status_code=400, detail=f"Invalid OPDS feed format: {str(e)}")
704
+ except Exception as e:
705
+ raise HTTPException(status_code=500, detail=f"OPDS import failed: {str(e)}")
706
+
707
+
708
+ @app.post("/api/books/import/isbn")
709
+ async def import_from_isbn(request: ISBNImportRequest):
710
+ """Create a book entry from ISBN lookup (metadata only, no file)."""
711
+ import httpx
712
+ import re
713
+
714
+ lib = get_library()
715
+ isbn = re.sub(r'[^0-9X]', '', request.isbn.upper())
716
+
717
+ if len(isbn) not in (10, 13):
718
+ raise HTTPException(status_code=400, detail="Invalid ISBN. Must be 10 or 13 digits.")
719
+
720
+ try:
721
+ async with httpx.AsyncClient(timeout=15.0) as client:
722
+ # Try Google Books API first
723
+ google_url = f"https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}"
724
+ response = await client.get(google_url)
725
+
726
+ metadata = None
727
+
728
+ if response.status_code == 200:
729
+ data = response.json()
730
+ if data.get('totalItems', 0) > 0:
731
+ volume = data['items'][0]['volumeInfo']
732
+ metadata = {
733
+ 'title': volume.get('title', 'Unknown'),
734
+ 'subtitle': volume.get('subtitle'),
735
+ 'authors': volume.get('authors', []),
736
+ 'publisher': volume.get('publisher'),
737
+ 'publication_date': volume.get('publishedDate'),
738
+ 'description': volume.get('description'),
739
+ 'page_count': volume.get('pageCount'),
740
+ 'language': volume.get('language'),
741
+ 'subjects': volume.get('categories', []),
742
+ 'identifiers': [{'scheme': 'isbn', 'value': isbn}],
743
+ }
744
+
745
+ # Fallback to Open Library if Google didn't find it
746
+ if not metadata:
747
+ ol_url = f"https://openlibrary.org/api/books?bibkeys=ISBN:{isbn}&format=json&jscmd=data"
748
+ response = await client.get(ol_url)
749
+
750
+ if response.status_code == 200:
751
+ data = response.json()
752
+ key = f"ISBN:{isbn}"
753
+ if key in data:
754
+ book_data = data[key]
755
+ metadata = {
756
+ 'title': book_data.get('title', 'Unknown'),
757
+ 'subtitle': book_data.get('subtitle'),
758
+ 'authors': [a.get('name') for a in book_data.get('authors', [])],
759
+ 'publisher': book_data.get('publishers', [{}])[0].get('name') if book_data.get('publishers') else None,
760
+ 'publication_date': book_data.get('publish_date'),
761
+ 'page_count': book_data.get('number_of_pages'),
762
+ 'subjects': [s.get('name') for s in book_data.get('subjects', [])],
763
+ 'identifiers': [{'scheme': 'isbn', 'value': isbn}],
764
+ }
765
+
766
+ if not metadata:
767
+ raise HTTPException(status_code=404, detail=f"No book found for ISBN: {isbn}")
768
+
769
+ # Create book entry without a file
770
+ from .db.models import Book, Author, Subject, Identifier
771
+ from .services.import_service import get_sort_name
772
+ import hashlib
773
+
774
+ # Generate unique_id based on ISBN
775
+ unique_id = hashlib.md5(f"isbn:{isbn}".encode()).hexdigest()
776
+
777
+ book = Book(
778
+ unique_id=unique_id,
779
+ title=metadata['title'],
780
+ subtitle=metadata.get('subtitle'),
781
+ publisher=metadata.get('publisher'),
782
+ publication_date=metadata.get('publication_date'),
783
+ description=metadata.get('description'),
784
+ page_count=metadata.get('page_count'),
785
+ language=metadata.get('language'),
786
+ )
787
+
788
+ # Add authors
789
+ for author_name in metadata.get('authors', []):
790
+ if author_name:
791
+ author = lib.session.query(Author).filter_by(name=author_name).first()
792
+ if not author:
793
+ author = Author(name=author_name, sort_name=get_sort_name(author_name))
794
+ lib.session.add(author)
795
+ book.authors.append(author)
796
+
797
+ # Add subjects
798
+ for subject_name in metadata.get('subjects', []):
799
+ if subject_name:
800
+ subject = lib.session.query(Subject).filter_by(name=subject_name).first()
801
+ if not subject:
802
+ subject = Subject(name=subject_name)
803
+ lib.session.add(subject)
804
+ book.subjects.append(subject)
805
+
806
+ # Add identifiers
807
+ for ident in metadata.get('identifiers', []):
808
+ identifier = Identifier(scheme=ident['scheme'], value=ident['value'])
809
+ book.identifiers.append(identifier)
810
+
811
+ lib.session.add(book)
812
+ lib.session.commit()
813
+
814
+ return _book_to_response(book)
815
+
816
+ except httpx.HTTPError as e:
817
+ raise HTTPException(status_code=400, detail=f"Failed to lookup ISBN: {str(e)}")
818
+ except Exception as e:
819
+ if isinstance(e, HTTPException):
820
+ raise
821
+ raise HTTPException(status_code=500, detail=f"ISBN import failed: {str(e)}")
822
+
823
+
824
+ @app.get("/api/books/{book_id}/files/{file_format}")
825
+ async def download_file(book_id: int, file_format: str):
826
+ """Download a book file."""
827
+ lib = get_library()
828
+ book = lib.get_book(book_id)
829
+
830
+ if not book:
831
+ raise HTTPException(status_code=404, detail="Book not found")
832
+
833
+ # Find file with matching format
834
+ book_file = next((f for f in book.files if f.format.lower() == file_format.lower()), None)
835
+
836
+ if not book_file or not _library_path:
837
+ raise HTTPException(status_code=404, detail="File not found")
838
+
839
+ file_path = _library_path / book_file.path
840
+
841
+ if not file_path.exists():
842
+ raise HTTPException(status_code=404, detail="File not found on disk")
843
+
844
+ return FileResponse(
845
+ file_path,
846
+ media_type="application/octet-stream",
847
+ filename=f"{book.title}.{file_format}"
848
+ )
849
+
850
+
851
+ @app.get("/api/books/{book_id}/cover")
852
+ async def get_cover(book_id: int):
853
+ """Get the cover image for a book."""
854
+ lib = get_library()
855
+ book = lib.get_book(book_id)
856
+
857
+ if not book:
858
+ raise HTTPException(status_code=404, detail="Book not found")
859
+
860
+ # Find primary cover
861
+ if not book.covers:
862
+ raise HTTPException(status_code=404, detail="No cover available")
863
+
864
+ primary_cover = next((c for c in book.covers if c.is_primary), book.covers[0])
865
+
866
+ if not _library_path:
867
+ raise HTTPException(status_code=500, detail="Library path not initialized")
868
+
869
+ cover_path = _library_path / primary_cover.path
870
+
871
+ if not cover_path.exists():
872
+ raise HTTPException(status_code=404, detail="Cover file not found on disk")
873
+
874
+ return FileResponse(
875
+ cover_path,
876
+ media_type="image/png",
877
+ filename=f"cover_{book_id}.png"
878
+ )
879
+
880
+
881
+ @app.get("/api/stats", response_model=LibraryStats)
882
+ async def get_stats():
883
+ """Get library statistics."""
884
+ lib = get_library()
885
+ stats = lib.stats()
886
+
887
+ # Calculate total size from all files
888
+ from .db.models import File, PersonalMetadata
889
+ from sqlalchemy import func
890
+ total_size = lib.session.query(func.sum(File.size_bytes)).scalar() or 0
891
+
892
+ # Get favorites count
893
+ favorites_count = lib.session.query(func.count(PersonalMetadata.id)).filter(
894
+ PersonalMetadata.favorite == True
895
+ ).scalar() or 0
896
+
897
+ # Convert language/format dicts to lists
898
+ languages = list(stats['languages'].keys()) if isinstance(stats['languages'], dict) else stats['languages']
899
+ formats = list(stats['formats'].keys()) if isinstance(stats['formats'], dict) else stats['formats']
900
+
901
+ return LibraryStats(
902
+ total_books=stats['total_books'],
903
+ total_authors=stats['total_authors'],
904
+ total_subjects=stats['total_subjects'],
905
+ total_files=stats['total_files'],
906
+ total_size_mb=total_size / (1024 ** 2),
907
+ languages=languages,
908
+ formats=formats,
909
+ favorites_count=favorites_count,
910
+ reading_count=stats.get('reading_count', 0),
911
+ completed_count=stats.get('read_count', 0)
912
+ )
913
+
914
+
915
+ @app.get("/api/search")
916
+ async def search_books(q: str, limit: int = Query(50, ge=1, le=1000)):
917
+ """Full-text search across books."""
918
+ lib = get_library()
919
+ results = lib.search(q, limit=limit)
920
+ return [_book_to_response(book) for book in results]
921
+
922
+
923
+ def _book_to_response(book) -> dict:
924
+ """Convert Book ORM object to API response."""
925
+ # Get primary cover if available
926
+ cover_path = None
927
+ if book.covers:
928
+ primary_cover = next((c for c in book.covers if c.is_primary), book.covers[0])
929
+ cover_path = primary_cover.path
930
+
931
+ return {
932
+ "id": book.id,
933
+ "title": book.title,
934
+ "subtitle": book.subtitle,
935
+ "authors": [a.name for a in book.authors],
936
+ "language": book.language,
937
+ "publisher": book.publisher,
938
+ "publication_date": book.publication_date,
939
+ "series": book.series,
940
+ "series_index": book.series_index,
941
+ "description": book.description,
942
+ "subjects": [s.name for s in book.subjects],
943
+ "files": [
944
+ {
945
+ "format": f.format,
946
+ "size_bytes": f.size_bytes,
947
+ "path": f.path
948
+ }
949
+ for f in book.files
950
+ ],
951
+ "rating": book.personal.rating if book.personal else None,
952
+ "favorite": book.personal.favorite if book.personal else False,
953
+ "reading_status": book.personal.reading_status if book.personal else "unread",
954
+ "tags": book.personal.personal_tags if (book.personal and book.personal.personal_tags) else [],
955
+ "cover_path": cover_path
956
+ }
957
+
958
+
959
+ # =========================================================================
960
+ # Views API Endpoints
961
+ # =========================================================================
962
+
963
+ @app.get("/api/views", response_model=List[ViewResponse])
964
+ async def list_views():
965
+ """List all views (builtin and user-defined)."""
966
+ from .views import ViewService
967
+ lib = get_library()
968
+ svc = ViewService(lib.session)
969
+ views = svc.list(include_builtin=True)
970
+
971
+ return [
972
+ ViewResponse(
973
+ name=v['name'],
974
+ description=v.get('description'),
975
+ builtin=v.get('builtin', False),
976
+ count=v.get('count'),
977
+ created_at=v.get('created_at').isoformat() if v.get('created_at') else None,
978
+ updated_at=v.get('updated_at').isoformat() if v.get('updated_at') else None,
979
+ )
980
+ for v in views
981
+ ]
982
+
983
+
984
+ @app.get("/api/views/{view_name}", response_model=ViewDetailResponse)
985
+ async def get_view(view_name: str):
986
+ """Get view details including definition."""
987
+ from .views import ViewService
988
+ from .views.dsl import is_builtin_view, get_builtin_view
989
+ lib = get_library()
990
+ svc = ViewService(lib.session)
991
+
992
+ if is_builtin_view(view_name):
993
+ defn = get_builtin_view(view_name)
994
+ count = svc.count(view_name)
995
+ return ViewDetailResponse(
996
+ name=view_name,
997
+ description=defn.get('description'),
998
+ builtin=True,
999
+ definition=defn,
1000
+ count=count
1001
+ )
1002
+
1003
+ view = svc.get(view_name)
1004
+ if not view:
1005
+ raise HTTPException(status_code=404, detail=f"View '{view_name}' not found")
1006
+
1007
+ return ViewDetailResponse(
1008
+ name=view.name,
1009
+ description=view.description,
1010
+ builtin=False,
1011
+ definition=view.definition,
1012
+ count=view.cached_count or svc.count(view_name)
1013
+ )
1014
+
1015
+
1016
+ @app.get("/api/views/{view_name}/books", response_model=PaginatedBooksResponse)
1017
+ async def get_view_books(
1018
+ view_name: str,
1019
+ limit: int = Query(50, ge=1, le=1000),
1020
+ offset: int = Query(0, ge=0)
1021
+ ):
1022
+ """Get books in a view with pagination."""
1023
+ from .views import ViewService
1024
+ lib = get_library()
1025
+ svc = ViewService(lib.session)
1026
+
1027
+ try:
1028
+ transformed = svc.evaluate(view_name)
1029
+ except ValueError as e:
1030
+ raise HTTPException(status_code=404, detail=str(e))
1031
+
1032
+ total = len(transformed)
1033
+ page = transformed[offset:offset + limit]
1034
+ books = [tb.book for tb in page]
1035
+
1036
+ return PaginatedBooksResponse(
1037
+ items=[_book_to_response(book) for book in books],
1038
+ total=total,
1039
+ offset=offset,
1040
+ limit=limit
1041
+ )
1042
+
1043
+
1044
+ @app.post("/api/views", response_model=ViewDetailResponse)
1045
+ async def create_view(request: ViewCreateRequest):
1046
+ """Create a new view."""
1047
+ from .views import ViewService
1048
+ lib = get_library()
1049
+ svc = ViewService(lib.session)
1050
+
1051
+ try:
1052
+ # Build filter kwargs from shorthand options
1053
+ filter_kwargs = {}
1054
+ if request.subject:
1055
+ filter_kwargs['subject'] = request.subject
1056
+ if request.author:
1057
+ filter_kwargs['author'] = request.author
1058
+ if request.favorite is not None:
1059
+ filter_kwargs['favorite'] = request.favorite
1060
+ if request.reading_status:
1061
+ filter_kwargs['reading_status'] = request.reading_status
1062
+
1063
+ view = svc.create(
1064
+ name=request.name,
1065
+ definition=request.definition,
1066
+ description=request.description,
1067
+ **filter_kwargs
1068
+ )
1069
+
1070
+ return ViewDetailResponse(
1071
+ name=view.name,
1072
+ description=view.description,
1073
+ builtin=False,
1074
+ definition=view.definition,
1075
+ count=svc.count(view.name)
1076
+ )
1077
+ except ValueError as e:
1078
+ raise HTTPException(status_code=400, detail=str(e))
1079
+
1080
+
1081
+ @app.patch("/api/views/{view_name}", response_model=ViewDetailResponse)
1082
+ async def update_view(view_name: str, request: ViewUpdateRequest):
1083
+ """Update a view."""
1084
+ from .views import ViewService
1085
+ from .views.dsl import is_builtin_view
1086
+ lib = get_library()
1087
+ svc = ViewService(lib.session)
1088
+
1089
+ if is_builtin_view(view_name):
1090
+ raise HTTPException(status_code=400, detail="Cannot modify builtin views")
1091
+
1092
+ try:
1093
+ view = svc.update(
1094
+ name=view_name,
1095
+ definition=request.definition,
1096
+ description=request.description
1097
+ )
1098
+
1099
+ return ViewDetailResponse(
1100
+ name=view.name,
1101
+ description=view.description,
1102
+ builtin=False,
1103
+ definition=view.definition,
1104
+ count=svc.count(view.name)
1105
+ )
1106
+ except ValueError as e:
1107
+ raise HTTPException(status_code=404, detail=str(e))
1108
+
1109
+
1110
+ @app.delete("/api/views/{view_name}")
1111
+ async def delete_view(view_name: str):
1112
+ """Delete a view."""
1113
+ from .views import ViewService
1114
+ from .views.dsl import is_builtin_view
1115
+ lib = get_library()
1116
+ svc = ViewService(lib.session)
1117
+
1118
+ if is_builtin_view(view_name):
1119
+ raise HTTPException(status_code=400, detail="Cannot delete builtin views")
1120
+
1121
+ if svc.delete(view_name):
1122
+ return {"message": f"View '{view_name}' deleted successfully"}
1123
+ else:
1124
+ raise HTTPException(status_code=404, detail=f"View '{view_name}' not found")
1125
+
1126
+
1127
+ @app.post("/api/views/{view_name}/books/{book_id}")
1128
+ async def add_book_to_view(view_name: str, book_id: int):
1129
+ """Add a book to a view."""
1130
+ from .views import ViewService
1131
+ from .views.dsl import is_builtin_view
1132
+ lib = get_library()
1133
+ svc = ViewService(lib.session)
1134
+
1135
+ if is_builtin_view(view_name):
1136
+ raise HTTPException(status_code=400, detail="Cannot modify builtin views")
1137
+
1138
+ try:
1139
+ svc.add_book(view_name, book_id)
1140
+ return {"message": f"Book {book_id} added to view '{view_name}'"}
1141
+ except ValueError as e:
1142
+ raise HTTPException(status_code=404, detail=str(e))
1143
+
1144
+
1145
+ @app.delete("/api/views/{view_name}/books/{book_id}")
1146
+ async def remove_book_from_view(view_name: str, book_id: int):
1147
+ """Remove a book from a view."""
1148
+ from .views import ViewService
1149
+ from .views.dsl import is_builtin_view
1150
+ lib = get_library()
1151
+ svc = ViewService(lib.session)
1152
+
1153
+ if is_builtin_view(view_name):
1154
+ raise HTTPException(status_code=400, detail="Cannot modify builtin views")
1155
+
1156
+ try:
1157
+ svc.remove_book(view_name, book_id)
1158
+ return {"message": f"Book {book_id} removed from view '{view_name}'"}
1159
+ except ValueError as e:
1160
+ raise HTTPException(status_code=404, detail=str(e))
1161
+
1162
+
1163
+ @app.put("/api/views/{view_name}/overrides/{book_id}")
1164
+ async def set_view_override(view_name: str, book_id: int, request: ViewOverrideRequest):
1165
+ """Set metadata overrides for a book within a view."""
1166
+ from .views import ViewService
1167
+ from .views.dsl import is_builtin_view
1168
+ lib = get_library()
1169
+ svc = ViewService(lib.session)
1170
+
1171
+ if is_builtin_view(view_name):
1172
+ raise HTTPException(status_code=400, detail="Cannot modify builtin views")
1173
+
1174
+ try:
1175
+ svc.set_override(
1176
+ view_name=view_name,
1177
+ book_id=book_id,
1178
+ title=request.title,
1179
+ description=request.description,
1180
+ position=request.position
1181
+ )
1182
+ return {"message": f"Override set for book {book_id} in view '{view_name}'"}
1183
+ except ValueError as e:
1184
+ raise HTTPException(status_code=404, detail=str(e))
1185
+
1186
+
1187
+ @app.delete("/api/views/{view_name}/overrides/{book_id}")
1188
+ async def remove_view_override(view_name: str, book_id: int, field: Optional[str] = None):
1189
+ """Remove overrides for a book within a view."""
1190
+ from .views import ViewService
1191
+ from .views.dsl import is_builtin_view
1192
+ lib = get_library()
1193
+ svc = ViewService(lib.session)
1194
+
1195
+ if is_builtin_view(view_name):
1196
+ raise HTTPException(status_code=400, detail="Cannot modify builtin views")
1197
+
1198
+ try:
1199
+ if svc.unset_override(view_name, book_id, field):
1200
+ return {"message": f"Override removed for book {book_id} in view '{view_name}'"}
1201
+ else:
1202
+ raise HTTPException(status_code=404, detail="Override not found")
1203
+ except ValueError as e:
1204
+ raise HTTPException(status_code=404, detail=str(e))
1205
+
1206
+
1207
+ @app.get("/api/views/{view_name}/yaml")
1208
+ async def export_view_yaml(view_name: str):
1209
+ """Export a view definition as YAML."""
1210
+ from .views import ViewService
1211
+ lib = get_library()
1212
+ svc = ViewService(lib.session)
1213
+
1214
+ try:
1215
+ yaml_content = svc.export_yaml(view_name)
1216
+ return JSONResponse(
1217
+ content={"yaml": yaml_content},
1218
+ media_type="application/json"
1219
+ )
1220
+ except ValueError as e:
1221
+ raise HTTPException(status_code=404, detail=str(e))
1222
+
1223
+
1224
+ @app.post("/api/views/import")
1225
+ async def import_view_yaml(yaml_content: str = Form(...), overwrite: bool = Form(False)):
1226
+ """Import a view from YAML."""
1227
+ from .views import ViewService
1228
+ lib = get_library()
1229
+ svc = ViewService(lib.session)
1230
+
1231
+ try:
1232
+ view = svc.import_yaml(yaml_content, overwrite=overwrite)
1233
+ return ViewDetailResponse(
1234
+ name=view.name,
1235
+ description=view.description,
1236
+ builtin=False,
1237
+ definition=view.definition,
1238
+ count=svc.count(view.name)
1239
+ )
1240
+ except ValueError as e:
1241
+ raise HTTPException(status_code=400, detail=str(e))
1242
+
1243
+
1244
+ def get_web_interface() -> str:
1245
+ """Generate the web interface HTML."""
1246
+ return '''<!DOCTYPE html>
1247
+ <html lang="en">
1248
+ <head>
1249
+ <meta charset="UTF-8">
1250
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1251
+ <title>ebk Library Manager</title>
1252
+ <style>
1253
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
1254
+
1255
+ :root {
1256
+ --bg-primary: #f8fafc;
1257
+ --bg-secondary: #ffffff;
1258
+ --bg-tertiary: #f1f5f9;
1259
+ --bg-hover: #e2e8f0;
1260
+ --text-primary: #0f172a;
1261
+ --text-secondary: #475569;
1262
+ --text-muted: #94a3b8;
1263
+ --border: #e2e8f0;
1264
+ --accent: #6366f1;
1265
+ --accent-hover: #4f46e5;
1266
+ --accent-light: #eef2ff;
1267
+ --success: #10b981;
1268
+ --success-light: #d1fae5;
1269
+ --warning: #f59e0b;
1270
+ --warning-light: #fef3c7;
1271
+ --danger: #ef4444;
1272
+ --danger-light: #fee2e2;
1273
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
1274
+ --shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
1275
+ --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06);
1276
+ --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05);
1277
+ --radius: 8px;
1278
+ --radius-lg: 12px;
1279
+ --radius-xl: 16px;
1280
+ --transition: 0.15s ease;
1281
+ }
1282
+
1283
+ [data-theme="dark"] {
1284
+ --bg-primary: #0f172a;
1285
+ --bg-secondary: #1e293b;
1286
+ --bg-tertiary: #334155;
1287
+ --bg-hover: #475569;
1288
+ --text-primary: #f1f5f9;
1289
+ --text-secondary: #cbd5e1;
1290
+ --text-muted: #64748b;
1291
+ --border: #334155;
1292
+ --accent-light: #312e81;
1293
+ --success-light: #064e3b;
1294
+ --warning-light: #78350f;
1295
+ --danger-light: #7f1d1d;
1296
+ }
1297
+
1298
+ html { font-size: 16px; }
1299
+
1300
+ body {
1301
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
1302
+ background: var(--bg-primary);
1303
+ color: var(--text-primary);
1304
+ line-height: 1.6;
1305
+ min-height: 100vh;
1306
+ }
1307
+
1308
+ /* Layout */
1309
+ .app { display: flex; min-height: 100vh; }
1310
+
1311
+ /* Sidebar */
1312
+ .sidebar {
1313
+ width: 280px;
1314
+ background: var(--bg-secondary);
1315
+ border-right: 1px solid var(--border);
1316
+ display: flex;
1317
+ flex-direction: column;
1318
+ position: fixed;
1319
+ top: 0;
1320
+ left: 0;
1321
+ bottom: 0;
1322
+ z-index: 100;
1323
+ transform: translateX(-100%);
1324
+ transition: transform 0.3s ease;
1325
+ }
1326
+
1327
+ .sidebar.open { transform: translateX(0); }
1328
+
1329
+ @media (min-width: 1024px) {
1330
+ .sidebar {
1331
+ transform: translateX(0);
1332
+ position: sticky;
1333
+ height: 100vh;
1334
+ }
1335
+ }
1336
+
1337
+ .sidebar-header {
1338
+ padding: 24px;
1339
+ border-bottom: 1px solid var(--border);
1340
+ }
1341
+
1342
+ .logo {
1343
+ display: flex;
1344
+ align-items: center;
1345
+ gap: 12px;
1346
+ font-size: 1.25rem;
1347
+ font-weight: 700;
1348
+ color: var(--accent);
1349
+ }
1350
+
1351
+ .logo-icon {
1352
+ width: 40px;
1353
+ height: 40px;
1354
+ background: linear-gradient(135deg, var(--accent), #8b5cf6);
1355
+ border-radius: var(--radius);
1356
+ display: flex;
1357
+ align-items: center;
1358
+ justify-content: center;
1359
+ font-size: 1.25rem;
1360
+ color: white;
1361
+ }
1362
+
1363
+ .sidebar-nav {
1364
+ flex: 1;
1365
+ overflow-y: auto;
1366
+ padding: 16px;
1367
+ }
1368
+
1369
+ .nav-section { margin-bottom: 24px; }
1370
+
1371
+ .nav-section-title {
1372
+ font-size: 0.7rem;
1373
+ font-weight: 600;
1374
+ text-transform: uppercase;
1375
+ letter-spacing: 0.08em;
1376
+ color: var(--text-muted);
1377
+ padding: 8px 12px;
1378
+ margin-bottom: 4px;
1379
+ }
1380
+
1381
+ .nav-item {
1382
+ display: flex;
1383
+ align-items: center;
1384
+ gap: 12px;
1385
+ padding: 10px 12px;
1386
+ border-radius: var(--radius);
1387
+ color: var(--text-secondary);
1388
+ cursor: pointer;
1389
+ transition: all var(--transition);
1390
+ font-size: 0.9rem;
1391
+ font-weight: 500;
1392
+ }
1393
+
1394
+ .nav-item:hover {
1395
+ background: var(--bg-tertiary);
1396
+ color: var(--text-primary);
1397
+ }
1398
+
1399
+ .nav-item.active {
1400
+ background: var(--accent);
1401
+ color: white;
1402
+ }
1403
+
1404
+ .nav-item-icon { font-size: 1.1rem; opacity: 0.9; }
1405
+
1406
+ .nav-item-count {
1407
+ margin-left: auto;
1408
+ font-size: 0.75rem;
1409
+ font-weight: 600;
1410
+ background: var(--bg-tertiary);
1411
+ padding: 2px 8px;
1412
+ border-radius: 10px;
1413
+ color: var(--text-muted);
1414
+ }
1415
+
1416
+ .nav-item.active .nav-item-count {
1417
+ background: rgba(255,255,255,0.2);
1418
+ color: white;
1419
+ }
1420
+
1421
+ .sidebar-footer {
1422
+ padding: 16px 24px;
1423
+ border-top: 1px solid var(--border);
1424
+ }
1425
+
1426
+ .sidebar-overlay {
1427
+ display: none;
1428
+ position: fixed;
1429
+ inset: 0;
1430
+ background: rgba(0,0,0,0.5);
1431
+ z-index: 99;
1432
+ }
1433
+
1434
+ .sidebar-overlay.active { display: block; }
1435
+
1436
+ /* Main Content */
1437
+ .main {
1438
+ flex: 1;
1439
+ display: flex;
1440
+ flex-direction: column;
1441
+ min-width: 0;
1442
+ }
1443
+
1444
+ /* Header */
1445
+ .header {
1446
+ background: var(--bg-secondary);
1447
+ border-bottom: 1px solid var(--border);
1448
+ padding: 16px 24px;
1449
+ display: flex;
1450
+ align-items: center;
1451
+ gap: 16px;
1452
+ position: sticky;
1453
+ top: 0;
1454
+ z-index: 50;
1455
+ }
1456
+
1457
+ .menu-btn {
1458
+ display: flex;
1459
+ align-items: center;
1460
+ justify-content: center;
1461
+ width: 40px;
1462
+ height: 40px;
1463
+ border: none;
1464
+ background: var(--bg-tertiary);
1465
+ border-radius: var(--radius);
1466
+ cursor: pointer;
1467
+ color: var(--text-primary);
1468
+ font-size: 1.25rem;
1469
+ transition: all var(--transition);
1470
+ }
1471
+
1472
+ .menu-btn:hover { background: var(--bg-hover); }
1473
+
1474
+ @media (min-width: 1024px) { .menu-btn { display: none; } }
1475
+
1476
+ .search-box {
1477
+ flex: 1;
1478
+ max-width: 600px;
1479
+ position: relative;
1480
+ }
1481
+
1482
+ .search-input {
1483
+ width: 100%;
1484
+ padding: 12px 16px 12px 48px;
1485
+ border: 2px solid var(--border);
1486
+ border-radius: var(--radius-lg);
1487
+ font-size: 0.95rem;
1488
+ background: var(--bg-primary);
1489
+ color: var(--text-primary);
1490
+ transition: all var(--transition);
1491
+ }
1492
+
1493
+ .search-input:focus {
1494
+ outline: none;
1495
+ border-color: var(--accent);
1496
+ box-shadow: 0 0 0 3px var(--accent-light);
1497
+ }
1498
+
1499
+ .search-input::placeholder { color: var(--text-muted); }
1500
+
1501
+ .search-icon {
1502
+ position: absolute;
1503
+ left: 16px;
1504
+ top: 50%;
1505
+ transform: translateY(-50%);
1506
+ color: var(--text-muted);
1507
+ font-size: 1.1rem;
1508
+ }
1509
+
1510
+ .header-actions {
1511
+ display: flex;
1512
+ align-items: center;
1513
+ gap: 8px;
1514
+ }
1515
+
1516
+ .icon-btn {
1517
+ display: flex;
1518
+ align-items: center;
1519
+ justify-content: center;
1520
+ width: 40px;
1521
+ height: 40px;
1522
+ border: none;
1523
+ background: var(--bg-tertiary);
1524
+ border-radius: var(--radius);
1525
+ cursor: pointer;
1526
+ color: var(--text-secondary);
1527
+ font-size: 1.1rem;
1528
+ transition: all var(--transition);
1529
+ }
1530
+
1531
+ .icon-btn:hover {
1532
+ background: var(--accent);
1533
+ color: white;
1534
+ }
1535
+
1536
+ .icon-btn.active {
1537
+ background: var(--accent);
1538
+ color: white;
1539
+ }
1540
+
1541
+ /* Toolbar */
1542
+ .toolbar {
1543
+ background: var(--bg-secondary);
1544
+ border-bottom: 1px solid var(--border);
1545
+ padding: 12px 24px;
1546
+ display: flex;
1547
+ align-items: center;
1548
+ gap: 12px;
1549
+ flex-wrap: wrap;
1550
+ }
1551
+
1552
+ .toolbar-group {
1553
+ display: flex;
1554
+ align-items: center;
1555
+ gap: 8px;
1556
+ }
1557
+
1558
+ .toolbar-label {
1559
+ font-size: 0.8rem;
1560
+ color: var(--text-muted);
1561
+ font-weight: 500;
1562
+ }
1563
+
1564
+ .toolbar-select {
1565
+ padding: 8px 12px;
1566
+ border: 1px solid var(--border);
1567
+ border-radius: var(--radius);
1568
+ font-size: 0.85rem;
1569
+ background: var(--bg-primary);
1570
+ color: var(--text-primary);
1571
+ cursor: pointer;
1572
+ transition: all var(--transition);
1573
+ }
1574
+
1575
+ .toolbar-select:focus {
1576
+ outline: none;
1577
+ border-color: var(--accent);
1578
+ }
1579
+
1580
+ .toolbar-divider {
1581
+ width: 1px;
1582
+ height: 24px;
1583
+ background: var(--border);
1584
+ margin: 0 8px;
1585
+ }
1586
+
1587
+ .results-info {
1588
+ margin-left: auto;
1589
+ font-size: 0.85rem;
1590
+ color: var(--text-muted);
1591
+ }
1592
+
1593
+ /* Stats Bar */
1594
+ .stats-bar {
1595
+ display: flex;
1596
+ gap: 24px;
1597
+ padding: 16px 24px;
1598
+ background: var(--bg-secondary);
1599
+ border-bottom: 1px solid var(--border);
1600
+ overflow-x: auto;
1601
+ }
1602
+
1603
+ .stat-item {
1604
+ display: flex;
1605
+ align-items: center;
1606
+ gap: 12px;
1607
+ padding: 12px 16px;
1608
+ background: var(--bg-tertiary);
1609
+ border-radius: var(--radius);
1610
+ min-width: fit-content;
1611
+ }
1612
+
1613
+ .stat-icon {
1614
+ width: 40px;
1615
+ height: 40px;
1616
+ border-radius: var(--radius);
1617
+ display: flex;
1618
+ align-items: center;
1619
+ justify-content: center;
1620
+ font-size: 1.2rem;
1621
+ }
1622
+
1623
+ .stat-icon.books { background: var(--accent-light); color: var(--accent); }
1624
+ .stat-icon.authors { background: var(--success-light); color: var(--success); }
1625
+ .stat-icon.files { background: var(--warning-light); color: var(--warning); }
1626
+ .stat-icon.storage { background: var(--danger-light); color: var(--danger); }
1627
+
1628
+ .stat-content { display: flex; flex-direction: column; }
1629
+ .stat-value { font-size: 1.25rem; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
1630
+ .stat-label { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
1631
+
1632
+ /* Content Area */
1633
+ .content {
1634
+ flex: 1;
1635
+ padding: 24px;
1636
+ overflow-y: auto;
1637
+ }
1638
+
1639
+ /* Grid View */
1640
+ .book-grid {
1641
+ display: grid;
1642
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
1643
+ gap: 20px;
1644
+ }
1645
+
1646
+ .book-card {
1647
+ background: var(--bg-secondary);
1648
+ border-radius: var(--radius-lg);
1649
+ overflow: hidden;
1650
+ box-shadow: var(--shadow);
1651
+ transition: all var(--transition);
1652
+ cursor: pointer;
1653
+ }
1654
+
1655
+ .book-card:hover {
1656
+ transform: translateY(-4px);
1657
+ box-shadow: var(--shadow-lg);
1658
+ }
1659
+
1660
+ .book-cover {
1661
+ aspect-ratio: 2/3;
1662
+ background: var(--bg-tertiary);
1663
+ display: flex;
1664
+ align-items: center;
1665
+ justify-content: center;
1666
+ overflow: hidden;
1667
+ position: relative;
1668
+ }
1669
+
1670
+ .book-cover img {
1671
+ width: 100%;
1672
+ height: 100%;
1673
+ object-fit: cover;
1674
+ }
1675
+
1676
+ .book-cover-placeholder {
1677
+ font-size: 3rem;
1678
+ color: var(--text-muted);
1679
+ }
1680
+
1681
+ .book-favorite {
1682
+ position: absolute;
1683
+ top: 8px;
1684
+ right: 8px;
1685
+ background: rgba(0,0,0,0.5);
1686
+ color: var(--warning);
1687
+ padding: 4px 8px;
1688
+ border-radius: var(--radius);
1689
+ font-size: 0.9rem;
1690
+ }
1691
+
1692
+ .book-info { padding: 16px; }
1693
+
1694
+ .book-title {
1695
+ font-size: 0.95rem;
1696
+ font-weight: 600;
1697
+ color: var(--text-primary);
1698
+ line-height: 1.4;
1699
+ display: -webkit-box;
1700
+ -webkit-line-clamp: 2;
1701
+ -webkit-box-orient: vertical;
1702
+ overflow: hidden;
1703
+ margin-bottom: 4px;
1704
+ }
1705
+
1706
+ .book-author {
1707
+ font-size: 0.85rem;
1708
+ color: var(--text-secondary);
1709
+ white-space: nowrap;
1710
+ overflow: hidden;
1711
+ text-overflow: ellipsis;
1712
+ }
1713
+
1714
+ .book-meta {
1715
+ display: flex;
1716
+ align-items: center;
1717
+ gap: 8px;
1718
+ margin-top: 12px;
1719
+ flex-wrap: wrap;
1720
+ }
1721
+
1722
+ .badge {
1723
+ font-size: 0.7rem;
1724
+ padding: 3px 8px;
1725
+ border-radius: 4px;
1726
+ font-weight: 600;
1727
+ text-transform: uppercase;
1728
+ letter-spacing: 0.02em;
1729
+ }
1730
+
1731
+ .badge-format { background: var(--bg-tertiary); color: var(--text-secondary); }
1732
+ .badge-language { background: var(--warning-light); color: #92400e; }
1733
+ .badge-rating { background: var(--accent-light); color: var(--accent); }
1734
+
1735
+ .book-rating {
1736
+ display: flex;
1737
+ align-items: center;
1738
+ gap: 4px;
1739
+ color: var(--warning);
1740
+ font-size: 0.85rem;
1741
+ }
1742
+
1743
+ /* List View */
1744
+ .book-list { display: flex; flex-direction: column; gap: 12px; }
1745
+
1746
+ .book-list-item {
1747
+ background: var(--bg-secondary);
1748
+ border-radius: var(--radius);
1749
+ padding: 16px;
1750
+ display: flex;
1751
+ gap: 16px;
1752
+ align-items: flex-start;
1753
+ box-shadow: var(--shadow);
1754
+ cursor: pointer;
1755
+ transition: all var(--transition);
1756
+ }
1757
+
1758
+ .book-list-item:hover { box-shadow: var(--shadow-md); }
1759
+
1760
+ .book-list-cover {
1761
+ width: 60px;
1762
+ height: 90px;
1763
+ background: var(--bg-tertiary);
1764
+ border-radius: 4px;
1765
+ overflow: hidden;
1766
+ flex-shrink: 0;
1767
+ }
1768
+
1769
+ .book-list-cover img { width: 100%; height: 100%; object-fit: cover; }
1770
+
1771
+ .book-list-info { flex: 1; min-width: 0; }
1772
+
1773
+ .book-list-title { font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
1774
+ .book-list-author { font-size: 0.9rem; color: var(--text-secondary); margin-bottom: 8px; }
1775
+
1776
+ .book-list-meta {
1777
+ display: flex;
1778
+ gap: 16px;
1779
+ font-size: 0.8rem;
1780
+ color: var(--text-muted);
1781
+ flex-wrap: wrap;
1782
+ }
1783
+
1784
+ /* Table View */
1785
+ .book-table {
1786
+ width: 100%;
1787
+ border-collapse: collapse;
1788
+ font-size: 0.9rem;
1789
+ }
1790
+
1791
+ .book-table th {
1792
+ text-align: left;
1793
+ padding: 12px 16px;
1794
+ background: var(--bg-tertiary);
1795
+ font-weight: 600;
1796
+ color: var(--text-secondary);
1797
+ border-bottom: 2px solid var(--border);
1798
+ cursor: pointer;
1799
+ user-select: none;
1800
+ transition: background var(--transition);
1801
+ }
1802
+
1803
+ .book-table th:hover { background: var(--bg-hover); }
1804
+
1805
+ .book-table td {
1806
+ padding: 12px 16px;
1807
+ border-bottom: 1px solid var(--border);
1808
+ color: var(--text-primary);
1809
+ }
1810
+
1811
+ .book-table tr:hover td { background: var(--bg-tertiary); }
1812
+
1813
+ .table-title { font-weight: 500; cursor: pointer; }
1814
+ .table-title:hover { color: var(--accent); }
1815
+
1816
+ /* Pagination */
1817
+ .pagination {
1818
+ display: flex;
1819
+ justify-content: center;
1820
+ align-items: center;
1821
+ gap: 8px;
1822
+ margin-top: 32px;
1823
+ flex-wrap: wrap;
1824
+ }
1825
+
1826
+ .page-btn {
1827
+ padding: 10px 16px;
1828
+ border: 1px solid var(--border);
1829
+ background: var(--bg-secondary);
1830
+ color: var(--text-primary);
1831
+ border-radius: var(--radius);
1832
+ cursor: pointer;
1833
+ font-size: 0.9rem;
1834
+ font-weight: 500;
1835
+ transition: all var(--transition);
1836
+ }
1837
+
1838
+ .page-btn:hover:not(:disabled) {
1839
+ border-color: var(--accent);
1840
+ color: var(--accent);
1841
+ }
1842
+
1843
+ .page-btn.active {
1844
+ background: var(--accent);
1845
+ border-color: var(--accent);
1846
+ color: white;
1847
+ }
1848
+
1849
+ .page-btn:disabled { opacity: 0.5; cursor: not-allowed; }
1850
+
1851
+ .page-info {
1852
+ padding: 0 16px;
1853
+ color: var(--text-muted);
1854
+ font-size: 0.9rem;
1855
+ }
1856
+
1857
+ /* Empty State */
1858
+ .empty-state {
1859
+ text-align: center;
1860
+ padding: 80px 20px;
1861
+ color: var(--text-muted);
1862
+ }
1863
+
1864
+ .empty-state-icon { font-size: 4rem; margin-bottom: 16px; opacity: 0.5; }
1865
+ .empty-state h3 { color: var(--text-secondary); margin-bottom: 8px; font-size: 1.25rem; }
1866
+
1867
+ /* Loading */
1868
+ .loading {
1869
+ display: flex;
1870
+ flex-direction: column;
1871
+ align-items: center;
1872
+ justify-content: center;
1873
+ padding: 60px 20px;
1874
+ color: var(--text-muted);
1875
+ }
1876
+
1877
+ .spinner {
1878
+ width: 40px;
1879
+ height: 40px;
1880
+ border: 3px solid var(--border);
1881
+ border-top-color: var(--accent);
1882
+ border-radius: 50%;
1883
+ animation: spin 0.8s linear infinite;
1884
+ margin-bottom: 16px;
1885
+ }
1886
+
1887
+ @keyframes spin { to { transform: rotate(360deg); } }
1888
+
1889
+ /* Modal */
1890
+ .modal {
1891
+ display: none;
1892
+ position: fixed;
1893
+ inset: 0;
1894
+ background: rgba(0,0,0,0.6);
1895
+ z-index: 200;
1896
+ padding: 20px;
1897
+ overflow-y: auto;
1898
+ backdrop-filter: blur(4px);
1899
+ }
1900
+
1901
+ .modal.active {
1902
+ display: flex;
1903
+ align-items: flex-start;
1904
+ justify-content: center;
1905
+ }
1906
+
1907
+ .modal-content {
1908
+ background: var(--bg-secondary);
1909
+ border-radius: var(--radius-xl);
1910
+ max-width: 700px;
1911
+ width: 100%;
1912
+ margin-top: 40px;
1913
+ box-shadow: var(--shadow-lg);
1914
+ animation: modalIn 0.2s ease;
1915
+ }
1916
+
1917
+ @keyframes modalIn {
1918
+ from { opacity: 0; transform: translateY(-20px); }
1919
+ to { opacity: 1; transform: translateY(0); }
1920
+ }
1921
+
1922
+ .modal-header {
1923
+ padding: 24px;
1924
+ border-bottom: 1px solid var(--border);
1925
+ display: flex;
1926
+ align-items: flex-start;
1927
+ justify-content: space-between;
1928
+ gap: 16px;
1929
+ }
1930
+
1931
+ .modal-title {
1932
+ font-size: 1.25rem;
1933
+ font-weight: 700;
1934
+ color: var(--text-primary);
1935
+ line-height: 1.3;
1936
+ }
1937
+
1938
+ .modal-close {
1939
+ width: 36px;
1940
+ height: 36px;
1941
+ border: none;
1942
+ background: var(--bg-tertiary);
1943
+ border-radius: var(--radius);
1944
+ cursor: pointer;
1945
+ font-size: 1.5rem;
1946
+ color: var(--text-secondary);
1947
+ display: flex;
1948
+ align-items: center;
1949
+ justify-content: center;
1950
+ flex-shrink: 0;
1951
+ transition: all var(--transition);
1952
+ }
1953
+
1954
+ .modal-close:hover { background: var(--danger); color: white; }
1955
+
1956
+ .modal-body {
1957
+ padding: 24px;
1958
+ max-height: 70vh;
1959
+ overflow-y: auto;
1960
+ }
1961
+
1962
+ /* Form Elements */
1963
+ .form-group { margin-bottom: 20px; }
1964
+
1965
+ .form-label {
1966
+ display: block;
1967
+ font-weight: 600;
1968
+ margin-bottom: 8px;
1969
+ color: var(--text-primary);
1970
+ font-size: 0.9rem;
1971
+ }
1972
+
1973
+ .form-control {
1974
+ width: 100%;
1975
+ padding: 12px 16px;
1976
+ border: 2px solid var(--border);
1977
+ border-radius: var(--radius);
1978
+ font-size: 0.95rem;
1979
+ background: var(--bg-primary);
1980
+ color: var(--text-primary);
1981
+ transition: all var(--transition);
1982
+ }
1983
+
1984
+ .form-control:focus {
1985
+ outline: none;
1986
+ border-color: var(--accent);
1987
+ box-shadow: 0 0 0 3px var(--accent-light);
1988
+ }
1989
+
1990
+ textarea.form-control { min-height: 120px; resize: vertical; }
1991
+
1992
+ .form-check {
1993
+ display: flex;
1994
+ align-items: center;
1995
+ gap: 8px;
1996
+ cursor: pointer;
1997
+ }
1998
+
1999
+ .form-check input[type="checkbox"] {
2000
+ width: 18px;
2001
+ height: 18px;
2002
+ accent-color: var(--accent);
2003
+ }
2004
+
2005
+ /* Buttons */
2006
+ .btn {
2007
+ display: inline-flex;
2008
+ align-items: center;
2009
+ justify-content: center;
2010
+ gap: 8px;
2011
+ padding: 12px 20px;
2012
+ border: none;
2013
+ border-radius: var(--radius);
2014
+ font-size: 0.9rem;
2015
+ font-weight: 600;
2016
+ cursor: pointer;
2017
+ transition: all var(--transition);
2018
+ }
2019
+
2020
+ .btn-primary { background: var(--accent); color: white; }
2021
+ .btn-primary:hover { background: var(--accent-hover); }
2022
+
2023
+ .btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); }
2024
+ .btn-secondary:hover { background: var(--bg-hover); }
2025
+
2026
+ .btn-success { background: var(--success); color: white; }
2027
+ .btn-success:hover { background: #059669; }
2028
+
2029
+ .btn-danger { background: var(--danger); color: white; }
2030
+ .btn-danger:hover { background: #dc2626; }
2031
+
2032
+ .btn-outline {
2033
+ background: transparent;
2034
+ border: 2px solid var(--border);
2035
+ color: var(--text-primary);
2036
+ }
2037
+ .btn-outline:hover { border-color: var(--accent); color: var(--accent); }
2038
+
2039
+ .btn-group { display: flex; gap: 12px; flex-wrap: wrap; }
2040
+
2041
+ /* Alerts */
2042
+ .alert {
2043
+ padding: 16px 20px;
2044
+ border-radius: var(--radius);
2045
+ margin-bottom: 20px;
2046
+ font-size: 0.9rem;
2047
+ display: flex;
2048
+ align-items: center;
2049
+ gap: 12px;
2050
+ }
2051
+
2052
+ .alert-success { background: var(--success-light); color: #065f46; }
2053
+ .alert-error { background: var(--danger-light); color: #991b1b; }
2054
+
2055
+ /* Detail Section */
2056
+ .detail-section { margin-bottom: 24px; }
2057
+
2058
+ .detail-label {
2059
+ font-size: 0.75rem;
2060
+ font-weight: 600;
2061
+ text-transform: uppercase;
2062
+ letter-spacing: 0.05em;
2063
+ color: var(--text-muted);
2064
+ margin-bottom: 8px;
2065
+ }
2066
+
2067
+ .detail-value { color: var(--text-primary); line-height: 1.6; }
2068
+
2069
+ .detail-tags {
2070
+ display: flex;
2071
+ flex-wrap: wrap;
2072
+ gap: 8px;
2073
+ }
2074
+
2075
+ .tag {
2076
+ background: var(--bg-tertiary);
2077
+ color: var(--text-secondary);
2078
+ padding: 6px 12px;
2079
+ border-radius: 20px;
2080
+ font-size: 0.8rem;
2081
+ }
2082
+
2083
+ /* File List */
2084
+ .file-list { list-style: none; }
2085
+
2086
+ .file-item {
2087
+ display: flex;
2088
+ align-items: center;
2089
+ gap: 12px;
2090
+ padding: 12px 0;
2091
+ border-bottom: 1px solid var(--border);
2092
+ }
2093
+
2094
+ .file-item:last-child { border-bottom: none; }
2095
+
2096
+ .file-btn {
2097
+ display: inline-flex;
2098
+ align-items: center;
2099
+ gap: 8px;
2100
+ padding: 10px 16px;
2101
+ background: var(--accent);
2102
+ color: white;
2103
+ border-radius: var(--radius);
2104
+ text-decoration: none;
2105
+ font-weight: 600;
2106
+ font-size: 0.85rem;
2107
+ transition: all var(--transition);
2108
+ }
2109
+
2110
+ .file-btn:hover { background: var(--accent-hover); }
2111
+
2112
+ .file-size { color: var(--text-muted); font-size: 0.85rem; }
2113
+
2114
+ /* Modal Cover */
2115
+ .modal-cover {
2116
+ text-align: center;
2117
+ margin-bottom: 24px;
2118
+ }
2119
+
2120
+ .modal-cover img {
2121
+ max-width: 200px;
2122
+ max-height: 300px;
2123
+ border-radius: var(--radius);
2124
+ box-shadow: var(--shadow-lg);
2125
+ }
2126
+
2127
+ /* Tabs */
2128
+ .tabs {
2129
+ display: flex;
2130
+ border-bottom: 2px solid var(--border);
2131
+ margin-bottom: 20px;
2132
+ }
2133
+
2134
+ .tab {
2135
+ padding: 12px 20px;
2136
+ border: none;
2137
+ background: none;
2138
+ color: var(--text-secondary);
2139
+ font-size: 0.9rem;
2140
+ font-weight: 500;
2141
+ cursor: pointer;
2142
+ border-bottom: 2px solid transparent;
2143
+ margin-bottom: -2px;
2144
+ transition: all var(--transition);
2145
+ }
2146
+
2147
+ .tab:hover {
2148
+ color: var(--text-primary);
2149
+ background: var(--bg-tertiary);
2150
+ }
2151
+
2152
+ .tab.active {
2153
+ color: var(--accent);
2154
+ border-bottom-color: var(--accent);
2155
+ }
2156
+
2157
+ .tab-content {
2158
+ display: none;
2159
+ }
2160
+
2161
+ .tab-content.active {
2162
+ display: block;
2163
+ }
2164
+
2165
+ .import-help {
2166
+ background: var(--bg-tertiary);
2167
+ border-radius: var(--radius);
2168
+ padding: 12px 16px;
2169
+ margin-bottom: 16px;
2170
+ font-size: 0.85rem;
2171
+ color: var(--text-secondary);
2172
+ }
2173
+
2174
+ .import-help code {
2175
+ background: var(--bg-secondary);
2176
+ padding: 2px 6px;
2177
+ border-radius: 4px;
2178
+ font-family: monospace;
2179
+ }
2180
+
2181
+ /* Keyboard Shortcuts Hint */
2182
+ .keyboard-hint {
2183
+ position: fixed;
2184
+ bottom: 20px;
2185
+ right: 20px;
2186
+ background: var(--bg-secondary);
2187
+ border: 1px solid var(--border);
2188
+ border-radius: var(--radius);
2189
+ padding: 12px 16px;
2190
+ font-size: 0.8rem;
2191
+ color: var(--text-muted);
2192
+ box-shadow: var(--shadow-lg);
2193
+ z-index: 50;
2194
+ display: none;
2195
+ }
2196
+
2197
+ @media (min-width: 1024px) { .keyboard-hint { display: block; } }
2198
+
2199
+ .kbd {
2200
+ display: inline-block;
2201
+ background: var(--bg-tertiary);
2202
+ border: 1px solid var(--border);
2203
+ border-radius: 4px;
2204
+ padding: 2px 6px;
2205
+ font-family: monospace;
2206
+ font-size: 0.75rem;
2207
+ margin: 0 2px;
2208
+ }
2209
+
2210
+ /* Print Styles */
2211
+ @media print {
2212
+ .sidebar, .header, .toolbar, .pagination, .keyboard-hint { display: none !important; }
2213
+ .main { margin-left: 0 !important; }
2214
+ .book-card, .book-list-item { break-inside: avoid; }
2215
+ }
2216
+
2217
+ /* Scrollbar */
2218
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
2219
+ ::-webkit-scrollbar-track { background: var(--bg-primary); }
2220
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
2221
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
2222
+ </style>
2223
+ </head>
2224
+ <body>
2225
+ <div class="sidebar-overlay" onclick="toggleSidebar()"></div>
2226
+
2227
+ <div class="app">
2228
+ <aside class="sidebar" id="sidebar">
2229
+ <div class="sidebar-header">
2230
+ <div class="logo">
2231
+ <div class="logo-icon">&#128218;</div>
2232
+ <span>ebk Library</span>
2233
+ </div>
2234
+ </div>
2235
+ <nav class="sidebar-nav">
2236
+ <div class="nav-section">
2237
+ <div class="nav-section-title">Library</div>
2238
+ <div class="nav-item active" data-filter="all" onclick="setFilter('all')">
2239
+ <span class="nav-item-icon">&#128214;</span>
2240
+ All Books
2241
+ <span class="nav-item-count" id="count-all">0</span>
2242
+ </div>
2243
+ <div class="nav-item" data-filter="favorites" onclick="setFilter('favorites')">
2244
+ <span class="nav-item-icon">&#11088;</span>
2245
+ Favorites
2246
+ <span class="nav-item-count" id="count-favorites">0</span>
2247
+ </div>
2248
+ <div class="nav-item" data-filter="reading" onclick="setFilter('reading')">
2249
+ <span class="nav-item-icon">&#128196;</span>
2250
+ Currently Reading
2251
+ <span class="nav-item-count" id="count-reading">0</span>
2252
+ </div>
2253
+ <div class="nav-item" data-filter="completed" onclick="setFilter('completed')">
2254
+ <span class="nav-item-icon">&#9989;</span>
2255
+ Completed
2256
+ <span class="nav-item-count" id="count-completed">0</span>
2257
+ </div>
2258
+ </div>
2259
+ <div class="nav-section">
2260
+ <div class="nav-section-title">Actions</div>
2261
+ <div class="nav-item" onclick="showImportModal()">
2262
+ <span class="nav-item-icon">&#10133;</span>
2263
+ Import Book
2264
+ </div>
2265
+ <div class="nav-item" onclick="refreshBooks()">
2266
+ <span class="nav-item-icon">&#128260;</span>
2267
+ Refresh
2268
+ </div>
2269
+ </div>
2270
+ <div class="nav-section" id="views-section">
2271
+ <div class="nav-section-title">Views</div>
2272
+ <div id="views-list">
2273
+ <div class="nav-item" style="color: var(--text-muted); font-style: italic;">
2274
+ Loading views...
2275
+ </div>
2276
+ </div>
2277
+ </div>
2278
+ </nav>
2279
+ <div class="sidebar-footer">
2280
+ <div style="font-size: 0.75rem; color: var(--text-muted);">
2281
+ ebk Library Manager
2282
+ </div>
2283
+ </div>
2284
+ </aside>
2285
+
2286
+ <main class="main">
2287
+ <header class="header">
2288
+ <button class="menu-btn" onclick="toggleSidebar()">&#9776;</button>
2289
+ <div class="search-box">
2290
+ <span class="search-icon">&#128269;</span>
2291
+ <input type="text" class="search-input" id="search-input"
2292
+ placeholder="Search books by title, author, description..." autocomplete="off">
2293
+ </div>
2294
+ <div class="header-actions">
2295
+ <button class="icon-btn active" id="view-grid" onclick="setView('grid')" title="Grid View">&#9638;</button>
2296
+ <button class="icon-btn" id="view-list" onclick="setView('list')" title="List View">&#9776;</button>
2297
+ <button class="icon-btn" id="view-table" onclick="setView('table')" title="Table View">&#9636;</button>
2298
+ <button class="icon-btn" id="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">&#127769;</button>
2299
+ </div>
2300
+ </header>
2301
+
2302
+ <div class="stats-bar" id="stats-bar">
2303
+ <div class="stat-item">
2304
+ <div class="stat-icon books">&#128218;</div>
2305
+ <div class="stat-content">
2306
+ <div class="stat-value" id="stat-books">0</div>
2307
+ <div class="stat-label">Books</div>
2308
+ </div>
2309
+ </div>
2310
+ <div class="stat-item">
2311
+ <div class="stat-icon authors">&#128100;</div>
2312
+ <div class="stat-content">
2313
+ <div class="stat-value" id="stat-authors">0</div>
2314
+ <div class="stat-label">Authors</div>
2315
+ </div>
2316
+ </div>
2317
+ <div class="stat-item">
2318
+ <div class="stat-icon files">&#128196;</div>
2319
+ <div class="stat-content">
2320
+ <div class="stat-value" id="stat-files">0</div>
2321
+ <div class="stat-label">Files</div>
2322
+ </div>
2323
+ </div>
2324
+ <div class="stat-item">
2325
+ <div class="stat-icon storage">&#128190;</div>
2326
+ <div class="stat-content">
2327
+ <div class="stat-value" id="stat-storage">0 MB</div>
2328
+ <div class="stat-label">Storage</div>
2329
+ </div>
2330
+ </div>
2331
+ </div>
2332
+
2333
+ <div class="toolbar">
2334
+ <div class="toolbar-group">
2335
+ <span class="toolbar-label">Sort:</span>
2336
+ <select class="toolbar-select" id="sort-field" onchange="applyFilters()">
2337
+ <option value="title">Title</option>
2338
+ <option value="created_at">Date Added</option>
2339
+ <option value="publication_date">Published</option>
2340
+ <option value="rating">Rating</option>
2341
+ </select>
2342
+ <select class="toolbar-select" id="sort-order" onchange="applyFilters()">
2343
+ <option value="asc">A-Z</option>
2344
+ <option value="desc">Z-A</option>
2345
+ </select>
2346
+ </div>
2347
+ <div class="toolbar-divider"></div>
2348
+ <div class="toolbar-group">
2349
+ <span class="toolbar-label">Language:</span>
2350
+ <select class="toolbar-select" id="filter-language" onchange="applyFilters()">
2351
+ <option value="">All</option>
2352
+ </select>
2353
+ </div>
2354
+ <div class="toolbar-group">
2355
+ <span class="toolbar-label">Format:</span>
2356
+ <select class="toolbar-select" id="filter-format" onchange="applyFilters()">
2357
+ <option value="">All</option>
2358
+ </select>
2359
+ </div>
2360
+ <div class="toolbar-group">
2361
+ <span class="toolbar-label">Rating:</span>
2362
+ <select class="toolbar-select" id="filter-rating" onchange="applyFilters()">
2363
+ <option value="">Any</option>
2364
+ <option value="4">4+ Stars</option>
2365
+ <option value="3">3+ Stars</option>
2366
+ <option value="2">2+ Stars</option>
2367
+ <option value="1">1+ Stars</option>
2368
+ </select>
2369
+ </div>
2370
+ <button class="btn btn-outline" onclick="clearFilters()" style="padding: 8px 12px; font-size: 0.8rem;">
2371
+ Clear Filters
2372
+ </button>
2373
+ <div class="results-info" id="results-info"></div>
2374
+ </div>
2375
+
2376
+ <div id="message-container"></div>
2377
+
2378
+ <div class="content">
2379
+ <div id="loading" class="loading" style="display: none;">
2380
+ <div class="spinner"></div>
2381
+ <p>Loading books...</p>
2382
+ </div>
2383
+ <div id="book-container"></div>
2384
+ <div id="empty-state" class="empty-state" style="display: none;">
2385
+ <div class="empty-state-icon">&#128366;</div>
2386
+ <h3>No books found</h3>
2387
+ <p>Try adjusting your search or filters</p>
2388
+ </div>
2389
+ <div class="pagination" id="pagination"></div>
2390
+ </div>
2391
+ </main>
2392
+ </div>
2393
+
2394
+ <!-- Edit Modal -->
2395
+ <div id="edit-modal" class="modal">
2396
+ <div class="modal-content">
2397
+ <div class="modal-header">
2398
+ <h2 class="modal-title">Edit Book</h2>
2399
+ <button class="modal-close" onclick="closeModal('edit-modal')">&times;</button>
2400
+ </div>
2401
+ <div class="modal-body">
2402
+ <form id="edit-form" onsubmit="saveBook(event)">
2403
+ <input type="hidden" id="edit-book-id">
2404
+ <div class="form-group">
2405
+ <label class="form-label">Title</label>
2406
+ <input type="text" id="edit-title" class="form-control">
2407
+ </div>
2408
+ <div class="form-group">
2409
+ <label class="form-label">Subtitle</label>
2410
+ <input type="text" id="edit-subtitle" class="form-control">
2411
+ </div>
2412
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
2413
+ <div class="form-group">
2414
+ <label class="form-label">Language</label>
2415
+ <input type="text" id="edit-language" class="form-control">
2416
+ </div>
2417
+ <div class="form-group">
2418
+ <label class="form-label">Publisher</label>
2419
+ <input type="text" id="edit-publisher" class="form-control">
2420
+ </div>
2421
+ </div>
2422
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
2423
+ <div class="form-group">
2424
+ <label class="form-label">Publication Date</label>
2425
+ <input type="text" id="edit-publication-date" class="form-control" placeholder="YYYY-MM-DD">
2426
+ </div>
2427
+ <div class="form-group">
2428
+ <label class="form-label">Rating (1-5)</label>
2429
+ <input type="number" id="edit-rating" class="form-control" min="1" max="5" step="0.5">
2430
+ </div>
2431
+ </div>
2432
+ <div class="form-group">
2433
+ <label class="form-label">Description</label>
2434
+ <textarea id="edit-description" class="form-control"></textarea>
2435
+ </div>
2436
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
2437
+ <div class="form-group">
2438
+ <label class="form-label">Reading Status</label>
2439
+ <select id="edit-status" class="form-control">
2440
+ <option value="unread">Unread</option>
2441
+ <option value="reading">Reading</option>
2442
+ <option value="completed">Completed</option>
2443
+ </select>
2444
+ </div>
2445
+ <div class="form-group">
2446
+ <label class="form-label">Tags (comma-separated)</label>
2447
+ <input type="text" id="edit-tags" class="form-control">
2448
+ </div>
2449
+ </div>
2450
+ <div class="form-group">
2451
+ <label class="form-check">
2452
+ <input type="checkbox" id="edit-favorite">
2453
+ <span>Mark as Favorite</span>
2454
+ </label>
2455
+ </div>
2456
+ <div class="btn-group">
2457
+ <button type="submit" class="btn btn-primary">Save Changes</button>
2458
+ <button type="button" class="btn btn-danger" onclick="deleteBook()">Delete Book</button>
2459
+ <button type="button" class="btn btn-secondary" onclick="closeModal('edit-modal')">Cancel</button>
2460
+ </div>
2461
+ </form>
2462
+ </div>
2463
+ </div>
2464
+ </div>
2465
+
2466
+ <!-- Import Modal -->
2467
+ <div id="import-modal" class="modal">
2468
+ <div class="modal-content" style="max-width: 600px;">
2469
+ <div class="modal-header">
2470
+ <h2 class="modal-title">Import Books</h2>
2471
+ <button class="modal-close" onclick="closeModal('import-modal')">&times;</button>
2472
+ </div>
2473
+ <div class="modal-body">
2474
+ <div class="tabs">
2475
+ <button class="tab active" onclick="switchImportTab('file')">File</button>
2476
+ <button class="tab" onclick="switchImportTab('url')">URL</button>
2477
+ <button class="tab" onclick="switchImportTab('folder')">Folder</button>
2478
+ <button class="tab" onclick="switchImportTab('calibre')">Calibre</button>
2479
+ <button class="tab" onclick="switchImportTab('opds')">OPDS</button>
2480
+ <button class="tab" onclick="switchImportTab('isbn')">ISBN</button>
2481
+ </div>
2482
+
2483
+ <!-- Single File Import -->
2484
+ <div id="import-tab-file" class="tab-content active">
2485
+ <div class="import-help">
2486
+ Upload a single ebook file (PDF, EPUB, MOBI, AZW3, TXT)
2487
+ </div>
2488
+ <form id="import-form-file" onsubmit="importSingleFile(event)">
2489
+ <div class="form-group">
2490
+ <label class="form-label">Select File</label>
2491
+ <input type="file" id="import-file" class="form-control"
2492
+ accept=".pdf,.epub,.mobi,.azw,.azw3,.txt" required>
2493
+ </div>
2494
+ <div class="form-group">
2495
+ <label class="form-check">
2496
+ <input type="checkbox" id="import-extract-text" checked>
2497
+ <span>Extract full text for search</span>
2498
+ </label>
2499
+ </div>
2500
+ <div class="form-group">
2501
+ <label class="form-check">
2502
+ <input type="checkbox" id="import-extract-cover" checked>
2503
+ <span>Extract cover image</span>
2504
+ </label>
2505
+ </div>
2506
+ <div class="btn-group">
2507
+ <button type="submit" class="btn btn-success">Import File</button>
2508
+ <button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">Cancel</button>
2509
+ </div>
2510
+ </form>
2511
+ </div>
2512
+
2513
+ <!-- Folder Import -->
2514
+ <div id="import-tab-folder" class="tab-content">
2515
+ <div class="import-help">
2516
+ Import all ebook files from a folder on the server. Enter the absolute path to the folder.
2517
+ </div>
2518
+ <form id="import-form-folder" onsubmit="importFolder(event)">
2519
+ <div class="form-group">
2520
+ <label class="form-label">Folder Path</label>
2521
+ <input type="text" id="import-folder-path" class="form-control"
2522
+ placeholder="/path/to/ebooks" required>
2523
+ </div>
2524
+ <div class="form-group">
2525
+ <label class="form-label">File Extensions</label>
2526
+ <input type="text" id="import-folder-extensions" class="form-control"
2527
+ value="pdf,epub,mobi,azw3,txt" placeholder="pdf,epub,mobi">
2528
+ </div>
2529
+ <div class="form-group">
2530
+ <label class="form-label">Limit (optional)</label>
2531
+ <input type="number" id="import-folder-limit" class="form-control"
2532
+ placeholder="No limit" min="1">
2533
+ </div>
2534
+ <div class="form-group">
2535
+ <label class="form-check">
2536
+ <input type="checkbox" id="import-folder-recursive" checked>
2537
+ <span>Search subdirectories recursively</span>
2538
+ </label>
2539
+ </div>
2540
+ <div class="form-group">
2541
+ <label class="form-check">
2542
+ <input type="checkbox" id="import-folder-extract-text" checked>
2543
+ <span>Extract full text for search</span>
2544
+ </label>
2545
+ </div>
2546
+ <div class="form-group">
2547
+ <label class="form-check">
2548
+ <input type="checkbox" id="import-folder-extract-cover" checked>
2549
+ <span>Extract cover images</span>
2550
+ </label>
2551
+ </div>
2552
+ <div class="btn-group">
2553
+ <button type="submit" class="btn btn-success">Import Folder</button>
2554
+ <button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">Cancel</button>
2555
+ </div>
2556
+ </form>
2557
+ </div>
2558
+
2559
+ <!-- Calibre Import -->
2560
+ <div id="import-tab-calibre" class="tab-content">
2561
+ <div class="import-help">
2562
+ Import books from a Calibre library. Enter the path to the Calibre library folder
2563
+ (the folder containing <code>metadata.db</code>).
2564
+ </div>
2565
+ <form id="import-form-calibre" onsubmit="importCalibre(event)">
2566
+ <div class="form-group">
2567
+ <label class="form-label">Calibre Library Path</label>
2568
+ <input type="text" id="import-calibre-path" class="form-control"
2569
+ placeholder="/path/to/Calibre Library" required>
2570
+ </div>
2571
+ <div class="form-group">
2572
+ <label class="form-label">Limit (optional)</label>
2573
+ <input type="number" id="import-calibre-limit" class="form-control"
2574
+ placeholder="No limit" min="1">
2575
+ </div>
2576
+ <div class="btn-group">
2577
+ <button type="submit" class="btn btn-success">Import from Calibre</button>
2578
+ <button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">Cancel</button>
2579
+ </div>
2580
+ </form>
2581
+ </div>
2582
+
2583
+ <!-- URL Import -->
2584
+ <div id="import-tab-url" class="tab-content">
2585
+ <div class="import-help">
2586
+ Download and import an ebook from a direct URL. The URL must point directly to an ebook file (PDF, EPUB, MOBI, etc.).
2587
+ </div>
2588
+ <form id="import-form-url" onsubmit="importFromURL(event)">
2589
+ <div class="form-group">
2590
+ <label class="form-label">Book URL</label>
2591
+ <input type="url" id="import-url" class="form-control"
2592
+ placeholder="https://example.com/book.epub" required>
2593
+ </div>
2594
+ <div class="form-group">
2595
+ <label class="form-check">
2596
+ <input type="checkbox" id="import-url-extract-text" checked>
2597
+ <span>Extract full text for search</span>
2598
+ </label>
2599
+ </div>
2600
+ <div class="form-group">
2601
+ <label class="form-check">
2602
+ <input type="checkbox" id="import-url-extract-cover" checked>
2603
+ <span>Extract cover image</span>
2604
+ </label>
2605
+ </div>
2606
+ <div class="btn-group">
2607
+ <button type="submit" class="btn btn-success">Download &amp; Import</button>
2608
+ <button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">Cancel</button>
2609
+ </div>
2610
+ </form>
2611
+ </div>
2612
+
2613
+ <!-- OPDS Import -->
2614
+ <div id="import-tab-opds" class="tab-content">
2615
+ <div class="import-help">
2616
+ Import books from an OPDS catalog feed. OPDS is a standard format used by many digital libraries and ebook servers.
2617
+ </div>
2618
+ <form id="import-form-opds" onsubmit="importFromOPDS(event)">
2619
+ <div class="form-group">
2620
+ <label class="form-label">OPDS Catalog URL</label>
2621
+ <input type="url" id="import-opds-url" class="form-control"
2622
+ placeholder="https://example.com/opds/catalog.xml" required>
2623
+ </div>
2624
+ <div class="form-group">
2625
+ <label class="form-label">Limit (optional)</label>
2626
+ <input type="number" id="import-opds-limit" class="form-control"
2627
+ placeholder="No limit" min="1">
2628
+ </div>
2629
+ <div class="form-group">
2630
+ <label class="form-check">
2631
+ <input type="checkbox" id="import-opds-extract-text" checked>
2632
+ <span>Extract full text for search</span>
2633
+ </label>
2634
+ </div>
2635
+ <div class="form-group">
2636
+ <label class="form-check">
2637
+ <input type="checkbox" id="import-opds-extract-cover" checked>
2638
+ <span>Extract cover images</span>
2639
+ </label>
2640
+ </div>
2641
+ <div class="btn-group">
2642
+ <button type="submit" class="btn btn-success">Import from OPDS</button>
2643
+ <button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">Cancel</button>
2644
+ </div>
2645
+ </form>
2646
+ </div>
2647
+
2648
+ <!-- ISBN Lookup Import -->
2649
+ <div id="import-tab-isbn" class="tab-content">
2650
+ <div class="import-help">
2651
+ Create a book entry by ISBN lookup. Fetches metadata from Google Books and Open Library.
2652
+ <strong>Note:</strong> This creates a metadata-only entry without an actual ebook file.
2653
+ </div>
2654
+ <form id="import-form-isbn" onsubmit="importFromISBN(event)">
2655
+ <div class="form-group">
2656
+ <label class="form-label">ISBN (10 or 13 digits)</label>
2657
+ <input type="text" id="import-isbn" class="form-control"
2658
+ placeholder="978-3-16-148410-0" required
2659
+ pattern="[0-9Xx\\-\\s]{10,17}">
2660
+ </div>
2661
+ <div class="btn-group">
2662
+ <button type="submit" class="btn btn-success">Lookup &amp; Create</button>
2663
+ <button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">Cancel</button>
2664
+ </div>
2665
+ </form>
2666
+ </div>
2667
+
2668
+ <!-- Import Progress -->
2669
+ <div id="import-progress" style="display: none;">
2670
+ <div class="loading">
2671
+ <div class="spinner"></div>
2672
+ <p id="import-progress-text">Importing books...</p>
2673
+ </div>
2674
+ </div>
2675
+
2676
+ <!-- Import Results -->
2677
+ <div id="import-results" style="display: none;"></div>
2678
+ </div>
2679
+ </div>
2680
+ </div>
2681
+
2682
+ <!-- Details Modal -->
2683
+ <div id="details-modal" class="modal">
2684
+ <div class="modal-content">
2685
+ <div class="modal-header">
2686
+ <h2 class="modal-title" id="details-title"></h2>
2687
+ <button class="modal-close" onclick="closeModal('details-modal')">&times;</button>
2688
+ </div>
2689
+ <div class="modal-body" id="details-body"></div>
2690
+ </div>
2691
+ </div>
2692
+
2693
+ <div class="keyboard-hint">
2694
+ <kbd>/</kbd> Search &nbsp; <kbd>Esc</kbd> Close &nbsp; <kbd>g</kbd> Grid &nbsp; <kbd>l</kbd> List &nbsp; <kbd>t</kbd> Table
2695
+ </div>
2696
+
2697
+ <script>
2698
+ // State
2699
+ let books = [];
2700
+ let currentBookId = null;
2701
+ let currentPage = 1;
2702
+ let booksPerPage = 48;
2703
+ let totalBooks = 0;
2704
+ let currentView = 'grid';
2705
+ let currentFilter = 'all';
2706
+ let currentViewName = null; // Name of the view when filtering by view
2707
+ let isSearching = false;
2708
+ let availableViews = []; // Cached list of views
2709
+
2710
+ // Initialize
2711
+ document.addEventListener('DOMContentLoaded', () => {
2712
+ initTheme();
2713
+ loadStats();
2714
+ loadViews();
2715
+ restoreStateFromURL();
2716
+ loadBooks();
2717
+ setupEventListeners();
2718
+ });
2719
+
2720
+ function initTheme() {
2721
+ const saved = localStorage.getItem('ebk_theme');
2722
+ if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
2723
+ document.documentElement.setAttribute('data-theme', 'dark');
2724
+ }
2725
+ }
2726
+
2727
+ function toggleTheme() {
2728
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
2729
+ document.documentElement.setAttribute('data-theme', isDark ? 'light' : 'dark');
2730
+ localStorage.setItem('ebk_theme', isDark ? 'light' : 'dark');
2731
+ }
2732
+
2733
+ function toggleSidebar() {
2734
+ document.getElementById('sidebar').classList.toggle('open');
2735
+ document.querySelector('.sidebar-overlay').classList.toggle('active');
2736
+ }
2737
+
2738
+ function setupEventListeners() {
2739
+ // Search debouncing
2740
+ let searchTimeout;
2741
+ document.getElementById('search-input').addEventListener('input', (e) => {
2742
+ clearTimeout(searchTimeout);
2743
+ searchTimeout = setTimeout(() => {
2744
+ if (e.target.value.length >= 2) {
2745
+ isSearching = true;
2746
+ currentPage = 1;
2747
+ updateURL();
2748
+ searchBooks(e.target.value);
2749
+ } else if (e.target.value.length === 0) {
2750
+ isSearching = false;
2751
+ currentPage = 1;
2752
+ updateURL();
2753
+ loadBooks();
2754
+ }
2755
+ }, 300);
2756
+ });
2757
+
2758
+ // Keyboard shortcuts
2759
+ document.addEventListener('keydown', (e) => {
2760
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
2761
+ if (e.key === 'Escape') e.target.blur();
2762
+ return;
2763
+ }
2764
+ if (e.key === '/') { e.preventDefault(); document.getElementById('search-input').focus(); }
2765
+ else if (e.key === 'Escape') { closeAllModals(); }
2766
+ else if (e.key === 'g') { setView('grid'); }
2767
+ else if (e.key === 'l') { setView('list'); }
2768
+ else if (e.key === 't') { setView('table'); }
2769
+ });
2770
+
2771
+ // Browser back/forward
2772
+ window.addEventListener('popstate', () => {
2773
+ restoreStateFromURL();
2774
+ loadBooks();
2775
+ });
2776
+ }
2777
+
2778
+ function updateURL() {
2779
+ const params = new URLSearchParams();
2780
+ if (currentPage > 1) params.set('page', currentPage);
2781
+ const searchQuery = document.getElementById('search-input').value;
2782
+ if (searchQuery) params.set('q', searchQuery);
2783
+ if (currentFilter !== 'all') params.set('filter', currentFilter);
2784
+ const language = document.getElementById('filter-language').value;
2785
+ const format = document.getElementById('filter-format').value;
2786
+ const rating = document.getElementById('filter-rating').value;
2787
+ if (language) params.set('language', language);
2788
+ if (format) params.set('format', format);
2789
+ if (rating) params.set('rating', rating);
2790
+ const sortField = document.getElementById('sort-field').value;
2791
+ const sortOrder = document.getElementById('sort-order').value;
2792
+ if (sortField !== 'title') params.set('sort', sortField);
2793
+ if (sortOrder !== 'asc') params.set('order', sortOrder);
2794
+ const newURL = params.toString() ? '?' + params.toString() : window.location.pathname;
2795
+ window.history.pushState({}, '', newURL);
2796
+ }
2797
+
2798
+ function restoreStateFromURL() {
2799
+ const params = new URLSearchParams(window.location.search);
2800
+ currentPage = parseInt(params.get('page')) || 1;
2801
+ const searchQuery = params.get('q') || '';
2802
+ document.getElementById('search-input').value = searchQuery;
2803
+ isSearching = searchQuery.length >= 2;
2804
+ currentFilter = params.get('filter') || 'all';
2805
+ document.getElementById('filter-language').value = params.get('language') || '';
2806
+ document.getElementById('filter-format').value = params.get('format') || '';
2807
+ document.getElementById('filter-rating').value = params.get('rating') || '';
2808
+ document.getElementById('sort-field').value = params.get('sort') || 'title';
2809
+ document.getElementById('sort-order').value = params.get('order') || 'asc';
2810
+ updateNavItems();
2811
+ }
2812
+
2813
+ let libraryStats = null;
2814
+
2815
+ async function loadStats() {
2816
+ try {
2817
+ const response = await fetch('/api/stats');
2818
+ libraryStats = await response.json();
2819
+
2820
+ document.getElementById('stat-books').textContent = libraryStats.total_books;
2821
+ document.getElementById('stat-authors').textContent = libraryStats.total_authors;
2822
+ document.getElementById('stat-files').textContent = libraryStats.total_files;
2823
+ document.getElementById('stat-storage').textContent = libraryStats.total_size_mb.toFixed(1) + ' MB';
2824
+
2825
+ // Update sidebar counts
2826
+ document.getElementById('count-all').textContent = libraryStats.total_books;
2827
+ document.getElementById('count-favorites').textContent = libraryStats.favorites_count;
2828
+ document.getElementById('count-reading').textContent = libraryStats.reading_count;
2829
+ document.getElementById('count-completed').textContent = libraryStats.completed_count;
2830
+
2831
+ // Populate dropdowns
2832
+ const langSelect = document.getElementById('filter-language');
2833
+ libraryStats.languages.forEach(lang => {
2834
+ const opt = document.createElement('option');
2835
+ opt.value = lang;
2836
+ opt.textContent = lang.toUpperCase();
2837
+ langSelect.appendChild(opt);
2838
+ });
2839
+
2840
+ const formatSelect = document.getElementById('filter-format');
2841
+ libraryStats.formats.forEach(fmt => {
2842
+ const opt = document.createElement('option');
2843
+ opt.value = fmt;
2844
+ opt.textContent = fmt.toUpperCase();
2845
+ formatSelect.appendChild(opt);
2846
+ });
2847
+ } catch (error) {
2848
+ console.error('Error loading stats:', error);
2849
+ }
2850
+ }
2851
+
2852
+ function buildQueryParams() {
2853
+ const params = new URLSearchParams();
2854
+ const offset = (currentPage - 1) * booksPerPage;
2855
+ params.append('limit', booksPerPage);
2856
+ params.append('offset', offset);
2857
+
2858
+ // Sidebar filter
2859
+ if (currentFilter === 'favorites') params.append('favorite', 'true');
2860
+ if (currentFilter === 'reading') params.append('reading_status', 'reading');
2861
+ if (currentFilter === 'completed') params.append('reading_status', 'completed');
2862
+
2863
+ const language = document.getElementById('filter-language').value;
2864
+ const format = document.getElementById('filter-format').value;
2865
+ const rating = document.getElementById('filter-rating').value;
2866
+ if (language) params.append('language', language);
2867
+ if (format) params.append('format_filter', format);
2868
+ if (rating) params.append('rating', rating);
2869
+
2870
+ const sortField = document.getElementById('sort-field').value;
2871
+ const sortOrder = document.getElementById('sort-order').value;
2872
+ params.append('sort', sortField);
2873
+ params.append('order', sortOrder);
2874
+
2875
+ return params.toString();
2876
+ }
2877
+
2878
+ async function loadBooks() {
2879
+ const loading = document.getElementById('loading');
2880
+ const container = document.getElementById('book-container');
2881
+ const emptyState = document.getElementById('empty-state');
2882
+
2883
+ loading.style.display = 'flex';
2884
+ container.innerHTML = '';
2885
+ emptyState.style.display = 'none';
2886
+
2887
+ try {
2888
+ const queryParams = buildQueryParams();
2889
+ const response = await fetch('/api/books?' + queryParams);
2890
+ const data = await response.json();
2891
+
2892
+ books = data.items;
2893
+ totalBooks = data.total;
2894
+
2895
+ if (books.length === 0) {
2896
+ emptyState.style.display = 'block';
2897
+ } else {
2898
+ renderBooks();
2899
+ }
2900
+ updatePagination();
2901
+ updateResultsInfo();
2902
+ updateNavCounts();
2903
+ } catch (error) {
2904
+ showError('Failed to load books: ' + error.message);
2905
+ } finally {
2906
+ loading.style.display = 'none';
2907
+ }
2908
+ }
2909
+
2910
+ async function searchBooks(query) {
2911
+ const loading = document.getElementById('loading');
2912
+ const container = document.getElementById('book-container');
2913
+ const emptyState = document.getElementById('empty-state');
2914
+
2915
+ loading.style.display = 'flex';
2916
+ container.innerHTML = '';
2917
+ emptyState.style.display = 'none';
2918
+
2919
+ try {
2920
+ const response = await fetch('/api/search?q=' + encodeURIComponent(query));
2921
+ books = await response.json();
2922
+ totalBooks = books.length;
2923
+
2924
+ if (books.length === 0) {
2925
+ emptyState.style.display = 'block';
2926
+ } else {
2927
+ renderBooks();
2928
+ }
2929
+ updateResultsInfo();
2930
+ document.getElementById('pagination').innerHTML = '';
2931
+ } catch (error) {
2932
+ showError('Search failed: ' + error.message);
2933
+ } finally {
2934
+ loading.style.display = 'none';
2935
+ }
2936
+ }
2937
+
2938
+ function renderBooks() {
2939
+ const container = document.getElementById('book-container');
2940
+ if (currentView === 'grid') {
2941
+ container.innerHTML = '<div class="book-grid">' + books.map(renderGridCard).join('') + '</div>';
2942
+ } else if (currentView === 'list') {
2943
+ container.innerHTML = '<div class="book-list">' + books.map(renderListItem).join('') + '</div>';
2944
+ } else {
2945
+ container.innerHTML = renderTable();
2946
+ }
2947
+ }
2948
+
2949
+ function renderGridCard(book) {
2950
+ const author = book.authors.join(', ') || 'Unknown Author';
2951
+ const rating = book.rating ? '&#9733;'.repeat(Math.round(book.rating)) : '';
2952
+ return '<div class="book-card" onclick="showBookDetails(' + book.id + ')">' +
2953
+ '<div class="book-cover">' +
2954
+ (book.cover_path ?
2955
+ '<img src="/api/books/' + book.id + '/cover" alt="" loading="lazy" onerror="this.parentElement.innerHTML=\\'<div class=book-cover-placeholder>&#128214;</div>\\'">' :
2956
+ '<div class="book-cover-placeholder">&#128214;</div>') +
2957
+ (book.favorite ? '<span class="book-favorite">&#11088;</span>' : '') +
2958
+ '</div>' +
2959
+ '<div class="book-info">' +
2960
+ '<div class="book-title">' + escapeHtml(book.title) + '</div>' +
2961
+ '<div class="book-author">' + escapeHtml(author) + '</div>' +
2962
+ '<div class="book-meta">' +
2963
+ book.files.map(f => '<span class="badge badge-format">' + f.format.toUpperCase() + '</span>').join('') +
2964
+ (book.language ? '<span class="badge badge-language">' + book.language.toUpperCase() + '</span>' : '') +
2965
+ '</div>' +
2966
+ (rating ? '<div class="book-rating">' + rating + '</div>' : '') +
2967
+ '</div>' +
2968
+ '</div>';
2969
+ }
2970
+
2971
+ function renderListItem(book) {
2972
+ const author = book.authors.join(', ') || 'Unknown Author';
2973
+ return '<div class="book-list-item" onclick="showBookDetails(' + book.id + ')">' +
2974
+ '<div class="book-list-cover">' +
2975
+ (book.cover_path ? '<img src="/api/books/' + book.id + '/cover" alt="" loading="lazy">' : '') +
2976
+ '</div>' +
2977
+ '<div class="book-list-info">' +
2978
+ '<div class="book-list-title">' + (book.favorite ? '&#11088; ' : '') + escapeHtml(book.title) + '</div>' +
2979
+ '<div class="book-list-author">' + escapeHtml(author) + '</div>' +
2980
+ '<div class="book-list-meta">' +
2981
+ (book.publication_date ? '<span>&#128197; ' + book.publication_date + '</span>' : '') +
2982
+ (book.language ? '<span>&#127760; ' + book.language.toUpperCase() + '</span>' : '') +
2983
+ book.files.map(f => '<span>&#128196; ' + f.format.toUpperCase() + '</span>').join('') +
2984
+ (book.rating ? '<span>&#11088; ' + book.rating + '</span>' : '') +
2985
+ '</div>' +
2986
+ '</div>' +
2987
+ '</div>';
2988
+ }
2989
+
2990
+ function renderTable() {
2991
+ return '<table class="book-table">' +
2992
+ '<thead><tr>' +
2993
+ '<th>Title</th>' +
2994
+ '<th>Author</th>' +
2995
+ '<th>Year</th>' +
2996
+ '<th>Format</th>' +
2997
+ '<th>Rating</th>' +
2998
+ '</tr></thead>' +
2999
+ '<tbody>' +
3000
+ books.map(book => '<tr>' +
3001
+ '<td><span class="table-title" onclick="showBookDetails(' + book.id + ')">' +
3002
+ (book.favorite ? '&#11088; ' : '') + escapeHtml(book.title) + '</span></td>' +
3003
+ '<td>' + escapeHtml(book.authors.join(', ') || '-') + '</td>' +
3004
+ '<td>' + (book.publication_date ? book.publication_date.substring(0,4) : '-') + '</td>' +
3005
+ '<td>' + book.files.map(f => f.format.toUpperCase()).join(', ') + '</td>' +
3006
+ '<td>' + (book.rating ? '&#9733;'.repeat(Math.round(book.rating)) : '-') + '</td>' +
3007
+ '</tr>').join('') +
3008
+ '</tbody></table>';
3009
+ }
3010
+
3011
+ function setView(view) {
3012
+ currentView = view;
3013
+ document.querySelectorAll('.header-actions .icon-btn').forEach(b => b.classList.remove('active'));
3014
+ document.getElementById('view-' + view).classList.add('active');
3015
+ renderBooks();
3016
+ }
3017
+
3018
+ function setFilter(filter) {
3019
+ currentFilter = filter;
3020
+ currentViewName = null; // Clear view selection
3021
+ currentPage = 1;
3022
+ updateNavItems();
3023
+ updateViewNavItems();
3024
+ updateURL();
3025
+ loadBooks();
3026
+ if (window.innerWidth < 1024) toggleSidebar();
3027
+ }
3028
+
3029
+ function updateNavItems() {
3030
+ document.querySelectorAll('.nav-item[data-filter]').forEach(item => {
3031
+ item.classList.toggle('active', item.dataset.filter === currentFilter);
3032
+ });
3033
+ }
3034
+
3035
+ function updateNavCounts() {
3036
+ // Update counts from cached stats
3037
+ if (libraryStats) {
3038
+ document.getElementById('count-all').textContent = libraryStats.total_books;
3039
+ document.getElementById('count-favorites').textContent = libraryStats.favorites_count;
3040
+ document.getElementById('count-reading').textContent = libraryStats.reading_count;
3041
+ document.getElementById('count-completed').textContent = libraryStats.completed_count;
3042
+ }
3043
+ }
3044
+
3045
+ // Views management
3046
+ async function loadViews() {
3047
+ try {
3048
+ const response = await fetch('/api/views');
3049
+ if (response.ok) {
3050
+ availableViews = await response.json();
3051
+ renderViewsList();
3052
+ }
3053
+ } catch (error) {
3054
+ console.error('Failed to load views:', error);
3055
+ document.getElementById('views-list').innerHTML =
3056
+ '<div class="nav-item" style="color: var(--text-muted);">Failed to load views</div>';
3057
+ }
3058
+ }
3059
+
3060
+ function renderViewsList() {
3061
+ const container = document.getElementById('views-list');
3062
+
3063
+ // Filter to only show user-defined views (not built-ins which are in Library section)
3064
+ const userViews = availableViews.filter(v => !v.builtin);
3065
+
3066
+ if (userViews.length === 0) {
3067
+ container.innerHTML = '<div class="nav-item" style="color: var(--text-muted); font-style: italic;">No custom views</div>';
3068
+ return;
3069
+ }
3070
+
3071
+ container.innerHTML = userViews.map(view => `
3072
+ <div class="nav-item ${currentFilter === 'view' && currentViewName === view.name ? 'active' : ''}"
3073
+ data-view="${escapeHtml(view.name)}"
3074
+ onclick="setViewFilter('${escapeHtml(view.name)}')"
3075
+ title="${escapeHtml(view.description || '')}">
3076
+ <span class="nav-item-icon">&#128209;</span>
3077
+ ${escapeHtml(view.name)}
3078
+ ${view.count !== null ? `<span class="nav-item-count">${view.count}</span>` : ''}
3079
+ </div>
3080
+ `).join('');
3081
+ }
3082
+
3083
+ async function setViewFilter(viewName) {
3084
+ currentFilter = 'view';
3085
+ currentViewName = viewName;
3086
+ currentPage = 1;
3087
+ updateNavItems();
3088
+ updateViewNavItems();
3089
+ updateURL();
3090
+
3091
+ // Load books from the view
3092
+ try {
3093
+ const response = await fetch(`/api/views/${encodeURIComponent(viewName)}/books?limit=${booksPerPage}&offset=${(currentPage-1)*booksPerPage}`);
3094
+ if (response.ok) {
3095
+ const data = await response.json();
3096
+ books = data.items;
3097
+ totalBooks = data.total;
3098
+ renderBooks();
3099
+ updateResultsInfo();
3100
+ } else {
3101
+ showNotification('Failed to load view', 'error');
3102
+ }
3103
+ } catch (error) {
3104
+ console.error('Failed to load view:', error);
3105
+ showNotification('Failed to load view', 'error');
3106
+ }
3107
+
3108
+ if (window.innerWidth < 1024) toggleSidebar();
3109
+ }
3110
+
3111
+ function updateViewNavItems() {
3112
+ document.querySelectorAll('.nav-item[data-view]').forEach(item => {
3113
+ item.classList.toggle('active', currentFilter === 'view' && item.dataset.view === currentViewName);
3114
+ });
3115
+ }
3116
+
3117
+ function updateResultsInfo() {
3118
+ const info = document.getElementById('results-info');
3119
+ if (isSearching) {
3120
+ info.textContent = totalBooks + ' results found';
3121
+ } else if (currentFilter === 'view' && currentViewName) {
3122
+ const start = (currentPage - 1) * booksPerPage + 1;
3123
+ const end = Math.min(currentPage * booksPerPage, totalBooks);
3124
+ info.textContent = `View: ${currentViewName} - Showing ${start}-${end} of ${totalBooks} books`;
3125
+ } else {
3126
+ const start = (currentPage - 1) * booksPerPage + 1;
3127
+ const end = Math.min(currentPage * booksPerPage, totalBooks);
3128
+ info.textContent = 'Showing ' + start + '-' + end + ' of ' + totalBooks + ' books';
3129
+ }
3130
+ }
3131
+
3132
+ function updatePagination() {
3133
+ const pagination = document.getElementById('pagination');
3134
+ const totalPages = Math.ceil(totalBooks / booksPerPage);
3135
+
3136
+ if (totalPages <= 1) {
3137
+ pagination.innerHTML = '';
3138
+ return;
3139
+ }
3140
+
3141
+ let html = '<button class="page-btn" onclick="goToPage(' + (currentPage - 1) + ')" ' +
3142
+ (currentPage === 1 ? 'disabled' : '') + '>&#8592; Prev</button>';
3143
+
3144
+ const start = Math.max(1, currentPage - 2);
3145
+ const end = Math.min(totalPages, currentPage + 2);
3146
+
3147
+ if (start > 1) html += '<button class="page-btn" onclick="goToPage(1)">1</button>';
3148
+ if (start > 2) html += '<span class="page-info">...</span>';
3149
+
3150
+ for (let i = start; i <= end; i++) {
3151
+ html += '<button class="page-btn' + (i === currentPage ? ' active' : '') + '" onclick="goToPage(' + i + ')">' + i + '</button>';
3152
+ }
3153
+
3154
+ if (end < totalPages - 1) html += '<span class="page-info">...</span>';
3155
+ if (end < totalPages) html += '<button class="page-btn" onclick="goToPage(' + totalPages + ')">' + totalPages + '</button>';
3156
+
3157
+ html += '<button class="page-btn" onclick="goToPage(' + (currentPage + 1) + ')" ' +
3158
+ (currentPage === totalPages ? 'disabled' : '') + '>Next &#8594;</button>';
3159
+
3160
+ pagination.innerHTML = html;
3161
+ }
3162
+
3163
+ function goToPage(page) {
3164
+ const totalPages = Math.ceil(totalBooks / booksPerPage);
3165
+ if (page < 1 || page > totalPages) return;
3166
+ currentPage = page;
3167
+ updateURL();
3168
+ loadBooks();
3169
+ window.scrollTo({ top: 0, behavior: 'smooth' });
3170
+ }
3171
+
3172
+ function applyFilters() {
3173
+ currentPage = 1;
3174
+ updateURL();
3175
+ loadBooks();
3176
+ }
3177
+
3178
+ function clearFilters() {
3179
+ document.getElementById('sort-field').value = 'title';
3180
+ document.getElementById('sort-order').value = 'asc';
3181
+ document.getElementById('filter-language').value = '';
3182
+ document.getElementById('filter-format').value = '';
3183
+ document.getElementById('filter-rating').value = '';
3184
+ document.getElementById('search-input').value = '';
3185
+ currentFilter = 'all';
3186
+ isSearching = false;
3187
+ currentPage = 1;
3188
+ updateNavItems();
3189
+ updateURL();
3190
+ loadBooks();
3191
+ }
3192
+
3193
+ async function showBookDetails(bookId) {
3194
+ try {
3195
+ const response = await fetch('/api/books/' + bookId);
3196
+ const book = await response.json();
3197
+
3198
+ document.getElementById('details-title').textContent = book.title;
3199
+
3200
+ let html = '';
3201
+
3202
+ if (book.cover_path) {
3203
+ html += '<div class="modal-cover"><img src="/api/books/' + book.id + '/cover" alt="" onerror="this.style.display=\\'none\\'"></div>';
3204
+ }
3205
+
3206
+ if (book.files && book.files.length > 0) {
3207
+ html += '<div class="detail-section"><div class="detail-label">Download</div><div class="detail-tags">' +
3208
+ book.files.map(f =>
3209
+ '<a href="/api/books/' + book.id + '/files/' + f.format.toLowerCase() + '" target="_blank" class="file-btn">' +
3210
+ '&#128196; ' + f.format.toUpperCase() + ' <span style="opacity:0.7">(' + formatBytes(f.size_bytes) + ')</span></a>'
3211
+ ).join('') + '</div></div>';
3212
+ }
3213
+
3214
+ if (book.authors && book.authors.length > 0) {
3215
+ html += '<div class="detail-section"><div class="detail-label">Authors</div><div class="detail-value">' + book.authors.join(', ') + '</div></div>';
3216
+ }
3217
+
3218
+ if (book.subtitle) {
3219
+ html += '<div class="detail-section"><div class="detail-label">Subtitle</div><div class="detail-value">' + escapeHtml(book.subtitle) + '</div></div>';
3220
+ }
3221
+
3222
+ if (book.description) {
3223
+ html += '<div class="detail-section"><div class="detail-label">Description</div><div class="detail-value">' + book.description + '</div></div>';
3224
+ }
3225
+
3226
+ const meta = [];
3227
+ if (book.publisher) meta.push('Publisher: ' + book.publisher);
3228
+ if (book.publication_date) meta.push('Published: ' + book.publication_date);
3229
+ if (book.language) meta.push('Language: ' + book.language.toUpperCase());
3230
+ if (book.rating) meta.push('Rating: ' + '&#9733;'.repeat(Math.round(book.rating)) + ' (' + book.rating + '/5)');
3231
+ if (book.reading_status) meta.push('Status: ' + book.reading_status);
3232
+
3233
+ if (meta.length > 0) {
3234
+ html += '<div class="detail-section"><div class="detail-label">Details</div><div class="detail-value">' + meta.join(' &bull; ') + '</div></div>';
3235
+ }
3236
+
3237
+ if (book.subjects && book.subjects.length > 0) {
3238
+ html += '<div class="detail-section"><div class="detail-label">Subjects</div><div class="detail-tags">' +
3239
+ book.subjects.map(s => '<span class="tag">' + escapeHtml(s) + '</span>').join('') + '</div></div>';
3240
+ }
3241
+
3242
+ if (book.tags && book.tags.length > 0) {
3243
+ html += '<div class="detail-section"><div class="detail-label">Tags</div><div class="detail-tags">' +
3244
+ book.tags.map(t => '<span class="tag">' + escapeHtml(t) + '</span>').join('') + '</div></div>';
3245
+ }
3246
+
3247
+ html += '<div style="margin-top: 24px;"><button class="btn btn-primary" onclick="closeModal(\\'details-modal\\'); editBook(' + book.id + ');">&#9998; Edit Metadata</button></div>';
3248
+
3249
+ document.getElementById('details-body').innerHTML = html;
3250
+ document.getElementById('details-modal').classList.add('active');
3251
+ } catch (error) {
3252
+ showError('Failed to load book details: ' + error.message);
3253
+ }
3254
+ }
3255
+
3256
+ async function editBook(bookId) {
3257
+ currentBookId = bookId;
3258
+ try {
3259
+ const response = await fetch('/api/books/' + bookId);
3260
+ const book = await response.json();
3261
+
3262
+ document.getElementById('edit-book-id').value = book.id;
3263
+ document.getElementById('edit-title').value = book.title || '';
3264
+ document.getElementById('edit-subtitle').value = book.subtitle || '';
3265
+ document.getElementById('edit-language').value = book.language || '';
3266
+ document.getElementById('edit-publisher').value = book.publisher || '';
3267
+ document.getElementById('edit-publication-date').value = book.publication_date || '';
3268
+ document.getElementById('edit-description').value = book.description || '';
3269
+ document.getElementById('edit-rating').value = book.rating || '';
3270
+ document.getElementById('edit-status').value = book.reading_status || 'unread';
3271
+ document.getElementById('edit-favorite').checked = book.favorite || false;
3272
+ document.getElementById('edit-tags').value = (book.tags || []).join(', ');
3273
+
3274
+ document.getElementById('edit-modal').classList.add('active');
3275
+ } catch (error) {
3276
+ showError('Failed to load book: ' + error.message);
3277
+ }
3278
+ }
3279
+
3280
+ async function saveBook(event) {
3281
+ event.preventDefault();
3282
+ const bookId = document.getElementById('edit-book-id').value;
3283
+ const tags = document.getElementById('edit-tags').value.split(',').map(t => t.trim()).filter(t => t);
3284
+
3285
+ const update = {
3286
+ title: document.getElementById('edit-title').value,
3287
+ subtitle: document.getElementById('edit-subtitle').value,
3288
+ language: document.getElementById('edit-language').value,
3289
+ publisher: document.getElementById('edit-publisher').value,
3290
+ publication_date: document.getElementById('edit-publication-date').value,
3291
+ description: document.getElementById('edit-description').value,
3292
+ rating: parseFloat(document.getElementById('edit-rating').value) || null,
3293
+ reading_status: document.getElementById('edit-status').value,
3294
+ favorite: document.getElementById('edit-favorite').checked,
3295
+ tags: tags
3296
+ };
3297
+
3298
+ try {
3299
+ const response = await fetch('/api/books/' + bookId, {
3300
+ method: 'PATCH',
3301
+ headers: { 'Content-Type': 'application/json' },
3302
+ body: JSON.stringify(update)
3303
+ });
3304
+ if (!response.ok) throw new Error('Failed to update book');
3305
+ closeModal('edit-modal');
3306
+ showSuccess('Book updated successfully');
3307
+ refreshBooks();
3308
+ } catch (error) {
3309
+ showError('Failed to save changes: ' + error.message);
3310
+ }
3311
+ }
3312
+
3313
+ async function deleteBook() {
3314
+ if (!confirm('Are you sure you want to delete this book?')) return;
3315
+ const bookId = currentBookId;
3316
+ const deleteFiles = confirm('Also delete files from disk?');
3317
+
3318
+ try {
3319
+ const response = await fetch('/api/books/' + bookId + '?delete_files=' + deleteFiles, { method: 'DELETE' });
3320
+ if (!response.ok) throw new Error('Failed to delete book');
3321
+ closeModal('edit-modal');
3322
+ showSuccess('Book deleted successfully');
3323
+ refreshBooks();
3324
+ } catch (error) {
3325
+ showError('Failed to delete book: ' + error.message);
3326
+ }
3327
+ }
3328
+
3329
+ function showImportModal() {
3330
+ // Reset modal state
3331
+ document.getElementById('import-progress').style.display = 'none';
3332
+ document.getElementById('import-results').style.display = 'none';
3333
+ document.querySelectorAll('.tab-content').forEach(t => t.style.display = '');
3334
+ document.querySelectorAll('.tabs').forEach(t => t.style.display = '');
3335
+ switchImportTab('file');
3336
+ document.getElementById('import-modal').classList.add('active');
3337
+ if (window.innerWidth < 1024) toggleSidebar();
3338
+ }
3339
+
3340
+ function switchImportTab(tabName) {
3341
+ // Update tab buttons
3342
+ document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));
3343
+ document.querySelector(`.tabs .tab[onclick*="${tabName}"]`).classList.add('active');
3344
+
3345
+ // Update tab content
3346
+ document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
3347
+ document.getElementById('import-tab-' + tabName).classList.add('active');
3348
+ }
3349
+
3350
+ function showImportProgress(text) {
3351
+ document.querySelectorAll('.tab-content').forEach(t => t.style.display = 'none');
3352
+ document.querySelectorAll('.tabs').forEach(t => t.style.display = 'none');
3353
+ document.getElementById('import-progress-text').textContent = text;
3354
+ document.getElementById('import-progress').style.display = 'block';
3355
+ document.getElementById('import-results').style.display = 'none';
3356
+ }
3357
+
3358
+ function showImportResults(results, type) {
3359
+ document.getElementById('import-progress').style.display = 'none';
3360
+ const resultsDiv = document.getElementById('import-results');
3361
+
3362
+ let html = '<div class="detail-section">';
3363
+ if (results.imported > 0) {
3364
+ html += `<div class="alert alert-success">Successfully imported ${results.imported} of ${results.total} books</div>`;
3365
+ }
3366
+ if (results.failed > 0) {
3367
+ html += `<div class="alert alert-error">${results.failed} books failed to import</div>`;
3368
+ }
3369
+ if (results.errors && results.errors.length > 0) {
3370
+ html += '<div class="detail-label">Errors</div>';
3371
+ html += '<ul style="color: var(--danger); font-size: 0.85rem; margin-left: 20px;">';
3372
+ results.errors.slice(0, 10).forEach(err => {
3373
+ html += `<li>${escapeHtml(err)}</li>`;
3374
+ });
3375
+ if (results.errors.length > 10) {
3376
+ html += `<li>...and ${results.errors.length - 10} more errors</li>`;
3377
+ }
3378
+ html += '</ul>';
3379
+ }
3380
+ html += '</div>';
3381
+ html += '<div class="btn-group">';
3382
+ html += '<button class="btn btn-primary" onclick="closeModal(&apos;import-modal&apos;); refreshBooks();">Done</button>';
3383
+ html += '</div>';
3384
+
3385
+ resultsDiv.innerHTML = html;
3386
+ resultsDiv.style.display = 'block';
3387
+ }
3388
+
3389
+ async function importSingleFile(event) {
3390
+ event.preventDefault();
3391
+ const fileInput = document.getElementById('import-file');
3392
+ const file = fileInput.files[0];
3393
+ if (!file) { showError('Please select a file'); return; }
3394
+
3395
+ showImportProgress('Importing ' + file.name + '...');
3396
+
3397
+ const formData = new FormData();
3398
+ formData.append('file', file);
3399
+ formData.append('extract_text', document.getElementById('import-extract-text').checked);
3400
+ formData.append('extract_cover', document.getElementById('import-extract-cover').checked);
3401
+
3402
+ try {
3403
+ const response = await fetch('/api/books/import', { method: 'POST', body: formData });
3404
+ if (!response.ok) {
3405
+ const error = await response.json();
3406
+ throw new Error(error.detail || 'Import failed');
3407
+ }
3408
+ showImportResults({ total: 1, imported: 1, failed: 0, errors: [] }, 'file');
3409
+ document.getElementById('import-form-file').reset();
3410
+ } catch (error) {
3411
+ showImportResults({ total: 1, imported: 0, failed: 1, errors: [error.message] }, 'file');
3412
+ }
3413
+ }
3414
+
3415
+ async function importFolder(event) {
3416
+ event.preventDefault();
3417
+ const folderPath = document.getElementById('import-folder-path').value.trim();
3418
+ if (!folderPath) { showError('Please enter a folder path'); return; }
3419
+
3420
+ showImportProgress('Scanning folder for ebooks...');
3421
+
3422
+ const data = {
3423
+ folder_path: folderPath,
3424
+ recursive: document.getElementById('import-folder-recursive').checked,
3425
+ extensions: document.getElementById('import-folder-extensions').value.trim() || 'pdf,epub,mobi,azw3,txt',
3426
+ limit: document.getElementById('import-folder-limit').value || null,
3427
+ extract_text: document.getElementById('import-folder-extract-text').checked,
3428
+ extract_cover: document.getElementById('import-folder-extract-cover').checked
3429
+ };
3430
+
3431
+ try {
3432
+ const response = await fetch('/api/books/import/folder', {
3433
+ method: 'POST',
3434
+ headers: { 'Content-Type': 'application/json' },
3435
+ body: JSON.stringify(data)
3436
+ });
3437
+ if (!response.ok) {
3438
+ const error = await response.json();
3439
+ throw new Error(error.detail || 'Folder import failed');
3440
+ }
3441
+ const results = await response.json();
3442
+ showImportResults(results, 'folder');
3443
+ document.getElementById('import-form-folder').reset();
3444
+ } catch (error) {
3445
+ showImportResults({ total: 0, imported: 0, failed: 0, errors: [error.message] }, 'folder');
3446
+ }
3447
+ }
3448
+
3449
+ async function importCalibre(event) {
3450
+ event.preventDefault();
3451
+ const calibrePath = document.getElementById('import-calibre-path').value.trim();
3452
+ if (!calibrePath) { showError('Please enter a Calibre library path'); return; }
3453
+
3454
+ showImportProgress('Importing from Calibre library...');
3455
+
3456
+ const data = {
3457
+ calibre_path: calibrePath,
3458
+ limit: document.getElementById('import-calibre-limit').value || null
3459
+ };
3460
+
3461
+ try {
3462
+ const response = await fetch('/api/books/import/calibre', {
3463
+ method: 'POST',
3464
+ headers: { 'Content-Type': 'application/json' },
3465
+ body: JSON.stringify(data)
3466
+ });
3467
+ if (!response.ok) {
3468
+ const error = await response.json();
3469
+ throw new Error(error.detail || 'Calibre import failed');
3470
+ }
3471
+ const results = await response.json();
3472
+ showImportResults(results, 'calibre');
3473
+ document.getElementById('import-form-calibre').reset();
3474
+ } catch (error) {
3475
+ showImportResults({ total: 0, imported: 0, failed: 0, errors: [error.message] }, 'calibre');
3476
+ }
3477
+ }
3478
+
3479
+ async function importFromURL(event) {
3480
+ event.preventDefault();
3481
+ const url = document.getElementById('import-url').value.trim();
3482
+ if (!url) { showError('Please enter a URL'); return; }
3483
+
3484
+ showImportProgress('Downloading and importing from URL...');
3485
+
3486
+ const data = {
3487
+ url: url,
3488
+ extract_text: document.getElementById('import-url-extract-text').checked,
3489
+ extract_cover: document.getElementById('import-url-extract-cover').checked
3490
+ };
3491
+
3492
+ try {
3493
+ const response = await fetch('/api/books/import/url', {
3494
+ method: 'POST',
3495
+ headers: { 'Content-Type': 'application/json' },
3496
+ body: JSON.stringify(data)
3497
+ });
3498
+ if (!response.ok) {
3499
+ const error = await response.json();
3500
+ throw new Error(error.detail || 'URL import failed');
3501
+ }
3502
+ showImportResults({ total: 1, imported: 1, failed: 0, errors: [] }, 'url');
3503
+ document.getElementById('import-form-url').reset();
3504
+ } catch (error) {
3505
+ showImportResults({ total: 1, imported: 0, failed: 1, errors: [error.message] }, 'url');
3506
+ }
3507
+ }
3508
+
3509
+ async function importFromOPDS(event) {
3510
+ event.preventDefault();
3511
+ const opdsUrl = document.getElementById('import-opds-url').value.trim();
3512
+ if (!opdsUrl) { showError('Please enter an OPDS catalog URL'); return; }
3513
+
3514
+ showImportProgress('Fetching OPDS catalog...');
3515
+
3516
+ const data = {
3517
+ opds_url: opdsUrl,
3518
+ limit: document.getElementById('import-opds-limit').value || null,
3519
+ extract_text: document.getElementById('import-opds-extract-text').checked,
3520
+ extract_cover: document.getElementById('import-opds-extract-cover').checked
3521
+ };
3522
+
3523
+ try {
3524
+ const response = await fetch('/api/books/import/opds', {
3525
+ method: 'POST',
3526
+ headers: { 'Content-Type': 'application/json' },
3527
+ body: JSON.stringify(data)
3528
+ });
3529
+ if (!response.ok) {
3530
+ const error = await response.json();
3531
+ throw new Error(error.detail || 'OPDS import failed');
3532
+ }
3533
+ const results = await response.json();
3534
+ showImportResults(results, 'opds');
3535
+ document.getElementById('import-form-opds').reset();
3536
+ } catch (error) {
3537
+ showImportResults({ total: 0, imported: 0, failed: 0, errors: [error.message] }, 'opds');
3538
+ }
3539
+ }
3540
+
3541
+ async function importFromISBN(event) {
3542
+ event.preventDefault();
3543
+ const isbn = document.getElementById('import-isbn').value.trim();
3544
+ if (!isbn) { showError('Please enter an ISBN'); return; }
3545
+
3546
+ showImportProgress('Looking up ISBN...');
3547
+
3548
+ const data = { isbn: isbn };
3549
+
3550
+ try {
3551
+ const response = await fetch('/api/books/import/isbn', {
3552
+ method: 'POST',
3553
+ headers: { 'Content-Type': 'application/json' },
3554
+ body: JSON.stringify(data)
3555
+ });
3556
+ if (!response.ok) {
3557
+ const error = await response.json();
3558
+ throw new Error(error.detail || 'ISBN lookup failed');
3559
+ }
3560
+ showImportResults({ total: 1, imported: 1, failed: 0, errors: [] }, 'isbn');
3561
+ document.getElementById('import-form-isbn').reset();
3562
+ } catch (error) {
3563
+ showImportResults({ total: 1, imported: 0, failed: 1, errors: [error.message] }, 'isbn');
3564
+ }
3565
+ }
3566
+
3567
+ function closeModal(modalId) {
3568
+ document.getElementById(modalId).classList.remove('active');
3569
+ }
3570
+
3571
+ function closeAllModals() {
3572
+ document.querySelectorAll('.modal.active').forEach(m => m.classList.remove('active'));
3573
+ }
3574
+
3575
+ function refreshBooks() {
3576
+ loadBooks();
3577
+ loadStats();
3578
+ }
3579
+
3580
+ function showError(message) {
3581
+ const container = document.getElementById('message-container');
3582
+ container.innerHTML = '<div class="alert alert-error">&#9888; ' + escapeHtml(message) + '</div>';
3583
+ setTimeout(() => container.innerHTML = '', 5000);
3584
+ }
3585
+
3586
+ function showSuccess(message) {
3587
+ const container = document.getElementById('message-container');
3588
+ container.innerHTML = '<div class="alert alert-success">&#9989; ' + escapeHtml(message) + '</div>';
3589
+ setTimeout(() => container.innerHTML = '', 3000);
3590
+ }
3591
+
3592
+ function escapeHtml(text) {
3593
+ if (!text) return '';
3594
+ const div = document.createElement('div');
3595
+ div.textContent = text;
3596
+ return div.innerHTML;
3597
+ }
3598
+
3599
+ function formatBytes(bytes) {
3600
+ if (!bytes) return '0 B';
3601
+ const k = 1024;
3602
+ const sizes = ['B', 'KB', 'MB', 'GB'];
3603
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
3604
+ return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
3605
+ }
3606
+ </script>
3607
+ </body>
3608
+ </html>'''