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.
Files changed (108) hide show
  1. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/PKG-INFO +16 -1
  2. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/README.md +15 -0
  3. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/__init__.py +1 -1
  4. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/epub_metadata.py +9 -3
  5. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/xhtml.py +30 -10
  6. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/readpos.py +15 -4
  7. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/types.py +5 -3
  8. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/wire.py +162 -66
  9. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/PKG-INFO +16 -1
  10. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/pyproject.toml +1 -1
  11. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/LICENSE +0 -0
  12. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/__init__.py +0 -0
  13. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/adoption.py +0 -0
  14. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/authors.py +0 -0
  15. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/chapter_naming.py +0 -0
  16. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/chapter_number.py +0 -0
  17. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/dates.py +0 -0
  18. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/epub_hash.py +0 -0
  19. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/ids.py +0 -0
  20. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/metadata.py +0 -0
  21. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/series_rules.py +0 -0
  22. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/story_url.py +0 -0
  23. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/survivor.py +0 -0
  24. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/text_encoding.py +0 -0
  25. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/__init__.py +0 -0
  26. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/availability.py +0 -0
  27. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/check.py +0 -0
  28. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/disposition.py +0 -0
  29. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/document.py +0 -0
  30. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/identity.py +0 -0
  31. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/paths.py +0 -0
  32. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/routing.py +0 -0
  33. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/__init__.py +0 -0
  34. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/archive.py +0 -0
  35. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/assets.py +0 -0
  36. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/build.py +0 -0
  37. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/builder.py +0 -0
  38. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/chapter_numbers.py +0 -0
  39. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/chapters.py +0 -0
  40. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/document.py +0 -0
  41. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/errors.py +0 -0
  42. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/fonts.py +0 -0
  43. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/nav.py +0 -0
  44. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/ncx.py +0 -0
  45. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/opf.py +0 -0
  46. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/roles.py +0 -0
  47. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/safe_xml.py +0 -0
  48. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/serialize.py +0 -0
  49. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/text.py +0 -0
  50. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/text_convert.py +0 -0
  51. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/titles.py +0 -0
  52. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/__init__.py +0 -0
  53. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/client.py +0 -0
  54. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/output.py +0 -0
  55. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/prompt_template.py +0 -0
  56. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/support.py +0 -0
  57. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/host.py +0 -0
  58. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/__init__.py +0 -0
  59. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/__main__.py +0 -0
  60. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/build.py +0 -0
  61. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/changelog.py +0 -0
  62. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/check.py +0 -0
  63. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/cli.py +0 -0
  64. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/files.py +0 -0
  65. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/imports.py +0 -0
  66. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/index.py +0 -0
  67. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/scaffold.py +0 -0
  68. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/__init__.py +0 -0
  69. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/anchor_types.py +0 -0
  70. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/anchoring.py +0 -0
  71. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/capture_warnings.py +0 -0
  72. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/connection.py +0 -0
  73. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/link_attempt.py +0 -0
  74. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/link_refusal.py +0 -0
  75. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/restore_marker.py +0 -0
  76. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/scan_ledger.py +0 -0
  77. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/urls.py +0 -0
  78. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/py.typed +0 -0
  79. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/registry_index.py +0 -0
  80. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/__init__.py +0 -0
  81. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/context.py +0 -0
  82. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/declarations.py +0 -0
  83. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/errors.py +0 -0
  84. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/glyphs.py +0 -0
  85. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/manifest.py +0 -0
  86. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/material_symbols.txt +0 -0
  87. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/protocols.py +0 -0
  88. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/settings.py +0 -0
  89. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/summary_template.py +0 -0
  90. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/views.py +0 -0
  91. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/testing/__init__.py +0 -0
  92. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/testing/context.py +0 -0
  93. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/testing/views.py +0 -0
  94. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/testing/wire.py +0 -0
  95. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/__init__.py +0 -0
  96. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/external.py +0 -0
  97. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/model.py +0 -0
  98. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_css.py +0 -0
  99. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_html.py +0 -0
  100. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_nav.py +0 -0
  101. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_opf.py +0 -0
  102. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_package.py +0 -0
  103. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/validate.py +0 -0
  104. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/SOURCES.txt +0 -0
  105. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/dependency_links.txt +0 -0
  106. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/requires.txt +0 -0
  107. {ebookerr_sdk-3.7.0 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/top_level.txt +0 -0
  108. {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.0
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
@@ -44,4 +44,4 @@ ruff D, pdoc, mutmut and the traceability gate all cover the SDK; it has its own
44
44
  ``pack/``, so a fetched index and a built one are validated by exactly the same rules.
45
45
  """
46
46
 
47
- __version__ = "3.7.0"
47
+ __version__ = "3.7.2"
@@ -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 format_identifiers, normalise_token_list, sanitize
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``). Strings are sanitised; URLs never are; dates
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": sanitize(meta.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 labeled(self, label: str) -> str | None:
457
- """Extract the text value for a ``<b>Label:</b> value<br/>`` pair.
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
- Returns the stripped text content following the bold label, or ``None``
460
- when the label is absent. HTML tags in the value are stripped.
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>(.*?)(?:<b|\Z)",
477
+ rf"<b(?:\s[^>]*)?>(?:{re.escape(label)}:?)</b>(.*?)(?:{end})",
470
478
  re.DOTALL,
471
479
  )
472
480
  m = pattern.search(raw)
473
- if not m:
474
- return None
475
- return _strip_html(m.group(1)) or None
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 — HTML-stripped, ``None`` when empty."""
498
- return self.labeled("Summary")
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``). Optional keys
106
- default per the ``ReadPosition`` dataclass when absent.
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 chapter_index is None or not isinstance(chapter_index, int):
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 chapter_progress is None or not isinstance(chapter_progress, (int, float)):
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 field (``title``,
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 write lands in the ``books`` column, except for a field the
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 or []:
284
+ for asset_dict in assets_raw:
240
285
  if not isinstance(asset_dict, dict):
241
286
  continue
242
- if "kind" not in asset_dict or "name" not in asset_dict:
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
- asset_dict["kind"],
253
- asset_dict["name"],
298
+ kind,
299
+ name,
254
300
  )
255
301
  continue
256
302
  assets.append(
257
303
  AssetWrite(
258
- kind=asset_dict["kind"],
259
- name=asset_dict["name"],
260
- path=asset_dict.get("path"),
261
- media_type=asset_dict.get("media_type"),
262
- delete=bool(asset_dict.get("delete", False)),
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; raises `WireError` if absent |
534
- | `fields` | Any native `Book` column |
535
- | `custom_values` | Bare keys (no prefix); `{value, value_type, updated_at?, delete?}` |
536
- | `delete`, `upsert`, `emit_followup` | Boolean, default `False` |
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` | Boolean, default False (SPI 2.19) |
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 missing `kind`/`name`, or a `chapters` entry with a missing or
548
- blank `url`, is silently dropped rather than raising. For `ordinal`: kept as int when ≥0,
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
- custom_values_raw = data.get("custom_values", {})
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=data["book_id"],
598
- fields=dict(data.get("fields", {})),
625
+ book_id=book_id,
626
+ fields=fields,
599
627
  custom_values=custom_values,
600
- delete=bool(data.get("delete", False)),
601
- upsert=bool(data.get("upsert", False)),
602
- emit_followup=bool(data.get("emit_followup", False)),
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=bool(data.get("relinked", False)),
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 | Coerced with `bool()`; absent means `False` |
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=bool(data.get("needs_update", False)),
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; a non-string marker is ``None``."""
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 data.get("items") or ()),
736
- collections=tuple(str(c) for c in data.get("collections") or ()),
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=bool(data.get("more", False)),
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 = bool(item_raw.get("selected", True))
904
- locked = bool(item_raw.get("locked", False))
905
- fixed = bool(item_raw.get("fixed", False))
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 = bool(section_raw.get("selectable", False))
984
- reorderable = bool(section_raw.get("reorderable", False))
985
- min_selected = int(section_raw.get("min_selected", 0))
986
- select_all = bool(section_raw.get("select_all", False))
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.0
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.0"
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