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