ebookerr-sdk 3.2.0__tar.gz → 3.4.0__tar.gz
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.
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/PKG-INFO +17 -3
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/README.md +16 -2
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/__init__.py +1 -1
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/domain/__init__.py +7 -3
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/domain/adoption.py +18 -2
- ebookerr_sdk-3.4.0/ebookerr_sdk/domain/authors.py +83 -0
- ebookerr_sdk-3.4.0/ebookerr_sdk/domain/epub_metadata.py +263 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/domain/ids.py +56 -0
- ebookerr_sdk-3.4.0/ebookerr_sdk/domain/metadata.py +120 -0
- ebookerr_sdk-3.4.0/ebookerr_sdk/domain/series_rules.py +62 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/download/document.py +139 -17
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/build.py +154 -117
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/document.py +43 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/nav.py +11 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/ncx.py +14 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/opf.py +40 -6
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/text_convert.py +80 -21
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/spi/declarations.py +1 -1
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk.egg-info/PKG-INFO +17 -3
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk.egg-info/SOURCES.txt +2 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/pyproject.toml +1 -1
- ebookerr_sdk-3.2.0/ebookerr_sdk/domain/epub_metadata.py +0 -126
- ebookerr_sdk-3.2.0/ebookerr_sdk/domain/metadata.py +0 -44
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/LICENSE +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/domain/chapter_naming.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/domain/chapter_number.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/domain/dates.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/domain/epub_hash.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/domain/story_url.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/domain/survivor.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/domain/text_encoding.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/download/__init__.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/download/availability.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/download/check.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/download/disposition.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/download/identity.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/download/paths.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/download/routing.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/__init__.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/archive.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/assets.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/builder.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/chapter_numbers.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/chapters.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/errors.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/fonts.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/roles.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/safe_xml.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/serialize.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/text.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/titles.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/epub/xhtml.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/generation/__init__.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/generation/client.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/generation/output.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/generation/prompt_template.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/generation/support.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/host.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/pack/__init__.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/pack/__main__.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/pack/build.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/pack/changelog.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/pack/check.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/pack/cli.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/pack/files.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/pack/imports.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/pack/index.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/pack/scaffold.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/providers/__init__.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/providers/anchor_types.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/providers/anchoring.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/providers/connection.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/providers/link_attempt.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/providers/link_refusal.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/providers/restore_marker.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/providers/scan_ledger.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/providers/urls.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/py.typed +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/readpos.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/registry_index.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/spi/__init__.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/spi/context.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/spi/errors.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/spi/glyphs.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/spi/manifest.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/spi/material_symbols.txt +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/spi/protocols.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/spi/settings.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/spi/summary_template.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/spi/types.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/spi/views.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/testing/__init__.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/testing/context.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/testing/views.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/testing/wire.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/validate/__init__.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/validate/external.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/validate/model.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/validate/rules_css.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/validate/rules_html.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/validate/rules_nav.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/validate/rules_opf.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/validate/rules_package.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/validate/validate.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk/wire.py +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk.egg-info/dependency_links.txt +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk.egg-info/requires.txt +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/ebookerr_sdk.egg-info/top_level.txt +0 -0
- {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ebookerr-sdk
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.4.0
|
|
4
4
|
Summary: The ebookerr plugin SDK: the plugin contract, the plugin host, a test kit, the pack tool and helper kits.
|
|
5
5
|
Author: ArcCdr
|
|
6
6
|
License-Expression: MIT
|
|
@@ -41,10 +41,10 @@ validation, library servers and text generation.
|
|
|
41
41
|
## Install
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
pip install "ebookerr-sdk==3.
|
|
44
|
+
pip install "ebookerr-sdk==3.4.*"
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
The SDK's MAJOR.MINOR is the SPI version it speaks: `ebookerr-sdk` 3.
|
|
47
|
+
The SDK's MAJOR.MINOR is the SPI version it speaks: `ebookerr-sdk` 3.4.x speaks SPI 3.4. It needs
|
|
48
48
|
Python 3.11 or later.
|
|
49
49
|
|
|
50
50
|
## Start a plugin
|
|
@@ -66,6 +66,8 @@ or `catalog`. The guide explains what to write next and how to install the zip i
|
|
|
66
66
|
| 2.24 | 3.0 | 3.0.x |
|
|
67
67
|
| 2.24.1 | 3.1 | 3.1.x |
|
|
68
68
|
| 2.24.7 | 3.2 | 3.2.x |
|
|
69
|
+
| 2.24.9 | 3.3 | 3.3.x |
|
|
70
|
+
| 2.24.10 | 3.4 | 3.4.x |
|
|
69
71
|
|
|
70
72
|
ebookerr loads a plugin when its `spi_version` has ebookerr's SPI MAJOR and a MINOR not above
|
|
71
73
|
ebookerr's; otherwise it shows the plugin as incompatible and offers a compatible release.
|
|
@@ -93,6 +95,18 @@ ebookerr's; otherwise it shows the plugin as incompatible and offers a compatibl
|
|
|
93
95
|
|
|
94
96
|
## Release notes
|
|
95
97
|
|
|
98
|
+
### 3.4.0
|
|
99
|
+
|
|
100
|
+
SPI 3.4. The domain and EPUB kits gain the helpers ebookerr's own doors share; later sentences
|
|
101
|
+
of this section name each one. A plugin written for SPI 3.3 needs no change.
|
|
102
|
+
|
|
103
|
+
`ebookerr_sdk.epub.opf.UNTYPED_IDENTIFIER_SCHEMES` names the identifier schemes a package cannot carry as a book identifier (`url`, `uri`, `uuid`, `calibre`, `http`, `https`, `urn`); `PackageDocument.set_typed_identifiers` no longer writes them, so writing back what `typed_identifiers` read changes nothing. `PackageDocument.set_title` drops a changed title's stale sort key (`calibre:title_sort`, or an EPUB 3 `file-as` refinement), so a reader re-derives it. `ebookerr_sdk.domain.authors` is the one parser of a book's `author` text: `split_authors` and `join_authors` read and write the names joined by `AUTHOR_SEPARATOR` (`" & "`, an `&` with whitespace on both sides), `authors_key` compares two texts whatever the order and case, and `includes_authors` tells whether a book is by given people. `ebookerr_sdk.domain.adoption.author_set` and `score_pair`'s author signal compare the set of names, so co-authors listed in another order match. `ebookerr_sdk.domain.ids.normalise_site`, `ebookerr_sdk.domain.series_rules` (`names_itself`, `without_self_references`) and `ebookerr_sdk.domain.metadata`'s `normalise_token_list`, `IDENTIFIER_RE`, `parse_identifiers` and `format_identifiers` are the helpers ebookerr itself uses for those fields. `ebookerr_sdk.domain.epub_metadata.epub_book_fields` maps what an EPUB says to the library fields every door stores — every author, the publisher unless it only names the site, typed identifiers, series, categories and tags — so a book pulled from a link and the same file uploaded agree. `epub_publisher` (the publisher an EPUB names, unless it only names the site) and `title_or_file_stem` (the title, or the file's name without its extension when the title is blank, `Untitled` or `Unknown`) are the two rules it is built from.
|
|
104
|
+
|
|
105
|
+
### 3.3.0
|
|
106
|
+
|
|
107
|
+
SPI 3.3. The EPUB kit gains helpers for the EPUBs ebookerr writes; later sentences of this
|
|
108
|
+
section name each one. A plugin written for SPI 3.2 needs no change. `build_epub` writes its package through `ebookerr_sdk.epub.builder`: the NCX `dtb:uid` is the OPF identifier and the package carries its creation date, so a built book validates without findings. `EpubDocument.set_title` writes a new title into the package and every table of contents (`NcxDocument.set_doc_title`, `NavDocument.set_doc_title`), so a retitled book's OPF and NCX agree. `retitle_built_epub` gives a `build_epub` output a new title in its package, tables of contents and title page; `convert_stage` and `download_convert_stage` use it, so a converted book's file carries the title the book gets. `ebookerr_sdk.epub.text_convert.is_chapter_heading` names the prose chapter-heading rule. `convert_stage` titles an uploaded document after its own title, else its file name unless the name is a placeholder such as `Document1` or `Untitled`, else a first line that is no chapter heading. `Book.front_matter` holds the text above a document's first heading; `build_epub` puts it on the title page, and `parse_text_to_book` fills it instead of making that text a chapter. `parse_text_to_book` keeps each line of a text that has no blank line at all as its own paragraph. `convert_stage` and `download_convert_stage` pass a `user_facing` conversion error's message through unchanged and answer "The file could not be converted." for any other failure, so no library message or path reaches a user. `EpubDocument.drm_protected()` tells a DRM-protected book (a `rights.xml`, or an `encryption.xml` using anything but font obfuscation) from one whose fonts are only obfuscated.
|
|
109
|
+
|
|
96
110
|
### 3.2.0
|
|
97
111
|
|
|
98
112
|
SPI 3.2. A story can name the multi-part work it is one part of: `StoryPatch.work` and
|
|
@@ -12,10 +12,10 @@ validation, library servers and text generation.
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
pip install "ebookerr-sdk==3.
|
|
15
|
+
pip install "ebookerr-sdk==3.4.*"
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
The SDK's MAJOR.MINOR is the SPI version it speaks: `ebookerr-sdk` 3.
|
|
18
|
+
The SDK's MAJOR.MINOR is the SPI version it speaks: `ebookerr-sdk` 3.4.x speaks SPI 3.4. It needs
|
|
19
19
|
Python 3.11 or later.
|
|
20
20
|
|
|
21
21
|
## Start a plugin
|
|
@@ -37,6 +37,8 @@ or `catalog`. The guide explains what to write next and how to install the zip i
|
|
|
37
37
|
| 2.24 | 3.0 | 3.0.x |
|
|
38
38
|
| 2.24.1 | 3.1 | 3.1.x |
|
|
39
39
|
| 2.24.7 | 3.2 | 3.2.x |
|
|
40
|
+
| 2.24.9 | 3.3 | 3.3.x |
|
|
41
|
+
| 2.24.10 | 3.4 | 3.4.x |
|
|
40
42
|
|
|
41
43
|
ebookerr loads a plugin when its `spi_version` has ebookerr's SPI MAJOR and a MINOR not above
|
|
42
44
|
ebookerr's; otherwise it shows the plugin as incompatible and offers a compatible release.
|
|
@@ -64,6 +66,18 @@ ebookerr's; otherwise it shows the plugin as incompatible and offers a compatibl
|
|
|
64
66
|
|
|
65
67
|
## Release notes
|
|
66
68
|
|
|
69
|
+
### 3.4.0
|
|
70
|
+
|
|
71
|
+
SPI 3.4. The domain and EPUB kits gain the helpers ebookerr's own doors share; later sentences
|
|
72
|
+
of this section name each one. A plugin written for SPI 3.3 needs no change.
|
|
73
|
+
|
|
74
|
+
`ebookerr_sdk.epub.opf.UNTYPED_IDENTIFIER_SCHEMES` names the identifier schemes a package cannot carry as a book identifier (`url`, `uri`, `uuid`, `calibre`, `http`, `https`, `urn`); `PackageDocument.set_typed_identifiers` no longer writes them, so writing back what `typed_identifiers` read changes nothing. `PackageDocument.set_title` drops a changed title's stale sort key (`calibre:title_sort`, or an EPUB 3 `file-as` refinement), so a reader re-derives it. `ebookerr_sdk.domain.authors` is the one parser of a book's `author` text: `split_authors` and `join_authors` read and write the names joined by `AUTHOR_SEPARATOR` (`" & "`, an `&` with whitespace on both sides), `authors_key` compares two texts whatever the order and case, and `includes_authors` tells whether a book is by given people. `ebookerr_sdk.domain.adoption.author_set` and `score_pair`'s author signal compare the set of names, so co-authors listed in another order match. `ebookerr_sdk.domain.ids.normalise_site`, `ebookerr_sdk.domain.series_rules` (`names_itself`, `without_self_references`) and `ebookerr_sdk.domain.metadata`'s `normalise_token_list`, `IDENTIFIER_RE`, `parse_identifiers` and `format_identifiers` are the helpers ebookerr itself uses for those fields. `ebookerr_sdk.domain.epub_metadata.epub_book_fields` maps what an EPUB says to the library fields every door stores — every author, the publisher unless it only names the site, typed identifiers, series, categories and tags — so a book pulled from a link and the same file uploaded agree. `epub_publisher` (the publisher an EPUB names, unless it only names the site) and `title_or_file_stem` (the title, or the file's name without its extension when the title is blank, `Untitled` or `Unknown`) are the two rules it is built from.
|
|
75
|
+
|
|
76
|
+
### 3.3.0
|
|
77
|
+
|
|
78
|
+
SPI 3.3. The EPUB kit gains helpers for the EPUBs ebookerr writes; later sentences of this
|
|
79
|
+
section name each one. A plugin written for SPI 3.2 needs no change. `build_epub` writes its package through `ebookerr_sdk.epub.builder`: the NCX `dtb:uid` is the OPF identifier and the package carries its creation date, so a built book validates without findings. `EpubDocument.set_title` writes a new title into the package and every table of contents (`NcxDocument.set_doc_title`, `NavDocument.set_doc_title`), so a retitled book's OPF and NCX agree. `retitle_built_epub` gives a `build_epub` output a new title in its package, tables of contents and title page; `convert_stage` and `download_convert_stage` use it, so a converted book's file carries the title the book gets. `ebookerr_sdk.epub.text_convert.is_chapter_heading` names the prose chapter-heading rule. `convert_stage` titles an uploaded document after its own title, else its file name unless the name is a placeholder such as `Document1` or `Untitled`, else a first line that is no chapter heading. `Book.front_matter` holds the text above a document's first heading; `build_epub` puts it on the title page, and `parse_text_to_book` fills it instead of making that text a chapter. `parse_text_to_book` keeps each line of a text that has no blank line at all as its own paragraph. `convert_stage` and `download_convert_stage` pass a `user_facing` conversion error's message through unchanged and answer "The file could not be converted." for any other failure, so no library message or path reaches a user. `EpubDocument.drm_protected()` tells a DRM-protected book (a `rights.xml`, or an `encryption.xml` using anything but font obfuscation) from one whose fonts are only obfuscated.
|
|
80
|
+
|
|
67
81
|
### 3.2.0
|
|
68
82
|
|
|
69
83
|
SPI 3.2. A story can name the multi-part work it is one part of: `StoryPatch.work` and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Pure domain helpers shared by the ebookerr core and its plugins.
|
|
2
2
|
|
|
3
|
-
Includes: ids, dates, metadata text, chapter numbers and naming, adoption, story URLs
|
|
3
|
+
Includes: ids, dates, metadata text, chapter numbers and naming, adoption, authors, story URLs
|
|
4
4
|
and EPUB metadata (``PMG-D22``).
|
|
5
5
|
|
|
6
6
|
**Contents**
|
|
@@ -13,6 +13,7 @@ election, text encoding, EPUB hashing.
|
|
|
13
13
|
|
|
14
14
|
- ``adoption.py`` — Pure normalisation helpers for adoption heuristic (no I/O, no
|
|
15
15
|
src.data/src.services).
|
|
16
|
+
- ``authors.py`` — Several authors in one ``author`` text — the one parser (``LIB-D6``).
|
|
16
17
|
- ``chapter_naming.py`` — Turn chapter labels into collision-free, human-readable
|
|
17
18
|
filenames (``MR-RENAME-1``).
|
|
18
19
|
- ``chapter_number.py`` — Split a chapter title into its book name and chapter number.
|
|
@@ -21,8 +22,11 @@ election, text encoding, EPUB hashing.
|
|
|
21
22
|
- ``epub_hash.py`` — Canonical hex SHA-256 content hash of .epub files.
|
|
22
23
|
- ``epub_metadata.py`` — What an EPUB says about its book — the OPF package metadata
|
|
23
24
|
plus a labelled title page.
|
|
24
|
-
- ``ids.py`` — Stable, deterministic book-id derivation from a story URL
|
|
25
|
-
-
|
|
25
|
+
- ``ids.py`` — Stable, deterministic book-id derivation from a story URL, and the site's
|
|
26
|
+
registrable-domain form.
|
|
27
|
+
- ``metadata.py`` — Field helpers for book text: sanitising human-readable values, token lists and
|
|
28
|
+
typed identifiers.
|
|
29
|
+
- ``series_rules.py`` — A book is never its own series or its own work (``LIB-D45``, ``LIB-D46``).
|
|
26
30
|
- ``story_url.py`` — Pure derivations from a story URL alone — identity, display
|
|
27
31
|
title, listing author.
|
|
28
32
|
- ``survivor.py`` — Pick the representative of a set of books holding one story, by
|
|
@@ -6,6 +6,7 @@ Because these functions are pure (no database, no external dependencies),
|
|
|
6
6
|
the adoption heuristic is fully unit-testable.
|
|
7
7
|
|
|
8
8
|
It also provides ``same_series``/``same_work`` (the two halves of signal E5).
|
|
9
|
+
``author_set`` compares several authors whatever their order.
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
12
|
from __future__ import annotations
|
|
@@ -16,6 +17,7 @@ import re
|
|
|
16
17
|
from dataclasses import dataclass
|
|
17
18
|
from urllib.parse import urlsplit
|
|
18
19
|
|
|
20
|
+
from ebookerr_sdk.domain.authors import split_authors
|
|
19
21
|
from ebookerr_sdk.domain.chapter_number import extract_chapter_info
|
|
20
22
|
from ebookerr_sdk.domain.ids import normalise_url
|
|
21
23
|
from ebookerr_sdk.domain.text_encoding import unicode_identity
|
|
@@ -35,6 +37,19 @@ def normalize_key(text: str | None) -> str:
|
|
|
35
37
|
return unicode_identity(text or "").replace("_", "")
|
|
36
38
|
|
|
37
39
|
|
|
40
|
+
def author_set(text: str | None) -> frozenset[str]:
|
|
41
|
+
"""Each author's :func:`normalize_key`, whatever their order (``LIB-D6``).
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
text: A stored ``author`` value (names joined by ``" & "``).
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The non-empty keys of the names :func:`~ebookerr_sdk.domain.authors.split_authors`
|
|
48
|
+
reads; an empty set for a blank value.
|
|
49
|
+
"""
|
|
50
|
+
return frozenset(key for key in map(normalize_key, split_authors(text)) if key)
|
|
51
|
+
|
|
52
|
+
|
|
38
53
|
def url_slug_key(url: str | None) -> str:
|
|
39
54
|
"""The normalised last non-empty path segment of *url*.
|
|
40
55
|
|
|
@@ -260,8 +275,9 @@ def score_pair(orphan: AdoptionSide, target: AdoptionSide) -> Evidence:
|
|
|
260
275
|
orphan_stem = normalize_key(extract_chapter_info(orphan.title).book_name)
|
|
261
276
|
target_stem = normalize_key(extract_chapter_info(target.title).book_name)
|
|
262
277
|
|
|
263
|
-
# 4. Author match:
|
|
264
|
-
|
|
278
|
+
# 4. Author match: the same non-empty set of authors, in any order
|
|
279
|
+
orphan_authors = author_set(orphan.author)
|
|
280
|
+
author_match = bool(orphan_authors) and orphan_authors == author_set(target.author)
|
|
265
281
|
|
|
266
282
|
# 5. Stem match: normalized stem must be non-empty and equal
|
|
267
283
|
stem_match = orphan_stem == target_stem != ""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Several authors in one ``author`` text — the one parser (``LIB-D6``).
|
|
2
|
+
|
|
3
|
+
ebookerr stores a book's authors as one text, the names joined by ``" & "``. Every reader of that
|
|
4
|
+
text goes through :func:`split_authors` and every writer through :func:`join_authors`, so the
|
|
5
|
+
core and the plugins agree on what a name is. :func:`authors_key` is the order- and case-blind
|
|
6
|
+
form identity comparisons use; :func:`includes_authors` answers "is this book by these people?".
|
|
7
|
+
An ``&`` with whitespace on both sides separates two names; an ``&`` inside a name ("AT&T")
|
|
8
|
+
does not.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
|
|
16
|
+
from ebookerr_sdk.domain.text_encoding import fold
|
|
17
|
+
|
|
18
|
+
AUTHOR_SEPARATOR = " & "
|
|
19
|
+
"""The text between two names in a stored ``author`` (``LIB-D6``)."""
|
|
20
|
+
|
|
21
|
+
_SEPARATOR = re.compile(r"\s+&\s+")
|
|
22
|
+
_SPACES = re.compile(r"\s+")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def split_authors(text: str | None) -> list[str]:
|
|
26
|
+
"""The names in *text*, in order: whitespace runs collapsed, a repeated name dropped.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
text: A stored ``author`` value; ``None`` reads as blank.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The names; a name repeated in another case keeps its first spelling; ``[]`` for blank.
|
|
33
|
+
"""
|
|
34
|
+
names: list[str] = []
|
|
35
|
+
seen: set[str] = set()
|
|
36
|
+
# Split on an ampersand with whitespace on both sides only, so "AT&T" stays one name.
|
|
37
|
+
for part in _SEPARATOR.split((text or "").strip()):
|
|
38
|
+
name = _SPACES.sub(" ", part).strip()
|
|
39
|
+
key = fold(name)
|
|
40
|
+
# Keep the first spelling of each name; skip blanks and repeats.
|
|
41
|
+
if name and key not in seen:
|
|
42
|
+
seen.add(key)
|
|
43
|
+
names.append(name)
|
|
44
|
+
return names
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def join_authors(names: Iterable[str]) -> str:
|
|
48
|
+
"""The stored ``author`` text for *names*, normalised as :func:`split_authors` reads it.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
names: The names, in order; blank ones are skipped.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The names joined by :data:`AUTHOR_SEPARATOR`; ``""`` when none remains.
|
|
55
|
+
"""
|
|
56
|
+
# Round-trip through split_authors so the written text is exactly what it reads back as.
|
|
57
|
+
return AUTHOR_SEPARATOR.join(split_authors(AUTHOR_SEPARATOR.join(n for n in names if n)))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def authors_key(text: str | None) -> str:
|
|
61
|
+
"""The order- and case-blind form of *text*'s names, for identity comparisons.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
text: A stored ``author`` value.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
The case-folded names, sorted and joined by :data:`AUTHOR_SEPARATOR`; ``""`` for blank.
|
|
68
|
+
"""
|
|
69
|
+
return AUTHOR_SEPARATOR.join(sorted(fold(name) for name in split_authors(text)))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def includes_authors(text: str | None, wanted: str | None) -> bool:
|
|
73
|
+
"""Whether *text* names every author *wanted* names, in any order and case.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
text: A book's stored ``author`` value.
|
|
77
|
+
wanted: One name, or several joined by :data:`AUTHOR_SEPARATOR`.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
``True`` when every name of *wanted* is one of *text*'s; ``False`` when *wanted* is blank.
|
|
81
|
+
"""
|
|
82
|
+
want = {fold(name) for name in split_authors(wanted)}
|
|
83
|
+
return bool(want) and want <= {fold(name) for name in split_authors(text)}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""What an EPUB says about its book — the OPF package metadata plus a labelled title page.
|
|
2
|
+
|
|
3
|
+
:class:`EpubMetadata` is read by :meth:`ebookerr_sdk.epub.EpubDocument.read_metadata`:
|
|
4
|
+
Dublin Core (title, creators, language, dates, publisher, subjects, typed identifiers), the
|
|
5
|
+
series in either OPF form, the ``ebookerr:`` metas the core writes (``LIB-D9``), Calibre's
|
|
6
|
+
custom columns (``LIB-D11``) and the title page's labelled fields (``LIB-D22``). The core
|
|
7
|
+
and the plugins turn it into a book record through :func:`epub_book_fields`; this module is
|
|
8
|
+
pure (no I/O).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import PurePosixPath
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ebookerr_sdk.domain.authors import join_authors
|
|
19
|
+
from ebookerr_sdk.domain.dates import parse_datetime
|
|
20
|
+
from ebookerr_sdk.domain.ids import normalise_site
|
|
21
|
+
from ebookerr_sdk.domain.metadata import format_identifiers, normalise_token_list, sanitize
|
|
22
|
+
from ebookerr_sdk.domain.series_rules import without_self_references
|
|
23
|
+
|
|
24
|
+
_LANGUAGE_NAMES = {
|
|
25
|
+
"en": "English",
|
|
26
|
+
"fr": "French",
|
|
27
|
+
"de": "German",
|
|
28
|
+
"es": "Spanish",
|
|
29
|
+
"it": "Italian",
|
|
30
|
+
"nl": "Dutch",
|
|
31
|
+
"pt": "Portuguese",
|
|
32
|
+
"ru": "Russian",
|
|
33
|
+
"ja": "Japanese",
|
|
34
|
+
"zh": "Chinese",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_LANGUAGE_CODES = {name.lower(): code for code, name in _LANGUAGE_NAMES.items()}
|
|
38
|
+
|
|
39
|
+
_NATIVE_LANGUAGE_NAMES: dict[str, str] = {
|
|
40
|
+
"english": "en",
|
|
41
|
+
"français": "fr",
|
|
42
|
+
"francais": "fr",
|
|
43
|
+
"deutsch": "de",
|
|
44
|
+
"español": "es",
|
|
45
|
+
"espanol": "es",
|
|
46
|
+
"italiano": "it",
|
|
47
|
+
"nederlands": "nl",
|
|
48
|
+
"português": "pt",
|
|
49
|
+
"portugues": "pt",
|
|
50
|
+
"русский": "ru",
|
|
51
|
+
"日本語": "ja",
|
|
52
|
+
"中文": "zh",
|
|
53
|
+
}
|
|
54
|
+
"""Languages by native name (with accent-free spellings), all lower-case."""
|
|
55
|
+
|
|
56
|
+
_LANGUAGE_CODES.update(_NATIVE_LANGUAGE_NAMES)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def language_name(code: str | None) -> str | None:
|
|
60
|
+
"""Map OPF language code to English name.
|
|
61
|
+
|
|
62
|
+
None/"" -> None; case-insensitive; region stripped ("en-US" -> "en");
|
|
63
|
+
unknown codes returned as-is.
|
|
64
|
+
"""
|
|
65
|
+
if not code:
|
|
66
|
+
return None
|
|
67
|
+
normalized = code.lower().split("-")[0]
|
|
68
|
+
return _LANGUAGE_NAMES.get(normalized, normalized)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def language_code(name: str | None) -> str:
|
|
72
|
+
"""Map a language display name — English or native — or a code to an OPF code (default 'en')."""
|
|
73
|
+
if not name:
|
|
74
|
+
return "en"
|
|
75
|
+
key = name.strip().lower()
|
|
76
|
+
return _LANGUAGE_CODES.get(key, key)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class EpubMetadata:
|
|
81
|
+
"""What one EPUB says about its book (``content.opf`` + the title page + chapter count).
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
source_url: ``dc:source`` (or the URL-scheme ``dc:identifier``) — the source of
|
|
85
|
+
both ``book_id`` and ``story_id``.
|
|
86
|
+
title: ``dc:title``.
|
|
87
|
+
author: ``dc:creator``.
|
|
88
|
+
language: ``dc:language`` (raw OPF code, e.g. ``"en"``); pass through
|
|
89
|
+
:func:`language_name` for a display name.
|
|
90
|
+
date_published: The publication ``dc:date`` (EPUB2 ``opf:event="publication"``, else the
|
|
91
|
+
first date without an event).
|
|
92
|
+
date_updated: ``dc:date``, most recent.
|
|
93
|
+
publisher: ``dc:publisher``.
|
|
94
|
+
chapter_count: Content-chapter count already computed by the EPUB reader
|
|
95
|
+
(``navPoints`` minus the title page).
|
|
96
|
+
section_url: Title-page field (``title_page.xhtml``); ``None`` when the title
|
|
97
|
+
page is absent or unparseable.
|
|
98
|
+
author_url: Title-page field; same ``None`` fallback as ``section_url``.
|
|
99
|
+
description: Title-page field; same ``None`` fallback.
|
|
100
|
+
category: Title-page field; same ``None`` fallback.
|
|
101
|
+
tags: Title-page field; same ``None`` fallback.
|
|
102
|
+
status: Title-page field; same ``None`` fallback.
|
|
103
|
+
authors: Every ``dc:creator``, in order (``LIB-D6``); empty when none.
|
|
104
|
+
subjects: Every non-empty ``dc:subject``, stripped, in order.
|
|
105
|
+
series: The series name (EPUB3 collection or ``calibre:series``), or ``None``.
|
|
106
|
+
series_index: The book's number in the series, or ``None``.
|
|
107
|
+
identifiers: Typed ``(scheme, value)`` identifiers, scheme lower-case (``LIB-D6``).
|
|
108
|
+
ebookerr: The ``ebookerr:`` metas present, key → value (``EBOOKERR_META_KEYS``).
|
|
109
|
+
calibre_user_metadata: Calibre custom columns, ``"#label"`` → JSON object (``LIB-D11``).
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
source_url: str | None # dc:source / dc:identifier[scheme=URL] -> book_id + story_url
|
|
113
|
+
title: str | None
|
|
114
|
+
author: str | None
|
|
115
|
+
language: str | None
|
|
116
|
+
date_published: str | None
|
|
117
|
+
date_updated: str | None
|
|
118
|
+
publisher: str | None
|
|
119
|
+
chapter_count: int
|
|
120
|
+
# Title-page fields (T3): None when title_page.xhtml is absent or unparseable.
|
|
121
|
+
section_url: str | None = None
|
|
122
|
+
author_url: str | None = None
|
|
123
|
+
description: str | None = None
|
|
124
|
+
category: str | None = None
|
|
125
|
+
tags: str | None = None
|
|
126
|
+
status: str | None = None
|
|
127
|
+
# Library fields (LIB-D6, D9, D11, D22)
|
|
128
|
+
authors: tuple[str, ...] = ()
|
|
129
|
+
subjects: tuple[str, ...] = ()
|
|
130
|
+
series: str | None = None
|
|
131
|
+
series_index: float | None = None
|
|
132
|
+
identifiers: tuple[tuple[str, str], ...] = ()
|
|
133
|
+
ebookerr: Mapping[str, str] = field(default_factory=dict)
|
|
134
|
+
calibre_user_metadata: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def epub_publisher(publisher: str | None, site: str | None) -> str | None:
|
|
138
|
+
"""An EPUB's ``dc:publisher`` as a publisher; ``None`` when it only names the site (``LIB-D9``).
|
|
139
|
+
|
|
140
|
+
Some tools write the source site into ``dc:publisher``, and the core's own embed writes
|
|
141
|
+
the site there for a book with no publisher; neither is a publisher.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
publisher: The ``dc:publisher`` text.
|
|
145
|
+
site: The book's site (a registrable domain), or ``None``.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
The sanitised publisher, or ``None``.
|
|
149
|
+
"""
|
|
150
|
+
value = sanitize(publisher)
|
|
151
|
+
if not value or (site and normalise_site(value) == site):
|
|
152
|
+
return None
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _epub_tags(meta: EpubMetadata) -> str | None:
|
|
157
|
+
"""The book's tags as an EPUB states them (``LIB-D9``).
|
|
158
|
+
|
|
159
|
+
A file ebookerr wrote carries ``ebookerr:`` metas and its tags in ``dc:subject``, with its
|
|
160
|
+
categories named by ``ebookerr:category`` (they are removed from the subjects). Any other
|
|
161
|
+
file's labelled title page is the more precise source; its subjects are the last resort.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
meta: What the EPUB says.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The tags as one comma-separated text, or ``None`` when there are none.
|
|
168
|
+
"""
|
|
169
|
+
category = meta.ebookerr.get("category")
|
|
170
|
+
if category:
|
|
171
|
+
drop = {token.strip().casefold() for token in category.split(",") if token.strip()}
|
|
172
|
+
kept = [subject for subject in meta.subjects if subject.casefold() not in drop]
|
|
173
|
+
return normalise_token_list(kept) or None
|
|
174
|
+
if meta.ebookerr:
|
|
175
|
+
return normalise_token_list(list(meta.subjects)) or None
|
|
176
|
+
if meta.tags:
|
|
177
|
+
return meta.tags
|
|
178
|
+
return normalise_token_list(list(meta.subjects)) or None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
_PLACEHOLDER_TITLES: frozenset[str] = frozenset({"untitled", "unknown"})
|
|
182
|
+
"""OPF titles that name no book; the file name stands in for them (``LIB-D22``)."""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def title_or_file_stem(title: str | None, file_name: str | None) -> str | None:
|
|
186
|
+
"""*title*, or the stem of *file_name* when *title* is a placeholder or blank (``LIB-D22``).
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
title: The sanitised OPF title.
|
|
190
|
+
file_name: The EPUB's file name as the door knows it (an upload's own name, the library
|
|
191
|
+
file's name, a download's name), or ``None``.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
*title* unless it is blank, ``Untitled`` or ``Unknown`` (any case); then the file
|
|
195
|
+
name's stem when there is one, else *title* unchanged.
|
|
196
|
+
"""
|
|
197
|
+
if title and title.strip().casefold() not in _PLACEHOLDER_TITLES:
|
|
198
|
+
return title
|
|
199
|
+
stem = PurePosixPath(file_name).stem.strip() if file_name else ""
|
|
200
|
+
return stem or title
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def epub_book_fields(
|
|
204
|
+
meta: EpubMetadata, *, source_url: str | None, file_name: str | None = None
|
|
205
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
206
|
+
"""The library fields an EPUB states, as every door stores them (``LIB-D9``).
|
|
207
|
+
|
|
208
|
+
``ebookerr:`` metas win over standard and Calibre metadata, which win over the labelled
|
|
209
|
+
title page. ``author`` joins every creator (``LIB-D6``); ``publisher`` is dropped when it only
|
|
210
|
+
names the site; ``tags`` follow :func:`_epub_tags`. Without *source_url* the book has no site
|
|
211
|
+
and no URL field; with it, ``site`` is its registrable domain and a work or series whose URL
|
|
212
|
+
names the book itself is dropped (``LIB-D46``). Strings are sanitised; URLs never are; dates
|
|
213
|
+
are timezone-aware UTC datetimes.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
meta: What the EPUB says.
|
|
217
|
+
source_url: The book's story URL as the door knows it, or ``None``.
|
|
218
|
+
file_name: The EPUB's file name as the door knows it, or ``None``; a placeholder OPF
|
|
219
|
+
title (``Untitled``, ``Unknown`` or blank) takes its stem (:func:`title_or_file_stem`).
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
``(fields, dropped)``: book column → value (``None`` when the file states none) for
|
|
223
|
+
``title``, ``author``, ``site``, ``publisher``, ``language`` (its display name,
|
|
224
|
+
:func:`language_name`), ``description``, ``category``, ``tags``,
|
|
225
|
+
``status``, ``series``, ``series_index``, ``identifiers``, ``date_published``,
|
|
226
|
+
``date_updated``, ``num_chapters``, ``section_url``, ``author_url``, ``series_url`` and
|
|
227
|
+
``work_url``; and the URL fields dropped as self-references (``"work_url"``,
|
|
228
|
+
``"series_url"``), for the door to log.
|
|
229
|
+
"""
|
|
230
|
+
ebookerr = meta.ebookerr
|
|
231
|
+
site = normalise_site(source_url) if source_url else None
|
|
232
|
+
authors = join_authors(sanitize(name) or "" for name in meta.authors)
|
|
233
|
+
fields: dict[str, Any] = {
|
|
234
|
+
"title": title_or_file_stem(sanitize(meta.title), file_name),
|
|
235
|
+
"author": authors or sanitize(meta.author),
|
|
236
|
+
"site": site,
|
|
237
|
+
"publisher": epub_publisher(meta.publisher, site),
|
|
238
|
+
"language": language_name(meta.language),
|
|
239
|
+
"description": sanitize(meta.description),
|
|
240
|
+
"category": sanitize(ebookerr.get("category") or meta.category),
|
|
241
|
+
"tags": sanitize(_epub_tags(meta)),
|
|
242
|
+
"status": sanitize(ebookerr.get("status") or meta.status),
|
|
243
|
+
"series": sanitize(meta.series),
|
|
244
|
+
"series_index": meta.series_index,
|
|
245
|
+
"identifiers": format_identifiers(meta.identifiers),
|
|
246
|
+
"date_published": parse_datetime(meta.date_published),
|
|
247
|
+
"date_updated": parse_datetime(meta.date_updated),
|
|
248
|
+
"num_chapters": meta.chapter_count,
|
|
249
|
+
"section_url": None,
|
|
250
|
+
"author_url": None,
|
|
251
|
+
"series_url": None,
|
|
252
|
+
"work_url": None,
|
|
253
|
+
}
|
|
254
|
+
if not source_url:
|
|
255
|
+
return fields, []
|
|
256
|
+
fields["section_url"] = meta.section_url # URL: never sanitised
|
|
257
|
+
fields["author_url"] = ebookerr.get("author_url") or meta.author_url
|
|
258
|
+
fields["series_url"] = ebookerr.get("series_url")
|
|
259
|
+
fields["work_url"] = ebookerr.get("work_url")
|
|
260
|
+
kept, dropped = without_self_references(
|
|
261
|
+
fields, story_url=source_url, work_url=fields["work_url"]
|
|
262
|
+
)
|
|
263
|
+
return kept, dropped
|
|
@@ -50,6 +50,62 @@ def site_key(url: str | None) -> str:
|
|
|
50
50
|
return host.removeprefix("www.")
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
_MULTI_PART_SUFFIXES: frozenset[str] = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"ac.uk",
|
|
56
|
+
"co.jp",
|
|
57
|
+
"co.in",
|
|
58
|
+
"co.kr",
|
|
59
|
+
"co.nz",
|
|
60
|
+
"co.uk",
|
|
61
|
+
"co.za",
|
|
62
|
+
"com.ar",
|
|
63
|
+
"com.au",
|
|
64
|
+
"com.br",
|
|
65
|
+
"com.cn",
|
|
66
|
+
"com.mx",
|
|
67
|
+
"com.tr",
|
|
68
|
+
"gov.uk",
|
|
69
|
+
"me.uk",
|
|
70
|
+
"net.au",
|
|
71
|
+
"org.au",
|
|
72
|
+
"org.uk",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def normalise_site(value: str | None) -> str | None:
|
|
78
|
+
"""Return the registrable-domain form of ``value``.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
value: A site label, hostname or URL as reported by a plugin, or ``None``.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The lowercase registrable domain for a hostname-shaped value; the trimmed input
|
|
85
|
+
otherwise (``None``, blank, multi-word label, dotless label, IPv4 literal), with
|
|
86
|
+
its case preserved. Never raises.
|
|
87
|
+
"""
|
|
88
|
+
if value is None or not value.strip():
|
|
89
|
+
return value
|
|
90
|
+
text = value.strip()
|
|
91
|
+
if "//" in text:
|
|
92
|
+
text = urlsplit(text).hostname or text
|
|
93
|
+
elif any(char.isspace() for char in text):
|
|
94
|
+
# A hand-written label such as "Example Site", not a hostname — leave it alone.
|
|
95
|
+
return text
|
|
96
|
+
text = text.split("/", 1)[0].split(":", 1)[0].rstrip(".")
|
|
97
|
+
if "." not in text:
|
|
98
|
+
# A bare label such as "examplesite"; its case is the user's, not ours to change.
|
|
99
|
+
return text
|
|
100
|
+
text = text.lower()
|
|
101
|
+
labels = text.split(".")
|
|
102
|
+
if all(label.isdigit() for label in labels):
|
|
103
|
+
return text
|
|
104
|
+
if len(labels) >= 3 and ".".join(labels[-2:]) in _MULTI_PART_SUFFIXES:
|
|
105
|
+
return ".".join(labels[-3:])
|
|
106
|
+
return ".".join(labels[-2:])
|
|
107
|
+
|
|
108
|
+
|
|
53
109
|
def make_book_id(story_url: str | None, section_url: str | None = None) -> str:
|
|
54
110
|
"""Hash the normalised story URL (falling back to the section URL)."""
|
|
55
111
|
source = story_url or section_url or ""
|