ebk 0.4.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. ebk/__init__.py +35 -0
  2. ebk/ai/__init__.py +23 -0
  3. ebk/ai/knowledge_graph.py +450 -0
  4. ebk/ai/llm_providers/__init__.py +26 -0
  5. ebk/ai/llm_providers/anthropic.py +209 -0
  6. ebk/ai/llm_providers/base.py +295 -0
  7. ebk/ai/llm_providers/gemini.py +285 -0
  8. ebk/ai/llm_providers/ollama.py +294 -0
  9. ebk/ai/metadata_enrichment.py +394 -0
  10. ebk/ai/question_generator.py +328 -0
  11. ebk/ai/reading_companion.py +224 -0
  12. ebk/ai/semantic_search.py +433 -0
  13. ebk/ai/text_extractor.py +393 -0
  14. ebk/calibre_import.py +66 -0
  15. ebk/cli.py +6433 -0
  16. ebk/config.py +230 -0
  17. ebk/db/__init__.py +37 -0
  18. ebk/db/migrations.py +507 -0
  19. ebk/db/models.py +725 -0
  20. ebk/db/session.py +144 -0
  21. ebk/decorators.py +1 -0
  22. ebk/exports/__init__.py +0 -0
  23. ebk/exports/base_exporter.py +218 -0
  24. ebk/exports/echo_export.py +279 -0
  25. ebk/exports/html_library.py +1743 -0
  26. ebk/exports/html_utils.py +87 -0
  27. ebk/exports/hugo.py +59 -0
  28. ebk/exports/jinja_export.py +286 -0
  29. ebk/exports/multi_facet_export.py +159 -0
  30. ebk/exports/opds_export.py +232 -0
  31. ebk/exports/symlink_dag.py +479 -0
  32. ebk/exports/zip.py +25 -0
  33. ebk/extract_metadata.py +341 -0
  34. ebk/ident.py +89 -0
  35. ebk/library_db.py +1440 -0
  36. ebk/opds.py +748 -0
  37. ebk/plugins/__init__.py +42 -0
  38. ebk/plugins/base.py +502 -0
  39. ebk/plugins/hooks.py +442 -0
  40. ebk/plugins/registry.py +499 -0
  41. ebk/repl/__init__.py +9 -0
  42. ebk/repl/find.py +126 -0
  43. ebk/repl/grep.py +173 -0
  44. ebk/repl/shell.py +1677 -0
  45. ebk/repl/text_utils.py +320 -0
  46. ebk/search_parser.py +413 -0
  47. ebk/server.py +3608 -0
  48. ebk/services/__init__.py +28 -0
  49. ebk/services/annotation_extraction.py +351 -0
  50. ebk/services/annotation_service.py +380 -0
  51. ebk/services/export_service.py +577 -0
  52. ebk/services/import_service.py +447 -0
  53. ebk/services/personal_metadata_service.py +347 -0
  54. ebk/services/queue_service.py +253 -0
  55. ebk/services/tag_service.py +281 -0
  56. ebk/services/text_extraction.py +317 -0
  57. ebk/services/view_service.py +12 -0
  58. ebk/similarity/__init__.py +77 -0
  59. ebk/similarity/base.py +154 -0
  60. ebk/similarity/core.py +471 -0
  61. ebk/similarity/extractors.py +168 -0
  62. ebk/similarity/metrics.py +376 -0
  63. ebk/skills/SKILL.md +182 -0
  64. ebk/skills/__init__.py +1 -0
  65. ebk/vfs/__init__.py +101 -0
  66. ebk/vfs/base.py +298 -0
  67. ebk/vfs/library_vfs.py +122 -0
  68. ebk/vfs/nodes/__init__.py +54 -0
  69. ebk/vfs/nodes/authors.py +196 -0
  70. ebk/vfs/nodes/books.py +480 -0
  71. ebk/vfs/nodes/files.py +155 -0
  72. ebk/vfs/nodes/metadata.py +385 -0
  73. ebk/vfs/nodes/root.py +100 -0
  74. ebk/vfs/nodes/similar.py +165 -0
  75. ebk/vfs/nodes/subjects.py +184 -0
  76. ebk/vfs/nodes/tags.py +371 -0
  77. ebk/vfs/resolver.py +228 -0
  78. ebk/vfs_router.py +275 -0
  79. ebk/views/__init__.py +32 -0
  80. ebk/views/dsl.py +668 -0
  81. ebk/views/service.py +619 -0
  82. ebk-0.4.4.dist-info/METADATA +755 -0
  83. ebk-0.4.4.dist-info/RECORD +87 -0
  84. ebk-0.4.4.dist-info/WHEEL +5 -0
  85. ebk-0.4.4.dist-info/entry_points.txt +2 -0
  86. ebk-0.4.4.dist-info/licenses/LICENSE +21 -0
  87. ebk-0.4.4.dist-info/top_level.txt +1 -0
ebk/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."""
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
+ """