ebookerr-sdk 2.30.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.
Files changed (91) hide show
  1. ebookerr_sdk-2.30.0/PKG-INFO +13 -0
  2. ebookerr_sdk-2.30.0/README.md +30 -0
  3. ebookerr_sdk-2.30.0/ebookerr_sdk/__init__.py +9 -0
  4. ebookerr_sdk-2.30.0/ebookerr_sdk/domain/__init__.py +5 -0
  5. ebookerr_sdk-2.30.0/ebookerr_sdk/domain/adoption.py +300 -0
  6. ebookerr_sdk-2.30.0/ebookerr_sdk/domain/chapter_naming.py +225 -0
  7. ebookerr_sdk-2.30.0/ebookerr_sdk/domain/chapter_number.py +402 -0
  8. ebookerr_sdk-2.30.0/ebookerr_sdk/domain/dates.py +174 -0
  9. ebookerr_sdk-2.30.0/ebookerr_sdk/domain/epub_hash.py +11 -0
  10. ebookerr_sdk-2.30.0/ebookerr_sdk/domain/epub_metadata.py +89 -0
  11. ebookerr_sdk-2.30.0/ebookerr_sdk/domain/ids.py +82 -0
  12. ebookerr_sdk-2.30.0/ebookerr_sdk/domain/metadata.py +44 -0
  13. ebookerr_sdk-2.30.0/ebookerr_sdk/domain/story_url.py +170 -0
  14. ebookerr_sdk-2.30.0/ebookerr_sdk/domain/survivor.py +64 -0
  15. ebookerr_sdk-2.30.0/ebookerr_sdk/domain/text_encoding.py +258 -0
  16. ebookerr_sdk-2.30.0/ebookerr_sdk/download/__init__.py +7 -0
  17. ebookerr_sdk-2.30.0/ebookerr_sdk/download/availability.py +48 -0
  18. ebookerr_sdk-2.30.0/ebookerr_sdk/download/check.py +182 -0
  19. ebookerr_sdk-2.30.0/ebookerr_sdk/download/disposition.py +105 -0
  20. ebookerr_sdk-2.30.0/ebookerr_sdk/download/document.py +365 -0
  21. ebookerr_sdk-2.30.0/ebookerr_sdk/download/identity.py +14 -0
  22. ebookerr_sdk-2.30.0/ebookerr_sdk/download/paths.py +78 -0
  23. ebookerr_sdk-2.30.0/ebookerr_sdk/download/routing.py +161 -0
  24. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/__init__.py +68 -0
  25. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/archive.py +171 -0
  26. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/assets.py +203 -0
  27. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/build.py +322 -0
  28. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/builder.py +516 -0
  29. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/chapter_numbers.py +71 -0
  30. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/chapters.py +495 -0
  31. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/document.py +628 -0
  32. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/errors.py +15 -0
  33. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/fonts.py +141 -0
  34. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/nav.py +276 -0
  35. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/ncx.py +239 -0
  36. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/opf.py +530 -0
  37. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/roles.py +177 -0
  38. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/safe_xml.py +42 -0
  39. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/serialize.py +88 -0
  40. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/text.py +59 -0
  41. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/text_convert.py +390 -0
  42. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/titles.py +75 -0
  43. ebookerr_sdk-2.30.0/ebookerr_sdk/epub/xhtml.py +464 -0
  44. ebookerr_sdk-2.30.0/ebookerr_sdk/generation/__init__.py +5 -0
  45. ebookerr_sdk-2.30.0/ebookerr_sdk/generation/client.py +1556 -0
  46. ebookerr_sdk-2.30.0/ebookerr_sdk/generation/output.py +124 -0
  47. ebookerr_sdk-2.30.0/ebookerr_sdk/generation/prompt_template.py +57 -0
  48. ebookerr_sdk-2.30.0/ebookerr_sdk/generation/support.py +507 -0
  49. ebookerr_sdk-2.30.0/ebookerr_sdk/host.py +768 -0
  50. ebookerr_sdk-2.30.0/ebookerr_sdk/pack/__init__.py +16 -0
  51. ebookerr_sdk-2.30.0/ebookerr_sdk/pack/__main__.py +7 -0
  52. ebookerr_sdk-2.30.0/ebookerr_sdk/pack/build.py +135 -0
  53. ebookerr_sdk-2.30.0/ebookerr_sdk/pack/changelog.py +39 -0
  54. ebookerr_sdk-2.30.0/ebookerr_sdk/pack/check.py +93 -0
  55. ebookerr_sdk-2.30.0/ebookerr_sdk/pack/cli.py +147 -0
  56. ebookerr_sdk-2.30.0/ebookerr_sdk/pack/files.py +50 -0
  57. ebookerr_sdk-2.30.0/ebookerr_sdk/pack/index.py +312 -0
  58. ebookerr_sdk-2.30.0/ebookerr_sdk/providers/__init__.py +5 -0
  59. ebookerr_sdk-2.30.0/ebookerr_sdk/providers/anchoring.py +708 -0
  60. ebookerr_sdk-2.30.0/ebookerr_sdk/providers/connection.py +87 -0
  61. ebookerr_sdk-2.30.0/ebookerr_sdk/providers/link_attempt.py +74 -0
  62. ebookerr_sdk-2.30.0/ebookerr_sdk/providers/link_refusal.py +64 -0
  63. ebookerr_sdk-2.30.0/ebookerr_sdk/providers/restore_marker.py +36 -0
  64. ebookerr_sdk-2.30.0/ebookerr_sdk/providers/scan_ledger.py +96 -0
  65. ebookerr_sdk-2.30.0/ebookerr_sdk/providers/urls.py +23 -0
  66. ebookerr_sdk-2.30.0/ebookerr_sdk/readpos.py +813 -0
  67. ebookerr_sdk-2.30.0/ebookerr_sdk/registry_index.py +720 -0
  68. ebookerr_sdk-2.30.0/ebookerr_sdk/spi/__init__.py +2508 -0
  69. ebookerr_sdk-2.30.0/ebookerr_sdk/spi/manifest.py +431 -0
  70. ebookerr_sdk-2.30.0/ebookerr_sdk/spi/summary_template.py +94 -0
  71. ebookerr_sdk-2.30.0/ebookerr_sdk/testing/__init__.py +21 -0
  72. ebookerr_sdk-2.30.0/ebookerr_sdk/testing/context.py +374 -0
  73. ebookerr_sdk-2.30.0/ebookerr_sdk/testing/views.py +50 -0
  74. ebookerr_sdk-2.30.0/ebookerr_sdk/testing/wire.py +212 -0
  75. ebookerr_sdk-2.30.0/ebookerr_sdk/validate/__init__.py +12 -0
  76. ebookerr_sdk-2.30.0/ebookerr_sdk/validate/external.py +219 -0
  77. ebookerr_sdk-2.30.0/ebookerr_sdk/validate/model.py +426 -0
  78. ebookerr_sdk-2.30.0/ebookerr_sdk/validate/rules_css.py +326 -0
  79. ebookerr_sdk-2.30.0/ebookerr_sdk/validate/rules_html.py +242 -0
  80. ebookerr_sdk-2.30.0/ebookerr_sdk/validate/rules_nav.py +366 -0
  81. ebookerr_sdk-2.30.0/ebookerr_sdk/validate/rules_opf.py +732 -0
  82. ebookerr_sdk-2.30.0/ebookerr_sdk/validate/rules_package.py +221 -0
  83. ebookerr_sdk-2.30.0/ebookerr_sdk/validate/validate.py +203 -0
  84. ebookerr_sdk-2.30.0/ebookerr_sdk/wire.py +1066 -0
  85. ebookerr_sdk-2.30.0/ebookerr_sdk.egg-info/PKG-INFO +13 -0
  86. ebookerr_sdk-2.30.0/ebookerr_sdk.egg-info/SOURCES.txt +89 -0
  87. ebookerr_sdk-2.30.0/ebookerr_sdk.egg-info/dependency_links.txt +1 -0
  88. ebookerr_sdk-2.30.0/ebookerr_sdk.egg-info/requires.txt +10 -0
  89. ebookerr_sdk-2.30.0/ebookerr_sdk.egg-info/top_level.txt +1 -0
  90. ebookerr_sdk-2.30.0/pyproject.toml +24 -0
  91. ebookerr_sdk-2.30.0/setup.cfg +4 -0
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: ebookerr-sdk
3
+ Version: 2.30.0
4
+ Summary: The ebookerr plugin SDK: SPI types, wire codec, Python plugin host, EPUB library and shared helpers.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: defusedxml>=0.7
7
+ Requires-Dist: tinycss2>=1.3
8
+ Requires-Dist: requests>=2.31
9
+ Requires-Dist: packaging>=24
10
+ Provides-Extra: text
11
+ Requires-Dist: charset-normalizer>=3.3; extra == "text"
12
+ Provides-Extra: generation
13
+ Requires-Dist: pillow>=10; extra == "generation"
@@ -0,0 +1,30 @@
1
+ # ebookerr-sdk
2
+
3
+ The plugin SDK for ebookerr: the plugin SPI (types and the manifest parser), the wire codec, the
4
+ Python plugin host, the EPUB library and the shared helpers every ebookerr plugin uses.
5
+
6
+ ## Install
7
+
8
+ pip install ebookerr-sdk==2.30.0
9
+
10
+ The SDK version is the SPI version plus `.0`: `ebookerr-sdk 2.30.0` speaks SPI `2.30`.
11
+
12
+ ## A plugin's entrypoint
13
+
14
+ A Python plugin speaks the ebookerr wire protocol through the host; its `entrypoint.py` is three
15
+ lines:
16
+
17
+ from ebookerr_sdk.host import run
18
+ from my_plugin.plugin import MyPlugin
19
+ run(MyPlugin())
20
+
21
+ ## Packaging and publishing
22
+
23
+ `python -m ebookerr_sdk.pack` builds and checks a plugin folder's release `.zip`, and turns a
24
+ `registry.toml` plus a folder of built packages into a registry's `index.json`/`README.md` — the
25
+ same tool and the same index-format rules the default registry and the core installer both use.
26
+ See the plugin author's guide's "Packaging & distribution" section for the full walkthrough:
27
+ packaging a release, `CHANGELOG.txt`, and publishing your own registry.
28
+
29
+ The plugin author's guide (manifest, settings, packaging, testing with `ebookerr_sdk.testing`):
30
+ <https://github.com/ArcCdr/ebookerr-plugins>.
@@ -0,0 +1,9 @@
1
+ """The ebookerr plugin SDK: SPI types, wire codec, Python plugin host, EPUB library and helpers.
2
+
3
+ The one shared-code home of the ebookerr core and every plugin (``PMG-D22``, ``PMG-D23``): the core
4
+ depends on the exact release and a plugin process imports it from the same interpreter. The SDK
5
+ imports nothing from the core package ``src`` (per ``tests/unit/test_sdk_boundary.py``). Its
6
+ version is the SPI version plus ``.0``.
7
+ """
8
+
9
+ __version__ = "2.30.0"
@@ -0,0 +1,5 @@
1
+ """Pure domain helpers shared by the ebookerr core and its plugins.
2
+
3
+ Includes: ids, dates, metadata text, chapter numbers and naming, adoption, story URLs
4
+ and EPUB metadata (``PMG-D22``).
5
+ """
@@ -0,0 +1,300 @@
1
+ """Pure normalisation helpers for adoption heuristic (no I/O, no src.data/src.services).
2
+
3
+ This module provides pure functions for normalising chapter identifiers
4
+ so they can be compared across different formatting conventions.
5
+ Because these functions are pure (no database, no external dependencies),
6
+ the adoption heuristic is fully unit-testable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import itertools
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass
15
+ from urllib.parse import urlsplit
16
+
17
+ from ebookerr_sdk.domain.chapter_number import extract_chapter_info
18
+ from ebookerr_sdk.domain.text_encoding import unicode_identity
19
+
20
+
21
+ def normalize_key(text: str | None) -> str:
22
+ """Unicode letters and digits of every script, case- and accent-insensitive (``TXE-TR-1``).
23
+
24
+ Args:
25
+ text: The input string to normalize. None and empty strings are treated as empty.
26
+
27
+ Returns:
28
+ The normalized string: Unicode letters and digits across all scripts, casefolded and
29
+ deaccented, with all whitespace and punctuation removed.
30
+ Returns empty string for None, empty input, or all-punctuation input.
31
+ """
32
+ return unicode_identity(text or "").replace("_", "")
33
+
34
+
35
+ def url_slug_key(url: str | None) -> str:
36
+ """The normalised last non-empty path segment of *url*.
37
+
38
+ Args:
39
+ url: A URL string or None.
40
+
41
+ Returns:
42
+ The normalized last path segment. Returns empty string for None, empty URL,
43
+ a URL with no path, or a path that is only slashes.
44
+ """
45
+ if not url:
46
+ return ""
47
+
48
+ parsed = urlsplit(url)
49
+ path = parsed.path.rstrip("/")
50
+
51
+ if not path:
52
+ return ""
53
+
54
+ # Extract last segment after the last /
55
+ last_segment = path.rsplit("/", 1)[-1]
56
+ return normalize_key(last_segment)
57
+
58
+
59
+ def expand_number(raw: str) -> frozenset[int]:
60
+ """Every integer an F8 chapter-number token covers.
61
+
62
+ Parses a chapter number format supporting:
63
+ - Single numbers: "5" or "05" → {5}
64
+ - Number with letter suffix: "07a" → {7}
65
+ - Ranges: "023-025" → {23, 24, 25}
66
+ - Ranges with spaces: "023 - 025" → {23, 24, 25}
67
+
68
+ A range spanning more than 500 chapters is capped to endpoints only.
69
+ Descending ranges (end <= start) also return only endpoints.
70
+
71
+ Args:
72
+ raw: A chapter number token string to parse.
73
+
74
+ Returns:
75
+ A frozenset of all integers the token represents.
76
+ Returns empty frozenset if the input doesn't match the pattern.
77
+ """
78
+ match = re.match(r"^(\d+)(?:\s*-\s*(\d+))?", raw)
79
+
80
+ if not match:
81
+ return frozenset()
82
+
83
+ start = int(match.group(1))
84
+
85
+ # No second group: single number
86
+ if match.group(2) is None:
87
+ return frozenset({start})
88
+
89
+ # Second group present: it's a range
90
+ end = int(match.group(2))
91
+
92
+ # Guard: if span > 500 or descending, return only endpoints
93
+ if end <= start or end - start > 500:
94
+ return frozenset({start, end})
95
+
96
+ # Normal range: return all integers from start to end (inclusive)
97
+ return frozenset(range(start, end + 1))
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class AdoptionSide:
102
+ """One side of a candidate adoption pair — everything scoring needs, already read.
103
+
104
+ Attributes:
105
+ book_id: The book's id.
106
+ title: The book title as stored, e.g. ``"Tending Bar Pt. 05"``.
107
+ author: The author as stored; ``""`` when unknown.
108
+ site: The site as stored; ``""`` when unknown.
109
+ series: The series as stored; ``""`` when unknown.
110
+ series_url: The series URL as stored; ``""`` when unknown.
111
+ story_url: The book's own story URL; ``""`` when unknown.
112
+ numbers: Raw F8 chapter-number tokens. For an orphan this holds exactly the one
113
+ number parsed from its title; for a target, every number in its EPUB table of
114
+ contents, in table order.
115
+ """
116
+
117
+ book_id: str
118
+ title: str
119
+ author: str
120
+ site: str
121
+ series: str
122
+ series_url: str
123
+ story_url: str
124
+ numbers: tuple[str, ...]
125
+
126
+
127
+ @dataclass(frozen=True, slots=True)
128
+ class Evidence:
129
+ """The seven DEC-57 signals for one candidate pair, plus the values behind them."""
130
+
131
+ author_match: bool
132
+ stem_match: bool
133
+ numbers_disjoint: bool
134
+ same_site: bool
135
+ same_series: bool
136
+ url_affinity: bool
137
+ adjacent: bool
138
+ orphan_number: str
139
+ target_numbers: tuple[str, ...]
140
+ stem: str
141
+
142
+ @property
143
+ def score(self) -> int:
144
+ """The optional-signal score: E4 +1, E5 +2, E6 +2, E7 +1 (0..6)."""
145
+ return (
146
+ (1 if self.same_site else 0)
147
+ + (2 if self.same_series else 0)
148
+ + (2 if self.url_affinity else 0)
149
+ + (1 if self.adjacent else 0)
150
+ )
151
+
152
+ @property
153
+ def confidence(self) -> str:
154
+ """``"strong"`` | ``"probable"`` | ``"weak"``, or ``""`` when mandatory signals missing."""
155
+ if not (self.author_match and self.stem_match and self.numbers_disjoint):
156
+ return ""
157
+
158
+ if self.score >= 2:
159
+ return "strong"
160
+ if self.score == 1:
161
+ return "probable"
162
+ return "weak"
163
+
164
+
165
+ def _common_prefix_len(a: str, b: str) -> int:
166
+ """Count the length of the common leading prefix of *a* and *b*."""
167
+ return sum(1 for _ in itertools.takewhile(lambda p: p[0] == p[1], zip(a, b, strict=False)))
168
+
169
+
170
+ def score_pair(orphan: AdoptionSide, target: AdoptionSide) -> Evidence:
171
+ """Score one candidate adoption pair against the seven DEC-57 signals.
172
+
173
+ Args:
174
+ orphan: The orphan side of the pair (usually the newer, unmatched book).
175
+ target: The target side of the pair (usually an existing book to merge into).
176
+
177
+ Returns:
178
+ An Evidence object containing the seven scoring signals (E1–E7) plus
179
+ contextual metadata (orphan_number, target_numbers, stem).
180
+ """
181
+ # 1. Extract orphan_number (first number or empty string)
182
+ orphan_number = orphan.numbers[0] if orphan.numbers else ""
183
+
184
+ # 2. Expand all numbers to integers
185
+ orphan_ints: frozenset[int] = frozenset().union(*(expand_number(n) for n in orphan.numbers))
186
+ target_ints: frozenset[int] = frozenset().union(*(expand_number(n) for n in target.numbers))
187
+
188
+ # 3. Extract book stems (names without chapter numbers)
189
+ orphan_stem = normalize_key(extract_chapter_info(orphan.title).book_name)
190
+ target_stem = normalize_key(extract_chapter_info(target.title).book_name)
191
+
192
+ # 4. Author match: normalized author must be non-empty and equal
193
+ author_match = normalize_key(orphan.author) == normalize_key(target.author) != ""
194
+
195
+ # 5. Stem match: normalized stem must be non-empty and equal
196
+ stem_match = orphan_stem == target_stem != ""
197
+
198
+ # 6. Numbers disjoint: orphan has numbers AND no overlap with target
199
+ numbers_disjoint = bool(orphan_ints) and orphan_ints.isdisjoint(target_ints)
200
+
201
+ # 7. Same site: normalized site must be non-empty and equal
202
+ same_site = normalize_key(orphan.site) == normalize_key(target.site) != ""
203
+
204
+ # 8. Same series: either series match OR series_url match (both non-empty)
205
+ same_series = (normalize_key(orphan.series) == normalize_key(target.series) != "") or (
206
+ orphan.series_url == target.series_url != ""
207
+ )
208
+
209
+ # 9. URL affinity: common prefix of slug keys >= 8 characters
210
+ a = url_slug_key(orphan.story_url)
211
+ b = url_slug_key(target.story_url)
212
+ url_affinity = _common_prefix_len(a, b) >= 8
213
+
214
+ # 10. Adjacent: orphan and target numbers are consecutive (both non-empty)
215
+ adjacent = (
216
+ bool(orphan_ints)
217
+ and bool(target_ints)
218
+ and (min(orphan_ints) == max(target_ints) + 1 or max(orphan_ints) == min(target_ints) - 1)
219
+ )
220
+
221
+ # 11. Return Evidence with all fields
222
+ return Evidence(
223
+ author_match=author_match,
224
+ stem_match=stem_match,
225
+ numbers_disjoint=numbers_disjoint,
226
+ same_site=same_site,
227
+ same_series=same_series,
228
+ url_affinity=url_affinity,
229
+ adjacent=adjacent,
230
+ orphan_number=orphan_number,
231
+ target_numbers=tuple(target.numbers),
232
+ stem=orphan_stem,
233
+ )
234
+
235
+
236
+ def evidence_json(evidence: Evidence) -> str:
237
+ """Serialise *evidence* to the stored JSON form (deterministic key order).
238
+
239
+ Args:
240
+ evidence: The Evidence object to serialize.
241
+
242
+ Returns:
243
+ A JSON string with keys in sorted order, containing the seven signals
244
+ (E1–E7) and contextual metadata (orphan_number, target_numbers, stem).
245
+ """
246
+ data = {
247
+ "E1": evidence.author_match,
248
+ "E2": evidence.stem_match,
249
+ "E3": evidence.numbers_disjoint,
250
+ "E4": evidence.same_site,
251
+ "E5": evidence.same_series,
252
+ "E6": evidence.url_affinity,
253
+ "E7": evidence.adjacent,
254
+ "orphan_number": evidence.orphan_number,
255
+ "target_numbers": list(evidence.target_numbers),
256
+ "stem": evidence.stem,
257
+ }
258
+ return json.dumps(data, sort_keys=True)
259
+
260
+
261
+ SIBLING_EVIDENCE_VERSION = 1
262
+ """Schema version of the sibling-set block inside ``merge_proposals.evidence``.
263
+
264
+ Bump this when the block's keys change meaning, and update every reader in the same change
265
+ (``EXP-156``/``EXP-157``).
266
+ """
267
+
268
+
269
+ def sibling_evidence_json(
270
+ evidence: Evidence,
271
+ *,
272
+ survivor_book_id: str,
273
+ set_size: int,
274
+ story_key: str,
275
+ ) -> str:
276
+ """Serialise a sibling-set proposal's evidence: the seven signals plus the set's own facts.
277
+
278
+ The seven ``DEC-57`` signals keep their exact keys and meanings, so a reader written for an
279
+ adoption proposal reads a sibling proposal correctly. The set's own facts live under a
280
+ versioned ``sibling`` block, which a reader must check before trusting
281
+ (``EXP-156``/``EXP-157``).
282
+
283
+ Args:
284
+ evidence: The pair's seven signals, from :func:`score_pair`.
285
+ survivor_book_id: The ``book_id`` the whole set merges into.
286
+ set_size: How many books were in the sibling set, survivor included.
287
+ story_key: ``"<site>|<story_id>"`` when the set also shares an ambiguous story
288
+ identity, else ``""``. Recorded for the audit trail; never used to score.
289
+
290
+ Returns:
291
+ A JSON string with keys in sorted order.
292
+ """
293
+ data = json.loads(evidence_json(evidence))
294
+ data["sibling"] = {
295
+ "version": SIBLING_EVIDENCE_VERSION,
296
+ "survivor_book_id": survivor_book_id,
297
+ "set_size": set_size,
298
+ "story_key": story_key,
299
+ }
300
+ return json.dumps(data, sort_keys=True)
@@ -0,0 +1,225 @@
1
+ """Turn chapter labels into collision-free, human-readable filenames (``MR-RENAME-1``).
2
+
3
+ The label→(book_name, chapter_number) split is delegated to
4
+ :func:`ebookerr_sdk.domain.chapter_number.extract_chapter_info` so the two never drift.
5
+ The module is pure (no I/O, no dependency on gateways).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Collection, Sequence
12
+ from dataclasses import dataclass
13
+
14
+ from ebookerr_sdk.domain.chapter_number import extract_chapter_info
15
+ from ebookerr_sdk.domain.text_encoding import unicode_identity
16
+
17
+ # Non-alphanumeric run → single underscore (after lowercase).
18
+ _NON_ALNUM_RE = re.compile(r"[^a-z0-9]+")
19
+
20
+ # Extract leading integer and optional letter suffix from a chapter number.
21
+ _NUMBER_RE = re.compile(r"^(\d+)([a-z])?")
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class NamedChapter:
26
+ """One chapter's planned output identity.
27
+
28
+ Attributes:
29
+ label: The chapter label the name was derived from.
30
+ stem: The filename stem, no extension (also the manifest/nav id).
31
+ filename: ``stem`` + ``".xhtml"``.
32
+ number: The chapter's integer number, or ``None`` when the label carries none.
33
+ """
34
+
35
+ label: str
36
+ stem: str
37
+ filename: str
38
+ number: int | None
39
+
40
+
41
+ def slugify(text: str, *, max_length: int = 80) -> str:
42
+ """Lowercase ``text``, replace every non-alphanumeric run with ``_``, trim, truncate.
43
+
44
+ Args:
45
+ text: The input string to slugify.
46
+ max_length: Maximum slug length (default 80). Trailing underscore is trimmed
47
+ after truncation to avoid ending on a separator.
48
+
49
+ Returns:
50
+ The slugified string: lowercase, alphanumeric-only (non-runs replaced by
51
+ single ``_``), with leading/trailing ``_`` stripped.
52
+ Returns empty string for empty input or all-nonalnum input.
53
+
54
+ Examples:
55
+ >>> slugify("Three Square Meals")
56
+ 'three_square_meals'
57
+ >>> slugify(" A -- B!! ")
58
+ 'a_b'
59
+ >>> slugify("")
60
+ ''
61
+ """
62
+ # 1. Lowercase
63
+ lower = text.lower()
64
+ # 2. Replace non-alphanumeric runs with single underscore
65
+ slugged = _NON_ALNUM_RE.sub("_", lower)
66
+ # 3. Strip leading/trailing underscores
67
+ slugged = slugged.strip("_")
68
+ # 4. Truncate to max_length
69
+ if len(slugged) > max_length:
70
+ slugged = slugged[:max_length]
71
+ # 5. Strip trailing underscores (in case truncation ended on a separator)
72
+ slugged = slugged.rstrip("_")
73
+ return slugged
74
+
75
+
76
+ def title_slug(text: str, *, max_length: int = 80) -> str:
77
+ """Slug a chapter title for its identity key, keeping every script (``TXE-D13``).
78
+
79
+ Built on :func:`ebookerr_sdk.domain.text_encoding.unicode_identity`: accents are removed and
80
+ case is folded, but Cyrillic, Greek, CJK and every other script keep their letters, so
81
+ distinct non-Latin titles get distinct keys instead of collapsing to an ASCII remnant
82
+ (``"Глава 1"`` → ``"глава_1"``, where :func:`slugify` gives ``"1"``). For an ASCII
83
+ title the result equals :func:`slugify`'s, which is why switching chapter keys to this
84
+ slug re-keyed no stored chapter. :func:`slugify` stays the filename slug.
85
+
86
+ Args:
87
+ text: The chapter title.
88
+ max_length: Maximum slug length (default 80); a trailing separator left by the
89
+ cut is trimmed.
90
+
91
+ Returns:
92
+ The slug, or ``""`` when the title has no letter or digit.
93
+ """
94
+ return unicode_identity(text)[:max_length].rstrip("_")
95
+
96
+
97
+ def _compute_base_stem(
98
+ label: str, number: int | None, letter: str, book_name: str, width: int
99
+ ) -> str:
100
+ """Compute the base stem (before collision resolution) for a chapter.
101
+
102
+ Args:
103
+ label: The original chapter label.
104
+ number: The extracted chapter number, or None.
105
+ letter: The optional letter suffix (e.g. 'a', 'b') from the chapter number.
106
+ book_name: The extracted book name.
107
+ width: The zero-padding width for numbered chapters.
108
+
109
+ Returns:
110
+ The base stem, potentially starting with a digit (caller adds underscore).
111
+ """
112
+ if number is not None:
113
+ # Numbered: "chapter_NNN{letter}" + book slug if present
114
+ padded_num = f"chapter_{number:0{width}d}{letter}"
115
+ book_slug = slugify(book_name)
116
+ return f"{padded_num}_{book_slug}" if book_slug else padded_num
117
+ # Unnumbered: slug the full label, or "section" as fallback
118
+ return slugify(label) or "section"
119
+
120
+
121
+ def _resolve_collision(base_stem: str, claimed: set[str]) -> str:
122
+ """Resolve collisions by appending letter/numeric suffixes.
123
+
124
+ Args:
125
+ base_stem: The desired stem.
126
+ claimed: The set of already-claimed stems.
127
+
128
+ Returns:
129
+ A collision-free stem derived from base_stem.
130
+ """
131
+ stem = base_stem
132
+ n = 1
133
+ while stem in claimed:
134
+ if n <= 25:
135
+ suffix = chr(ord("a") + n)
136
+ stem = f"{base_stem}_{suffix}"
137
+ else:
138
+ stem = f"{base_stem}_z{n}"
139
+ n += 1
140
+ return stem
141
+
142
+
143
+ def plan_chapter_filenames(
144
+ labels: Sequence[str], *, reserved: Collection[str] = ()
145
+ ) -> list[NamedChapter]:
146
+ r"""Plan collision-free filename per label, in input order (``MR-RENAME-1``).
147
+
148
+ Algorithm (in two passes):
149
+
150
+ 1. For each label, extract (book_name, chapter_number) via
151
+ :func:`ebookerr_sdk.domain.chapter_number.extract_chapter_info`.
152
+ 2. From chapter_number, extract leading integer and optional letter suffix:
153
+ ``^(\d+)([a-z])?`` → (number: int, letter: str). If no match, number=None.
154
+ 3. Calculate ``width = max(2, len(str(max(numbers))))`` across all numbered
155
+ labels; when no label is numbered, width is unused.
156
+ 4. First pass: compute base stems and metadata for each label.
157
+ 5. Second pass, seeded with ``reserved`` (stems already taken by something
158
+ outside this call): resolve collisions by appending ``_b``, ``_c``, ...,
159
+ ``_z``, then ``_z1``, ``_z2``, ... when 26 letters are exhausted.
160
+ 6. XML NCName guard: if a stem starts with a digit, prefix it with ``_``.
161
+ 7. Return list of :class:`NamedChapter` in input order.
162
+
163
+ Args:
164
+ labels: Chapter labels in order.
165
+ reserved: Stems already taken by something outside this call (a carried
166
+ resource, a generated page); a label whose stem collides with one is
167
+ suffixed exactly as a label-vs-label collision is.
168
+
169
+ Returns:
170
+ A list of :class:`NamedChapter`, one per input label, in the same order.
171
+ The stem + filename are collision-free and XML-safe (NCName).
172
+
173
+ Examples:
174
+ >>> plan_chapter_filenames(["Title Page", "Chapter 5"])
175
+ [NamedChapter(label='Title Page', stem='title_page',
176
+ filename='title_page.xhtml', number=None),
177
+ NamedChapter(label='Chapter 5', stem='chapter_05',
178
+ filename='chapter_05.xhtml', number=5)]
179
+ """
180
+ if not labels:
181
+ return []
182
+
183
+ # Pass 1: Prepare metadata for each label (number, letter, book_name).
184
+ prepared: list[tuple[str, int | None, str, str]] = []
185
+ numbers: list[int] = []
186
+
187
+ for label in labels:
188
+ info = extract_chapter_info(label)
189
+ chapter_number_str = info.chapter_number
190
+
191
+ # Extract leading integer and optional letter suffix from chapter_number.
192
+ number: int | None = None
193
+ letter = ""
194
+ if chapter_number_str:
195
+ match = _NUMBER_RE.match(chapter_number_str)
196
+ if match:
197
+ number = int(match.group(1))
198
+ letter = match.group(2) or ""
199
+ numbers.append(number)
200
+
201
+ prepared.append((label, number, letter, info.book_name))
202
+
203
+ # Calculate width for zero-padding numbered chapters.
204
+ width = 2
205
+ if numbers:
206
+ width = max(2, len(str(max(numbers))))
207
+
208
+ # Pass 2: Build stems and resolve collisions.
209
+ claimed: set[str] = set(reserved)
210
+ planned: list[NamedChapter] = []
211
+
212
+ for label, number, letter, book_name in prepared:
213
+ base_stem = _compute_base_stem(label, number, letter, book_name, width)
214
+
215
+ # XML NCName guard: if stem starts with digit, prefix with underscore.
216
+ if base_stem and base_stem[0].isdigit():
217
+ base_stem = "_" + base_stem
218
+
219
+ # Collision resolution.
220
+ stem = _resolve_collision(base_stem, claimed)
221
+ claimed.add(stem)
222
+ filename = f"{stem}.xhtml"
223
+ planned.append(NamedChapter(label, stem, filename, number))
224
+
225
+ return planned