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.

Files changed (84) hide show
  1. ebk/__init__.py +35 -0
  2. ebk/ai/__init__.py +23 -0
  3. ebk/ai/knowledge_graph.py +443 -0
  4. ebk/ai/llm_providers/__init__.py +21 -0
  5. ebk/ai/llm_providers/base.py +230 -0
  6. ebk/ai/llm_providers/ollama.py +362 -0
  7. ebk/ai/metadata_enrichment.py +396 -0
  8. ebk/ai/question_generator.py +328 -0
  9. ebk/ai/reading_companion.py +224 -0
  10. ebk/ai/semantic_search.py +434 -0
  11. ebk/ai/text_extractor.py +394 -0
  12. ebk/cli.py +2828 -680
  13. ebk/config.py +260 -22
  14. ebk/db/__init__.py +37 -0
  15. ebk/db/migrations.py +180 -0
  16. ebk/db/models.py +526 -0
  17. ebk/db/session.py +144 -0
  18. ebk/decorators.py +132 -0
  19. ebk/exports/base_exporter.py +218 -0
  20. ebk/exports/html_library.py +1390 -0
  21. ebk/exports/html_utils.py +117 -0
  22. ebk/exports/hugo.py +7 -3
  23. ebk/exports/jinja_export.py +287 -0
  24. ebk/exports/multi_facet_export.py +164 -0
  25. ebk/exports/symlink_dag.py +479 -0
  26. ebk/extract_metadata.py +76 -7
  27. ebk/library_db.py +899 -0
  28. ebk/plugins/__init__.py +42 -0
  29. ebk/plugins/base.py +502 -0
  30. ebk/plugins/hooks.py +444 -0
  31. ebk/plugins/registry.py +500 -0
  32. ebk/repl/__init__.py +9 -0
  33. ebk/repl/find.py +126 -0
  34. ebk/repl/grep.py +174 -0
  35. ebk/repl/shell.py +1677 -0
  36. ebk/repl/text_utils.py +320 -0
  37. ebk/search_parser.py +413 -0
  38. ebk/server.py +1633 -0
  39. ebk/services/__init__.py +11 -0
  40. ebk/services/import_service.py +442 -0
  41. ebk/services/tag_service.py +282 -0
  42. ebk/services/text_extraction.py +317 -0
  43. ebk/similarity/__init__.py +77 -0
  44. ebk/similarity/base.py +154 -0
  45. ebk/similarity/core.py +445 -0
  46. ebk/similarity/extractors.py +168 -0
  47. ebk/similarity/metrics.py +376 -0
  48. ebk/vfs/__init__.py +101 -0
  49. ebk/vfs/base.py +301 -0
  50. ebk/vfs/library_vfs.py +124 -0
  51. ebk/vfs/nodes/__init__.py +54 -0
  52. ebk/vfs/nodes/authors.py +196 -0
  53. ebk/vfs/nodes/books.py +480 -0
  54. ebk/vfs/nodes/files.py +155 -0
  55. ebk/vfs/nodes/metadata.py +385 -0
  56. ebk/vfs/nodes/root.py +100 -0
  57. ebk/vfs/nodes/similar.py +165 -0
  58. ebk/vfs/nodes/subjects.py +184 -0
  59. ebk/vfs/nodes/tags.py +371 -0
  60. ebk/vfs/resolver.py +228 -0
  61. ebk-0.3.2.dist-info/METADATA +755 -0
  62. ebk-0.3.2.dist-info/RECORD +69 -0
  63. {ebk-0.1.0.dist-info → ebk-0.3.2.dist-info}/WHEEL +1 -1
  64. ebk-0.3.2.dist-info/licenses/LICENSE +21 -0
  65. ebk/imports/__init__.py +0 -0
  66. ebk/imports/calibre.py +0 -144
  67. ebk/imports/ebooks.py +0 -116
  68. ebk/llm.py +0 -58
  69. ebk/manager.py +0 -44
  70. ebk/merge.py +0 -308
  71. ebk/streamlit/__init__.py +0 -0
  72. ebk/streamlit/__pycache__/__init__.cpython-310.pyc +0 -0
  73. ebk/streamlit/__pycache__/display.cpython-310.pyc +0 -0
  74. ebk/streamlit/__pycache__/filters.cpython-310.pyc +0 -0
  75. ebk/streamlit/__pycache__/utils.cpython-310.pyc +0 -0
  76. ebk/streamlit/app.py +0 -185
  77. ebk/streamlit/display.py +0 -168
  78. ebk/streamlit/filters.py +0 -151
  79. ebk/streamlit/utils.py +0 -58
  80. ebk/utils.py +0 -311
  81. ebk-0.1.0.dist-info/METADATA +0 -457
  82. ebk-0.1.0.dist-info/RECORD +0 -29
  83. {ebk-0.1.0.dist-info → ebk-0.3.2.dist-info}/entry_points.txt +0 -0
  84. {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 ~/.btkrc.
260
+ Loads configuration from ~/.ebkrc (legacy).
7
261
 
8
- If using LLM interface, expects a section [llm] with at least 'endpoint' and 'api_key'.
9
- If using cloud interface (for generating complex networks), the section [cloud] may be used to specify various parameters.
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
- raise FileNotFoundError(f"Could not find config file at {config_path}")
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)