bookery-cli 2026.10.1__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 (170) hide show
  1. bookery/__init__.py +6 -0
  2. bookery/__main__.py +7 -0
  3. bookery/cli/__init__.py +95 -0
  4. bookery/cli/_dispatch.py +42 -0
  5. bookery/cli/_match_helpers.py +201 -0
  6. bookery/cli/_pdf_support.py +21 -0
  7. bookery/cli/commands/__init__.py +2 -0
  8. bookery/cli/commands/add_cmd.py +482 -0
  9. bookery/cli/commands/authors_cmd.py +426 -0
  10. bookery/cli/commands/collection_cmd.py +506 -0
  11. bookery/cli/commands/convert_cmd.py +161 -0
  12. bookery/cli/commands/genre_cmd.py +179 -0
  13. bookery/cli/commands/info_cmd.py +392 -0
  14. bookery/cli/commands/inventory_cmd.py +130 -0
  15. bookery/cli/commands/ls_cmd.py +137 -0
  16. bookery/cli/commands/mark_cmd.py +159 -0
  17. bookery/cli/commands/match_cmd.py +213 -0
  18. bookery/cli/commands/prune_cmd.py +172 -0
  19. bookery/cli/commands/rematch_cmd.py +300 -0
  20. bookery/cli/commands/remove_cmd.py +175 -0
  21. bookery/cli/commands/reveal_cmd.py +103 -0
  22. bookery/cli/commands/search_cmd.py +49 -0
  23. bookery/cli/commands/series_cmd.py +438 -0
  24. bookery/cli/commands/serve_cmd.py +44 -0
  25. bookery/cli/commands/sync_cmd.py +279 -0
  26. bookery/cli/commands/tag_cmd.py +93 -0
  27. bookery/cli/commands/vault_export_cmd.py +335 -0
  28. bookery/cli/commands/verify_cmd.py +57 -0
  29. bookery/cli/deprecation.py +207 -0
  30. bookery/cli/options.py +125 -0
  31. bookery/cli/review.py +227 -0
  32. bookery/collections/__init__.py +29 -0
  33. bookery/collections/lucene_compose.py +42 -0
  34. bookery/collections/query.py +331 -0
  35. bookery/convert/__init__.py +2 -0
  36. bookery/convert/assemble.py +117 -0
  37. bookery/convert/assets/kobo.css +48 -0
  38. bookery/convert/cache.py +47 -0
  39. bookery/convert/errors.py +42 -0
  40. bookery/convert/extract.py +128 -0
  41. bookery/convert/llm.py +145 -0
  42. bookery/convert/preflight.py +51 -0
  43. bookery/convert/types.py +52 -0
  44. bookery/core/__init__.py +0 -0
  45. bookery/core/book_lookup.py +82 -0
  46. bookery/core/config.py +279 -0
  47. bookery/core/converter.py +175 -0
  48. bookery/core/coverfetch.py +66 -0
  49. bookery/core/dedup.py +118 -0
  50. bookery/core/enrichment.py +140 -0
  51. bookery/core/filecopy.py +24 -0
  52. bookery/core/genre_applier.py +121 -0
  53. bookery/core/importer.py +257 -0
  54. bookery/core/pathformat.py +153 -0
  55. bookery/core/pdf_converter.py +68 -0
  56. bookery/core/pipeline.py +371 -0
  57. bookery/core/prune.py +90 -0
  58. bookery/core/remove.py +195 -0
  59. bookery/core/scanner.py +181 -0
  60. bookery/core/text_sort.py +57 -0
  61. bookery/core/vault/__init__.py +2 -0
  62. bookery/core/vault/assemble.py +245 -0
  63. bookery/core/vault/epub.py +117 -0
  64. bookery/core/vault/frontmatter.py +60 -0
  65. bookery/core/vault/image.py +64 -0
  66. bookery/core/vault/index.py +49 -0
  67. bookery/core/vault/note.py +66 -0
  68. bookery/core/vault/walker.py +85 -0
  69. bookery/core/vault/wikilink.py +31 -0
  70. bookery/core/verifier.py +69 -0
  71. bookery/db/__init__.py +16 -0
  72. bookery/db/catalog.py +2183 -0
  73. bookery/db/connection.py +103 -0
  74. bookery/db/hashing.py +32 -0
  75. bookery/db/mapping.py +158 -0
  76. bookery/db/schema.py +356 -0
  77. bookery/db/status.py +74 -0
  78. bookery/device/__init__.py +0 -0
  79. bookery/device/errors.py +32 -0
  80. bookery/device/kepub_cache.py +159 -0
  81. bookery/device/kepubify.py +60 -0
  82. bookery/device/kobo.py +904 -0
  83. bookery/device/kobo_backup.py +89 -0
  84. bookery/device/kobo_reader.py +208 -0
  85. bookery/device/kobo_writer.py +482 -0
  86. bookery/formats/__init__.py +2 -0
  87. bookery/formats/epub.py +593 -0
  88. bookery/formats/mobi.py +534 -0
  89. bookery/metadata/__init__.py +15 -0
  90. bookery/metadata/author_names.py +97 -0
  91. bookery/metadata/cache.py +83 -0
  92. bookery/metadata/candidate.py +25 -0
  93. bookery/metadata/consensus.py +319 -0
  94. bookery/metadata/genres.py +320 -0
  95. bookery/metadata/googlebooks.py +288 -0
  96. bookery/metadata/hardcover.py +249 -0
  97. bookery/metadata/http.py +231 -0
  98. bookery/metadata/normalizer.py +323 -0
  99. bookery/metadata/openlibrary.py +373 -0
  100. bookery/metadata/openlibrary_parser.py +268 -0
  101. bookery/metadata/provider.py +26 -0
  102. bookery/metadata/registry.py +52 -0
  103. bookery/metadata/scoring.py +106 -0
  104. bookery/metadata/series_heuristic.py +66 -0
  105. bookery/metadata/title_correspondence.py +125 -0
  106. bookery/metadata/types.py +48 -0
  107. bookery/plugins/__init__.py +0 -0
  108. bookery/py.typed +0 -0
  109. bookery/util/__init__.py +0 -0
  110. bookery/util/file_manager.py +114 -0
  111. bookery/util/text.py +79 -0
  112. bookery/web/__init__.py +46 -0
  113. bookery/web/browse.py +258 -0
  114. bookery/web/candidate_payload.py +63 -0
  115. bookery/web/covers.py +117 -0
  116. bookery/web/diff.py +124 -0
  117. bookery/web/routes.py +1768 -0
  118. bookery/web/static/fonts/Fraunces-OFL.txt +93 -0
  119. bookery/web/static/fonts/fraunces-latin-wght-italic.woff2 +0 -0
  120. bookery/web/static/fonts/fraunces-latin-wght-normal.woff2 +0 -0
  121. bookery/web/static/htmx.min.js +1 -0
  122. bookery/web/static/pico.min.css +4 -0
  123. bookery/web/static/style.css +1327 -0
  124. bookery/web/templates/_book_card.html +36 -0
  125. bookery/web/templates/_book_list.html +174 -0
  126. bookery/web/templates/_book_subhead.html +7 -0
  127. bookery/web/templates/_collection_delete_confirm.html +23 -0
  128. bookery/web/templates/_collection_detail.html +52 -0
  129. bookery/web/templates/_collection_form.html +105 -0
  130. bookery/web/templates/_collection_preview.html +21 -0
  131. bookery/web/templates/_collection_query_error.html +3 -0
  132. bookery/web/templates/_collection_query_field.html +5 -0
  133. bookery/web/templates/_collections_list.html +18 -0
  134. bookery/web/templates/_columns_menu.html +35 -0
  135. bookery/web/templates/_delete_confirm.html +60 -0
  136. bookery/web/templates/_detail.html +11 -0
  137. bookery/web/templates/_detail_classification.html +37 -0
  138. bookery/web/templates/_detail_collections.html +55 -0
  139. bookery/web/templates/_detail_description.html +8 -0
  140. bookery/web/templates/_detail_file.html +21 -0
  141. bookery/web/templates/_detail_header.html +37 -0
  142. bookery/web/templates/_detail_identity.html +24 -0
  143. bookery/web/templates/_detail_publication.html +11 -0
  144. bookery/web/templates/_detail_reading.html +40 -0
  145. bookery/web/templates/_edit_form.html +80 -0
  146. bookery/web/templates/_enrich_candidate_error.html +33 -0
  147. bookery/web/templates/_enrich_candidate_row.html +28 -0
  148. bookery/web/templates/_enrich_candidates.html +20 -0
  149. bookery/web/templates/_enrich_diff.html +134 -0
  150. bookery/web/templates/_enrich_search.html +58 -0
  151. bookery/web/templates/_field_diff_row.html +67 -0
  152. bookery/web/templates/_filter_chips.html +37 -0
  153. bookery/web/templates/_provenance.html +21 -0
  154. bookery/web/templates/_status_filter.html +38 -0
  155. bookery/web/templates/_table.html +22 -0
  156. bookery/web/templates/base.html +47 -0
  157. bookery/web/templates/collection_detail.html +15 -0
  158. bookery/web/templates/collection_form.html +19 -0
  159. bookery/web/templates/collections_list.html +24 -0
  160. bookery/web/templates/detail.html +15 -0
  161. bookery/web/templates/edit.html +17 -0
  162. bookery/web/templates/enrich.html +17 -0
  163. bookery/web/templates/enrich_candidate.html +21 -0
  164. bookery/web/templates/list.html +46 -0
  165. bookery/web/templates/series_detail.html +28 -0
  166. bookery/web/templates/series_list.html +41 -0
  167. bookery_cli-2026.10.1.dist-info/METADATA +543 -0
  168. bookery_cli-2026.10.1.dist-info/RECORD +170 -0
  169. bookery_cli-2026.10.1.dist-info/WHEEL +4 -0
  170. bookery_cli-2026.10.1.dist-info/entry_points.txt +3 -0
bookery/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # ABOUTME: Bookery - a CLI-first ebook library manager inspired by beets.
2
+ # ABOUTME: Package root. Exposes version info.
3
+
4
+ from importlib.metadata import version
5
+
6
+ __version__ = version("bookery-cli")
bookery/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # ABOUTME: Entry point for `python -m bookery` invocation.
2
+ # ABOUTME: Delegates to the Click CLI group.
3
+
4
+ from bookery.cli import cli
5
+
6
+ if __name__ == "__main__":
7
+ cli()
@@ -0,0 +1,95 @@
1
+ # ABOUTME: CLI package for Bookery, built on Click.
2
+ # ABOUTME: Defines the root command group and registers subcommands.
3
+
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from bookery.cli.commands import (
10
+ add_cmd,
11
+ authors_cmd,
12
+ collection_cmd,
13
+ convert_cmd,
14
+ genre_cmd,
15
+ info_cmd,
16
+ inventory_cmd,
17
+ ls_cmd,
18
+ mark_cmd,
19
+ match_cmd,
20
+ prune_cmd,
21
+ rematch_cmd,
22
+ remove_cmd,
23
+ reveal_cmd,
24
+ search_cmd,
25
+ series_cmd,
26
+ serve_cmd,
27
+ sync_cmd,
28
+ tag_cmd,
29
+ vault_export_cmd,
30
+ verify_cmd,
31
+ )
32
+ from bookery.cli.deprecation import deprecated_command_alias
33
+
34
+
35
+ @click.group()
36
+ @click.version_option(package_name="bookery-cli")
37
+ @click.option("-v", "--verbose", count=True, help="Increase verbosity (-v info, -vv debug).")
38
+ @click.option(
39
+ "--db",
40
+ "db_path",
41
+ type=click.Path(path_type=Path),
42
+ default=None,
43
+ help="Path to library database. Subcommand --db overrides this.",
44
+ )
45
+ @click.pass_context
46
+ def cli(ctx: click.Context, verbose: int, db_path: Path | None) -> None:
47
+ """Bookery - a CLI-first ebook library manager."""
48
+ ctx.ensure_object(dict)
49
+ if db_path is not None:
50
+ ctx.obj["db_path"] = db_path
51
+ if verbose >= 2:
52
+ level = logging.DEBUG
53
+ elif verbose >= 1:
54
+ level = logging.INFO
55
+ else:
56
+ level = logging.WARNING
57
+ logging.basicConfig(
58
+ level=level,
59
+ format="%(name)s %(levelname)s: %(message)s",
60
+ )
61
+
62
+
63
+ cli.add_command(add_cmd.add_command)
64
+ cli.add_command(authors_cmd.authors)
65
+ cli.add_command(collection_cmd.collections)
66
+ cli.add_command(convert_cmd.convert)
67
+ cli.add_command(genre_cmd.genre)
68
+ cli.add_command(info_cmd.info)
69
+ cli.add_command(inventory_cmd.inventory)
70
+ cli.add_command(ls_cmd.ls)
71
+ cli.add_command(mark_cmd.mark)
72
+ cli.add_command(match_cmd.match)
73
+ cli.add_command(prune_cmd.prune)
74
+ cli.add_command(rematch_cmd.rematch)
75
+ cli.add_command(remove_cmd.remove)
76
+ cli.add_command(reveal_cmd.reveal)
77
+ cli.add_command(search_cmd.search)
78
+ cli.add_command(series_cmd.series)
79
+ cli.add_command(serve_cmd.serve)
80
+ cli.add_command(sync_cmd.sync)
81
+ cli.add_command(tag_cmd.tag)
82
+ cli.add_command(vault_export_cmd.vault_export)
83
+ cli.add_command(verify_cmd.verify)
84
+
85
+ # Deprecated alias for the old `folder` command name. Remove after one release.
86
+ deprecated_command_alias(cli, alias="folder", canonical="reveal")
87
+
88
+ # Deprecated alias for the old `import` command name. Unified under `add`,
89
+ # which now dispatches on file-vs-directory paths. Remove after one release.
90
+ deprecated_command_alias(cli, alias="import", canonical="add")
91
+
92
+ # Deprecated alias for the old `inspect` command name. Unified under `info`,
93
+ # which now dispatches on cataloged-ID vs path-to-loose-file. Remove after
94
+ # one release.
95
+ deprecated_command_alias(cli, alias="inspect", canonical="info")
@@ -0,0 +1,42 @@
1
+ # ABOUTME: Detects source book format (epub / kindle / pdf) by suffix + magic bytes.
2
+ # ABOUTME: Used by add and import commands to route non-EPUB inputs through converters.
3
+
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ SourceFormat = Literal["epub", "mobi", "pdf"]
8
+
9
+ # Kindle containers that KindleUnpack reads; all route through the MOBI pipeline.
10
+ KINDLE_SUFFIXES = frozenset({".mobi", ".azw", ".azw3"})
11
+
12
+
13
+ class UnknownFormatError(ValueError):
14
+ """Raised when a file's extension and magic bytes don't match a supported format."""
15
+
16
+
17
+ def detect_source_format(path: Path) -> SourceFormat:
18
+ """Return the file's format based on suffix + small magic-byte sanity check."""
19
+ suffix = path.suffix.lower()
20
+ if suffix == ".epub":
21
+ return "epub"
22
+ if suffix in KINDLE_SUFFIXES:
23
+ return "mobi"
24
+ if suffix == ".pdf":
25
+ try:
26
+ with path.open("rb") as fh:
27
+ head = fh.read(5)
28
+ except OSError as exc:
29
+ raise UnknownFormatError(f"cannot read {path}: {exc}") from exc
30
+ if not head.startswith(b"%PDF-"):
31
+ raise UnknownFormatError(f"{path.name} has a .pdf suffix but is not a PDF file.")
32
+ return "pdf"
33
+ raise UnknownFormatError(
34
+ f"{path.name}: unsupported format (expected .epub, .mobi, .azw, .azw3, or .pdf)."
35
+ )
36
+
37
+
38
+ def find_kindle_files(directory: Path) -> list[Path]:
39
+ """Recursively find Kindle files (any suffix case) under a directory."""
40
+ return sorted(
41
+ p for p in directory.rglob("*") if p.is_file() and p.suffix.lower() in KINDLE_SUFFIXES
42
+ )
@@ -0,0 +1,201 @@
1
+ # ABOUTME: Shared helpers for building match and progress callbacks.
2
+ # ABOUTME: Used by both `import` and `add` commands so behavior stays in one place.
3
+
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+
8
+ from bookery.core.importer import ImportResult, MatchFn, MatchResult, ProgressFn
9
+ from bookery.metadata.provider import MetadataProvider
10
+ from bookery.metadata.types import BookMetadata
11
+
12
+
13
+ def build_active_providers(*, use_cache: bool = True) -> dict[str, MetadataProvider]:
14
+ """Build a mapping of active MetadataProviders from configuration.
15
+
16
+ Reads ``[matching].providers`` and instantiates each listed provider in
17
+ priority order. Unknown names log a warning and are skipped. Falls back
18
+ to a single Open Library provider when nothing else is configured.
19
+
20
+ Each provider's HTTP client is wrapped in a :class:`CachingHttpClient`
21
+ when ``use_cache`` is true so repeated lookups (e.g. the web enrich
22
+ flow) hit the local cache.
23
+
24
+ Returns a dict keyed by provider name so callers can preserve order
25
+ and look providers up by key.
26
+ """
27
+ from bookery.core.config import get_data_dir, get_matching_config
28
+ from bookery.metadata.cache import MetadataCache
29
+ from bookery.metadata.http import BookeryHttpClient, CachingHttpClient
30
+ from bookery.metadata.registry import PROVIDER_FACTORIES, ProviderContext
31
+
32
+ matching = get_matching_config()
33
+ provider_names = matching.providers or ("openlibrary",)
34
+
35
+ cache: MetadataCache | None = None
36
+ if use_cache:
37
+ cache = MetadataCache(
38
+ get_data_dir() / "metadata_cache.db",
39
+ ttl_seconds=matching.cache_ttl_days * 86400.0,
40
+ )
41
+
42
+ def _http_for(provider_name: str):
43
+ # ponytail: one library-wide interval for every provider, not per-provider
44
+ # config — both providers want the same throttle today (#267).
45
+ client: object = BookeryHttpClient(min_request_interval=matching.min_request_interval)
46
+ if cache is not None:
47
+ client = CachingHttpClient(
48
+ client, # type: ignore[arg-type]
49
+ cache,
50
+ provider=provider_name,
51
+ )
52
+ return client
53
+
54
+ ctx = ProviderContext(http_client_for=_http_for)
55
+
56
+ providers: dict[str, MetadataProvider] = {}
57
+ for name in provider_names:
58
+ factory = PROVIDER_FACTORIES.get(name)
59
+ if factory is None:
60
+ import logging
61
+
62
+ logging.getLogger(__name__).warning("Unknown metadata provider %r; skipping", name)
63
+ continue
64
+ providers[name] = factory(ctx)
65
+
66
+ if not providers:
67
+ providers["openlibrary"] = PROVIDER_FACTORIES["openlibrary"](ctx)
68
+
69
+ return providers
70
+
71
+
72
+ def build_metadata_provider(*, use_cache: bool = True) -> MetadataProvider:
73
+ """Build the configured metadata provider with optional response caching.
74
+
75
+ Wraps :func:`build_active_providers` so legacy single-provider/consensus
76
+ behavior is preserved. A single active provider is returned directly;
77
+ multiple providers are wrapped in a :class:`ConsensusProvider` that
78
+ merges per-field values and prefers cross-provider agreement.
79
+ """
80
+ from bookery.metadata.consensus import ConsensusProvider
81
+
82
+ providers = list(build_active_providers(use_cache=use_cache).values())
83
+ if len(providers) == 1:
84
+ return providers[0]
85
+ return ConsensusProvider(providers)
86
+
87
+
88
+ def build_match_fn(
89
+ console: Console,
90
+ output_dir: Path,
91
+ quiet: bool,
92
+ threshold: float,
93
+ *,
94
+ use_cache: bool = True,
95
+ fetch_covers: bool = True,
96
+ ) -> MatchFn:
97
+ """Build a match callback that runs the full metadata pipeline.
98
+
99
+ Imports match-pipeline dependencies lazily so callers don't pay for
100
+ them when matching is not used.
101
+ """
102
+ from bookery.cli.review import ReviewSession
103
+ from bookery.core.pipeline import match_one
104
+
105
+ provider = build_metadata_provider(use_cache=use_cache)
106
+ review = ReviewSession(
107
+ console=console,
108
+ quiet=quiet,
109
+ threshold=threshold,
110
+ lookup_fn=provider.lookup_by_url,
111
+ )
112
+
113
+ def match_fn(
114
+ _extracted: BookMetadata,
115
+ epub_path: Path,
116
+ ) -> MatchResult | None:
117
+ del _extracted # signature required by MatchFn protocol
118
+ result = match_one(epub_path, provider, review, output_dir, fetch_covers=fetch_covers)
119
+
120
+ if result.cover_skipped:
121
+ console.print(
122
+ " [yellow]warning:[/yellow] cover fetch failed; "
123
+ "applied text metadata without cover"
124
+ )
125
+
126
+ if not quiet and result.normalization and result.normalization.was_modified:
127
+ console.print(f" [dim]Normalized:[/dim] {result.normalization.normalized.title}")
128
+
129
+ if result.status == "matched" and result.metadata is not None:
130
+ if not quiet:
131
+ console.print(f" [green]Written:[/green] {result.output_path}")
132
+ return MatchResult(
133
+ metadata=result.metadata,
134
+ output_path=result.output_path,
135
+ )
136
+
137
+ if result.status == "error" and not quiet:
138
+ console.print(f" [red]Write failed:[/red] {result.error}")
139
+
140
+ return None
141
+
142
+ return match_fn
143
+
144
+
145
+ def build_progress_fn(console: Console) -> ProgressFn:
146
+ """Build a per-file progress callback for Rich console output."""
147
+
148
+ def on_progress(
149
+ path: Path,
150
+ title: str,
151
+ author: str,
152
+ status: str,
153
+ reason: str | None,
154
+ existing_id: int | None,
155
+ ) -> None:
156
+ label = f"{title} — {author}" if title and author else path.name
157
+ if status == "added":
158
+ console.print(f" [green]✓[/green] {label}")
159
+ elif status == "skipped" and reason:
160
+ reason_label = reason.replace("_", "+")
161
+ id_suffix = f", #{existing_id}" if existing_id else ""
162
+ console.print(
163
+ f" [yellow]⊘[/yellow] {label} — "
164
+ f"[dim]skipped (duplicate: {reason_label}{id_suffix})[/dim]"
165
+ )
166
+ elif status == "forced" and reason:
167
+ reason_label = reason.replace("_", "+")
168
+ id_suffix = f", #{existing_id}" if existing_id else ""
169
+ console.print(
170
+ f" [yellow]⚠[/yellow] {label} — "
171
+ f"[dim]imported (duplicate: {reason_label}{id_suffix})[/dim]"
172
+ )
173
+ elif status == "error":
174
+ console.print(f" [red]✗[/red] {path.name} — [red]{reason}[/red]")
175
+ elif status == "move_failed":
176
+ console.print(
177
+ f" [yellow]⚠[/yellow] {path.name} — "
178
+ f"[dim]cataloged but source not removed: {reason}[/dim]"
179
+ )
180
+
181
+ return on_progress
182
+
183
+
184
+ def format_skip_breakdown(result: ImportResult) -> str:
185
+ """Format a skip count with breakdown by reason."""
186
+ if result.skipped == 0:
187
+ return ""
188
+
189
+ parts = []
190
+ if result.skipped_hash:
191
+ parts.append(f"{result.skipped_hash} hash")
192
+ if result.skipped_metadata:
193
+ reason_counts: dict[str, int] = {}
194
+ for detail in result.skip_details:
195
+ if detail.reason in ("isbn", "title_author"):
196
+ label = detail.reason.replace("_", "+")
197
+ reason_counts[label] = reason_counts.get(label, 0) + 1
198
+ parts.extend(f"{count} {reason}" for reason, count in reason_counts.items())
199
+
200
+ breakdown = f" ({', '.join(parts)})" if parts else ""
201
+ return f"{result.skipped} skipped{breakdown}"
@@ -0,0 +1,21 @@
1
+ # ABOUTME: Shared helper for wiring PDF conversion into add/import commands.
2
+ # ABOUTME: Wraps convert_pdf and surfaces any warnings to the console.
3
+
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+
8
+ from bookery.core.pdf_converter import convert_pdf
9
+
10
+
11
+ def convert_pdf_to_epub(
12
+ source: Path,
13
+ out_dir: Path,
14
+ *,
15
+ console: Console,
16
+ ) -> Path:
17
+ """Run the PDF→EPUB pipeline; return the produced EPUB path."""
18
+ result = convert_pdf(source, out_dir, console=console)
19
+ for warning in result.warnings:
20
+ console.print(f" [yellow]warning:[/yellow] {warning}")
21
+ return result.epub_path
@@ -0,0 +1,2 @@
1
+ # ABOUTME: CLI command modules for Bookery.
2
+ # ABOUTME: Each module registers one or more Click commands.