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/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())