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/decorators.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Decorators for ebk functionality."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable, Any
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def handle_library_errors(func: Callable) -> Callable:
|
|
15
|
+
"""
|
|
16
|
+
Decorator to handle common library operation errors.
|
|
17
|
+
|
|
18
|
+
Reduces code duplication by centralizing error handling for:
|
|
19
|
+
- FileNotFoundError: Library doesn't exist
|
|
20
|
+
- PermissionError: No access to files
|
|
21
|
+
- ValueError: Invalid data or arguments
|
|
22
|
+
- General exceptions: Unexpected errors
|
|
23
|
+
"""
|
|
24
|
+
@functools.wraps(func)
|
|
25
|
+
def wrapper(*args, **kwargs) -> Any:
|
|
26
|
+
try:
|
|
27
|
+
return func(*args, **kwargs)
|
|
28
|
+
except FileNotFoundError as e:
|
|
29
|
+
console.print(f"[bold red]Error:[/bold red] Library or file not found: {e}")
|
|
30
|
+
raise typer.Exit(code=1)
|
|
31
|
+
except PermissionError as e:
|
|
32
|
+
console.print(f"[bold red]Error:[/bold red] Permission denied: {e}")
|
|
33
|
+
console.print("[yellow]Tip: Check file permissions or run with appropriate privileges[/yellow]")
|
|
34
|
+
raise typer.Exit(code=1)
|
|
35
|
+
except ValueError as e:
|
|
36
|
+
console.print(f"[bold red]Error:[/bold red] Invalid input: {e}")
|
|
37
|
+
raise typer.Exit(code=1)
|
|
38
|
+
except KeyboardInterrupt:
|
|
39
|
+
console.print("\n[yellow]Operation cancelled by user[/yellow]")
|
|
40
|
+
raise typer.Exit(code=130)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
logger.error(f"Unexpected error in {func.__name__}: {e}", exc_info=True)
|
|
43
|
+
console.print(f"[bold red]Unexpected error:[/bold red] {e}")
|
|
44
|
+
console.print("[dim]See log file for details[/dim]")
|
|
45
|
+
raise typer.Exit(code=1)
|
|
46
|
+
|
|
47
|
+
return wrapper
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_path(path_type: str = "directory") -> Callable:
|
|
51
|
+
"""
|
|
52
|
+
Decorator to validate and sanitize file paths for security.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
path_type: Either "directory" or "file"
|
|
56
|
+
|
|
57
|
+
Prevents:
|
|
58
|
+
- Path traversal attacks
|
|
59
|
+
- Access to system directories
|
|
60
|
+
- Symbolic link attacks
|
|
61
|
+
"""
|
|
62
|
+
def decorator(func: Callable) -> Callable:
|
|
63
|
+
@functools.wraps(func)
|
|
64
|
+
def wrapper(*args, **kwargs) -> Any:
|
|
65
|
+
# Find path arguments (usually first positional arg)
|
|
66
|
+
if args:
|
|
67
|
+
path = Path(args[0]).resolve()
|
|
68
|
+
|
|
69
|
+
# Security checks
|
|
70
|
+
try:
|
|
71
|
+
# Ensure path is within current directory or explicitly allowed
|
|
72
|
+
cwd = Path.cwd()
|
|
73
|
+
home = Path.home()
|
|
74
|
+
|
|
75
|
+
# Check if path is trying to escape to system directories
|
|
76
|
+
if path.parts[0] in ('/', '\\') and not (
|
|
77
|
+
path.is_relative_to(cwd) or
|
|
78
|
+
path.is_relative_to(home)
|
|
79
|
+
):
|
|
80
|
+
raise ValueError(f"Access to system path not allowed: {path}")
|
|
81
|
+
|
|
82
|
+
# Check for suspicious patterns
|
|
83
|
+
suspicious_patterns = ['../', '...', '~/', '/etc/', '/usr/', '/bin/', '/sys/']
|
|
84
|
+
path_str = str(path)
|
|
85
|
+
for pattern in suspicious_patterns:
|
|
86
|
+
if pattern in path_str and not path.is_relative_to(home):
|
|
87
|
+
raise ValueError(f"Suspicious path pattern detected: {pattern}")
|
|
88
|
+
|
|
89
|
+
# Validate based on type
|
|
90
|
+
if path_type == "directory":
|
|
91
|
+
if path.exists() and not path.is_dir():
|
|
92
|
+
raise ValueError(f"Path exists but is not a directory: {path}")
|
|
93
|
+
elif path_type == "file":
|
|
94
|
+
if path.exists() and not path.is_file():
|
|
95
|
+
raise ValueError(f"Path exists but is not a file: {path}")
|
|
96
|
+
|
|
97
|
+
# Replace the path with the resolved, safe version
|
|
98
|
+
args = (str(path),) + args[1:]
|
|
99
|
+
|
|
100
|
+
except ValueError as e:
|
|
101
|
+
console.print(f"[bold red]Security Error:[/bold red] {e}")
|
|
102
|
+
raise typer.Exit(code=1)
|
|
103
|
+
|
|
104
|
+
return func(*args, **kwargs)
|
|
105
|
+
|
|
106
|
+
return wrapper
|
|
107
|
+
return decorator
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def require_confirmation(message: str = "Are you sure you want to continue?") -> Callable:
|
|
111
|
+
"""
|
|
112
|
+
Decorator to require user confirmation for destructive operations.
|
|
113
|
+
"""
|
|
114
|
+
def decorator(func: Callable) -> Callable:
|
|
115
|
+
@functools.wraps(func)
|
|
116
|
+
def wrapper(*args, **kwargs) -> Any:
|
|
117
|
+
# Check if --yes flag was passed (common pattern)
|
|
118
|
+
if kwargs.get('yes', False):
|
|
119
|
+
return func(*args, **kwargs)
|
|
120
|
+
|
|
121
|
+
# Ask for confirmation
|
|
122
|
+
console.print(f"[yellow]⚠️ {message}[/yellow]")
|
|
123
|
+
response = typer.confirm("Continue?")
|
|
124
|
+
|
|
125
|
+
if not response:
|
|
126
|
+
console.print("[red]Operation cancelled[/red]")
|
|
127
|
+
raise typer.Exit(code=0)
|
|
128
|
+
|
|
129
|
+
return func(*args, **kwargs)
|
|
130
|
+
|
|
131
|
+
return wrapper
|
|
132
|
+
return decorator
|
|
@@ -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())
|