ebookerr-sdk 3.7.0__tar.gz → 3.7.2__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.7.0 → ebookerr_sdk-3.7.2}/PKG-INFO +16 -1
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/README.md +15 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/__init__.py +1 -1
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/epub_metadata.py +9 -3
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/xhtml.py +30 -10
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/readpos.py +15 -4
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/types.py +5 -3
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/wire.py +162 -66
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/PKG-INFO +16 -1
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/pyproject.toml +1 -1
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/LICENSE +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/__init__.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/adoption.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/authors.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/chapter_naming.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/chapter_number.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/dates.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/epub_hash.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/ids.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/metadata.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/series_rules.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/story_url.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/survivor.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/text_encoding.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/__init__.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/availability.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/check.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/disposition.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/document.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/identity.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/paths.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/routing.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/__init__.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/archive.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/assets.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/build.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/builder.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/chapter_numbers.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/chapters.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/document.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/errors.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/fonts.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/nav.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/ncx.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/opf.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/roles.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/safe_xml.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/serialize.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/text.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/text_convert.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/titles.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/__init__.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/client.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/output.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/prompt_template.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/support.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/host.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/__init__.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/__main__.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/build.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/changelog.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/check.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/cli.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/files.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/imports.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/index.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/scaffold.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/__init__.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/anchor_types.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/anchoring.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/capture_warnings.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/connection.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/link_attempt.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/link_refusal.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/restore_marker.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/scan_ledger.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/urls.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/py.typed +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/registry_index.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/__init__.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/context.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/declarations.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/errors.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/glyphs.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/manifest.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/material_symbols.txt +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/protocols.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/settings.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/summary_template.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/views.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/testing/__init__.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/testing/context.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/testing/views.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/testing/wire.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/__init__.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/external.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/model.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_css.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_html.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_nav.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_opf.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_package.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/validate.py +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/SOURCES.txt +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/dependency_links.txt +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/requires.txt +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/top_level.txt +0 -0
- {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ebookerr-sdk
|
|
3
|
-
Version: 3.7.
|
|
3
|
+
Version: 3.7.2
|
|
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
|
|
@@ -98,6 +98,21 @@ ebookerr's; otherwise it shows the plugin as incompatible and offers a compatibl
|
|
|
98
98
|
|
|
99
99
|
## Release notes
|
|
100
100
|
|
|
101
|
+
### 3.7.2
|
|
102
|
+
|
|
103
|
+
The wire decoders fail closed: a flag (`delete`, `upsert`, `emit_followup`, `relinked`, an asset's
|
|
104
|
+
or custom value's `delete`, `needs_update`, a view item's `selected`/`locked`/`fixed`) is true only
|
|
105
|
+
for the JSON literal `true`; a patch's `book_id` must be a non-empty string and its `fields` an
|
|
106
|
+
object with string keys; malformed custom values and assets are dropped; a story entry that is not
|
|
107
|
+
an object, and a read position whose progress is not a finite number between 0 and 1, are refused.
|
|
108
|
+
|
|
109
|
+
### 3.7.1
|
|
110
|
+
|
|
111
|
+
A title page's summary keeps its paragraphs: `TitlePageDocument.description()` — and so
|
|
112
|
+
`EpubDocument.read_metadata().description` — turns each block end and `<br>` into a line break and
|
|
113
|
+
reads the summary up to the next bold label, and `epub_book_fields` keeps those breaks
|
|
114
|
+
(`sanitize_multiline`) instead of folding the description into one line.
|
|
115
|
+
|
|
101
116
|
### 3.7.0
|
|
102
117
|
|
|
103
118
|
SPI 3.7. A plugin can check its settings when the user saves them, and the host sends each log
|
|
@@ -69,6 +69,21 @@ ebookerr's; otherwise it shows the plugin as incompatible and offers a compatibl
|
|
|
69
69
|
|
|
70
70
|
## Release notes
|
|
71
71
|
|
|
72
|
+
### 3.7.2
|
|
73
|
+
|
|
74
|
+
The wire decoders fail closed: a flag (`delete`, `upsert`, `emit_followup`, `relinked`, an asset's
|
|
75
|
+
or custom value's `delete`, `needs_update`, a view item's `selected`/`locked`/`fixed`) is true only
|
|
76
|
+
for the JSON literal `true`; a patch's `book_id` must be a non-empty string and its `fields` an
|
|
77
|
+
object with string keys; malformed custom values and assets are dropped; a story entry that is not
|
|
78
|
+
an object, and a read position whose progress is not a finite number between 0 and 1, are refused.
|
|
79
|
+
|
|
80
|
+
### 3.7.1
|
|
81
|
+
|
|
82
|
+
A title page's summary keeps its paragraphs: `TitlePageDocument.description()` — and so
|
|
83
|
+
`EpubDocument.read_metadata().description` — turns each block end and `<br>` into a line break and
|
|
84
|
+
reads the summary up to the next bold label, and `epub_book_fields` keeps those breaks
|
|
85
|
+
(`sanitize_multiline`) instead of folding the description into one line.
|
|
86
|
+
|
|
72
87
|
### 3.7.0
|
|
73
88
|
|
|
74
89
|
SPI 3.7. A plugin can check its settings when the user saves them, and the host sends each log
|
|
@@ -18,7 +18,12 @@ from typing import Any
|
|
|
18
18
|
from ebookerr_sdk.domain.authors import join_authors
|
|
19
19
|
from ebookerr_sdk.domain.dates import parse_datetime
|
|
20
20
|
from ebookerr_sdk.domain.ids import normalise_site
|
|
21
|
-
from ebookerr_sdk.domain.metadata import
|
|
21
|
+
from ebookerr_sdk.domain.metadata import (
|
|
22
|
+
format_identifiers,
|
|
23
|
+
normalise_token_list,
|
|
24
|
+
sanitize,
|
|
25
|
+
sanitize_multiline,
|
|
26
|
+
)
|
|
22
27
|
from ebookerr_sdk.domain.series_rules import without_self_references
|
|
23
28
|
|
|
24
29
|
_LANGUAGE_NAMES = {
|
|
@@ -209,7 +214,8 @@ def epub_book_fields(
|
|
|
209
214
|
title page. ``author`` joins every creator (``LIB-D6``); ``publisher`` is dropped when it only
|
|
210
215
|
names the site; ``tags`` follow :func:`_epub_tags`. Without *source_url* the book has no site
|
|
211
216
|
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``).
|
|
217
|
+
names the book itself is dropped (``LIB-D46``). The description keeps its paragraph breaks
|
|
218
|
+
(``sanitize_multiline``); every other string is sanitised to one line; URLs never are; dates
|
|
213
219
|
are timezone-aware UTC datetimes.
|
|
214
220
|
|
|
215
221
|
Args:
|
|
@@ -236,7 +242,7 @@ def epub_book_fields(
|
|
|
236
242
|
"site": site,
|
|
237
243
|
"publisher": epub_publisher(meta.publisher, site),
|
|
238
244
|
"language": language_name(meta.language),
|
|
239
|
-
"description":
|
|
245
|
+
"description": sanitize_multiline(meta.description),
|
|
240
246
|
"category": sanitize(ebookerr.get("category") or meta.category),
|
|
241
247
|
"tags": sanitize(_epub_tags(meta)),
|
|
242
248
|
"status": sanitize(ebookerr.get("status") or meta.status),
|
|
@@ -14,6 +14,7 @@ import xml.etree.ElementTree as ET
|
|
|
14
14
|
from collections.abc import Callable, Sequence
|
|
15
15
|
from urllib.parse import unquote
|
|
16
16
|
|
|
17
|
+
from ebookerr_sdk.domain.metadata import sanitize_multiline
|
|
17
18
|
from ebookerr_sdk.epub.errors import MalformedEpubError
|
|
18
19
|
from ebookerr_sdk.epub.safe_xml import safe_fromstring
|
|
19
20
|
|
|
@@ -406,6 +407,13 @@ def _strip_html(raw: str) -> str | None:
|
|
|
406
407
|
return text or None
|
|
407
408
|
|
|
408
409
|
|
|
410
|
+
_ONE_LINE_END = r"<b|\Z"
|
|
411
|
+
"""Where a one-line label value ends: the next tag starting ``<b`` (its closing ``<br />``)."""
|
|
412
|
+
|
|
413
|
+
_NEXT_BOLD = r"<b[\s>]|\Z"
|
|
414
|
+
"""Where a multi-line label value ends: the next bold tag, so its own ``<br />`` breaks stay."""
|
|
415
|
+
|
|
416
|
+
|
|
409
417
|
class TitlePageDocument:
|
|
410
418
|
"""Read the labelled fields of an EPUB title page (``LIB-D22``).
|
|
411
419
|
|
|
@@ -453,11 +461,11 @@ class TitlePageDocument:
|
|
|
453
461
|
return (a.get("href") or "").strip() or None
|
|
454
462
|
return None
|
|
455
463
|
|
|
456
|
-
def
|
|
457
|
-
"""
|
|
464
|
+
def _labeled_markup(self, label: str, *, end: str = _ONE_LINE_END) -> str | None:
|
|
465
|
+
"""Return the markup following a ``<b>Label:</b>`` up to *end*, else ``None``.
|
|
458
466
|
|
|
459
|
-
|
|
460
|
-
|
|
467
|
+
*end* is :data:`_ONE_LINE_END` for a one-line value and :data:`_NEXT_BOLD` for a
|
|
468
|
+
multi-line one.
|
|
461
469
|
"""
|
|
462
470
|
body = self._body()
|
|
463
471
|
if body is None:
|
|
@@ -466,13 +474,20 @@ class TitlePageDocument:
|
|
|
466
474
|
# handles both direct text-tail sibling patterns and nested markup.
|
|
467
475
|
raw = ET.tostring(body, encoding="unicode")
|
|
468
476
|
pattern = re.compile(
|
|
469
|
-
rf"<b(?:\s[^>]*)?>(?:{re.escape(label)}:?)</b>(.*?)(
|
|
477
|
+
rf"<b(?:\s[^>]*)?>(?:{re.escape(label)}:?)</b>(.*?)(?:{end})",
|
|
470
478
|
re.DOTALL,
|
|
471
479
|
)
|
|
472
480
|
m = pattern.search(raw)
|
|
473
|
-
if
|
|
474
|
-
|
|
475
|
-
|
|
481
|
+
return m.group(1) if m else None
|
|
482
|
+
|
|
483
|
+
def labeled(self, label: str) -> str | None:
|
|
484
|
+
"""Extract the text value for a ``<b>Label:</b> value<br/>`` pair.
|
|
485
|
+
|
|
486
|
+
Returns the stripped text content following the bold label, or ``None``
|
|
487
|
+
when the label is absent. HTML tags in the value are stripped.
|
|
488
|
+
"""
|
|
489
|
+
markup = self._labeled_markup(label)
|
|
490
|
+
return _strip_html(markup) if markup is not None else None
|
|
476
491
|
|
|
477
492
|
def labelled_values(self) -> list[tuple[str, str]]:
|
|
478
493
|
"""Every ``<b>Label:</b> value`` pair in document order (``LIB-D22``).
|
|
@@ -494,5 +509,10 @@ class TitlePageDocument:
|
|
|
494
509
|
return pairs
|
|
495
510
|
|
|
496
511
|
def description(self) -> str | None:
|
|
497
|
-
"""The story summary
|
|
498
|
-
|
|
512
|
+
"""The story summary with its paragraph breaks kept, ``None`` when empty (``LIB-D9``).
|
|
513
|
+
|
|
514
|
+
The summary is a multi-paragraph field, so its markup goes through
|
|
515
|
+
:func:`~ebookerr_sdk.domain.metadata.sanitize_multiline` — a block end or ``<br>`` becomes a
|
|
516
|
+
line break — where every other label is stripped to one line (:meth:`labeled`).
|
|
517
|
+
"""
|
|
518
|
+
return sanitize_multiline(self._labeled_markup("Summary", end=_NEXT_BOLD)) or None
|
|
@@ -17,6 +17,7 @@ bookmark back stay in each provider plugin (``RPH-ARCH-3``).
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import logging
|
|
20
|
+
import math
|
|
20
21
|
import re
|
|
21
22
|
from collections.abc import Mapping, Sequence
|
|
22
23
|
from dataclasses import dataclass
|
|
@@ -102,8 +103,9 @@ def read_position_from_dict(data: Mapping[str, Any] | None) -> ReadPosition | No
|
|
|
102
103
|
Returns:
|
|
103
104
|
The reconstructed ``ReadPosition``, or ``None`` when ``data`` is ``None``,
|
|
104
105
|
not a mapping, or missing/mistyped one of the required keys
|
|
105
|
-
(``captured_at``, ``chapter_index``, ``chapter_progress``)
|
|
106
|
-
|
|
106
|
+
(``captured_at``, ``chapter_index``, ``chapter_progress``), or ``chapter_index`` is
|
|
107
|
+
a bool or negative, or ``chapter_progress`` is a bool, not finite or outside 0..1.
|
|
108
|
+
Optional keys default per the ``ReadPosition`` dataclass when absent.
|
|
107
109
|
"""
|
|
108
110
|
if data is None or not isinstance(data, Mapping):
|
|
109
111
|
return None
|
|
@@ -117,10 +119,19 @@ def read_position_from_dict(data: Mapping[str, Any] | None) -> ReadPosition | No
|
|
|
117
119
|
# Check required fields exist and have correct types
|
|
118
120
|
if captured_at is None or not isinstance(captured_at, str):
|
|
119
121
|
return None
|
|
120
|
-
if
|
|
122
|
+
if (
|
|
123
|
+
not isinstance(chapter_index, int)
|
|
124
|
+
or isinstance(chapter_index, bool)
|
|
125
|
+
or chapter_index < 0
|
|
126
|
+
):
|
|
121
127
|
return None
|
|
122
128
|
# Accept int for float (will be coerced by dataclass)
|
|
123
|
-
if
|
|
129
|
+
if (
|
|
130
|
+
not isinstance(chapter_progress, (int, float))
|
|
131
|
+
or isinstance(chapter_progress, bool)
|
|
132
|
+
or not math.isfinite(chapter_progress)
|
|
133
|
+
or not 0 <= chapter_progress <= 1
|
|
134
|
+
):
|
|
124
135
|
return None
|
|
125
136
|
|
|
126
137
|
# Extract optional fields with defaults
|
|
@@ -592,11 +592,13 @@ class BookPatch:
|
|
|
592
592
|
|
|
593
593
|
Attributes:
|
|
594
594
|
book_id: For a Source, derived from the URL (the upsert key).
|
|
595
|
-
fields: Any native ``Book`` column, including every user-editable
|
|
596
|
-
``author``, ``author_url``, ``series``, ``series_url``, ``category``,
|
|
595
|
+
fields: Any native ``Book`` column the core does not own, including every user-editable
|
|
596
|
+
field (``title``, ``author``, ``author_url``, ``series``, ``series_url``, ``category``,
|
|
597
597
|
``tags``, ``status``, ``site``, ``language``, ``date_published``,
|
|
598
598
|
``description``, ``story_url``, ``rating``, ``auto_pull``, ``synopsis``) — no
|
|
599
|
-
whitelist (``DEC-4``). A
|
|
599
|
+
whitelist (``DEC-4``). A key naming a core-owned or unknown column, or a value that
|
|
600
|
+
does not fit its column, is left out with one WARNING and the rest of the patch
|
|
601
|
+
applies (``TR-DATA-9``). A write lands in the ``books`` column, except for a field the
|
|
600
602
|
user has locked by editing it: their value stays and the incoming value is recorded
|
|
601
603
|
as the lock's source value, what a revert restores (``LIB-D2``).
|
|
602
604
|
custom_values: This plugin's own custom-value writes, namespaced to its manifest id at
|
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
import base64
|
|
17
17
|
import binascii
|
|
18
18
|
import logging
|
|
19
|
+
import math
|
|
19
20
|
from collections.abc import Mapping, Sequence
|
|
20
21
|
from datetime import datetime
|
|
21
22
|
from pathlib import Path
|
|
@@ -226,20 +227,65 @@ def _wire_meta(raw: Any) -> dict[str, str]:
|
|
|
226
227
|
return {str(k): str(v) for k, v in raw.items() if v is not None}
|
|
227
228
|
|
|
228
229
|
|
|
230
|
+
def _flag(value: object, name: str) -> bool:
|
|
231
|
+
"""A wire boolean: ``True`` only for the JSON literal ``true`` (``value is True``).
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
value: The decoded wire value (``None`` when the key was absent).
|
|
235
|
+
name: The wire key, used only for the DEBUG line.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
``True`` only when *value* is the boolean ``True``.
|
|
239
|
+
"""
|
|
240
|
+
if value is not True and value is not False and value is not None:
|
|
241
|
+
logger.debug("Wire flag %s=%r is not a JSON boolean; read as false", name, value)
|
|
242
|
+
return value is True
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _wire_int(value: object, default: int) -> int:
|
|
246
|
+
"""A wire integer: a non-bool int, or a finite float with an integral value; else default.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
value: The decoded wire value.
|
|
250
|
+
default: The result when *value* is not a usable integer.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
The integer, or *default*.
|
|
254
|
+
"""
|
|
255
|
+
if isinstance(value, bool):
|
|
256
|
+
return default
|
|
257
|
+
if isinstance(value, int):
|
|
258
|
+
return value
|
|
259
|
+
if isinstance(value, float) and math.isfinite(value) and value.is_integer():
|
|
260
|
+
return int(value)
|
|
261
|
+
return default
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _str_or_none(value: object) -> str | None:
|
|
265
|
+
"""Return *value* when it is a string, else ``None``."""
|
|
266
|
+
return value if isinstance(value, str) else None
|
|
267
|
+
|
|
268
|
+
|
|
229
269
|
def _wire_patch_assets(assets_raw: Any) -> list[AssetWrite]:
|
|
230
270
|
"""Decode patch assets from the wire, dropping malformed entries and invalid base64 data.
|
|
231
271
|
|
|
272
|
+
An entry whose ``kind`` or ``name`` is not a non-empty string is dropped; ``path`` and
|
|
273
|
+
``media_type`` are kept only when strings; ``delete`` is true only for JSON ``true``.
|
|
274
|
+
|
|
232
275
|
Args:
|
|
233
|
-
assets_raw: The raw assets list from the patch dict.
|
|
276
|
+
assets_raw: The raw assets list from the patch dict (anything but a list is empty).
|
|
234
277
|
|
|
235
278
|
Returns:
|
|
236
279
|
A list of decoded `AssetWrite` objects, with invalid entries dropped.
|
|
237
280
|
"""
|
|
281
|
+
if not isinstance(assets_raw, list):
|
|
282
|
+
return []
|
|
238
283
|
assets = []
|
|
239
|
-
for asset_dict in assets_raw
|
|
284
|
+
for asset_dict in assets_raw:
|
|
240
285
|
if not isinstance(asset_dict, dict):
|
|
241
286
|
continue
|
|
242
|
-
|
|
287
|
+
kind, name = asset_dict.get("kind"), asset_dict.get("name")
|
|
288
|
+
if not isinstance(kind, str) or not kind or not isinstance(name, str) or not name:
|
|
243
289
|
continue
|
|
244
290
|
data_decoded: bytes | None = None
|
|
245
291
|
data_raw = asset_dict.get("data")
|
|
@@ -249,17 +295,17 @@ def _wire_patch_assets(assets_raw: Any) -> list[AssetWrite]:
|
|
|
249
295
|
except (binascii.Error, ValueError):
|
|
250
296
|
logger.warning(
|
|
251
297
|
"Dropped an exec plugin asset with invalid base64 data: kind=%s name=%s",
|
|
252
|
-
|
|
253
|
-
|
|
298
|
+
kind,
|
|
299
|
+
name,
|
|
254
300
|
)
|
|
255
301
|
continue
|
|
256
302
|
assets.append(
|
|
257
303
|
AssetWrite(
|
|
258
|
-
kind=
|
|
259
|
-
name=
|
|
260
|
-
path=asset_dict.get("path"),
|
|
261
|
-
media_type=asset_dict.get("media_type"),
|
|
262
|
-
delete=
|
|
304
|
+
kind=kind,
|
|
305
|
+
name=name,
|
|
306
|
+
path=_str_or_none(asset_dict.get("path")),
|
|
307
|
+
media_type=_str_or_none(asset_dict.get("media_type")),
|
|
308
|
+
delete=_flag(asset_dict.get("delete"), "asset delete"),
|
|
263
309
|
data=data_decoded,
|
|
264
310
|
meta=_wire_meta(asset_dict.get("meta")),
|
|
265
311
|
)
|
|
@@ -530,23 +576,24 @@ def decode_book_patch(data: Mapping[str, Any]) -> BookPatch:
|
|
|
530
576
|
|
|
531
577
|
| Wire key | Notes |
|
|
532
578
|
|---|---|
|
|
533
|
-
| `book_id` | Required
|
|
534
|
-
| `fields` |
|
|
535
|
-
| `custom_values` | Bare keys (no prefix); `{value, value_type, updated_at?, delete?}
|
|
536
|
-
|
|
|
579
|
+
| `book_id` | Required string (`""` for a conversion patch); `WireError` if absent or not one |
|
|
580
|
+
| `fields` | Object with string keys, any native `Book` column; else `WireError` |
|
|
581
|
+
| `custom_values` | Bare keys (no prefix); `{value, value_type, updated_at?, delete?}`; |
|
|
582
|
+
| | | malformed entries are dropped |
|
|
583
|
+
| `delete`, `upsert`, `emit_followup` | JSON `true` only, default `False` |
|
|
537
584
|
| `assets` | Top-level (never inside `fields`); `{kind, name, path?, media_type?, |
|
|
538
585
|
| | | delete?, data? (base64, SPI 2.25), meta? (object of strings, SPI 2.25)}` |
|
|
539
586
|
| `chapters` | Top-level; `{url, title?, ordinal?}` (SPI 2.22) |
|
|
540
587
|
| `read_position` | Via `read_position_from_dict` |
|
|
541
|
-
| `relinked` |
|
|
588
|
+
| `relinked` | JSON `true` only, default False (SPI 2.19) |
|
|
542
589
|
| `superseded_by` | Optional non-empty string (SPI 2.23); any other value is dropped to |
|
|
543
590
|
| | | `None` |
|
|
544
591
|
| `logs` | Optional; entries `{"level", "message"}` bridged by `parse_response`, |
|
|
545
592
|
| | | never stored |
|
|
546
593
|
|
|
547
|
-
An `assets` entry
|
|
548
|
-
blank `url`, is silently dropped rather than raising. For `ordinal`: kept as int
|
|
549
|
-
dropped (set to `None`) for non-int values, bools, or negatives.
|
|
594
|
+
An `assets` entry whose `kind`/`name` is not a non-empty string, or a `chapters` entry with a
|
|
595
|
+
missing or blank `url`, is silently dropped rather than raising. For `ordinal`: kept as int
|
|
596
|
+
when ≥0, dropped (set to `None`) for non-int values, bools, or negatives.
|
|
550
597
|
An asset whose `data` is not valid base64 is dropped with a WARNING.
|
|
551
598
|
|
|
552
599
|
Args:
|
|
@@ -556,58 +603,96 @@ def decode_book_patch(data: Mapping[str, Any]) -> BookPatch:
|
|
|
556
603
|
The parsed `BookPatch`.
|
|
557
604
|
|
|
558
605
|
Raises:
|
|
559
|
-
WireError: If `book_id` is missing
|
|
606
|
+
WireError: If `book_id` is missing or not a string, or `fields` is not an
|
|
607
|
+
object with string keys.
|
|
560
608
|
"""
|
|
561
609
|
if "book_id" not in data:
|
|
562
610
|
raise WireError("book_id is required")
|
|
611
|
+
book_id = data["book_id"]
|
|
612
|
+
# A conversion patch carries ``""`` (no book exists yet): only a non-string is refused.
|
|
613
|
+
if not isinstance(book_id, str):
|
|
614
|
+
raise WireError("book_id must be a string")
|
|
563
615
|
|
|
564
|
-
|
|
565
|
-
custom_values =
|
|
566
|
-
k: CustomValueWrite(
|
|
567
|
-
value=v["value"],
|
|
568
|
-
value_type=v["value_type"],
|
|
569
|
-
updated_at=v.get("updated_at"),
|
|
570
|
-
delete=bool(v.get("delete", False)),
|
|
571
|
-
)
|
|
572
|
-
for k, v in custom_values_raw.items()
|
|
573
|
-
}
|
|
574
|
-
|
|
616
|
+
fields = _wire_fields(data.get("fields"))
|
|
617
|
+
custom_values = _wire_custom_values(data.get("custom_values"))
|
|
575
618
|
assets = _wire_patch_assets(data.get("assets", []))
|
|
576
|
-
|
|
577
|
-
chapters_raw = data.get("chapters", [])
|
|
578
|
-
chapters = []
|
|
579
|
-
if isinstance(chapters_raw, list):
|
|
580
|
-
for entry in chapters_raw:
|
|
581
|
-
if not isinstance(entry, dict):
|
|
582
|
-
continue
|
|
583
|
-
url = entry.get("url")
|
|
584
|
-
if not isinstance(url, str) or not url:
|
|
585
|
-
logger.debug("Dropped invalid chapter entry: %r", entry)
|
|
586
|
-
continue
|
|
587
|
-
title = entry.get("title")
|
|
588
|
-
if title is not None:
|
|
589
|
-
title = str(title)
|
|
590
|
-
ordinal = _parse_chapter_ordinal(entry.get("ordinal"))
|
|
591
|
-
chapters.append(ChapterLink(url=url, title=title, ordinal=ordinal))
|
|
619
|
+
chapters = _wire_chapters(data.get("chapters", []))
|
|
592
620
|
|
|
593
621
|
superseded_raw = data.get("superseded_by")
|
|
594
622
|
superseded_by = superseded_raw if isinstance(superseded_raw, str) and superseded_raw else None
|
|
595
623
|
|
|
596
624
|
return BookPatch(
|
|
597
|
-
book_id=
|
|
598
|
-
fields=
|
|
625
|
+
book_id=book_id,
|
|
626
|
+
fields=fields,
|
|
599
627
|
custom_values=custom_values,
|
|
600
|
-
delete=
|
|
601
|
-
upsert=
|
|
602
|
-
emit_followup=
|
|
628
|
+
delete=_flag(data.get("delete"), "delete"),
|
|
629
|
+
upsert=_flag(data.get("upsert"), "upsert"),
|
|
630
|
+
emit_followup=_flag(data.get("emit_followup"), "emit_followup"),
|
|
603
631
|
assets=tuple(assets),
|
|
604
632
|
chapters=tuple(chapters),
|
|
605
633
|
read_position=read_position_from_dict(data.get("read_position")),
|
|
606
|
-
relinked=
|
|
634
|
+
relinked=_flag(data.get("relinked"), "relinked"),
|
|
607
635
|
superseded_by=superseded_by,
|
|
608
636
|
)
|
|
609
637
|
|
|
610
638
|
|
|
639
|
+
def _wire_fields(raw: object) -> dict[str, Any]:
|
|
640
|
+
"""Decode a patch's ``fields``: absent or null is empty, else an object with string keys."""
|
|
641
|
+
if raw is None:
|
|
642
|
+
return {}
|
|
643
|
+
if not isinstance(raw, Mapping):
|
|
644
|
+
raise WireError("fields must be an object")
|
|
645
|
+
if not all(isinstance(k, str) for k in raw):
|
|
646
|
+
raise WireError("fields keys must be strings")
|
|
647
|
+
return dict(raw)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _wire_custom_values(raw: object) -> dict[str, CustomValueWrite]:
|
|
651
|
+
"""Decode a patch's ``custom_values``, dropping every malformed entry."""
|
|
652
|
+
if not isinstance(raw, Mapping):
|
|
653
|
+
if raw is not None:
|
|
654
|
+
logger.debug("Dropped invalid custom_values: %r", raw)
|
|
655
|
+
return {}
|
|
656
|
+
custom_values: dict[str, CustomValueWrite] = {}
|
|
657
|
+
for key, entry in raw.items():
|
|
658
|
+
if (
|
|
659
|
+
not isinstance(key, str)
|
|
660
|
+
or not key
|
|
661
|
+
or not isinstance(entry, Mapping)
|
|
662
|
+
or "value" not in entry
|
|
663
|
+
or "value_type" not in entry
|
|
664
|
+
):
|
|
665
|
+
logger.debug("Dropped invalid custom value %r", key)
|
|
666
|
+
continue
|
|
667
|
+
custom_values[key] = CustomValueWrite(
|
|
668
|
+
value=entry["value"],
|
|
669
|
+
value_type=entry["value_type"],
|
|
670
|
+
updated_at=entry.get("updated_at"),
|
|
671
|
+
delete=_flag(entry.get("delete"), "custom value delete"),
|
|
672
|
+
)
|
|
673
|
+
return custom_values
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _wire_chapters(raw: object) -> list[ChapterLink]:
|
|
677
|
+
"""Decode a patch's ``chapters``, dropping entries without a usable url."""
|
|
678
|
+
chapters: list[ChapterLink] = []
|
|
679
|
+
if not isinstance(raw, list):
|
|
680
|
+
return chapters
|
|
681
|
+
for entry in raw:
|
|
682
|
+
if not isinstance(entry, dict):
|
|
683
|
+
continue
|
|
684
|
+
url = entry.get("url")
|
|
685
|
+
if not isinstance(url, str) or not url:
|
|
686
|
+
logger.debug("Dropped invalid chapter entry: %r", entry)
|
|
687
|
+
continue
|
|
688
|
+
title = entry.get("title")
|
|
689
|
+
if title is not None:
|
|
690
|
+
title = str(title)
|
|
691
|
+
ordinal = _parse_chapter_ordinal(entry.get("ordinal"))
|
|
692
|
+
chapters.append(ChapterLink(url=url, title=title, ordinal=ordinal))
|
|
693
|
+
return chapters
|
|
694
|
+
|
|
695
|
+
|
|
611
696
|
def encode_book_patch(patch: BookPatch) -> dict[str, Any]:
|
|
612
697
|
"""Serialize a ``BookPatch`` to the wire object :func:`decode_book_patch` reads.
|
|
613
698
|
|
|
@@ -688,7 +773,7 @@ def decode_update_check(data: Mapping[str, Any]) -> UpdateCheck:
|
|
|
688
773
|
|
|
689
774
|
| Wire key | Type | Notes |
|
|
690
775
|
|---|---|---|
|
|
691
|
-
| `needs_update` | bool |
|
|
776
|
+
| `needs_update` | bool | JSON `true` only; anything else, or absent, means `False` |
|
|
692
777
|
| `meta` | object or null | Kept only when it is a mapping; anything else becomes `None` |
|
|
693
778
|
| `book_id` | string or null | — |
|
|
694
779
|
| `error` | string or null | Set by the plugin when its own check failed |
|
|
@@ -707,7 +792,7 @@ def decode_update_check(data: Mapping[str, Any]) -> UpdateCheck:
|
|
|
707
792
|
fields_raw = data.get("fields")
|
|
708
793
|
fields = dict(fields_raw) if isinstance(fields_raw, Mapping) else {}
|
|
709
794
|
return UpdateCheck(
|
|
710
|
-
needs_update=
|
|
795
|
+
needs_update=_flag(data.get("needs_update"), "needs_update"),
|
|
711
796
|
meta=meta,
|
|
712
797
|
book_id=str(book_id_raw) if book_id_raw is not None else None,
|
|
713
798
|
error=str(error_raw) if error_raw is not None else None,
|
|
@@ -729,13 +814,18 @@ def encode_read_state_changes(changes: ReadStateChanges) -> dict[str, Any]:
|
|
|
729
814
|
|
|
730
815
|
|
|
731
816
|
def decode_read_state_changes(data: Mapping[str, Any]) -> ReadStateChanges:
|
|
732
|
-
"""Rebuild ReadStateChanges; a missing list is empty
|
|
817
|
+
"""Rebuild ReadStateChanges; a missing or non-list list is empty, a non-string marker ``None``.
|
|
818
|
+
|
|
819
|
+
``more`` is true only for the JSON literal ``true``.
|
|
820
|
+
"""
|
|
733
821
|
marker = data.get("marker")
|
|
822
|
+
items = data.get("items")
|
|
823
|
+
collections = data.get("collections")
|
|
734
824
|
return ReadStateChanges(
|
|
735
|
-
items=tuple(str(i) for i in
|
|
736
|
-
collections=tuple(str(c) for c in
|
|
825
|
+
items=tuple(str(i) for i in items) if isinstance(items, list) else (),
|
|
826
|
+
collections=tuple(str(c) for c in collections) if isinstance(collections, list) else (),
|
|
737
827
|
marker=marker if isinstance(marker, str) else None,
|
|
738
|
-
more=
|
|
828
|
+
more=_flag(data.get("more"), "more"),
|
|
739
829
|
)
|
|
740
830
|
|
|
741
831
|
|
|
@@ -797,8 +887,10 @@ def decode_story_patch(data: Mapping[str, Any]) -> StoryPatch: # noqa: C901
|
|
|
797
887
|
The parsed `StoryPatch`.
|
|
798
888
|
|
|
799
889
|
Raises:
|
|
800
|
-
WireError: If ``url`` is missing or blank.
|
|
890
|
+
WireError: If ``data`` is not an object, or ``url`` is missing or blank.
|
|
801
891
|
"""
|
|
892
|
+
if not isinstance(data, Mapping):
|
|
893
|
+
raise WireError("story entry must be an object")
|
|
802
894
|
url = data.get("url")
|
|
803
895
|
if not isinstance(url, str) or not url:
|
|
804
896
|
raise WireError("story url is required")
|
|
@@ -900,9 +992,9 @@ def _decode_view_items(items_data: Any) -> tuple[ViewItem, ...]:
|
|
|
900
992
|
item_label = str(item_raw.get("label", ""))
|
|
901
993
|
sublabel = str(item_raw.get("sublabel", ""))
|
|
902
994
|
badges = tuple(str(b) for b in item_raw.get("badges", []))
|
|
903
|
-
selected =
|
|
904
|
-
locked =
|
|
905
|
-
fixed =
|
|
995
|
+
selected = True if "selected" not in item_raw else _flag(item_raw["selected"], "selected")
|
|
996
|
+
locked = _flag(item_raw.get("locked"), "locked")
|
|
997
|
+
fixed = _flag(item_raw.get("fixed"), "fixed")
|
|
906
998
|
fixed_reason = str(item_raw.get("fixed_reason", ""))
|
|
907
999
|
|
|
908
1000
|
items.append(
|
|
@@ -944,6 +1036,10 @@ def _decode_view_fields(fields_data: Any) -> tuple[SettingsField, ...]:
|
|
|
944
1036
|
def decode_plugin_view(raw: Mapping[str, Any]) -> PluginView:
|
|
945
1037
|
"""Rebuild a ``PluginView`` from an exec plugin's ``op: "view"`` frame (``PV-FR-4``).
|
|
946
1038
|
|
|
1039
|
+
Fail-closed: a section or item flag is true only for JSON ``true`` (an item without
|
|
1040
|
+
``selected`` is selected), and ``min_selected`` that is not a usable integer is ``0``.
|
|
1041
|
+
A truthy non-boolean ``danger`` is read as dangerous (the safe direction).
|
|
1042
|
+
|
|
947
1043
|
Args:
|
|
948
1044
|
raw: The frame's ``view`` object.
|
|
949
1045
|
|
|
@@ -980,10 +1076,10 @@ def decode_plugin_view(raw: Mapping[str, Any]) -> PluginView:
|
|
|
980
1076
|
|
|
981
1077
|
label = str(section_raw.get("label", ""))
|
|
982
1078
|
text = str(section_raw.get("text", ""))
|
|
983
|
-
selectable =
|
|
984
|
-
reorderable =
|
|
985
|
-
min_selected =
|
|
986
|
-
select_all =
|
|
1079
|
+
selectable = _flag(section_raw.get("selectable"), "selectable")
|
|
1080
|
+
reorderable = _flag(section_raw.get("reorderable"), "reorderable")
|
|
1081
|
+
min_selected = _wire_int(section_raw.get("min_selected", 0), 0)
|
|
1082
|
+
select_all = _flag(section_raw.get("select_all"), "select_all")
|
|
987
1083
|
item_verb = str(section_raw.get("item_verb", "Select"))
|
|
988
1084
|
|
|
989
1085
|
items = _decode_view_items(section_raw.get("items", []))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ebookerr-sdk
|
|
3
|
-
Version: 3.7.
|
|
3
|
+
Version: 3.7.2
|
|
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
|
|
@@ -98,6 +98,21 @@ ebookerr's; otherwise it shows the plugin as incompatible and offers a compatibl
|
|
|
98
98
|
|
|
99
99
|
## Release notes
|
|
100
100
|
|
|
101
|
+
### 3.7.2
|
|
102
|
+
|
|
103
|
+
The wire decoders fail closed: a flag (`delete`, `upsert`, `emit_followup`, `relinked`, an asset's
|
|
104
|
+
or custom value's `delete`, `needs_update`, a view item's `selected`/`locked`/`fixed`) is true only
|
|
105
|
+
for the JSON literal `true`; a patch's `book_id` must be a non-empty string and its `fields` an
|
|
106
|
+
object with string keys; malformed custom values and assets are dropped; a story entry that is not
|
|
107
|
+
an object, and a read position whose progress is not a finite number between 0 and 1, are refused.
|
|
108
|
+
|
|
109
|
+
### 3.7.1
|
|
110
|
+
|
|
111
|
+
A title page's summary keeps its paragraphs: `TitlePageDocument.description()` — and so
|
|
112
|
+
`EpubDocument.read_metadata().description` — turns each block end and `<br>` into a line break and
|
|
113
|
+
reads the summary up to the next bold label, and `epub_book_fields` keeps those breaks
|
|
114
|
+
(`sanitize_multiline`) instead of folding the description into one line.
|
|
115
|
+
|
|
101
116
|
### 3.7.0
|
|
102
117
|
|
|
103
118
|
SPI 3.7. A plugin can check its settings when the user saves them, and the host sends each log
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ebookerr-sdk"
|
|
7
|
-
version = "3.7.
|
|
7
|
+
version = "3.7.2"
|
|
8
8
|
description = "The ebookerr plugin SDK: the plugin contract, the plugin host, a test kit, the pack tool and helper kits."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|