ffs-triumph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ """FFS — Full-manual Fetcher & Stitcher.
2
+
3
+ Turn a Triumph Technical Information service manual (a JavaScript SPA) into a
4
+ single, self-contained, paginated PDF for offline viewing and printing.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ from .client import LoginError, TriumphClient
10
+ from .config import ManualConfig
11
+ from .render import HtmlRenderer
12
+
13
+ __all__ = ["TriumphClient", "LoginError", "ManualConfig", "HtmlRenderer", "__version__"]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
ffs_triumph/cli.py ADDED
@@ -0,0 +1,388 @@
1
+ """FFS command-line interface: discover, build, list, audit, install-browser."""
2
+
3
+ import argparse
4
+ import getpass
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import requests
9
+
10
+ from . import __version__
11
+ from .client import LoginError, TriumphClient
12
+ from .config import (
13
+ API_BASE,
14
+ DEFAULT_DOC_TYPE,
15
+ DEFAULT_LANGUAGE,
16
+ ManualConfig,
17
+ credentials_from_env_and_dotenv,
18
+ load_config_file,
19
+ save_config_file,
20
+ )
21
+ from .document import assemble_document
22
+ from .pdf import install_browser, render_pdf
23
+ from .render import HtmlRenderer
24
+
25
+ HTML_NAME = "manual.html"
26
+
27
+
28
+ # --- small interactive helpers ---------------------------------------------
29
+
30
+ def interactive() -> bool:
31
+ return sys.stdin.isatty() and sys.stdout.isatty()
32
+
33
+
34
+ def menu(prompt: str, labels: list[str]) -> int:
35
+ print(prompt)
36
+ for i, label in enumerate(labels, 1):
37
+ print(f" {i}. {label}")
38
+ while True:
39
+ raw = input(f"Enter 1-{len(labels)}: ").strip()
40
+ if raw.isdigit() and 1 <= int(raw) <= len(labels):
41
+ return int(raw) - 1
42
+ print("Invalid selection.")
43
+
44
+
45
+ def confirm(prompt: str, default=True) -> bool:
46
+ if not interactive():
47
+ return default
48
+ suffix = " [Y/n] " if default else " [y/N] "
49
+ ans = input(prompt + suffix).strip().lower()
50
+ if not ans:
51
+ return default
52
+ return ans.startswith("y")
53
+
54
+
55
+ def _systitle(doc: dict):
56
+ return ((doc.get("metadata") or {}).get("docType") or {}).get("systemTitle")
57
+
58
+
59
+ # --- resolution pipeline ----------------------------------------------------
60
+
61
+ def resolve_credentials(args, cfg_file) -> tuple[str, str]:
62
+ email, password = args.email, args.password
63
+ if not (email and password):
64
+ env_email, env_pw = credentials_from_env_and_dotenv()
65
+ email = email or env_email
66
+ password = password or env_pw
67
+ email = email or cfg_file.get("email")
68
+ if not email:
69
+ if interactive():
70
+ email = input("Triumph email: ").strip()
71
+ else:
72
+ sys.exit("ERROR: no email. Set TTI_EMAIL, --email, or a config file.")
73
+ if not password:
74
+ if interactive():
75
+ password = getpass.getpass("Triumph password: ")
76
+ else:
77
+ sys.exit("ERROR: no password. Set TTI_PASSWORD or --password.")
78
+ return email, password
79
+
80
+
81
+ def make_client(args, cfg_file) -> TriumphClient:
82
+ email, password = resolve_credentials(args, cfg_file)
83
+ try:
84
+ return TriumphClient(
85
+ email, password,
86
+ cache_dir=Path(args.cache_dir),
87
+ verbose=args.verbose,
88
+ use_cache=not args.no_cache,
89
+ api_base=args.api_base,
90
+ )
91
+ except LoginError as err:
92
+ sys.exit(f"ERROR: {err}")
93
+
94
+
95
+ def resolve_vin(client: TriumphClient, args, cfg_file) -> str:
96
+ if args.vin:
97
+ return args.vin
98
+ if cfg_file.get("vin"):
99
+ return cfg_file["vin"]
100
+ vins = client.subscribed_vins()
101
+ if len(vins) == 1:
102
+ client.log(0, f"Auto-detected VIN from your subscription: {vins[0]}")
103
+ return vins[0]
104
+ if len(vins) > 1:
105
+ if not interactive():
106
+ sys.exit("ERROR: multiple VINs on this account; pass --vin.")
107
+ labels = []
108
+ for v in vins:
109
+ try:
110
+ p = client.product_search(v)
111
+ labels.append(f"{v} — {p.get('modelYear', '')} {p.get('modelName', '')}".strip())
112
+ except requests.HTTPError:
113
+ labels.append(v)
114
+ return vins[menu("Select your motorcycle:", labels)]
115
+ if interactive():
116
+ return input("Enter your VIN (frame/registration/key tag): ").strip()
117
+ sys.exit("ERROR: no VIN found on your account; pass --vin.")
118
+
119
+
120
+ def select_document(client: TriumphClient, product_context, language, args):
121
+ """Return (root_id, title). Defaults to the single Service Manual."""
122
+ if args.root_id:
123
+ return args.root_id, None
124
+ docs = client.list_documents(product_context)
125
+ in_lang = [d for d in docs if d.get("language") == language]
126
+ if not in_lang:
127
+ sys.exit(f"ERROR: no documents found for language {language!r}.")
128
+
129
+ if args.all_types:
130
+ candidates = in_lang
131
+ else:
132
+ candidates = [d for d in in_lang if _systitle(d) == args.doc_type]
133
+ if not candidates:
134
+ print(f"No {args.doc_type!r} document for language {language!r}; "
135
+ "showing all available documents.")
136
+ candidates = in_lang
137
+
138
+ if len(candidates) == 1:
139
+ d = candidates[0]
140
+ client.log(0, f"Selected document: {d.get('title')} [{d['_id']}]")
141
+ return d["_id"], d.get("title")
142
+ if not interactive():
143
+ sys.exit("ERROR: multiple documents match; pass --root-id "
144
+ "(run `ffs discover` to list them).")
145
+ labels = [f"{d.get('title')} [{_systitle(d)}]" for d in candidates]
146
+ d = candidates[menu("Select a document:", labels)]
147
+ return d["_id"], d.get("title")
148
+
149
+
150
+ def build_manual_config(client: TriumphClient, args, cfg_file) -> ManualConfig:
151
+ language = (args.language or cfg_file.get("language")
152
+ or client.default_language() or DEFAULT_LANGUAGE)
153
+
154
+ # Full power-user override: explicit root + product context from flags.
155
+ if args.root_id and args.model_code and args.serial:
156
+ pc = {
157
+ "modelCode": args.model_code,
158
+ "modelYear": args.model_year or "",
159
+ "serial": args.serial,
160
+ "engineNo": args.engine_no or "",
161
+ "market": args.market or "",
162
+ "state": "published",
163
+ "onlyValid": "true",
164
+ }
165
+ return ManualConfig(root_id=args.root_id, language=language,
166
+ api_base=args.api_base, product_context=pc)
167
+
168
+ vin = resolve_vin(client, args, cfg_file)
169
+ product = client.product_search(vin)
170
+ cfg = ManualConfig.from_product(product, language=language,
171
+ api_base=args.api_base,
172
+ root_id=args.root_id or cfg_file.get("root_id"))
173
+ # individual flag overrides of product-context fields
174
+ for flag, key in (("model_code", "modelCode"), ("model_year", "modelYear"),
175
+ ("serial", "serial"), ("engine_no", "engineNo"),
176
+ ("market", "market")):
177
+ val = getattr(args, flag)
178
+ if val:
179
+ cfg.product_context[key] = val
180
+ if not cfg.root_id:
181
+ cfg.root_id, _ = select_document(client, cfg.product_context, language, args)
182
+ cfg.vin = vin
183
+ return cfg
184
+
185
+
186
+ def maybe_save(args, cfg_file, email, cfg: ManualConfig):
187
+ if args.no_save or not interactive():
188
+ return
189
+ proposed = {"email": email, "vin": cfg.vin, "root_id": cfg.root_id,
190
+ "language": cfg.language}
191
+ if all(cfg_file.get(k) == v for k, v in proposed.items()):
192
+ return # nothing new
193
+ if confirm("Save these settings for next time?"):
194
+ path = save_config_file(proposed)
195
+ print(f"Saved settings to {path} (password not stored).")
196
+
197
+
198
+ # --- subcommands ------------------------------------------------------------
199
+
200
+ def cmd_build(args):
201
+ cfg_file = load_config_file()
202
+ client = make_client(args, cfg_file)
203
+ email, _ = resolve_credentials(args, cfg_file) # already validated; for save
204
+ cfg = build_manual_config(client, args, cfg_file)
205
+ client.config = cfg
206
+ maybe_save(args, cfg_file, email, cfg)
207
+
208
+ out_dir = Path(args.output_path)
209
+ out_dir.mkdir(parents=True, exist_ok=True)
210
+ renderer = HtmlRenderer(client, inline_images=args.inline_images,
211
+ image_out_dir=out_dir / "images")
212
+ document, leaf_count = assemble_document(
213
+ client, renderer, start_topic=args.topic, limit=args.limit)
214
+
215
+ html_path = out_dir / HTML_NAME
216
+ html_path.write_text(document, encoding="utf-8")
217
+ print(f"Wrote {html_path} ({leaf_count} topics, {len(document):,} bytes)")
218
+
219
+ if renderer.unhandled:
220
+ print("\nWARNING: unhandled node types fell through to the generic fallback:")
221
+ for nt, cnt in sorted(renderer.unhandled.items(), key=lambda kv: -kv[1]):
222
+ print(f" {cnt:5d} {nt}")
223
+ else:
224
+ print("Coverage: all node types handled.")
225
+
226
+ if args.html_only:
227
+ return
228
+ doc_title = client.get_root().get("title", "Service Manual")
229
+ pdf_path = out_dir / f"{cfg.slug()}.pdf"
230
+ render_pdf(html_path, pdf_path, doc_title)
231
+ print(f"Wrote {pdf_path}")
232
+
233
+
234
+ def cmd_list(args):
235
+ cfg_file = load_config_file()
236
+ client = make_client(args, cfg_file)
237
+ client.config = build_manual_config(client, args, cfg_file)
238
+ for node, depth in client.iter_toc(args.topic):
239
+ children = node.get("children") or []
240
+ is_leaf = node.get("selectable") and not children
241
+ marker = "-" if is_leaf else "#"
242
+ print(f"{' ' * depth}{marker} [{node.get('id')}] {node.get('title') or ''}")
243
+
244
+
245
+ def cmd_audit(args):
246
+ cfg_file = load_config_file()
247
+ client = make_client(args, cfg_file)
248
+ client.config = build_manual_config(client, args, cfg_file)
249
+ leaves = client.leaf_topics(args.topic, args.limit)
250
+ print(f"Selectable leaf topics in toc: {len(leaves)}")
251
+ errors, empty, fetched = [], [], 0
252
+ for nid, title, _ in leaves:
253
+ try:
254
+ topic = client.get_topic(nid)
255
+ except requests.HTTPError as err:
256
+ errors.append((nid, title, str(err)))
257
+ continue
258
+ fetched += 1
259
+ if not (topic.get("content") or []):
260
+ empty.append((nid, title))
261
+ if str(topic.get("id")) != nid:
262
+ errors.append((nid, title, f"returned id {topic.get('id')!r} != requested"))
263
+ print(f"Fetched OK: {fetched} / {len(leaves)}")
264
+ print(f"Empty content: {len(empty)}")
265
+ print(f"Errors/mismatches: {len(errors)}")
266
+ for nid, title, msg in errors[:20]:
267
+ print(f" ERROR {nid} {title!r}: {msg}")
268
+ for nid, title in empty[:20]:
269
+ print(f" EMPTY {nid} {title!r}")
270
+
271
+
272
+ def cmd_discover(args):
273
+ cfg_file = load_config_file()
274
+ client = make_client(args, cfg_file)
275
+ language = (args.language or cfg_file.get("language")
276
+ or client.default_language() or DEFAULT_LANGUAGE)
277
+ vins = [args.vin] if args.vin else client.subscribed_vins()
278
+ if not vins:
279
+ print("No VINs found on this account. Pass --vin to inspect one.")
280
+ return
281
+ chosen_root = None
282
+ for vin in vins:
283
+ product = client.product_search(vin)
284
+ cfg = ManualConfig.from_product(product, language=language, api_base=args.api_base)
285
+ print(f"\nVIN {vin}: {product.get('modelYear', '')} {product.get('modelName', '')}")
286
+ print(f" product context: {cfg.product_context}")
287
+ docs = client.list_documents(cfg.product_context)
288
+ in_lang = [d for d in docs if d.get("language") == language]
289
+ print(f" documents ({language}): {len(in_lang)}")
290
+ for d in sorted(in_lang, key=lambda d: (_systitle(d) or "", d.get("title", ""))):
291
+ mark = "*" if _systitle(d) == DEFAULT_DOC_TYPE else " "
292
+ print(f" {mark} [{d['_id']}] {_systitle(d)!r:24} {d.get('title')}")
293
+ if _systitle(d) == DEFAULT_DOC_TYPE and chosen_root is None:
294
+ chosen_root = d["_id"]
295
+ if args.write_config:
296
+ path = save_config_file({"email": client.auth().get("user", {}).get("email"),
297
+ "vin": vins[0], "root_id": chosen_root,
298
+ "language": language})
299
+ print(f"\nWrote starter config to {path} (* = default Service Manual).")
300
+
301
+
302
+ def cmd_install_browser(args):
303
+ return install_browser()
304
+
305
+
306
+ # --- argument parser --------------------------------------------------------
307
+
308
+ def _add_common(sp):
309
+ sp.add_argument("--email", help="Triumph account email (else env TTI_EMAIL / prompt)")
310
+ sp.add_argument("--password", help="Triumph password (else env TTI_PASSWORD / prompt)")
311
+ sp.add_argument("--vin", help="Motorcycle VIN (else auto-detected from subscription)")
312
+ sp.add_argument("--root-id", help="Document id to use (skips document selection)")
313
+ sp.add_argument("--doc-type", default=DEFAULT_DOC_TYPE,
314
+ help=f"docType systemTitle to pick (default: {DEFAULT_DOC_TYPE!r})")
315
+ sp.add_argument("--all-types", action="store_true",
316
+ help="Don't filter by document type when selecting")
317
+ sp.add_argument("--language", help="Document language (default: account/en-gb)")
318
+ sp.add_argument("--model-code", help="Override product context modelCode")
319
+ sp.add_argument("--model-year", help="Override product context modelYear")
320
+ sp.add_argument("--serial", help="Override product context serial")
321
+ sp.add_argument("--engine-no", help="Override product context engineNo")
322
+ sp.add_argument("--market", help="Override product context market")
323
+ sp.add_argument("--api-base", default=API_BASE, help="API base URL")
324
+ sp.add_argument("--cache-dir", default=".cache",
325
+ help="Directory for cached topics/images (default: .cache)")
326
+ sp.add_argument("--no-cache", action="store_true",
327
+ help="Ignore cached topics/images (still writes cache)")
328
+ sp.add_argument("--no-save", action="store_true",
329
+ help="Don't offer to save settings to a config file")
330
+ sp.add_argument("--verbose", "-v", action="count", default=0)
331
+
332
+
333
+ def get_argparser():
334
+ p = argparse.ArgumentParser(
335
+ prog="ffs",
336
+ description="FFS — Full-manual Fetcher & Stitcher: turn a Triumph "
337
+ "Technical Information service manual into an offline PDF.")
338
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
339
+ sub = p.add_subparsers(dest="command")
340
+
341
+ b = sub.add_parser("build", help="Fetch a manual and render it to PDF")
342
+ b.add_argument("output_path", help="Directory for manual.html + the PDF")
343
+ b.add_argument("--topic", help="Only this topic id's subtree (dev/preview)")
344
+ b.add_argument("--limit", type=int, help="Render at most N leaf topics")
345
+ b.add_argument("--html-only", action="store_true", help="Skip PDF rendering")
346
+ b.add_argument("--inline-images", action="store_true",
347
+ help="Embed images as base64 in one large portable HTML file "
348
+ "(default: write to OUTPUT/images/; the PDF is self-contained "
349
+ "either way)")
350
+ _add_common(b)
351
+ b.set_defaults(func=cmd_build)
352
+
353
+ le = sub.add_parser("list", help="Print the table of contents with topic IDs")
354
+ le.add_argument("--topic", help="Only this topic id's subtree")
355
+ _add_common(le)
356
+ le.set_defaults(func=cmd_list)
357
+
358
+ a = sub.add_parser("audit", help="Verify every topic fetches with content")
359
+ a.add_argument("--topic", help="Only this topic id's subtree")
360
+ a.add_argument("--limit", type=int, help="Check at most N topics")
361
+ _add_common(a)
362
+ a.set_defaults(func=cmd_audit)
363
+
364
+ d = sub.add_parser("discover",
365
+ help="List your VIN(s), product context and available documents")
366
+ d.add_argument("--write-config", action="store_true",
367
+ help="Write a starter config file from the discovered values")
368
+ _add_common(d)
369
+ d.set_defaults(func=cmd_discover)
370
+
371
+ ib = sub.add_parser("install-browser",
372
+ help="Download the Chromium build needed for PDF rendering")
373
+ ib.set_defaults(func=cmd_install_browser)
374
+
375
+ return p
376
+
377
+
378
+ def main(argv=None):
379
+ parser = get_argparser()
380
+ args = parser.parse_args(argv)
381
+ if not getattr(args, "func", None):
382
+ parser.print_help()
383
+ return 2
384
+ return args.func(args)
385
+
386
+
387
+ if __name__ == "__main__":
388
+ sys.exit(main())
ffs_triumph/client.py ADDED
@@ -0,0 +1,203 @@
1
+ """Authenticated client for the Triumph Technical Information documents API."""
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+ from urllib.parse import urlencode
8
+
9
+ import requests
10
+
11
+ from .config import API_BASE, ManualConfig
12
+
13
+ REQUEST_DELAY = 0.15 # seconds, polite pause between network requests
14
+
15
+
16
+ class LoginError(RuntimeError):
17
+ """Raised when authentication fails."""
18
+
19
+
20
+ class TriumphClient:
21
+ """Logs in and fetches account, product, document, topic and image data.
22
+
23
+ Credentials are passed in explicitly (the CLI resolves them from env/.env/
24
+ prompt). A :class:`ManualConfig` supplies the manual's root id, language and
25
+ product context; it may be set after construction (e.g. after VIN discovery).
26
+ """
27
+
28
+ def __init__(self, email: str, password: str, *, config: ManualConfig | None = None,
29
+ cache_dir: Path = Path(".cache"), verbose: int = 0,
30
+ use_cache: bool = True, api_base: str = API_BASE):
31
+ self.verbose = verbose
32
+ self.use_cache = use_cache
33
+ self.api_base = api_base
34
+ self.config = config
35
+ self.cache_dir = Path(cache_dir)
36
+ self.topic_cache = self.cache_dir / "topics"
37
+ self.image_cache = self.cache_dir / "images"
38
+ self.topic_cache.mkdir(parents=True, exist_ok=True)
39
+ self.image_cache.mkdir(parents=True, exist_ok=True)
40
+
41
+ self.session = requests.Session()
42
+ self._auth = None
43
+ self._root = None
44
+ self._image_mem = {} # href -> bytes
45
+ self._login(email, password)
46
+
47
+ def log(self, level, *args):
48
+ if self.verbose >= level:
49
+ print(*args, file=sys.stderr)
50
+
51
+ def _login(self, email, password):
52
+ resp = self.session.post(
53
+ f"{self.api_base}/users/login",
54
+ data={"email": email, "password": password, "remember": True},
55
+ )
56
+ if resp.status_code != 200:
57
+ raise LoginError(f"Login failed ({resp.status_code}). Check email/password.")
58
+ self.log(1, "Logged in OK")
59
+
60
+ # -- account / product / document discovery ----------------------------
61
+
62
+ def auth(self) -> dict:
63
+ """Fetch (and memoize) the /auth payload (account, subscriptions, prefs)."""
64
+ if self._auth is None:
65
+ r = self.session.get(f"{self.api_base}/auth")
66
+ r.raise_for_status()
67
+ self._auth = r.json()
68
+ return self._auth
69
+
70
+ def default_language(self) -> str | None:
71
+ return self.auth().get("user", {}).get("preferences", {}).get("documentLang")
72
+
73
+ def subscribed_vins(self) -> list[str]:
74
+ """VIN(s) the account is entitled to, from subscription serial restrictions.
75
+
76
+ Active subscriptions are preferred; if none are active, fall back to all.
77
+ """
78
+ subs = self.auth().get("organisationSubscriptions") or []
79
+
80
+ def serials(only_active):
81
+ out = []
82
+ for s in subs:
83
+ if only_active and s.get("state") != "active":
84
+ continue
85
+ for entry in (s.get("restrictions") or {}).get("serial") or []:
86
+ vin = entry.get("serial")
87
+ if vin and vin not in out:
88
+ out.append(vin)
89
+ return out
90
+
91
+ return serials(True) or serials(False)
92
+
93
+ def product_search(self, vin: str) -> dict:
94
+ """Look up a VIN's product record (model code/year, serial, engine, market)."""
95
+ r = self.session.get(f"{self.api_base}/products/search/{vin}")
96
+ r.raise_for_status()
97
+ return r.json()
98
+
99
+ def list_documents(self, product_context: dict) -> list[dict]:
100
+ """List documents available for a product context."""
101
+ r = self.session.get(f"{self.api_base}/documents?{urlencode(product_context)}")
102
+ r.raise_for_status()
103
+ return r.json()
104
+
105
+ # -- raw document/topic/image fetches ----------------------------------
106
+
107
+ def _ctx(self) -> ManualConfig:
108
+ if self.config is None or not self.config.root_id:
109
+ raise RuntimeError("No manual selected (ManualConfig.root_id is unset).")
110
+ return self.config
111
+
112
+ def get_root(self):
113
+ """Fetch (and memoize) the root document, which carries the toc tree."""
114
+ if self._root is None:
115
+ cfg = self._ctx()
116
+ resp = self.session.get(f"{self.api_base}/documents/{cfg.root_id}")
117
+ resp.raise_for_status()
118
+ self._root = resp.json()
119
+ if self._root.get("language") != cfg.language:
120
+ self.log(0, f"WARNING: root language is {self._root.get('language')!r}, "
121
+ f"expected {cfg.language!r}")
122
+ return self._root
123
+
124
+ def get_topic(self, topic_id):
125
+ """Fetch one topic's full record (including its `content` AST)."""
126
+ cfg = self._ctx()
127
+ cache_file = self.topic_cache / f"{topic_id}.json"
128
+ if self.use_cache and cache_file.exists():
129
+ return json.loads(cache_file.read_text())
130
+
131
+ time.sleep(REQUEST_DELAY)
132
+ url = (f"{self.api_base}/documents/{cfg.root_id}/{topic_id}"
133
+ f"?{urlencode(cfg.product_context)}")
134
+ resp = self.session.get(url)
135
+ resp.raise_for_status()
136
+ data = resp.json()
137
+ cache_file.write_text(json.dumps(data))
138
+ return data
139
+
140
+ def get_image(self, href):
141
+ """Fetch one image asset's bytes."""
142
+ if href in self._image_mem:
143
+ return self._image_mem[href]
144
+ cache_file = self.image_cache / href
145
+ if self.use_cache and cache_file.exists():
146
+ data = cache_file.read_bytes()
147
+ self._image_mem[href] = data
148
+ return data
149
+
150
+ cfg = self._ctx()
151
+ time.sleep(REQUEST_DELAY)
152
+ resp = self.session.get(f"{self.api_base}/documents/{cfg.root_id}/images/{href}")
153
+ resp.raise_for_status()
154
+ data = resp.content
155
+ cache_file.write_bytes(data)
156
+ self._image_mem[href] = data
157
+ return data
158
+
159
+ # -- toc traversal -----------------------------------------------------
160
+
161
+ def iter_toc(self, start_topic=None):
162
+ """Yield (node, depth) for every toc entry, depth-first.
163
+
164
+ If start_topic is given, only that node's subtree is yielded.
165
+ """
166
+ root = self.get_root()
167
+ toc = root.get("toc", [])
168
+
169
+ def walk(nodes, depth):
170
+ for node in nodes:
171
+ yield node, depth
172
+ yield from walk(node.get("children") or [], depth + 1)
173
+
174
+ if start_topic is None:
175
+ yield from walk(toc, 0)
176
+ return
177
+
178
+ def find(nodes, depth):
179
+ for node in nodes:
180
+ if str(node.get("id")) == str(start_topic):
181
+ return node, depth
182
+ hit = find(node.get("children") or [], depth + 1)
183
+ if hit:
184
+ return hit
185
+ return None
186
+
187
+ hit = find(toc, 0)
188
+ if not hit:
189
+ sys.exit(f"ERROR: topic id {start_topic} not found in table of contents")
190
+ node, depth = hit
191
+ yield node, depth
192
+ yield from walk(node.get("children") or [], depth + 1)
193
+
194
+ def leaf_topics(self, start_topic=None, limit=None):
195
+ """Return ordered list of (id, title, depth) for selectable leaf topics."""
196
+ leaves = []
197
+ for node, depth in self.iter_toc(start_topic):
198
+ children = node.get("children") or []
199
+ if node.get("selectable") and not children:
200
+ leaves.append((str(node["id"]), node.get("title", ""), depth))
201
+ if limit and len(leaves) >= limit:
202
+ break
203
+ return leaves