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
ebk/db/session.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database session management for ebk.
|
|
3
|
+
|
|
4
|
+
Provides session factory and initialization utilities.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import create_engine, event, text
|
|
12
|
+
from sqlalchemy.orm import sessionmaker, Session
|
|
13
|
+
from sqlalchemy.engine import Engine
|
|
14
|
+
|
|
15
|
+
from .models import Base
|
|
16
|
+
|
|
17
|
+
# Global session factory
|
|
18
|
+
_SessionFactory: Optional[sessionmaker] = None
|
|
19
|
+
_engine: Optional[Engine] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def init_db(library_path: Path, echo: bool = False) -> Engine:
|
|
23
|
+
"""
|
|
24
|
+
Initialize database and create all tables.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
library_path: Path to library directory
|
|
28
|
+
echo: If True, log all SQL statements (debug mode)
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
SQLAlchemy engine
|
|
32
|
+
"""
|
|
33
|
+
global _engine, _SessionFactory
|
|
34
|
+
|
|
35
|
+
library_path = Path(library_path)
|
|
36
|
+
library_path.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
db_path = library_path / 'library.db'
|
|
39
|
+
db_url = f'sqlite:///{db_path}'
|
|
40
|
+
|
|
41
|
+
_engine = create_engine(db_url, echo=echo)
|
|
42
|
+
|
|
43
|
+
# Enable foreign keys for SQLite
|
|
44
|
+
@event.listens_for(_engine, "connect")
|
|
45
|
+
def set_sqlite_pragma(dbapi_conn, connection_record):
|
|
46
|
+
cursor = dbapi_conn.cursor()
|
|
47
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
48
|
+
cursor.close()
|
|
49
|
+
|
|
50
|
+
# Create all tables
|
|
51
|
+
Base.metadata.create_all(_engine)
|
|
52
|
+
|
|
53
|
+
# Create FTS5 virtual table for full-text search
|
|
54
|
+
with _engine.connect() as conn:
|
|
55
|
+
# Check if FTS table exists
|
|
56
|
+
result = conn.execute(
|
|
57
|
+
text("SELECT name FROM sqlite_master WHERE type='table' AND name='books_fts'")
|
|
58
|
+
)
|
|
59
|
+
if not result.fetchone():
|
|
60
|
+
conn.execute(text("""
|
|
61
|
+
CREATE VIRTUAL TABLE books_fts USING fts5(
|
|
62
|
+
book_id UNINDEXED,
|
|
63
|
+
title,
|
|
64
|
+
description,
|
|
65
|
+
extracted_text,
|
|
66
|
+
tokenize='porter unicode61'
|
|
67
|
+
)
|
|
68
|
+
"""))
|
|
69
|
+
conn.commit()
|
|
70
|
+
|
|
71
|
+
# Create session factory
|
|
72
|
+
_SessionFactory = sessionmaker(bind=_engine)
|
|
73
|
+
|
|
74
|
+
return _engine
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_session() -> Session:
|
|
78
|
+
"""
|
|
79
|
+
Get a new database session.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
SQLAlchemy session
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
RuntimeError: If database not initialized
|
|
86
|
+
"""
|
|
87
|
+
if _SessionFactory is None:
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
"Database not initialized. Call init_db() first."
|
|
90
|
+
)
|
|
91
|
+
return _SessionFactory()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@contextmanager
|
|
95
|
+
def session_scope():
|
|
96
|
+
"""
|
|
97
|
+
Provide a transactional scope around a series of operations.
|
|
98
|
+
|
|
99
|
+
Usage:
|
|
100
|
+
with session_scope() as session:
|
|
101
|
+
session.add(book)
|
|
102
|
+
# Automatically commits or rolls back
|
|
103
|
+
"""
|
|
104
|
+
session = get_session()
|
|
105
|
+
try:
|
|
106
|
+
yield session
|
|
107
|
+
session.commit()
|
|
108
|
+
except Exception:
|
|
109
|
+
session.rollback()
|
|
110
|
+
raise
|
|
111
|
+
finally:
|
|
112
|
+
session.close()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def close_db():
|
|
116
|
+
"""Close database connection and cleanup."""
|
|
117
|
+
global _engine, _SessionFactory
|
|
118
|
+
|
|
119
|
+
if _engine:
|
|
120
|
+
_engine.dispose()
|
|
121
|
+
_engine = None
|
|
122
|
+
|
|
123
|
+
_SessionFactory = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_or_create(session: Session, model, **kwargs):
|
|
127
|
+
"""
|
|
128
|
+
Get existing instance or create new one.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
session: Database session
|
|
132
|
+
model: SQLAlchemy model class
|
|
133
|
+
**kwargs: Filter criteria and/or values to set
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Tuple of (instance, created: bool)
|
|
137
|
+
"""
|
|
138
|
+
instance = session.query(model).filter_by(**kwargs).first()
|
|
139
|
+
if instance:
|
|
140
|
+
return instance, False
|
|
141
|
+
else:
|
|
142
|
+
instance = model(**kwargs)
|
|
143
|
+
session.add(instance)
|
|
144
|
+
return instance, True
|
ebk/decorators.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Decorators for ebk functionality."""
|
ebk/exports/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Base exporter class for ebk library exports."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional, Any
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import re
|
|
9
|
+
from .html_utils import create_safe_filename, sanitize_for_html
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseExporter(ABC):
|
|
13
|
+
"""
|
|
14
|
+
Abstract base class for all ebk exporters.
|
|
15
|
+
|
|
16
|
+
Provides common functionality for exporting library data:
|
|
17
|
+
- Loading metadata
|
|
18
|
+
- File operations (copy/symlink)
|
|
19
|
+
- Filename sanitization
|
|
20
|
+
- Directory management
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
"""Initialize the base exporter."""
|
|
25
|
+
self.library_path = None
|
|
26
|
+
self.output_path = None
|
|
27
|
+
self.entries = []
|
|
28
|
+
|
|
29
|
+
def load_metadata(self, library_path: Path) -> List[Dict]:
|
|
30
|
+
"""
|
|
31
|
+
Load metadata from the library.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
library_path: Path to the ebk library
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of entry dictionaries
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
FileNotFoundError: If metadata.json doesn't exist
|
|
41
|
+
json.JSONDecodeError: If metadata is invalid
|
|
42
|
+
"""
|
|
43
|
+
self.library_path = Path(library_path)
|
|
44
|
+
metadata_path = self.library_path / "metadata.json"
|
|
45
|
+
|
|
46
|
+
if not metadata_path.exists():
|
|
47
|
+
raise FileNotFoundError(f"Metadata file not found at {metadata_path}")
|
|
48
|
+
|
|
49
|
+
with open(metadata_path, 'r', encoding='utf-8') as f:
|
|
50
|
+
self.entries = json.load(f)
|
|
51
|
+
|
|
52
|
+
return self.entries
|
|
53
|
+
|
|
54
|
+
def prepare_output_directory(self, output_path: Path, clean: bool = True):
|
|
55
|
+
"""
|
|
56
|
+
Prepare the output directory.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
output_path: Path for output
|
|
60
|
+
clean: Whether to clean existing directory
|
|
61
|
+
"""
|
|
62
|
+
self.output_path = Path(output_path)
|
|
63
|
+
|
|
64
|
+
if clean and self.output_path.exists():
|
|
65
|
+
shutil.rmtree(self.output_path)
|
|
66
|
+
|
|
67
|
+
self.output_path.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
def copy_entry_files(self, entry: Dict, source_dir: Path, dest_dir: Path):
|
|
70
|
+
"""
|
|
71
|
+
Copy entry files (ebooks and covers) to destination.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
entry: Entry dictionary
|
|
75
|
+
source_dir: Source library directory
|
|
76
|
+
dest_dir: Destination directory
|
|
77
|
+
"""
|
|
78
|
+
# Copy ebook files
|
|
79
|
+
for file_path in entry.get('file_paths', []):
|
|
80
|
+
src_file = source_dir / file_path
|
|
81
|
+
if src_file.exists():
|
|
82
|
+
dest_file = dest_dir / Path(file_path).name
|
|
83
|
+
shutil.copy2(src_file, dest_file)
|
|
84
|
+
|
|
85
|
+
# Copy cover image
|
|
86
|
+
cover_path = entry.get('cover_path')
|
|
87
|
+
if cover_path:
|
|
88
|
+
src_cover = source_dir / cover_path
|
|
89
|
+
if src_cover.exists():
|
|
90
|
+
dest_cover = dest_dir / Path(cover_path).name
|
|
91
|
+
shutil.copy2(src_cover, dest_cover)
|
|
92
|
+
|
|
93
|
+
def symlink_entry_files(self, entry: Dict, source_dir: Path, dest_dir: Path):
|
|
94
|
+
"""
|
|
95
|
+
Create symlinks to entry files instead of copying.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
entry: Entry dictionary
|
|
99
|
+
source_dir: Source library directory
|
|
100
|
+
dest_dir: Destination directory
|
|
101
|
+
"""
|
|
102
|
+
# Symlink ebook files
|
|
103
|
+
for file_path in entry.get('file_paths', []):
|
|
104
|
+
src_file = source_dir / file_path
|
|
105
|
+
if src_file.exists():
|
|
106
|
+
dest_file = dest_dir / Path(file_path).name
|
|
107
|
+
if not dest_file.exists():
|
|
108
|
+
dest_file.symlink_to(src_file.absolute())
|
|
109
|
+
|
|
110
|
+
# Symlink cover image
|
|
111
|
+
cover_path = entry.get('cover_path')
|
|
112
|
+
if cover_path:
|
|
113
|
+
src_cover = source_dir / cover_path
|
|
114
|
+
if src_cover.exists():
|
|
115
|
+
dest_cover = dest_dir / Path(cover_path).name
|
|
116
|
+
if not dest_cover.exists():
|
|
117
|
+
dest_cover.symlink_to(src_cover.absolute())
|
|
118
|
+
|
|
119
|
+
def sanitize_filename(self, name: str, max_length: int = 100) -> str:
|
|
120
|
+
"""
|
|
121
|
+
Sanitize filename to be filesystem-safe.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
name: Original filename
|
|
125
|
+
max_length: Maximum length for filename
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Sanitized filename
|
|
129
|
+
"""
|
|
130
|
+
return create_safe_filename(name, max_length=max_length)
|
|
131
|
+
|
|
132
|
+
def get_readable_name(self, entry: Dict) -> str:
|
|
133
|
+
"""
|
|
134
|
+
Get a human-readable name for an entry.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
entry: Entry dictionary
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Readable name combining title and author
|
|
141
|
+
"""
|
|
142
|
+
title = entry.get('title', 'Unknown')
|
|
143
|
+
creators = entry.get('creators', [])
|
|
144
|
+
|
|
145
|
+
if creators:
|
|
146
|
+
author = creators[0]
|
|
147
|
+
if len(creators) > 1:
|
|
148
|
+
author += " et al."
|
|
149
|
+
return f"{title} - {author}"
|
|
150
|
+
|
|
151
|
+
return title
|
|
152
|
+
|
|
153
|
+
def write_json(self, data: Any, file_path: Path):
|
|
154
|
+
"""
|
|
155
|
+
Write JSON data to file with proper encoding.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
data: Data to serialize
|
|
159
|
+
file_path: Output file path
|
|
160
|
+
"""
|
|
161
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
162
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
163
|
+
|
|
164
|
+
def create_readme(self, output_dir: Path, stats: Dict):
|
|
165
|
+
"""
|
|
166
|
+
Create a README file with export information.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
output_dir: Output directory
|
|
170
|
+
stats: Statistics dictionary
|
|
171
|
+
"""
|
|
172
|
+
readme_path = output_dir / "README.md"
|
|
173
|
+
|
|
174
|
+
content = f"""# EBK Library Export
|
|
175
|
+
|
|
176
|
+
This directory contains an export of an EBK library.
|
|
177
|
+
|
|
178
|
+
## Statistics
|
|
179
|
+
- Total entries: {stats.get('total_entries', 0)}
|
|
180
|
+
- Export date: {stats.get('export_date', 'Unknown')}
|
|
181
|
+
- Export type: {stats.get('export_type', 'Unknown')}
|
|
182
|
+
|
|
183
|
+
## Structure
|
|
184
|
+
{stats.get('structure_description', 'See directory contents for structure.')}
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
Generated by EBK Library Manager
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
with open(readme_path, 'w', encoding='utf-8') as f:
|
|
191
|
+
f.write(content)
|
|
192
|
+
|
|
193
|
+
@abstractmethod
|
|
194
|
+
def export(self, library_path: Path, output_path: Path, **options):
|
|
195
|
+
"""
|
|
196
|
+
Export the library.
|
|
197
|
+
|
|
198
|
+
This method must be implemented by subclasses.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
library_path: Path to source library
|
|
202
|
+
output_path: Path for output
|
|
203
|
+
**options: Additional export options
|
|
204
|
+
"""
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
def validate_export(self) -> bool:
|
|
208
|
+
"""
|
|
209
|
+
Validate that the export was successful.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True if validation passes
|
|
213
|
+
"""
|
|
214
|
+
if not self.output_path or not self.output_path.exists():
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
# Check if at least some files were created
|
|
218
|
+
return any(self.output_path.iterdir())
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ECHO format exporter for ebk e-book library.
|
|
3
|
+
|
|
4
|
+
Exports library in an ECHO-compliant directory structure with:
|
|
5
|
+
- README.md explaining the archive
|
|
6
|
+
- library.db (SQLite database copy)
|
|
7
|
+
- books.jsonl (one book per line)
|
|
8
|
+
- covers/ directory with cover images
|
|
9
|
+
- by-author/ directory with markdown indexes
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Dict, Any, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def export_echo(
|
|
20
|
+
library_path: Path,
|
|
21
|
+
output_dir: Path,
|
|
22
|
+
db_path: Optional[Path] = None,
|
|
23
|
+
entries: Optional[List[Dict[str, Any]]] = None,
|
|
24
|
+
owner_name: str = "Unknown"
|
|
25
|
+
) -> Dict[str, Any]:
|
|
26
|
+
"""
|
|
27
|
+
Export library to ECHO-compliant directory structure.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
library_path: Source library path
|
|
31
|
+
output_dir: Output directory
|
|
32
|
+
db_path: Path to SQLite database (optional, for copy)
|
|
33
|
+
entries: List of book entries (if not provided, reads from library)
|
|
34
|
+
owner_name: Name of archive owner for README
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Summary dict with export statistics
|
|
38
|
+
"""
|
|
39
|
+
output_path = Path(output_dir)
|
|
40
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
# Load entries if not provided
|
|
43
|
+
if entries is None:
|
|
44
|
+
metadata_path = library_path / "metadata.json"
|
|
45
|
+
if metadata_path.exists():
|
|
46
|
+
with open(metadata_path, 'r', encoding='utf-8') as f:
|
|
47
|
+
entries = json.load(f)
|
|
48
|
+
else:
|
|
49
|
+
entries = []
|
|
50
|
+
|
|
51
|
+
# Copy database
|
|
52
|
+
db_included = False
|
|
53
|
+
if db_path and db_path.exists():
|
|
54
|
+
shutil.copy2(db_path, output_path / "library.db")
|
|
55
|
+
db_included = True
|
|
56
|
+
|
|
57
|
+
# Export JSONL
|
|
58
|
+
jsonl_path = output_path / "books.jsonl"
|
|
59
|
+
with open(jsonl_path, "w", encoding="utf-8") as f:
|
|
60
|
+
for entry in entries:
|
|
61
|
+
record = {
|
|
62
|
+
"id": entry.get("id"),
|
|
63
|
+
"title": entry.get("title", "Unknown"),
|
|
64
|
+
"creators": entry.get("creators", []),
|
|
65
|
+
"language": entry.get("language"),
|
|
66
|
+
"publisher": entry.get("publisher"),
|
|
67
|
+
"published_date": entry.get("published_date"),
|
|
68
|
+
"isbn": entry.get("isbn"),
|
|
69
|
+
"subjects": entry.get("subjects", []),
|
|
70
|
+
"description": entry.get("description"),
|
|
71
|
+
"file_paths": entry.get("file_paths", []),
|
|
72
|
+
"file_formats": entry.get("file_formats", []),
|
|
73
|
+
"cover_path": entry.get("cover_path"),
|
|
74
|
+
"added_at": entry.get("added_at"),
|
|
75
|
+
"status": entry.get("status"),
|
|
76
|
+
"rating": entry.get("rating"),
|
|
77
|
+
"favorite": entry.get("favorite"),
|
|
78
|
+
"tags": entry.get("tags", []),
|
|
79
|
+
}
|
|
80
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
81
|
+
|
|
82
|
+
# Copy covers
|
|
83
|
+
covers_dir = output_path / "covers"
|
|
84
|
+
covers_dir.mkdir(exist_ok=True)
|
|
85
|
+
covers_copied = 0
|
|
86
|
+
|
|
87
|
+
for entry in entries:
|
|
88
|
+
cover_path = entry.get("cover_path")
|
|
89
|
+
if cover_path:
|
|
90
|
+
src_cover = library_path / cover_path
|
|
91
|
+
if src_cover.exists():
|
|
92
|
+
# Use entry ID as filename
|
|
93
|
+
entry_id = entry.get("id", "unknown")
|
|
94
|
+
suffix = src_cover.suffix or ".jpg"
|
|
95
|
+
dest_cover = covers_dir / f"{entry_id}{suffix}"
|
|
96
|
+
shutil.copy2(src_cover, dest_cover)
|
|
97
|
+
covers_copied += 1
|
|
98
|
+
|
|
99
|
+
# Create by-author index
|
|
100
|
+
by_author_dir = output_path / "by-author"
|
|
101
|
+
by_author_dir.mkdir(exist_ok=True)
|
|
102
|
+
|
|
103
|
+
author_books = {}
|
|
104
|
+
for entry in entries:
|
|
105
|
+
creators = entry.get("creators", [])
|
|
106
|
+
if not creators:
|
|
107
|
+
creators = ["Unknown"]
|
|
108
|
+
for author in creators:
|
|
109
|
+
if author not in author_books:
|
|
110
|
+
author_books[author] = []
|
|
111
|
+
author_books[author].append(entry)
|
|
112
|
+
|
|
113
|
+
for author, books in sorted(author_books.items()):
|
|
114
|
+
# Create safe filename
|
|
115
|
+
safe_name = "".join(c if c.isalnum() or c in " -_" else "_" for c in author)
|
|
116
|
+
safe_name = safe_name[:100].strip()
|
|
117
|
+
if not safe_name:
|
|
118
|
+
safe_name = "unknown"
|
|
119
|
+
|
|
120
|
+
md_path = by_author_dir / f"{safe_name}.md"
|
|
121
|
+
|
|
122
|
+
lines = [f"# {author}", "", f"Books by {author} ({len(books)} total)", ""]
|
|
123
|
+
|
|
124
|
+
for book in sorted(books, key=lambda x: x.get("title", "")):
|
|
125
|
+
title = book.get("title", "Unknown")
|
|
126
|
+
year = ""
|
|
127
|
+
pub_date = book.get("published_date")
|
|
128
|
+
if pub_date:
|
|
129
|
+
year = f" ({pub_date[:4]})" if len(pub_date) >= 4 else ""
|
|
130
|
+
|
|
131
|
+
lines.append(f"## {title}{year}")
|
|
132
|
+
lines.append("")
|
|
133
|
+
|
|
134
|
+
if book.get("description"):
|
|
135
|
+
desc = book["description"]
|
|
136
|
+
if len(desc) > 300:
|
|
137
|
+
desc = desc[:297] + "..."
|
|
138
|
+
lines.append(desc)
|
|
139
|
+
lines.append("")
|
|
140
|
+
|
|
141
|
+
formats = book.get("file_formats", [])
|
|
142
|
+
if formats:
|
|
143
|
+
lines.append(f"Formats: {', '.join(formats)}")
|
|
144
|
+
lines.append("")
|
|
145
|
+
|
|
146
|
+
with open(md_path, "w", encoding="utf-8") as f:
|
|
147
|
+
f.write("\n".join(lines))
|
|
148
|
+
|
|
149
|
+
# Generate README
|
|
150
|
+
readme_content = _generate_echo_readme(
|
|
151
|
+
owner_name=owner_name,
|
|
152
|
+
total_books=len(entries),
|
|
153
|
+
total_authors=len(author_books),
|
|
154
|
+
covers_included=covers_copied,
|
|
155
|
+
db_included=db_included
|
|
156
|
+
)
|
|
157
|
+
(output_path / "README.md").write_text(readme_content, encoding="utf-8")
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"total_exported": len(entries),
|
|
161
|
+
"covers_copied": covers_copied,
|
|
162
|
+
"authors": len(author_books),
|
|
163
|
+
"db_included": db_included,
|
|
164
|
+
"output_dir": str(output_path)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _generate_echo_readme(
|
|
169
|
+
owner_name: str,
|
|
170
|
+
total_books: int,
|
|
171
|
+
total_authors: int,
|
|
172
|
+
covers_included: int,
|
|
173
|
+
db_included: bool
|
|
174
|
+
) -> str:
|
|
175
|
+
"""Generate ECHO-compliant README for ebook archive."""
|
|
176
|
+
db_section = ""
|
|
177
|
+
if db_included:
|
|
178
|
+
db_section = """
|
|
179
|
+
### SQLite Database
|
|
180
|
+
|
|
181
|
+
The `library.db` file is a copy of the source database.
|
|
182
|
+
|
|
183
|
+
Key tables:
|
|
184
|
+
- `books`: id, title, language, publisher, published_date, isbn, ...
|
|
185
|
+
- `authors`: id, name
|
|
186
|
+
- `book_authors`: book_id, author_id (many-to-many)
|
|
187
|
+
- `subjects`: id, name
|
|
188
|
+
- `book_subjects`: book_id, subject_id
|
|
189
|
+
|
|
190
|
+
Query examples:
|
|
191
|
+
```sql
|
|
192
|
+
-- List all books
|
|
193
|
+
sqlite3 library.db "SELECT title, published_date FROM books ORDER BY title"
|
|
194
|
+
|
|
195
|
+
-- Books by author
|
|
196
|
+
sqlite3 library.db "SELECT b.title FROM books b
|
|
197
|
+
JOIN book_authors ba ON b.id = ba.book_id
|
|
198
|
+
JOIN authors a ON ba.author_id = a.id
|
|
199
|
+
WHERE a.name LIKE '%Tolkien%'"
|
|
200
|
+
```
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
return f"""# E-Book Library Archive
|
|
204
|
+
|
|
205
|
+
{owner_name}'s e-book collection.
|
|
206
|
+
|
|
207
|
+
Exported: {datetime.now(timezone.utc).strftime('%Y-%m-%d')}
|
|
208
|
+
Total books: {total_books}
|
|
209
|
+
Total authors: {total_authors}
|
|
210
|
+
Covers included: {covers_included}
|
|
211
|
+
|
|
212
|
+
## Format
|
|
213
|
+
|
|
214
|
+
This is an ECHO-compliant archive. All data is in durable, open formats.
|
|
215
|
+
|
|
216
|
+
### Directory Structure
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
├── README.md # This file
|
|
220
|
+
├── books.jsonl # One book per line
|
|
221
|
+
{"├── library.db # SQLite database" if db_included else ""}
|
|
222
|
+
├── covers/ # Cover images
|
|
223
|
+
│ └── {{id}}.jpg
|
|
224
|
+
└── by-author/ # Markdown index by author
|
|
225
|
+
├── author-name.md
|
|
226
|
+
└── ...
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### books.jsonl
|
|
230
|
+
|
|
231
|
+
Each line is a JSON object:
|
|
232
|
+
|
|
233
|
+
```json
|
|
234
|
+
{{"id": "...", "title": "...", "creators": ["..."], "subjects": [...], ...}}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Fields:
|
|
238
|
+
- `id`: Unique identifier
|
|
239
|
+
- `title`: Book title
|
|
240
|
+
- `creators`: Array of author names
|
|
241
|
+
- `language`: ISO language code
|
|
242
|
+
- `publisher`: Publisher name
|
|
243
|
+
- `published_date`: Publication date
|
|
244
|
+
- `isbn`: ISBN (if available)
|
|
245
|
+
- `subjects`: Array of subject/genre tags
|
|
246
|
+
- `description`: Book description
|
|
247
|
+
- `file_paths`: Relative paths to ebook files
|
|
248
|
+
- `file_formats`: Array of formats (epub, pdf, etc.)
|
|
249
|
+
- `cover_path`: Relative path to cover image
|
|
250
|
+
- `status`: Reading status (read, reading, to-read)
|
|
251
|
+
- `rating`: User rating (1-5)
|
|
252
|
+
- `favorite`: Boolean
|
|
253
|
+
- `tags`: User tags
|
|
254
|
+
{db_section}
|
|
255
|
+
### covers/ Directory
|
|
256
|
+
|
|
257
|
+
Cover images named by book ID. Original format preserved.
|
|
258
|
+
|
|
259
|
+
### by-author/ Directory
|
|
260
|
+
|
|
261
|
+
Markdown files for each author listing their books.
|
|
262
|
+
|
|
263
|
+
## Exploring
|
|
264
|
+
|
|
265
|
+
1. **Browse authors**: Look in `by-author/` directory
|
|
266
|
+
2. **Search**: `grep -l "search term" by-author/*.md`
|
|
267
|
+
3. **Parse**: Process `books.jsonl` with any JSON tool
|
|
268
|
+
4. **Query**: Use SQLite browser on `library.db` (if included)
|
|
269
|
+
5. **View covers**: Browse `covers/` directory
|
|
270
|
+
|
|
271
|
+
## About ECHO
|
|
272
|
+
|
|
273
|
+
ECHO is a philosophy for durable personal data archives.
|
|
274
|
+
Learn more: https://github.com/alextowell/longecho
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
*Generated by ebk (E-Book Library Manager)*
|
|
279
|
+
"""
|