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/config.py
CHANGED
|
@@ -1,35 +1,273 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for EBK.
|
|
3
|
+
|
|
4
|
+
Handles loading and saving user configuration from:
|
|
5
|
+
- XDG config directory: ~/.config/ebk/config.json
|
|
6
|
+
- Fallback: ~/.ebk/config.json
|
|
7
|
+
- Legacy: ~/.ebkrc (for backward compatibility)
|
|
8
|
+
"""
|
|
9
|
+
|
|
1
10
|
import configparser
|
|
11
|
+
import json
|
|
2
12
|
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, Any, Optional
|
|
15
|
+
from dataclasses import dataclass, asdict, field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class LLMConfig:
|
|
20
|
+
"""LLM provider configuration."""
|
|
21
|
+
provider: str = "ollama"
|
|
22
|
+
model: str = "llama3.2"
|
|
23
|
+
host: str = "localhost"
|
|
24
|
+
port: int = 11434
|
|
25
|
+
api_key: Optional[str] = None
|
|
26
|
+
temperature: float = 0.7
|
|
27
|
+
max_tokens: Optional[int] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ServerConfig:
|
|
32
|
+
"""Web server configuration."""
|
|
33
|
+
host: str = "0.0.0.0"
|
|
34
|
+
port: int = 8000
|
|
35
|
+
auto_open_browser: bool = False
|
|
36
|
+
page_size: int = 50
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class CLIConfig:
|
|
41
|
+
"""CLI default options."""
|
|
42
|
+
verbose: bool = False
|
|
43
|
+
color: bool = True
|
|
44
|
+
page_size: int = 50
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class LibraryConfig:
|
|
49
|
+
"""Library-related settings."""
|
|
50
|
+
default_path: Optional[str] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class EBKConfig:
|
|
55
|
+
"""Main EBK configuration."""
|
|
56
|
+
llm: LLMConfig = field(default_factory=LLMConfig)
|
|
57
|
+
server: ServerConfig = field(default_factory=ServerConfig)
|
|
58
|
+
cli: CLIConfig = field(default_factory=CLIConfig)
|
|
59
|
+
library: LibraryConfig = field(default_factory=LibraryConfig)
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
62
|
+
"""Convert to dictionary."""
|
|
63
|
+
return {
|
|
64
|
+
"llm": asdict(self.llm),
|
|
65
|
+
"server": asdict(self.server),
|
|
66
|
+
"cli": asdict(self.cli),
|
|
67
|
+
"library": asdict(self.library),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'EBKConfig':
|
|
72
|
+
"""Create from dictionary."""
|
|
73
|
+
llm_data = data.get("llm", {})
|
|
74
|
+
server_data = data.get("server", {})
|
|
75
|
+
cli_data = data.get("cli", {})
|
|
76
|
+
library_data = data.get("library", {})
|
|
77
|
+
return cls(
|
|
78
|
+
llm=LLMConfig(**llm_data),
|
|
79
|
+
server=ServerConfig(**server_data),
|
|
80
|
+
cli=CLIConfig(**cli_data),
|
|
81
|
+
library=LibraryConfig(**library_data),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_config_path() -> Path:
|
|
86
|
+
"""
|
|
87
|
+
Get configuration file path.
|
|
88
|
+
|
|
89
|
+
Follows XDG Base Directory specification:
|
|
90
|
+
1. $XDG_CONFIG_HOME/ebk/config.json (usually ~/.config/ebk/config.json)
|
|
91
|
+
2. Fallback: ~/.ebk/config.json
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Path to config file
|
|
95
|
+
"""
|
|
96
|
+
# Try XDG config directory first
|
|
97
|
+
xdg_config_home = Path.home() / ".config"
|
|
98
|
+
if xdg_config_home.exists():
|
|
99
|
+
config_dir = xdg_config_home / "ebk"
|
|
100
|
+
else:
|
|
101
|
+
# Fallback to ~/.ebk
|
|
102
|
+
config_dir = Path.home() / ".ebk"
|
|
103
|
+
|
|
104
|
+
return config_dir / "config.json"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_config() -> EBKConfig:
|
|
108
|
+
"""
|
|
109
|
+
Load configuration from file.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
EBKConfig instance with loaded values or defaults
|
|
113
|
+
"""
|
|
114
|
+
config_path = get_config_path()
|
|
115
|
+
|
|
116
|
+
if not config_path.exists():
|
|
117
|
+
# Return default config
|
|
118
|
+
return EBKConfig()
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
with open(config_path, 'r') as f:
|
|
122
|
+
data = json.load(f)
|
|
123
|
+
return EBKConfig.from_dict(data)
|
|
124
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
125
|
+
print(f"Warning: Failed to load config from {config_path}: {e}")
|
|
126
|
+
print("Using default configuration")
|
|
127
|
+
return EBKConfig()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def save_config(config: EBKConfig) -> None:
|
|
131
|
+
"""
|
|
132
|
+
Save configuration to file.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
config: Configuration to save
|
|
136
|
+
"""
|
|
137
|
+
config_path = get_config_path()
|
|
138
|
+
|
|
139
|
+
# Create directory if it doesn't exist
|
|
140
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
# Write config
|
|
143
|
+
with open(config_path, 'w') as f:
|
|
144
|
+
json.dump(config.to_dict(), f, indent=2)
|
|
145
|
+
|
|
146
|
+
print(f"Configuration saved to {config_path}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def ensure_config_exists() -> Path:
|
|
150
|
+
"""
|
|
151
|
+
Ensure configuration file exists, creating with defaults if not.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Path to config file
|
|
155
|
+
"""
|
|
156
|
+
config_path = get_config_path()
|
|
157
|
+
|
|
158
|
+
if not config_path.exists():
|
|
159
|
+
config = EBKConfig()
|
|
160
|
+
save_config(config)
|
|
161
|
+
print(f"Created default configuration at {config_path}")
|
|
162
|
+
|
|
163
|
+
return config_path
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def update_config(
|
|
167
|
+
# LLM settings
|
|
168
|
+
llm_provider: Optional[str] = None,
|
|
169
|
+
llm_model: Optional[str] = None,
|
|
170
|
+
llm_host: Optional[str] = None,
|
|
171
|
+
llm_port: Optional[int] = None,
|
|
172
|
+
llm_api_key: Optional[str] = None,
|
|
173
|
+
llm_temperature: Optional[float] = None,
|
|
174
|
+
llm_max_tokens: Optional[int] = None,
|
|
175
|
+
# Server settings
|
|
176
|
+
server_host: Optional[str] = None,
|
|
177
|
+
server_port: Optional[int] = None,
|
|
178
|
+
server_auto_open: Optional[bool] = None,
|
|
179
|
+
server_page_size: Optional[int] = None,
|
|
180
|
+
# CLI settings
|
|
181
|
+
cli_verbose: Optional[bool] = None,
|
|
182
|
+
cli_color: Optional[bool] = None,
|
|
183
|
+
cli_page_size: Optional[int] = None,
|
|
184
|
+
# Library settings
|
|
185
|
+
library_default_path: Optional[str] = None,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""
|
|
188
|
+
Update configuration.
|
|
3
189
|
|
|
190
|
+
Only updates provided values, leaving others unchanged.
|
|
191
|
+
"""
|
|
192
|
+
config = load_config()
|
|
193
|
+
|
|
194
|
+
# Update LLM config
|
|
195
|
+
if llm_provider is not None:
|
|
196
|
+
config.llm.provider = llm_provider
|
|
197
|
+
if llm_model is not None:
|
|
198
|
+
config.llm.model = llm_model
|
|
199
|
+
if llm_host is not None:
|
|
200
|
+
config.llm.host = llm_host
|
|
201
|
+
if llm_port is not None:
|
|
202
|
+
config.llm.port = llm_port
|
|
203
|
+
if llm_api_key is not None:
|
|
204
|
+
config.llm.api_key = llm_api_key
|
|
205
|
+
if llm_temperature is not None:
|
|
206
|
+
config.llm.temperature = llm_temperature
|
|
207
|
+
if llm_max_tokens is not None:
|
|
208
|
+
config.llm.max_tokens = llm_max_tokens
|
|
209
|
+
|
|
210
|
+
# Update server config
|
|
211
|
+
if server_host is not None:
|
|
212
|
+
config.server.host = server_host
|
|
213
|
+
if server_port is not None:
|
|
214
|
+
config.server.port = server_port
|
|
215
|
+
if server_auto_open is not None:
|
|
216
|
+
config.server.auto_open_browser = server_auto_open
|
|
217
|
+
if server_page_size is not None:
|
|
218
|
+
config.server.page_size = server_page_size
|
|
219
|
+
|
|
220
|
+
# Update CLI config
|
|
221
|
+
if cli_verbose is not None:
|
|
222
|
+
config.cli.verbose = cli_verbose
|
|
223
|
+
if cli_color is not None:
|
|
224
|
+
config.cli.color = cli_color
|
|
225
|
+
if cli_page_size is not None:
|
|
226
|
+
config.cli.page_size = cli_page_size
|
|
227
|
+
|
|
228
|
+
# Update library config
|
|
229
|
+
if library_default_path is not None:
|
|
230
|
+
config.library.default_path = library_default_path
|
|
231
|
+
|
|
232
|
+
save_config(config)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# Backward compatibility
|
|
236
|
+
def update_llm_config(
|
|
237
|
+
provider: Optional[str] = None,
|
|
238
|
+
model: Optional[str] = None,
|
|
239
|
+
host: Optional[str] = None,
|
|
240
|
+
port: Optional[int] = None,
|
|
241
|
+
api_key: Optional[str] = None,
|
|
242
|
+
temperature: Optional[float] = None,
|
|
243
|
+
max_tokens: Optional[int] = None
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Update LLM configuration (legacy function)."""
|
|
246
|
+
update_config(
|
|
247
|
+
llm_provider=provider,
|
|
248
|
+
llm_model=model,
|
|
249
|
+
llm_host=host,
|
|
250
|
+
llm_port=port,
|
|
251
|
+
llm_api_key=api_key,
|
|
252
|
+
llm_temperature=temperature,
|
|
253
|
+
llm_max_tokens=max_tokens,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# Legacy support for ~/.ebkrc
|
|
4
258
|
def load_ebkrc_config():
|
|
5
259
|
"""
|
|
6
|
-
Loads configuration from ~/.
|
|
260
|
+
Loads configuration from ~/.ebkrc (legacy).
|
|
7
261
|
|
|
8
|
-
|
|
9
|
-
|
|
262
|
+
The configuration file can contain various sections for different features.
|
|
263
|
+
For example, [streamlit] section for dashboard configuration.
|
|
10
264
|
"""
|
|
11
265
|
config_path = os.path.expanduser("~/.ebkrc")
|
|
12
266
|
parser = configparser.ConfigParser()
|
|
13
267
|
|
|
14
268
|
if not os.path.exists(config_path):
|
|
15
|
-
|
|
269
|
+
# Config file is optional
|
|
270
|
+
return parser
|
|
16
271
|
|
|
17
272
|
parser.read(config_path)
|
|
18
|
-
|
|
19
|
-
if "llm" not in parser:
|
|
20
|
-
raise ValueError(
|
|
21
|
-
"Config file ~/.btkrc is missing the [llm] section. "
|
|
22
|
-
"Please add it with 'endpoint' and 'api_key' keys."
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
endpoint = parser["llm"].get("endpoint", "")
|
|
26
|
-
api_key = parser["llm"].get("api_key", "")
|
|
27
|
-
model = parser["llm"].get("model", "gpt-3.5-turbo")
|
|
28
|
-
|
|
29
|
-
if not endpoint or not api_key or not model:
|
|
30
|
-
raise ValueError(
|
|
31
|
-
"Please make sure your [llm] section in ~/.btkrc "
|
|
32
|
-
"includes 'endpoint', 'api_key', and 'model' keys."
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
return endpoint, api_key, model
|
|
273
|
+
return parser
|
ebk/db/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database module for ebk.
|
|
3
|
+
|
|
4
|
+
Provides SQLAlchemy session management and initialization.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .models import (
|
|
8
|
+
Base, Book, Author, Subject, Identifier, File, ExtractedText,
|
|
9
|
+
TextChunk, Cover, Concept, BookConcept, ConceptRelation,
|
|
10
|
+
ReadingSession, Annotation, PersonalMetadata, Tag
|
|
11
|
+
)
|
|
12
|
+
from .session import get_session, init_db, close_db
|
|
13
|
+
from .migrations import run_all_migrations, check_migrations
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
'Base',
|
|
17
|
+
'Book',
|
|
18
|
+
'Author',
|
|
19
|
+
'Subject',
|
|
20
|
+
'Identifier',
|
|
21
|
+
'File',
|
|
22
|
+
'ExtractedText',
|
|
23
|
+
'TextChunk',
|
|
24
|
+
'Cover',
|
|
25
|
+
'Concept',
|
|
26
|
+
'BookConcept',
|
|
27
|
+
'ConceptRelation',
|
|
28
|
+
'ReadingSession',
|
|
29
|
+
'Annotation',
|
|
30
|
+
'PersonalMetadata',
|
|
31
|
+
'Tag',
|
|
32
|
+
'get_session',
|
|
33
|
+
'init_db',
|
|
34
|
+
'close_db',
|
|
35
|
+
'run_all_migrations',
|
|
36
|
+
'check_migrations'
|
|
37
|
+
]
|
ebk/db/migrations.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database migration utilities for ebk.
|
|
3
|
+
|
|
4
|
+
Since this project uses SQLAlchemy's create_all() approach rather than Alembic,
|
|
5
|
+
this module provides simple migration functions for schema changes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from sqlalchemy import create_engine, text, inspect
|
|
10
|
+
from sqlalchemy.engine import Engine
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_engine(library_path: Path) -> Engine:
|
|
19
|
+
"""Get database engine for a library."""
|
|
20
|
+
db_path = library_path / 'library.db'
|
|
21
|
+
if not db_path.exists():
|
|
22
|
+
raise FileNotFoundError(f"Database not found at {db_path}")
|
|
23
|
+
|
|
24
|
+
db_url = f'sqlite:///{db_path}'
|
|
25
|
+
return create_engine(db_url, echo=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def table_exists(engine: Engine, table_name: str) -> bool:
|
|
29
|
+
"""Check if a table exists in the database."""
|
|
30
|
+
inspector = inspect(engine)
|
|
31
|
+
return table_name in inspector.get_table_names()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def migrate_add_tags(library_path: Path, dry_run: bool = False) -> bool:
|
|
35
|
+
"""
|
|
36
|
+
Add tags table and book_tags association table to existing database.
|
|
37
|
+
|
|
38
|
+
This migration adds support for hierarchical user-defined tags,
|
|
39
|
+
separate from bibliographic subjects.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
library_path: Path to library directory
|
|
43
|
+
dry_run: If True, only check if migration is needed
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if migration was applied (or would be applied in dry_run),
|
|
47
|
+
False if already up-to-date
|
|
48
|
+
"""
|
|
49
|
+
engine = get_engine(library_path)
|
|
50
|
+
|
|
51
|
+
# Check if migration is needed
|
|
52
|
+
if table_exists(engine, 'tags'):
|
|
53
|
+
logger.info("Tags table already exists, skipping migration")
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
if dry_run:
|
|
57
|
+
logger.info("Migration needed: tags table does not exist")
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
logger.info("Applying migration: Adding tags table and book_tags association")
|
|
61
|
+
|
|
62
|
+
with engine.begin() as conn:
|
|
63
|
+
# Create tags table
|
|
64
|
+
conn.execute(text("""
|
|
65
|
+
CREATE TABLE tags (
|
|
66
|
+
id INTEGER NOT NULL PRIMARY KEY,
|
|
67
|
+
name VARCHAR(200) NOT NULL,
|
|
68
|
+
path VARCHAR(500) NOT NULL UNIQUE,
|
|
69
|
+
parent_id INTEGER,
|
|
70
|
+
description TEXT,
|
|
71
|
+
color VARCHAR(7),
|
|
72
|
+
created_at DATETIME NOT NULL,
|
|
73
|
+
FOREIGN KEY(parent_id) REFERENCES tags (id) ON DELETE CASCADE
|
|
74
|
+
)
|
|
75
|
+
"""))
|
|
76
|
+
|
|
77
|
+
# Create indexes on tags table
|
|
78
|
+
conn.execute(text("CREATE INDEX idx_tag_path ON tags (path)"))
|
|
79
|
+
conn.execute(text("CREATE INDEX idx_tag_parent ON tags (parent_id)"))
|
|
80
|
+
conn.execute(text("CREATE INDEX ix_tags_name ON tags (name)"))
|
|
81
|
+
|
|
82
|
+
# Create book_tags association table
|
|
83
|
+
conn.execute(text("""
|
|
84
|
+
CREATE TABLE book_tags (
|
|
85
|
+
book_id INTEGER NOT NULL,
|
|
86
|
+
tag_id INTEGER NOT NULL,
|
|
87
|
+
created_at DATETIME,
|
|
88
|
+
PRIMARY KEY (book_id, tag_id),
|
|
89
|
+
FOREIGN KEY(book_id) REFERENCES books (id) ON DELETE CASCADE,
|
|
90
|
+
FOREIGN KEY(tag_id) REFERENCES tags (id) ON DELETE CASCADE
|
|
91
|
+
)
|
|
92
|
+
"""))
|
|
93
|
+
|
|
94
|
+
logger.info("Migration completed successfully")
|
|
95
|
+
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def migrate_add_book_color(library_path: Path, dry_run: bool = False) -> bool:
|
|
100
|
+
"""
|
|
101
|
+
Add color column to books table.
|
|
102
|
+
|
|
103
|
+
This migration adds a color field to books for user customization.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
library_path: Path to library directory
|
|
107
|
+
dry_run: If True, only check if migration is needed
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
True if migration was applied (or would be applied in dry_run),
|
|
111
|
+
False if already up-to-date
|
|
112
|
+
"""
|
|
113
|
+
engine = get_engine(library_path)
|
|
114
|
+
inspector = inspect(engine)
|
|
115
|
+
|
|
116
|
+
# Check if migration is needed
|
|
117
|
+
if 'books' not in inspector.get_table_names():
|
|
118
|
+
logger.error("Books table does not exist")
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
columns = [col['name'] for col in inspector.get_columns('books')]
|
|
122
|
+
if 'color' in columns:
|
|
123
|
+
logger.info("Books.color column already exists, skipping migration")
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
if dry_run:
|
|
127
|
+
logger.info("Migration needed: books.color column does not exist")
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
logger.info("Applying migration: Adding color column to books table")
|
|
131
|
+
|
|
132
|
+
with engine.begin() as conn:
|
|
133
|
+
conn.execute(text("ALTER TABLE books ADD COLUMN color VARCHAR(7)"))
|
|
134
|
+
logger.info("Migration completed successfully")
|
|
135
|
+
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def run_all_migrations(library_path: Path, dry_run: bool = False) -> dict:
|
|
140
|
+
"""
|
|
141
|
+
Run all pending migrations on a library database.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
library_path: Path to library directory
|
|
145
|
+
dry_run: If True, only check which migrations are needed
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Dict mapping migration name to whether it was applied
|
|
149
|
+
"""
|
|
150
|
+
results = {}
|
|
151
|
+
|
|
152
|
+
# Add future migrations here
|
|
153
|
+
migrations = [
|
|
154
|
+
('add_tags', migrate_add_tags),
|
|
155
|
+
('add_book_color', migrate_add_book_color),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
for name, migration_func in migrations:
|
|
159
|
+
try:
|
|
160
|
+
applied = migration_func(library_path, dry_run=dry_run)
|
|
161
|
+
results[name] = applied
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.error(f"Migration '{name}' failed: {e}")
|
|
164
|
+
results[name] = False
|
|
165
|
+
raise
|
|
166
|
+
|
|
167
|
+
return results
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def check_migrations(library_path: Path) -> dict:
|
|
171
|
+
"""
|
|
172
|
+
Check which migrations need to be applied.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
library_path: Path to library directory
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Dict mapping migration name to whether it's needed
|
|
179
|
+
"""
|
|
180
|
+
return run_all_migrations(library_path, dry_run=True)
|