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.
- bookery/__init__.py +6 -0
- bookery/__main__.py +7 -0
- bookery/cli/__init__.py +95 -0
- bookery/cli/_dispatch.py +42 -0
- bookery/cli/_match_helpers.py +201 -0
- bookery/cli/_pdf_support.py +21 -0
- bookery/cli/commands/__init__.py +2 -0
- bookery/cli/commands/add_cmd.py +482 -0
- bookery/cli/commands/authors_cmd.py +426 -0
- bookery/cli/commands/collection_cmd.py +506 -0
- bookery/cli/commands/convert_cmd.py +161 -0
- bookery/cli/commands/genre_cmd.py +179 -0
- bookery/cli/commands/info_cmd.py +392 -0
- bookery/cli/commands/inventory_cmd.py +130 -0
- bookery/cli/commands/ls_cmd.py +137 -0
- bookery/cli/commands/mark_cmd.py +159 -0
- bookery/cli/commands/match_cmd.py +213 -0
- bookery/cli/commands/prune_cmd.py +172 -0
- bookery/cli/commands/rematch_cmd.py +300 -0
- bookery/cli/commands/remove_cmd.py +175 -0
- bookery/cli/commands/reveal_cmd.py +103 -0
- bookery/cli/commands/search_cmd.py +49 -0
- bookery/cli/commands/series_cmd.py +438 -0
- bookery/cli/commands/serve_cmd.py +44 -0
- bookery/cli/commands/sync_cmd.py +279 -0
- bookery/cli/commands/tag_cmd.py +93 -0
- bookery/cli/commands/vault_export_cmd.py +335 -0
- bookery/cli/commands/verify_cmd.py +57 -0
- bookery/cli/deprecation.py +207 -0
- bookery/cli/options.py +125 -0
- bookery/cli/review.py +227 -0
- bookery/collections/__init__.py +29 -0
- bookery/collections/lucene_compose.py +42 -0
- bookery/collections/query.py +331 -0
- bookery/convert/__init__.py +2 -0
- bookery/convert/assemble.py +117 -0
- bookery/convert/assets/kobo.css +48 -0
- bookery/convert/cache.py +47 -0
- bookery/convert/errors.py +42 -0
- bookery/convert/extract.py +128 -0
- bookery/convert/llm.py +145 -0
- bookery/convert/preflight.py +51 -0
- bookery/convert/types.py +52 -0
- bookery/core/__init__.py +0 -0
- bookery/core/book_lookup.py +82 -0
- bookery/core/config.py +279 -0
- bookery/core/converter.py +175 -0
- bookery/core/coverfetch.py +66 -0
- bookery/core/dedup.py +118 -0
- bookery/core/enrichment.py +140 -0
- bookery/core/filecopy.py +24 -0
- bookery/core/genre_applier.py +121 -0
- bookery/core/importer.py +257 -0
- bookery/core/pathformat.py +153 -0
- bookery/core/pdf_converter.py +68 -0
- bookery/core/pipeline.py +371 -0
- bookery/core/prune.py +90 -0
- bookery/core/remove.py +195 -0
- bookery/core/scanner.py +181 -0
- bookery/core/text_sort.py +57 -0
- bookery/core/vault/__init__.py +2 -0
- bookery/core/vault/assemble.py +245 -0
- bookery/core/vault/epub.py +117 -0
- bookery/core/vault/frontmatter.py +60 -0
- bookery/core/vault/image.py +64 -0
- bookery/core/vault/index.py +49 -0
- bookery/core/vault/note.py +66 -0
- bookery/core/vault/walker.py +85 -0
- bookery/core/vault/wikilink.py +31 -0
- bookery/core/verifier.py +69 -0
- bookery/db/__init__.py +16 -0
- bookery/db/catalog.py +2183 -0
- bookery/db/connection.py +103 -0
- bookery/db/hashing.py +32 -0
- bookery/db/mapping.py +158 -0
- bookery/db/schema.py +356 -0
- bookery/db/status.py +74 -0
- bookery/device/__init__.py +0 -0
- bookery/device/errors.py +32 -0
- bookery/device/kepub_cache.py +159 -0
- bookery/device/kepubify.py +60 -0
- bookery/device/kobo.py +904 -0
- bookery/device/kobo_backup.py +89 -0
- bookery/device/kobo_reader.py +208 -0
- bookery/device/kobo_writer.py +482 -0
- bookery/formats/__init__.py +2 -0
- bookery/formats/epub.py +593 -0
- bookery/formats/mobi.py +534 -0
- bookery/metadata/__init__.py +15 -0
- bookery/metadata/author_names.py +97 -0
- bookery/metadata/cache.py +83 -0
- bookery/metadata/candidate.py +25 -0
- bookery/metadata/consensus.py +319 -0
- bookery/metadata/genres.py +320 -0
- bookery/metadata/googlebooks.py +288 -0
- bookery/metadata/hardcover.py +249 -0
- bookery/metadata/http.py +231 -0
- bookery/metadata/normalizer.py +323 -0
- bookery/metadata/openlibrary.py +373 -0
- bookery/metadata/openlibrary_parser.py +268 -0
- bookery/metadata/provider.py +26 -0
- bookery/metadata/registry.py +52 -0
- bookery/metadata/scoring.py +106 -0
- bookery/metadata/series_heuristic.py +66 -0
- bookery/metadata/title_correspondence.py +125 -0
- bookery/metadata/types.py +48 -0
- bookery/plugins/__init__.py +0 -0
- bookery/py.typed +0 -0
- bookery/util/__init__.py +0 -0
- bookery/util/file_manager.py +114 -0
- bookery/util/text.py +79 -0
- bookery/web/__init__.py +46 -0
- bookery/web/browse.py +258 -0
- bookery/web/candidate_payload.py +63 -0
- bookery/web/covers.py +117 -0
- bookery/web/diff.py +124 -0
- bookery/web/routes.py +1768 -0
- bookery/web/static/fonts/Fraunces-OFL.txt +93 -0
- bookery/web/static/fonts/fraunces-latin-wght-italic.woff2 +0 -0
- bookery/web/static/fonts/fraunces-latin-wght-normal.woff2 +0 -0
- bookery/web/static/htmx.min.js +1 -0
- bookery/web/static/pico.min.css +4 -0
- bookery/web/static/style.css +1327 -0
- bookery/web/templates/_book_card.html +36 -0
- bookery/web/templates/_book_list.html +174 -0
- bookery/web/templates/_book_subhead.html +7 -0
- bookery/web/templates/_collection_delete_confirm.html +23 -0
- bookery/web/templates/_collection_detail.html +52 -0
- bookery/web/templates/_collection_form.html +105 -0
- bookery/web/templates/_collection_preview.html +21 -0
- bookery/web/templates/_collection_query_error.html +3 -0
- bookery/web/templates/_collection_query_field.html +5 -0
- bookery/web/templates/_collections_list.html +18 -0
- bookery/web/templates/_columns_menu.html +35 -0
- bookery/web/templates/_delete_confirm.html +60 -0
- bookery/web/templates/_detail.html +11 -0
- bookery/web/templates/_detail_classification.html +37 -0
- bookery/web/templates/_detail_collections.html +55 -0
- bookery/web/templates/_detail_description.html +8 -0
- bookery/web/templates/_detail_file.html +21 -0
- bookery/web/templates/_detail_header.html +37 -0
- bookery/web/templates/_detail_identity.html +24 -0
- bookery/web/templates/_detail_publication.html +11 -0
- bookery/web/templates/_detail_reading.html +40 -0
- bookery/web/templates/_edit_form.html +80 -0
- bookery/web/templates/_enrich_candidate_error.html +33 -0
- bookery/web/templates/_enrich_candidate_row.html +28 -0
- bookery/web/templates/_enrich_candidates.html +20 -0
- bookery/web/templates/_enrich_diff.html +134 -0
- bookery/web/templates/_enrich_search.html +58 -0
- bookery/web/templates/_field_diff_row.html +67 -0
- bookery/web/templates/_filter_chips.html +37 -0
- bookery/web/templates/_provenance.html +21 -0
- bookery/web/templates/_status_filter.html +38 -0
- bookery/web/templates/_table.html +22 -0
- bookery/web/templates/base.html +47 -0
- bookery/web/templates/collection_detail.html +15 -0
- bookery/web/templates/collection_form.html +19 -0
- bookery/web/templates/collections_list.html +24 -0
- bookery/web/templates/detail.html +15 -0
- bookery/web/templates/edit.html +17 -0
- bookery/web/templates/enrich.html +17 -0
- bookery/web/templates/enrich_candidate.html +21 -0
- bookery/web/templates/list.html +46 -0
- bookery/web/templates/series_detail.html +28 -0
- bookery/web/templates/series_list.html +41 -0
- bookery_cli-2026.10.1.dist-info/METADATA +543 -0
- bookery_cli-2026.10.1.dist-info/RECORD +170 -0
- bookery_cli-2026.10.1.dist-info/WHEEL +4 -0
- bookery_cli-2026.10.1.dist-info/entry_points.txt +3 -0
bookery/__init__.py
ADDED
bookery/__main__.py
ADDED
bookery/cli/__init__.py
ADDED
|
@@ -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")
|
bookery/cli/_dispatch.py
ADDED
|
@@ -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
|