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.
- ebk/__init__.py +35 -0
- ebk/ai/__init__.py +23 -0
- ebk/ai/knowledge_graph.py +450 -0
- ebk/ai/llm_providers/__init__.py +26 -0
- ebk/ai/llm_providers/anthropic.py +209 -0
- ebk/ai/llm_providers/base.py +295 -0
- ebk/ai/llm_providers/gemini.py +285 -0
- ebk/ai/llm_providers/ollama.py +294 -0
- ebk/ai/metadata_enrichment.py +394 -0
- ebk/ai/question_generator.py +328 -0
- ebk/ai/reading_companion.py +224 -0
- ebk/ai/semantic_search.py +433 -0
- ebk/ai/text_extractor.py +393 -0
- ebk/calibre_import.py +66 -0
- ebk/cli.py +6433 -0
- ebk/config.py +230 -0
- ebk/db/__init__.py +37 -0
- ebk/db/migrations.py +507 -0
- ebk/db/models.py +725 -0
- ebk/db/session.py +144 -0
- ebk/decorators.py +1 -0
- ebk/exports/__init__.py +0 -0
- ebk/exports/base_exporter.py +218 -0
- ebk/exports/echo_export.py +279 -0
- ebk/exports/html_library.py +1743 -0
- ebk/exports/html_utils.py +87 -0
- ebk/exports/hugo.py +59 -0
- ebk/exports/jinja_export.py +286 -0
- ebk/exports/multi_facet_export.py +159 -0
- ebk/exports/opds_export.py +232 -0
- ebk/exports/symlink_dag.py +479 -0
- ebk/exports/zip.py +25 -0
- ebk/extract_metadata.py +341 -0
- ebk/ident.py +89 -0
- ebk/library_db.py +1440 -0
- ebk/opds.py +748 -0
- ebk/plugins/__init__.py +42 -0
- ebk/plugins/base.py +502 -0
- ebk/plugins/hooks.py +442 -0
- ebk/plugins/registry.py +499 -0
- ebk/repl/__init__.py +9 -0
- ebk/repl/find.py +126 -0
- ebk/repl/grep.py +173 -0
- ebk/repl/shell.py +1677 -0
- ebk/repl/text_utils.py +320 -0
- ebk/search_parser.py +413 -0
- ebk/server.py +3608 -0
- ebk/services/__init__.py +28 -0
- ebk/services/annotation_extraction.py +351 -0
- ebk/services/annotation_service.py +380 -0
- ebk/services/export_service.py +577 -0
- ebk/services/import_service.py +447 -0
- ebk/services/personal_metadata_service.py +347 -0
- ebk/services/queue_service.py +253 -0
- ebk/services/tag_service.py +281 -0
- ebk/services/text_extraction.py +317 -0
- ebk/services/view_service.py +12 -0
- ebk/similarity/__init__.py +77 -0
- ebk/similarity/base.py +154 -0
- ebk/similarity/core.py +471 -0
- ebk/similarity/extractors.py +168 -0
- ebk/similarity/metrics.py +376 -0
- ebk/skills/SKILL.md +182 -0
- ebk/skills/__init__.py +1 -0
- ebk/vfs/__init__.py +101 -0
- ebk/vfs/base.py +298 -0
- ebk/vfs/library_vfs.py +122 -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/vfs_router.py +275 -0
- ebk/views/__init__.py +32 -0
- ebk/views/dsl.py +668 -0
- ebk/views/service.py +619 -0
- ebk-0.4.4.dist-info/METADATA +755 -0
- ebk-0.4.4.dist-info/RECORD +87 -0
- ebk-0.4.4.dist-info/WHEEL +5 -0
- ebk-0.4.4.dist-info/entry_points.txt +2 -0
- ebk-0.4.4.dist-info/licenses/LICENSE +21 -0
- ebk-0.4.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Export service for library data.
|
|
3
|
+
|
|
4
|
+
Provides a unified interface for exporting library data in various formats:
|
|
5
|
+
- JSON: Machine-readable data export
|
|
6
|
+
- CSV: Spreadsheet-compatible format
|
|
7
|
+
- HTML: Standalone web interface
|
|
8
|
+
- OPDS: E-reader compatible catalog
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import csv
|
|
13
|
+
import io
|
|
14
|
+
import shutil
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Dict, Any, Optional
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
from sqlalchemy.orm import Session
|
|
21
|
+
|
|
22
|
+
from ..db.models import Book
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExportService:
|
|
28
|
+
"""Service for exporting library data in various formats."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, session: Session, library_path: Optional[Path] = None):
|
|
31
|
+
"""
|
|
32
|
+
Initialize the export service.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
session: SQLAlchemy database session
|
|
36
|
+
library_path: Path to the library root (needed for file copying)
|
|
37
|
+
"""
|
|
38
|
+
self.session = session
|
|
39
|
+
self.library_path = Path(library_path) if library_path else None
|
|
40
|
+
|
|
41
|
+
def export_json(
|
|
42
|
+
self,
|
|
43
|
+
books: List[Book],
|
|
44
|
+
include_annotations: bool = True,
|
|
45
|
+
include_personal: bool = True,
|
|
46
|
+
pretty: bool = True,
|
|
47
|
+
) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Export books to JSON format.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
books: List of Book objects to export
|
|
53
|
+
include_annotations: Include notes and annotations
|
|
54
|
+
include_personal: Include ratings, favorites, reading status
|
|
55
|
+
pretty: Pretty-print the JSON output
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
JSON string representation of the books
|
|
59
|
+
"""
|
|
60
|
+
export_data = {
|
|
61
|
+
"exported_at": datetime.now().isoformat(),
|
|
62
|
+
"total_books": len(books),
|
|
63
|
+
"books": []
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for book in books:
|
|
67
|
+
book_data = self._book_to_dict(book, include_annotations, include_personal)
|
|
68
|
+
export_data["books"].append(book_data)
|
|
69
|
+
|
|
70
|
+
if pretty:
|
|
71
|
+
return json.dumps(export_data, indent=2, ensure_ascii=False)
|
|
72
|
+
return json.dumps(export_data, ensure_ascii=False)
|
|
73
|
+
|
|
74
|
+
def export_csv(
|
|
75
|
+
self,
|
|
76
|
+
books: List[Book],
|
|
77
|
+
fields: Optional[List[str]] = None,
|
|
78
|
+
) -> str:
|
|
79
|
+
"""
|
|
80
|
+
Export books to CSV format.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
books: List of Book objects to export
|
|
84
|
+
fields: List of field names to include (None = default fields)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
CSV string representation of the books
|
|
88
|
+
"""
|
|
89
|
+
if fields is None:
|
|
90
|
+
fields = [
|
|
91
|
+
"id", "title", "authors", "language", "publisher",
|
|
92
|
+
"publication_date", "subjects", "formats", "rating",
|
|
93
|
+
"favorite", "reading_status"
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
output = io.StringIO()
|
|
97
|
+
writer = csv.DictWriter(output, fieldnames=fields, extrasaction='ignore')
|
|
98
|
+
writer.writeheader()
|
|
99
|
+
|
|
100
|
+
for book in books:
|
|
101
|
+
row = self._book_to_csv_row(book)
|
|
102
|
+
writer.writerow(row)
|
|
103
|
+
|
|
104
|
+
return output.getvalue()
|
|
105
|
+
|
|
106
|
+
def export_html(
|
|
107
|
+
self,
|
|
108
|
+
books: List[Book],
|
|
109
|
+
output_path: Path,
|
|
110
|
+
include_stats: bool = True,
|
|
111
|
+
base_url: str = "",
|
|
112
|
+
views: Optional[List[Dict[str, Any]]] = None,
|
|
113
|
+
copy_files: bool = False,
|
|
114
|
+
) -> Dict[str, Any]:
|
|
115
|
+
"""
|
|
116
|
+
Export books to a standalone HTML file.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
books: List of Book objects to export
|
|
120
|
+
output_path: Path to output HTML file
|
|
121
|
+
include_stats: Include library statistics
|
|
122
|
+
base_url: Base URL for file links
|
|
123
|
+
views: List of view definitions for sidebar
|
|
124
|
+
copy_files: Copy ebook/cover files to output directory
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Dictionary with export statistics
|
|
128
|
+
"""
|
|
129
|
+
from ..exports.html_library import export_to_html
|
|
130
|
+
|
|
131
|
+
output_path = Path(output_path)
|
|
132
|
+
stats = {
|
|
133
|
+
"books": len(books),
|
|
134
|
+
"files_copied": 0,
|
|
135
|
+
"covers_copied": 0,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Copy files if requested
|
|
139
|
+
if copy_files and self.library_path:
|
|
140
|
+
stats.update(self._copy_files(books, output_path.parent, base_url))
|
|
141
|
+
|
|
142
|
+
# Export HTML
|
|
143
|
+
export_to_html(
|
|
144
|
+
books=books,
|
|
145
|
+
output_path=output_path,
|
|
146
|
+
include_stats=include_stats,
|
|
147
|
+
base_url=base_url,
|
|
148
|
+
views=views,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return stats
|
|
152
|
+
|
|
153
|
+
def export_opds(
|
|
154
|
+
self,
|
|
155
|
+
books: List[Book],
|
|
156
|
+
output_path: Path,
|
|
157
|
+
title: str = "ebk Library",
|
|
158
|
+
subtitle: str = "",
|
|
159
|
+
base_url: str = "",
|
|
160
|
+
copy_files: bool = False,
|
|
161
|
+
copy_covers: bool = False,
|
|
162
|
+
) -> Dict[str, Any]:
|
|
163
|
+
"""
|
|
164
|
+
Export books to an OPDS catalog file.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
books: List of Book objects to export
|
|
168
|
+
output_path: Path to output XML file
|
|
169
|
+
title: Catalog title
|
|
170
|
+
subtitle: Catalog subtitle
|
|
171
|
+
base_url: Base URL for file/cover links
|
|
172
|
+
copy_files: Copy ebook files to output directory
|
|
173
|
+
copy_covers: Copy cover images to output directory
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Dictionary with export statistics
|
|
177
|
+
"""
|
|
178
|
+
from ..exports.opds_export import export_to_opds
|
|
179
|
+
|
|
180
|
+
if not self.library_path:
|
|
181
|
+
raise ValueError("library_path required for OPDS export")
|
|
182
|
+
|
|
183
|
+
return export_to_opds(
|
|
184
|
+
books=books,
|
|
185
|
+
output_path=output_path,
|
|
186
|
+
library_path=self.library_path,
|
|
187
|
+
title=title,
|
|
188
|
+
subtitle=subtitle,
|
|
189
|
+
base_url=base_url,
|
|
190
|
+
copy_files=copy_files,
|
|
191
|
+
copy_covers=copy_covers,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def get_views_data(
|
|
195
|
+
self,
|
|
196
|
+
books: List[Book],
|
|
197
|
+
include_builtin: bool = True,
|
|
198
|
+
) -> List[Dict[str, Any]]:
|
|
199
|
+
"""
|
|
200
|
+
Get views data for export, with book IDs resolved.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
books: List of books being exported
|
|
204
|
+
include_builtin: Include builtin views
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
List of view definitions with resolved book_ids
|
|
208
|
+
"""
|
|
209
|
+
from ..views import ViewService
|
|
210
|
+
|
|
211
|
+
views_svc = ViewService(self.session)
|
|
212
|
+
all_views = views_svc.list(include_builtin=include_builtin)
|
|
213
|
+
|
|
214
|
+
book_ids_set = {b.id for b in books}
|
|
215
|
+
views_data = []
|
|
216
|
+
|
|
217
|
+
for v in all_views:
|
|
218
|
+
try:
|
|
219
|
+
view_books = views_svc.evaluate(v['name'])
|
|
220
|
+
view_book_ids = [tb.book.id for tb in view_books if tb.book.id in book_ids_set]
|
|
221
|
+
if view_book_ids:
|
|
222
|
+
views_data.append({
|
|
223
|
+
'name': v['name'],
|
|
224
|
+
'description': v.get('description', ''),
|
|
225
|
+
'book_ids': view_book_ids,
|
|
226
|
+
'builtin': v.get('builtin', False)
|
|
227
|
+
})
|
|
228
|
+
except Exception:
|
|
229
|
+
pass # Skip views that fail to evaluate
|
|
230
|
+
|
|
231
|
+
return views_data
|
|
232
|
+
|
|
233
|
+
def _book_to_dict(
|
|
234
|
+
self,
|
|
235
|
+
book: Book,
|
|
236
|
+
include_annotations: bool = True,
|
|
237
|
+
include_personal: bool = True,
|
|
238
|
+
) -> Dict[str, Any]:
|
|
239
|
+
"""Convert a Book object to a dictionary for export."""
|
|
240
|
+
data = {
|
|
241
|
+
"id": book.id,
|
|
242
|
+
"unique_id": book.unique_id,
|
|
243
|
+
"title": book.title,
|
|
244
|
+
"subtitle": book.subtitle,
|
|
245
|
+
"authors": [a.name for a in book.authors],
|
|
246
|
+
"language": book.language,
|
|
247
|
+
"publisher": book.publisher,
|
|
248
|
+
"publication_date": book.publication_date,
|
|
249
|
+
"description": book.description,
|
|
250
|
+
"subjects": [s.name for s in book.subjects],
|
|
251
|
+
"series": book.series,
|
|
252
|
+
"series_index": book.series_index,
|
|
253
|
+
"identifiers": {i.scheme: i.value for i in book.identifiers},
|
|
254
|
+
"files": [
|
|
255
|
+
{
|
|
256
|
+
"format": f.format,
|
|
257
|
+
"path": f.path,
|
|
258
|
+
"size_bytes": f.size_bytes,
|
|
259
|
+
"file_hash": f.file_hash,
|
|
260
|
+
}
|
|
261
|
+
for f in book.files
|
|
262
|
+
],
|
|
263
|
+
"covers": [
|
|
264
|
+
{
|
|
265
|
+
"path": c.path,
|
|
266
|
+
"width": c.width,
|
|
267
|
+
"height": c.height,
|
|
268
|
+
"is_primary": c.is_primary,
|
|
269
|
+
}
|
|
270
|
+
for c in book.covers
|
|
271
|
+
],
|
|
272
|
+
"tags": [t.full_path for t in book.tags] if book.tags else [],
|
|
273
|
+
"created_at": book.created_at.isoformat() if book.created_at else None,
|
|
274
|
+
"updated_at": book.updated_at.isoformat() if book.updated_at else None,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if include_personal and book.personal:
|
|
278
|
+
pm = book.personal
|
|
279
|
+
data["personal"] = {
|
|
280
|
+
"rating": pm.rating,
|
|
281
|
+
"is_favorite": pm.favorite,
|
|
282
|
+
"reading_status": pm.reading_status,
|
|
283
|
+
"reading_progress": pm.reading_progress,
|
|
284
|
+
"date_started": pm.date_started.isoformat() if pm.date_started else None,
|
|
285
|
+
"date_finished": pm.date_finished.isoformat() if pm.date_finished else None,
|
|
286
|
+
"personal_tags": pm.personal_tags or [],
|
|
287
|
+
"queue_position": pm.queue_position,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if include_annotations:
|
|
291
|
+
data["annotations"] = [
|
|
292
|
+
{
|
|
293
|
+
"id": a.id,
|
|
294
|
+
"type": a.annotation_type,
|
|
295
|
+
"content": a.content,
|
|
296
|
+
"page": a.page_number,
|
|
297
|
+
"created_at": a.created_at.isoformat() if a.created_at else None,
|
|
298
|
+
}
|
|
299
|
+
for a in book.annotations
|
|
300
|
+
] if hasattr(book, 'annotations') and book.annotations else []
|
|
301
|
+
|
|
302
|
+
return data
|
|
303
|
+
|
|
304
|
+
def _book_to_csv_row(self, book: Book) -> Dict[str, Any]:
|
|
305
|
+
"""Convert a Book object to a CSV row dictionary."""
|
|
306
|
+
pm = book.personal
|
|
307
|
+
return {
|
|
308
|
+
"id": book.id,
|
|
309
|
+
"unique_id": book.unique_id,
|
|
310
|
+
"title": book.title,
|
|
311
|
+
"subtitle": book.subtitle or "",
|
|
312
|
+
"authors": "; ".join(a.name for a in book.authors),
|
|
313
|
+
"language": book.language or "",
|
|
314
|
+
"publisher": book.publisher or "",
|
|
315
|
+
"publication_date": book.publication_date or "",
|
|
316
|
+
"description": (book.description or "")[:200], # Truncate for CSV
|
|
317
|
+
"subjects": "; ".join(s.name for s in book.subjects),
|
|
318
|
+
"series": book.series or "",
|
|
319
|
+
"series_index": book.series_index or "",
|
|
320
|
+
"formats": ", ".join(f.format for f in book.files),
|
|
321
|
+
"tags": "; ".join(t.full_path for t in book.tags) if book.tags else "",
|
|
322
|
+
"rating": pm.rating if pm else "",
|
|
323
|
+
"favorite": "yes" if pm and pm.favorite else "no",
|
|
324
|
+
"reading_status": pm.reading_status if pm else "",
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
def export_goodreads_csv(self, books: List[Book]) -> str:
|
|
328
|
+
"""
|
|
329
|
+
Export books to Goodreads-compatible CSV format.
|
|
330
|
+
|
|
331
|
+
The exported CSV can be imported into Goodreads via their import feature.
|
|
332
|
+
See: https://www.goodreads.com/review/import
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
books: List of Book objects to export
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
CSV string in Goodreads format
|
|
339
|
+
"""
|
|
340
|
+
# Goodreads CSV columns (required and optional)
|
|
341
|
+
fields = [
|
|
342
|
+
"Title", "Author", "Additional Authors", "ISBN", "ISBN13",
|
|
343
|
+
"My Rating", "Average Rating", "Publisher", "Binding", "Number of Pages",
|
|
344
|
+
"Year Published", "Original Publication Year", "Date Read", "Date Added",
|
|
345
|
+
"Bookshelves", "Bookshelves with positions", "Exclusive Shelf",
|
|
346
|
+
"My Review", "Spoiler", "Private Notes", "Read Count", "Owned Copies"
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
output = io.StringIO()
|
|
350
|
+
writer = csv.DictWriter(output, fieldnames=fields, extrasaction='ignore')
|
|
351
|
+
writer.writeheader()
|
|
352
|
+
|
|
353
|
+
for book in books:
|
|
354
|
+
pm = book.personal
|
|
355
|
+
|
|
356
|
+
# Get authors
|
|
357
|
+
authors = list(book.authors)
|
|
358
|
+
primary_author = authors[0].name if authors else ""
|
|
359
|
+
additional_authors = ", ".join(a.name for a in authors[1:]) if len(authors) > 1 else ""
|
|
360
|
+
|
|
361
|
+
# Get identifiers
|
|
362
|
+
identifiers = {i.scheme.lower(): i.value for i in book.identifiers}
|
|
363
|
+
isbn = identifiers.get('isbn', '')
|
|
364
|
+
isbn13 = identifiers.get('isbn13', '')
|
|
365
|
+
|
|
366
|
+
# Map reading status to Goodreads exclusive shelf
|
|
367
|
+
status_map = {
|
|
368
|
+
'read': 'read',
|
|
369
|
+
'reading': 'currently-reading',
|
|
370
|
+
'to_read': 'to-read',
|
|
371
|
+
'unread': 'to-read',
|
|
372
|
+
'abandoned': 'read', # No abandoned shelf in Goodreads
|
|
373
|
+
'reference': 'read',
|
|
374
|
+
}
|
|
375
|
+
status = pm.reading_status if pm else 'unread'
|
|
376
|
+
exclusive_shelf = status_map.get(status, 'to-read')
|
|
377
|
+
|
|
378
|
+
# Convert rating (ebk uses 0-5, Goodreads uses 1-5)
|
|
379
|
+
rating = ""
|
|
380
|
+
if pm and pm.rating:
|
|
381
|
+
# Round to integer, minimum 1
|
|
382
|
+
rating = str(max(1, round(pm.rating)))
|
|
383
|
+
|
|
384
|
+
# Get bookshelves (tags)
|
|
385
|
+
bookshelves = []
|
|
386
|
+
if book.tags:
|
|
387
|
+
bookshelves.extend(t.name for t in book.tags)
|
|
388
|
+
if pm and pm.personal_tags:
|
|
389
|
+
bookshelves.extend(pm.personal_tags)
|
|
390
|
+
|
|
391
|
+
# Format dates
|
|
392
|
+
date_read = ""
|
|
393
|
+
date_added = ""
|
|
394
|
+
if pm:
|
|
395
|
+
if pm.date_finished:
|
|
396
|
+
date_read = pm.date_finished.strftime("%Y/%m/%d")
|
|
397
|
+
if book.created_at:
|
|
398
|
+
date_added = book.created_at.strftime("%Y/%m/%d")
|
|
399
|
+
|
|
400
|
+
# Parse publication year
|
|
401
|
+
pub_year = ""
|
|
402
|
+
if book.publication_date:
|
|
403
|
+
# Try to extract year from various formats
|
|
404
|
+
pub_date = book.publication_date
|
|
405
|
+
if len(pub_date) >= 4 and pub_date[:4].isdigit():
|
|
406
|
+
pub_year = pub_date[:4]
|
|
407
|
+
|
|
408
|
+
row = {
|
|
409
|
+
"Title": book.title or "",
|
|
410
|
+
"Author": primary_author,
|
|
411
|
+
"Additional Authors": additional_authors,
|
|
412
|
+
"ISBN": isbn,
|
|
413
|
+
"ISBN13": isbn13,
|
|
414
|
+
"My Rating": rating,
|
|
415
|
+
"Average Rating": "", # We don't have this
|
|
416
|
+
"Publisher": book.publisher or "",
|
|
417
|
+
"Binding": "", # Could map from file format
|
|
418
|
+
"Number of Pages": str(book.page_count) if book.page_count else "",
|
|
419
|
+
"Year Published": pub_year,
|
|
420
|
+
"Original Publication Year": pub_year,
|
|
421
|
+
"Date Read": date_read,
|
|
422
|
+
"Date Added": date_added,
|
|
423
|
+
"Bookshelves": ", ".join(bookshelves),
|
|
424
|
+
"Bookshelves with positions": "",
|
|
425
|
+
"Exclusive Shelf": exclusive_shelf,
|
|
426
|
+
"My Review": "", # Could add from annotations
|
|
427
|
+
"Spoiler": "",
|
|
428
|
+
"Private Notes": "",
|
|
429
|
+
"Read Count": "1" if status == 'read' else "0",
|
|
430
|
+
"Owned Copies": "1",
|
|
431
|
+
}
|
|
432
|
+
writer.writerow(row)
|
|
433
|
+
|
|
434
|
+
return output.getvalue()
|
|
435
|
+
|
|
436
|
+
def export_calibre_csv(self, books: List[Book]) -> str:
|
|
437
|
+
"""
|
|
438
|
+
Export books to Calibre-compatible CSV format.
|
|
439
|
+
|
|
440
|
+
The exported CSV can be imported into Calibre using the "Add books" >
|
|
441
|
+
"Add from ISBN" or via calibredb command-line tool.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
books: List of Book objects to export
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
CSV string in Calibre format
|
|
448
|
+
"""
|
|
449
|
+
# Calibre CSV columns
|
|
450
|
+
fields = [
|
|
451
|
+
"title", "authors", "author_sort", "publisher", "pubdate",
|
|
452
|
+
"languages", "rating", "tags", "series", "series_index",
|
|
453
|
+
"identifiers", "comments", "isbn"
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
output = io.StringIO()
|
|
457
|
+
writer = csv.DictWriter(output, fieldnames=fields, extrasaction='ignore')
|
|
458
|
+
writer.writeheader()
|
|
459
|
+
|
|
460
|
+
for book in books:
|
|
461
|
+
pm = book.personal
|
|
462
|
+
|
|
463
|
+
# Get authors
|
|
464
|
+
authors = " & ".join(a.name for a in book.authors)
|
|
465
|
+
# Author sort: "Last, First & Last, First"
|
|
466
|
+
author_sort_parts = []
|
|
467
|
+
for a in book.authors:
|
|
468
|
+
parts = a.name.split()
|
|
469
|
+
if len(parts) >= 2:
|
|
470
|
+
author_sort_parts.append(f"{parts[-1]}, {' '.join(parts[:-1])}")
|
|
471
|
+
else:
|
|
472
|
+
author_sort_parts.append(a.name)
|
|
473
|
+
author_sort = " & ".join(author_sort_parts)
|
|
474
|
+
|
|
475
|
+
# Get identifiers in Calibre format: isbn:123,asin:B00...
|
|
476
|
+
identifiers = {i.scheme.lower(): i.value for i in book.identifiers}
|
|
477
|
+
id_str = ",".join(f"{k}:{v}" for k, v in identifiers.items())
|
|
478
|
+
isbn = identifiers.get('isbn', identifiers.get('isbn13', ''))
|
|
479
|
+
|
|
480
|
+
# Collect tags
|
|
481
|
+
tags_list = []
|
|
482
|
+
if book.subjects:
|
|
483
|
+
tags_list.extend(s.name for s in book.subjects)
|
|
484
|
+
if book.tags:
|
|
485
|
+
tags_list.extend(t.full_path for t in book.tags)
|
|
486
|
+
if pm and pm.personal_tags:
|
|
487
|
+
tags_list.extend(pm.personal_tags)
|
|
488
|
+
# Add reading status as tag
|
|
489
|
+
if pm and pm.reading_status:
|
|
490
|
+
tags_list.append(f"status:{pm.reading_status}")
|
|
491
|
+
if pm and pm.favorite:
|
|
492
|
+
tags_list.append("favorite")
|
|
493
|
+
|
|
494
|
+
# Convert rating (ebk uses 0-5, Calibre uses 0-10)
|
|
495
|
+
rating = ""
|
|
496
|
+
if pm and pm.rating:
|
|
497
|
+
rating = str(int(pm.rating * 2))
|
|
498
|
+
|
|
499
|
+
# Language codes
|
|
500
|
+
language = book.language or ""
|
|
501
|
+
|
|
502
|
+
# Series info
|
|
503
|
+
series = book.series or ""
|
|
504
|
+
series_index = str(book.series_index) if book.series_index else ""
|
|
505
|
+
|
|
506
|
+
# Description/comments
|
|
507
|
+
comments = book.description or ""
|
|
508
|
+
|
|
509
|
+
row = {
|
|
510
|
+
"title": book.title or "",
|
|
511
|
+
"authors": authors,
|
|
512
|
+
"author_sort": author_sort,
|
|
513
|
+
"publisher": book.publisher or "",
|
|
514
|
+
"pubdate": book.publication_date or "",
|
|
515
|
+
"languages": language,
|
|
516
|
+
"rating": rating,
|
|
517
|
+
"tags": ", ".join(tags_list),
|
|
518
|
+
"series": series,
|
|
519
|
+
"series_index": series_index,
|
|
520
|
+
"identifiers": id_str,
|
|
521
|
+
"comments": comments,
|
|
522
|
+
"isbn": isbn,
|
|
523
|
+
}
|
|
524
|
+
writer.writerow(row)
|
|
525
|
+
|
|
526
|
+
return output.getvalue()
|
|
527
|
+
|
|
528
|
+
def _copy_files(
|
|
529
|
+
self,
|
|
530
|
+
books: List[Book],
|
|
531
|
+
output_dir: Path,
|
|
532
|
+
base_url: str,
|
|
533
|
+
) -> Dict[str, int]:
|
|
534
|
+
"""
|
|
535
|
+
Copy ebook and cover files to output directory.
|
|
536
|
+
|
|
537
|
+
Returns statistics about copied files.
|
|
538
|
+
"""
|
|
539
|
+
if not self.library_path:
|
|
540
|
+
return {"files_copied": 0, "covers_copied": 0}
|
|
541
|
+
|
|
542
|
+
# Determine copy destination
|
|
543
|
+
copy_dest = output_dir / base_url.lstrip('/') if base_url else output_dir
|
|
544
|
+
copy_dest.mkdir(parents=True, exist_ok=True)
|
|
545
|
+
|
|
546
|
+
files_copied = 0
|
|
547
|
+
covers_copied = 0
|
|
548
|
+
total_size = 0
|
|
549
|
+
|
|
550
|
+
for book in books:
|
|
551
|
+
# Copy ebook files
|
|
552
|
+
for file in book.files:
|
|
553
|
+
src = self.library_path / file.path
|
|
554
|
+
dest = copy_dest / file.path
|
|
555
|
+
|
|
556
|
+
if src.exists():
|
|
557
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
558
|
+
shutil.copy2(src, dest)
|
|
559
|
+
files_copied += 1
|
|
560
|
+
total_size += file.size_bytes or 0
|
|
561
|
+
|
|
562
|
+
# Copy cover images
|
|
563
|
+
for cover in book.covers:
|
|
564
|
+
src = self.library_path / cover.path
|
|
565
|
+
dest = copy_dest / cover.path
|
|
566
|
+
|
|
567
|
+
if src.exists():
|
|
568
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
569
|
+
shutil.copy2(src, dest)
|
|
570
|
+
covers_copied += 1
|
|
571
|
+
total_size += src.stat().st_size
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
"files_copied": files_copied,
|
|
575
|
+
"covers_copied": covers_copied,
|
|
576
|
+
"total_size_bytes": total_size,
|
|
577
|
+
}
|