Coverage for nexusLIMS/tui/apps/config/screens.py: 100%

682 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2026-03-24 05:23 +0000

1""" 

2Screens for the NexusLIMS configuration TUI. 

3 

4Provides :class:`ConfigScreen` (the main tabbed form) and 

5:class:`FieldDetailScreen` (popup help modal for configuration fields). 

6""" 

7 

8import contextlib 

9import json 

10import re 

11from pathlib import Path 

12from typing import ClassVar 

13 

14import pytz 

15from dotenv import dotenv_values 

16from pydantic_core import PydanticUndefined 

17from textual import on 

18from textual.app import ComposeResult 

19from textual.containers import Horizontal, Vertical, VerticalScroll 

20from textual.screen import ModalScreen, Screen 

21from textual.widgets import ( 

22 Button, 

23 Footer, 

24 Header, 

25 Input, 

26 Label, 

27 Select, 

28 Static, 

29 Switch, 

30 TabbedContent, 

31 TabPane, 

32 Tabs, 

33 TextArea, 

34) 

35 

36from nexusLIMS.cli.config import ( 

37 _flatten_to_env, 

38 _write_env_file, 

39) 

40from nexusLIMS.config import EmailConfig, NemoHarvesterConfig, Settings 

41from nexusLIMS.tui.apps.config.validators import ( 

42 validate_float_nonneg, 

43 validate_float_positive, 

44 validate_nemo_address, 

45 validate_optional_iana_timezone, 

46 validate_optional_int, 

47 validate_optional_url, 

48 validate_smtp_port, 

49) 

50from nexusLIMS.tui.common.base_screens import ConfirmDialog 

51from nexusLIMS.tui.common.validators import validate_required, validate_url 

52from nexusLIMS.tui.common.widgets import AutocompleteInput, FormField 

53 

54# --------------------------------------------------------------------------- # 

55# Constants # 

56# --------------------------------------------------------------------------- # 

57 

58_DEFAULT_STRFTIME = "%Y-%m-%dT%H:%M:%S%z" 

59_DEFAULT_STRPTIME = "%Y-%m-%dT%H:%M:%S%z" 

60 

61 

62# --------------------------------------------------------------------------- # 

63# Helpers: pull descriptions/defaults from the Settings model # 

64# --------------------------------------------------------------------------- # 

65 

66 

67def _fdesc(name: str) -> str: 

68 """Return the field description from Settings for the given env var name.""" 

69 field = Settings.model_fields.get(name) 

70 if field and field.description: 

71 return field.description 

72 return "" 

73 

74 

75def _fdefault(name: str) -> str: 

76 """Return the field default from Settings as a string, or empty string.""" 

77 field = Settings.model_fields.get(name) 

78 if field is None: 

79 return "" 

80 default = field.default 

81 if default is PydanticUndefined or default is None: 

82 return "" 

83 if isinstance(default, list): 

84 return ", ".join(str(v) for v in default) 

85 return str(default) 

86 

87 

88def _edesc(name: str) -> str: 

89 """Return the field description from EmailConfig for the given field name.""" 

90 field = EmailConfig.model_fields.get(name) 

91 if field and field.description: 

92 return field.description 

93 return "" 

94 

95 

96def _edefault(name: str) -> str: 

97 """Return the field default from EmailConfig as a string, or empty string.""" 

98 field = EmailConfig.model_fields.get(name) 

99 if field is None: 

100 return "" 

101 default = field.default 

102 if default is PydanticUndefined or default is None: 

103 return "" 

104 return str(default) 

105 

106 

107def _md_to_rich(text: str) -> str: 

108 """Convert a small subset of Markdown to Rich markup for TUI display. 

109 

110 Handles: 

111 - ``[label](url)`` → ``label (url)`` 

112 - bare URLs (http/https not inside a markdown link) → plain text URL 

113 - `` `code` `` → ``[bold]code[/bold]`` 

114 

115 Uses a single combined regex pass so that matched spans are never 

116 processed a second time, avoiding broken nested markup. 

117 """ 

118 # Combined pattern — alternatives are tried left-to-right, first match wins: 

119 # 1. backtick code span 

120 # 2. markdown link [label](url) 

121 # 3. bare http/https URL 

122 pattern = re.compile( 

123 r"`([^`]+)`" # group 1: backtick code span 

124 r"|" 

125 r"\[([^\]]+)\]\((https?://[^\)]+)\)" # group 2+3: markdown link 

126 r"|" 

127 r"(https?://\S+)" # group 4: bare URL 

128 ) 

129 

130 def _replace(m: re.Match) -> str: 

131 if m.group(1) is not None: 

132 return f"[bold]{m.group(1)}[/bold]" 

133 if m.group(2) is not None: 

134 # Render as "label (url)" — avoids MarkupError with URLs in [link=...] 

135 return f"{m.group(2)} ({m.group(3)})" 

136 # Bare URL — render as plain text to avoid MarkupError with "://" 

137 return m.group(4) 

138 

139 return pattern.sub(_replace, text) 

140 

141 

142def _fdetail(name: str) -> str: 

143 """Return extended detail text from Settings.json_schema_extra['detail'].""" 

144 field = Settings.model_fields.get(name) 

145 if field is None: 

146 return "" 

147 jse = getattr(field, "json_schema_extra", None) or {} 

148 if callable(jse): 

149 return "" 

150 return _md_to_rich(jse.get("detail", "")) 

151 

152 

153def _edetail(name: str) -> str: 

154 """Return extended detail text from EmailConfig.json_schema_extra['detail'].""" 

155 field = EmailConfig.model_fields.get(name) 

156 if field is None: 

157 return "" 

158 jse = getattr(field, "json_schema_extra", None) or {} 

159 if callable(jse): 

160 return "" 

161 return _md_to_rich(jse.get("detail", "")) 

162 

163 

164def _ndetail(name: str) -> str: 

165 """Return extended detail text from NemoHarvesterConfig for the given field.""" 

166 field = NemoHarvesterConfig.model_fields.get(name) 

167 if field is None: 

168 return "" 

169 jse = getattr(field, "json_schema_extra", None) or {} 

170 if callable(jse): 

171 return "" 

172 return _md_to_rich(jse.get("detail", "")) 

173 

174 

175# Maps NEMO Input widget id prefixes → NemoHarvesterConfig field name. 

176# Used for dynamic ids like "nemo-address-1", "nemo-token-2", etc. 

177_NEMO_INPUT_PREFIX_TO_FIELD: dict[str, str] = { 

178 "nemo-address-": "address", 

179 "nemo-token-": "token", 

180 "nemo-tz-": "tz", 

181 "nemo-strftime-": "strftime_fmt", 

182 "nemo-strptime-": "strptime_fmt", 

183} 

184 

185# Maps Input widget ids → (model_class, field_name) for detail lookup. 

186# Select widgets (nx-file-strategy, nx-export-strategy) and TextArea 

187# (nx-cert-bundle) are handled inline in action_show_field_detail. 

188# Switch widgets with detail text are handled inline in action_show_field_detail. 

189_INPUT_ID_TO_FIELD: dict[str, tuple[str, str]] = { 

190 "nx-instrument-data-path": ("settings", "NX_INSTRUMENT_DATA_PATH"), 

191 "nx-data-path": ("settings", "NX_DATA_PATH"), 

192 "nx-db-path": ("settings", "NX_DB_PATH"), 

193 "nx-log-path": ("settings", "NX_LOG_PATH"), 

194 "nx-records-path": ("settings", "NX_RECORDS_PATH"), 

195 "nx-local-profiles-path": ("settings", "NX_LOCAL_PROFILES_PATH"), 

196 "nx-cdcs-url": ("settings", "NX_CDCS_URL"), 

197 "nx-cdcs-token": ("settings", "NX_CDCS_TOKEN"), 

198 "nx-ignore-patterns": ("settings", "NX_IGNORE_PATTERNS"), 

199 "nx-file-delay-days": ("settings", "NX_FILE_DELAY_DAYS"), 

200 "nx-clustering-sensitivity": ("settings", "NX_CLUSTERING_SENSITIVITY"), 

201 "nx-elabftw-url": ("settings", "NX_ELABFTW_URL"), 

202 "nx-elabftw-api-key": ("settings", "NX_ELABFTW_API_KEY"), 

203 "nx-elabftw-category": ("settings", "NX_ELABFTW_EXPERIMENT_CATEGORY"), 

204 "nx-elabftw-status": ("settings", "NX_ELABFTW_EXPERIMENT_STATUS"), 

205 "nx-labarchives-url": ("settings", "NX_LABARCHIVES_URL"), 

206 "nx-labarchives-access-key-id": ("settings", "NX_LABARCHIVES_ACCESS_KEY_ID"), 

207 "nx-labarchives-access-password": ("settings", "NX_LABARCHIVES_ACCESS_PASSWORD"), 

208 "nx-labarchives-user-id": ("settings", "NX_LABARCHIVES_USER_ID"), 

209 "nx-labarchives-notebook-id": ("settings", "NX_LABARCHIVES_NOTEBOOK_ID"), 

210 "nx-email-smtp-host": ("email", "smtp_host"), 

211 "nx-email-smtp-port": ("email", "smtp_port"), 

212 "nx-email-smtp-username": ("email", "smtp_username"), 

213 "nx-email-smtp-password": ("email", "smtp_password"), 

214 "nx-email-sender": ("email", "sender"), 

215 "nx-email-recipients": ("email", "recipients"), 

216 "nx-cert-bundle-file": ("settings", "NX_CERT_BUNDLE_FILE"), 

217} 

218 

219 

220# --------------------------------------------------------------------------- # 

221# FieldDetailScreen # 

222# --------------------------------------------------------------------------- # 

223 

224 

225class FieldDetailScreen(ModalScreen): 

226 """ 

227 Modal popup displaying extended help text for a configuration field. 

228 

229 Invoked by pressing F1 while an Input or Select is focused in 

230 ConfigScreen. Dismisses on Escape, F1, or the Close button. 

231 

232 Parameters 

233 ---------- 

234 field_name : str 

235 The environment variable / field name shown as the popup title. 

236 detail_text : str 

237 The extended description to display in the scrollable body. 

238 """ 

239 

240 CSS_PATH: ClassVar = [ 

241 Path(__file__).parent.parent.parent / "styles" / "config" / "screens.tcss" 

242 ] 

243 

244 BINDINGS: ClassVar = [ 

245 ("escape", "dismiss_detail", "Close"), 

246 ("f1", "dismiss_detail", "Close"), 

247 ] 

248 

249 def __init__(self, field_name: str, detail_text: str, **kwargs) -> None: 

250 """Initialize with the field name and detail text to display.""" 

251 super().__init__(**kwargs) 

252 self._field_name = field_name 

253 self._detail_text = detail_text 

254 

255 def compose(self) -> ComposeResult: 

256 """Compose the modal layout.""" 

257 with Vertical(id="field-detail-dialog"): 

258 yield Label(self._field_name, id="field-detail-title") 

259 with VerticalScroll(id="field-detail-body"): 

260 yield Static(self._detail_text, id="field-detail-text") 

261 with Horizontal(id="field-detail-footer"): 

262 yield Button( 

263 "Close (Esc)", id="field-detail-close-btn", variant="default" 

264 ) 

265 

266 def action_dismiss_detail(self) -> None: 

267 """Dismiss this modal.""" 

268 self.dismiss() 

269 

270 @on(Button.Pressed, "#field-detail-close-btn") 

271 def _on_close_btn(self) -> None: 

272 self.dismiss() 

273 

274 

275# --------------------------------------------------------------------------- # 

276# LabArchivesGetUidDialog # 

277# --------------------------------------------------------------------------- # 

278 

279 

280class LabArchivesGetUidDialog(ModalScreen[str | None]): 

281 """Modal dialog to look up a LabArchives user ID. 

282 

283 Prompts for the user's email and LabArchives account password/app token, 

284 calls the ``user_access_info`` API endpoint, and dismisses with the 

285 returned UID string on success or ``None`` on cancel / error. 

286 

287 Parameters 

288 ---------- 

289 base_url : str 

290 LabArchives API base URL including the ``/api`` path 

291 (e.g. ``"https://api.labarchives.com/api"``). 

292 akid : str 

293 Access Key ID (NX_LABARCHIVES_ACCESS_KEY_ID). 

294 access_password : str 

295 HMAC signing secret (NX_LABARCHIVES_ACCESS_PASSWORD). 

296 """ 

297 

298 CSS_PATH: ClassVar = [ 

299 Path(__file__).parent.parent.parent / "styles" / "config" / "screens.tcss" 

300 ] 

301 

302 BINDINGS: ClassVar = [ 

303 ("escape", "cancel_dialog", "Cancel"), 

304 ] 

305 

306 def __init__( 

307 self, base_url: str, akid: str, access_password: str, **kwargs 

308 ) -> None: 

309 """Initialize with API credentials.""" 

310 super().__init__(**kwargs) 

311 self._base_url = base_url 

312 self._akid = akid 

313 self._access_password = access_password 

314 

315 def compose(self) -> ComposeResult: 

316 """Compose the dialog layout.""" 

317 with Vertical(id="field-detail-dialog"): 

318 yield Label("Look Up LabArchives User ID", id="field-detail-title") 

319 with VerticalScroll(id="field-detail-body"): 

320 yield Static( 

321 "Enter your LabArchives login credentials to retrieve your UID.\n\n" 

322 "[bold]Email[/bold]: your LabArchives account email address.\n" 

323 "[bold]LA Password[/bold]: your LabArchives external app password " 

324 "(click your username in the top-right of the LabArchives web UI → " 

325 "[italic]External App authentication[/italic]). " 

326 "This is NOT the NX_LABARCHIVES_ACCESS_PASSWORD signing secret.", 

327 id="field-detail-text", 

328 ) 

329 yield Label("Email", classes="form-label") 

330 yield Input( 

331 placeholder="you@example.com", 

332 id="la-uid-email", 

333 ) 

334 yield Label("LabArchives Password / App Token", classes="form-label") 

335 yield Input( 

336 placeholder="your LabArchives password or app token", 

337 password=True, 

338 id="la-uid-password", 

339 ) 

340 yield Static("", id="la-uid-error", classes="form-error") 

341 with Horizontal(id="field-detail-footer"): 

342 yield Button( 

343 "Look Up", 

344 id="la-uid-lookup-btn", 

345 variant="primary", 

346 disabled=True, 

347 ) 

348 yield Button( 

349 "Cancel (Esc)", id="field-detail-close-btn", variant="default" 

350 ) 

351 

352 @on(Input.Changed, "#la-uid-email") 

353 @on(Input.Changed, "#la-uid-password") 

354 def _update_lookup_btn(self, _event: Input.Changed) -> None: 

355 email = self.query_one("#la-uid-email", Input).value.strip() 

356 pw = self.query_one("#la-uid-password", Input).value.strip() 

357 self.query_one("#la-uid-lookup-btn", Button).disabled = not (email and pw) 

358 

359 @on(Button.Pressed, "#la-uid-lookup-btn") 

360 def _on_lookup(self) -> None: 

361 from nexusLIMS.utils.labarchives import ( # noqa: PLC0415 

362 LabArchivesClient, 

363 LabArchivesError, 

364 ) 

365 

366 email = self.query_one("#la-uid-email", Input).value.strip() 

367 password = self.query_one("#la-uid-password", Input).value.strip() 

368 error_widget = self.query_one("#la-uid-error", Static) 

369 

370 client = LabArchivesClient( 

371 base_url=self._base_url, 

372 akid=self._akid, 

373 password=self._access_password, 

374 uid="", 

375 ) 

376 try: 

377 info = client.get_user_info(email, password) 

378 except LabArchivesError as exc: 

379 error_widget.update(f"API error: {exc}") 

380 error_widget.add_class("visible") 

381 return 

382 

383 uid = info.get("uid") 

384 if not uid: 

385 error_widget.update("No UID returned. Check your email and password.") 

386 error_widget.add_class("visible") 

387 return 

388 

389 self.dismiss(uid) 

390 

391 @on(Button.Pressed, "#field-detail-close-btn") 

392 def _on_cancel_btn(self) -> None: 

393 self.dismiss(None) 

394 

395 def action_cancel_dialog(self) -> None: 

396 """Dismiss without a result.""" 

397 self.dismiss(None) 

398 

399 

400# --------------------------------------------------------------------------- # 

401# ConfigScreen # 

402# --------------------------------------------------------------------------- # 

403 

404 

405class ConfigScreen(Screen): 

406 """ 

407 Main configuration screen with 7 tabbed sections. 

408 

409 Reads an existing ``.env`` file (if present), pre-populates all fields, 

410 and writes a new ``.env`` when the user saves. 

411 

412 Parameters 

413 ---------- 

414 env_path : pathlib.Path 

415 Path to the ``.env`` file to read/write. 

416 """ 

417 

418 CSS_PATH: ClassVar = [ 

419 Path(__file__).parent.parent.parent / "styles" / "config" / "screens.tcss" 

420 ] 

421 

422 BINDINGS: ClassVar = [ 

423 ("ctrl+s", "save", "Save"), 

424 ("escape", "cancel", "Cancel"), 

425 ("f1", "show_field_detail", "Field Help"), 

426 ("<", "previous_tab", "Previous tab"), 

427 (">", "next_tab", "Next tab"), 

428 ("?", "app.help", "Help"), 

429 ] 

430 

431 def __init__(self, env_path: Path, **kwargs): 

432 """Initialize the config screen from an optional existing .env file.""" 

433 super().__init__(**kwargs) 

434 self._env_path = env_path 

435 self._existing: dict[str, str] = ( 

436 dotenv_values(env_path) if env_path.exists() else {} 

437 ) 

438 self._nemo_harvesters: dict[int, dict] = {} 

439 self._parse_nemo_harvesters() 

440 # Snapshot of the env as loaded — used for unsaved-changes detection. 

441 self._initial_env: dict[str, str] = dict(self._existing) 

442 

443 # ---------------------------------------------------------------------- # 

444 # Helpers for reading existing env # 

445 # ---------------------------------------------------------------------- # 

446 

447 def _get(self, key: str, default: str = "") -> str: 

448 val = self._existing.get(key, default) 

449 return val if val is not None else default 

450 

451 def _get_bool(self, key: str, *, default: bool = False) -> bool: 

452 val = self._existing.get(key, "").lower() 

453 if val in ("true", "1", "yes"): 

454 return True 

455 if val in ("false", "0", "no"): 

456 return False 

457 return default 

458 

459 def _parse_nemo_harvesters(self) -> None: 

460 """Populate ``self._nemo_harvesters`` from the existing env vars.""" 

461 n = 1 

462 while f"NX_NEMO_ADDRESS_{n}" in self._existing: 

463 self._nemo_harvesters[n] = { 

464 "address": self._existing.get(f"NX_NEMO_ADDRESS_{n}", ""), 

465 "token": self._existing.get(f"NX_NEMO_TOKEN_{n}", ""), 

466 "tz": self._existing.get(f"NX_NEMO_TZ_{n}"), 

467 "strftime_fmt": self._existing.get( 

468 f"NX_NEMO_STRFTIME_FMT_{n}", _DEFAULT_STRFTIME 

469 ), 

470 "strptime_fmt": self._existing.get( 

471 f"NX_NEMO_STRPTIME_FMT_{n}", _DEFAULT_STRPTIME 

472 ), 

473 } 

474 n += 1 

475 

476 def _has_elabftw(self) -> bool: 

477 return bool( 

478 self._existing.get("NX_ELABFTW_URL") 

479 or self._existing.get("NX_ELABFTW_API_KEY") 

480 ) 

481 

482 def _has_email(self) -> bool: 

483 return bool( 

484 self._existing.get("NX_EMAIL_SMTP_HOST") 

485 or self._existing.get("NX_EMAIL_SENDER") 

486 ) 

487 

488 def _has_labarchives(self) -> bool: 

489 return bool( 

490 self._existing.get("NX_LABARCHIVES_URL") 

491 or self._existing.get("NX_LABARCHIVES_ACCESS_KEY_ID") 

492 ) 

493 

494 # ---------------------------------------------------------------------- # 

495 # Compose # 

496 # ---------------------------------------------------------------------- # 

497 

498 def compose(self) -> ComposeResult: 

499 """Compose the tabbed config form layout.""" 

500 yield Header() 

501 

502 with TabbedContent(): 

503 with TabPane("Core Paths", id="tab-core-paths"): 

504 yield from self._compose_core_paths() 

505 with TabPane("CDCS", id="tab-cdcs"): 

506 yield from self._compose_cdcs() 

507 with TabPane("File Processing", id="tab-file-processing"): 

508 yield from self._compose_file_processing() 

509 with TabPane("NEMO Harvesters", id="tab-nemo"): 

510 yield from self._compose_nemo() 

511 with TabPane("eLabFTW", id="tab-elabftw"): 

512 yield from self._compose_elabftw() 

513 with TabPane("LabArchives", id="tab-labarchives"): 

514 yield from self._compose_labarchives() 

515 with TabPane("Email", id="tab-email"): 

516 yield from self._compose_email() 

517 with TabPane("SSL / Certs", id="tab-ssl"): 

518 yield from self._compose_ssl() 

519 

520 with Horizontal(id="config-footer-buttons"): 

521 yield Button("Save (Ctrl+S)", id="config-save-btn", variant="primary") 

522 yield Button("Cancel (Esc)", id="config-cancel-btn", variant="default") 

523 

524 yield Footer() 

525 

526 # ---------------------------------------------------------------------- # 

527 # Tab content composers # 

528 # ---------------------------------------------------------------------- # 

529 

530 def _compose_core_paths(self) -> ComposeResult: 

531 with VerticalScroll(): 

532 yield Label( 

533 "Core file paths for NexusLIMS operation", 

534 classes="tab-description", 

535 ) 

536 with Horizontal(classes="form-columns"): 

537 with Vertical(classes="form-column"): 

538 yield FormField( 

539 "NX_INSTRUMENT_DATA_PATH", 

540 Input( 

541 value=self._get("NX_INSTRUMENT_DATA_PATH"), 

542 placeholder="/mnt/instrument_data", 

543 id="nx-instrument-data-path", 

544 ), 

545 required=True, 

546 help_text=_fdesc("NX_INSTRUMENT_DATA_PATH"), 

547 ) 

548 yield FormField( 

549 "NX_DATA_PATH", 

550 Input( 

551 value=self._get("NX_DATA_PATH"), 

552 placeholder="/mnt/nexuslims_data", 

553 id="nx-data-path", 

554 ), 

555 required=True, 

556 help_text=_fdesc("NX_DATA_PATH"), 

557 ) 

558 yield FormField( 

559 "NX_DB_PATH", 

560 Input( 

561 value=self._get("NX_DB_PATH"), 

562 placeholder="/mnt/nexuslims_data/nexuslims.db", 

563 id="nx-db-path", 

564 ), 

565 required=True, 

566 help_text=_fdesc("NX_DB_PATH"), 

567 ) 

568 with Vertical(classes="form-column"): 

569 yield FormField( 

570 "NX_LOG_PATH (optional)", 

571 Input( 

572 value=self._get("NX_LOG_PATH"), 

573 placeholder="(defaults to NX_DATA_PATH/logs/)", 

574 id="nx-log-path", 

575 ), 

576 help_text=_fdesc("NX_LOG_PATH"), 

577 ) 

578 yield FormField( 

579 "NX_RECORDS_PATH (optional)", 

580 Input( 

581 value=self._get("NX_RECORDS_PATH"), 

582 placeholder="(defaults to NX_DATA_PATH/records/)", 

583 id="nx-records-path", 

584 ), 

585 help_text=_fdesc("NX_RECORDS_PATH"), 

586 ) 

587 yield FormField( 

588 "NX_LOCAL_PROFILES_PATH (optional)", 

589 Input( 

590 value=self._get("NX_LOCAL_PROFILES_PATH"), 

591 placeholder="(leave blank if unused)", 

592 id="nx-local-profiles-path", 

593 ), 

594 help_text=_fdesc("NX_LOCAL_PROFILES_PATH"), 

595 ) 

596 

597 def _compose_cdcs(self) -> ComposeResult: 

598 with VerticalScroll(): 

599 yield Label("CDCS front-end connection settings", classes="tab-description") 

600 with Horizontal(classes="form-columns"): 

601 with Vertical(classes="form-column"): 

602 yield FormField( 

603 "NX_CDCS_URL", 

604 Input( 

605 value=self._get("NX_CDCS_URL"), 

606 placeholder="https://cdcs.example.com", 

607 id="nx-cdcs-url", 

608 ), 

609 required=True, 

610 help_text=_fdesc("NX_CDCS_URL"), 

611 ) 

612 with Vertical(classes="form-column"): 

613 yield FormField( 

614 "NX_CDCS_TOKEN", 

615 Input( 

616 value=self._get("NX_CDCS_TOKEN"), 

617 placeholder="your-cdcs-api-token", 

618 password=True, 

619 id="nx-cdcs-token", 

620 ), 

621 required=True, 

622 help_text=_fdesc("NX_CDCS_TOKEN"), 

623 ) 

624 

625 def _compose_file_processing(self) -> ComposeResult: 

626 with VerticalScroll(): 

627 yield Label( 

628 "Controls file discovery and record building", 

629 classes="tab-description", 

630 ) 

631 

632 raw_patterns = self._get("NX_IGNORE_PATTERNS") 

633 if raw_patterns: 

634 try: 

635 patterns_list = json.loads(raw_patterns) 

636 patterns_display = ", ".join(patterns_list) 

637 except (json.JSONDecodeError, TypeError): 

638 patterns_display = raw_patterns 

639 else: 

640 patterns_display = "*.mib, *.db, *.emi, *.hdr" 

641 

642 with Horizontal(classes="form-columns"): 

643 with Vertical(classes="form-column"): 

644 strategy_opts = [ 

645 ( 

646 "exclusive \u2014 only files with known extractors", 

647 "exclusive", 

648 ), 

649 ( 

650 "inclusive \u2014 all files (basic metadata for unknowns)", 

651 "inclusive", 

652 ), 

653 ] 

654 current_strategy = self._get( 

655 "NX_FILE_STRATEGY", _fdefault("NX_FILE_STRATEGY") 

656 ) 

657 yield FormField( 

658 "NX_FILE_STRATEGY", 

659 Select( 

660 options=strategy_opts, 

661 value=current_strategy, 

662 id="nx-file-strategy", 

663 ), 

664 help_text=_fdesc("NX_FILE_STRATEGY"), 

665 ) 

666 

667 export_opts = [ 

668 ( 

669 "all \u2014 all destinations must succeed (recommended)", 

670 "all", 

671 ), 

672 ( 

673 "first_success \u2014 stop after first success", 

674 "first_success", 

675 ), 

676 ( 

677 "best_effort \u2014 try all, succeed if any succeed", 

678 "best_effort", 

679 ), 

680 ] 

681 current_export = self._get( 

682 "NX_EXPORT_STRATEGY", _fdefault("NX_EXPORT_STRATEGY") 

683 ) 

684 yield FormField( 

685 "NX_EXPORT_STRATEGY", 

686 Select( 

687 options=export_opts, 

688 value=current_export, 

689 id="nx-export-strategy", 

690 ), 

691 help_text=_fdesc("NX_EXPORT_STRATEGY"), 

692 ) 

693 

694 yield FormField( 

695 "NX_IGNORE_PATTERNS", 

696 Input( 

697 value=patterns_display, 

698 placeholder=_fdefault("NX_IGNORE_PATTERNS"), 

699 id="nx-ignore-patterns", 

700 ), 

701 help_text=_fdesc("NX_IGNORE_PATTERNS"), 

702 ) 

703 

704 with Vertical(classes="form-column"): 

705 yield FormField( 

706 "NX_FILE_DELAY_DAYS", 

707 Input( 

708 value=self._get( 

709 "NX_FILE_DELAY_DAYS", _fdefault("NX_FILE_DELAY_DAYS") 

710 ), 

711 placeholder=_fdefault("NX_FILE_DELAY_DAYS"), 

712 id="nx-file-delay-days", 

713 ), 

714 help_text=_fdesc("NX_FILE_DELAY_DAYS"), 

715 ) 

716 

717 yield FormField( 

718 "NX_CLUSTERING_SENSITIVITY", 

719 Input( 

720 value=self._get( 

721 "NX_CLUSTERING_SENSITIVITY", 

722 _fdefault("NX_CLUSTERING_SENSITIVITY"), 

723 ), 

724 placeholder=_fdefault("NX_CLUSTERING_SENSITIVITY"), 

725 id="nx-clustering-sensitivity", 

726 ), 

727 help_text=_fdesc("NX_CLUSTERING_SENSITIVITY"), 

728 ) 

729 

730 def _compose_nemo(self) -> ComposeResult: 

731 with VerticalScroll(): 

732 yield Label( 

733 "NEMO harvester instances — one group per NEMO server", 

734 classes="tab-description", 

735 ) 

736 yield Vertical(id="nemo-groups-container") 

737 with Horizontal(classes="nemo-action-bar"): 

738 yield Button( 

739 "+ Add NEMO Harvester", id="nemo-add-btn", variant="primary" 

740 ) 

741 

742 # ---------------------------------------------------------------------- # 

743 # NEMO group helpers # 

744 # ---------------------------------------------------------------------- # 

745 

746 def _nemo_group_widget(self, n: int, data: dict) -> Vertical: 

747 """Build and return a single NEMO harvester group widget.""" 

748 left_col = Vertical( 

749 FormField( 

750 "API Address", 

751 Input( 

752 value=data.get("address", ""), 

753 placeholder="https://nemo.example.com/api/", 

754 id=f"nemo-address-{n}", 

755 ), 

756 required=True, 

757 help_text="Full URL to the NEMO API root (must end with '/')", 

758 ), 

759 FormField( 

760 "API Token", 

761 Input( 

762 value=data.get("token", ""), 

763 placeholder="your-api-token-here", 

764 password=True, 

765 id=f"nemo-token-{n}", 

766 ), 

767 required=True, 

768 help_text=("Authentication token from the NEMO administration page"), 

769 ), 

770 FormField( 

771 "Timezone (optional)", 

772 AutocompleteInput( 

773 suggestions=pytz.common_timezones, 

774 value=data.get("tz") or "", 

775 placeholder=("America/New_York (leave blank to use NEMO default)"), 

776 id=f"nemo-tz-{n}", 

777 ), 

778 help_text="IANA timezone for coercing NEMO datetime strings", 

779 ), 

780 classes="form-column", 

781 ) 

782 right_col = Vertical( 

783 FormField( 

784 "strftime format (optional)", 

785 Input( 

786 value=data.get("strftime_fmt", _DEFAULT_STRFTIME), 

787 placeholder=_DEFAULT_STRFTIME, 

788 id=f"nemo-strftime-{n}", 

789 ), 

790 help_text="Python strftime format sent to the NEMO API", 

791 ), 

792 FormField( 

793 "strptime format (optional)", 

794 Input( 

795 value=data.get("strptime_fmt", _DEFAULT_STRPTIME), 

796 placeholder=_DEFAULT_STRPTIME, 

797 id=f"nemo-strptime-{n}", 

798 ), 

799 help_text=("Python strptime format for parsing NEMO API responses"), 

800 ), 

801 classes="form-column", 

802 ) 

803 return Vertical( 

804 Horizontal( 

805 Label(f"NEMO Harvester #{n}", classes="nemo-group-title"), 

806 Button( 

807 "Delete", 

808 id=f"nemo-delete-{n}", 

809 classes="nemo-delete-btn", 

810 ), 

811 classes="nemo-group-header", 

812 ), 

813 Horizontal(left_col, right_col, classes="form-columns"), 

814 id=f"nemo-group-{n}", 

815 classes="nemo-group", 

816 ) 

817 

818 def _compose_elabftw(self) -> ComposeResult: 

819 with VerticalScroll(): 

820 yield Label( 

821 "Export experiment records to an eLabFTW instance", 

822 classes="tab-description", 

823 ) 

824 with Horizontal(classes="section-toggle-row", id="elabftw-toggle-row"): 

825 yield Label( 

826 "Enable eLabFTW integration", 

827 classes="section-toggle-label", 

828 ) 

829 yield Switch( 

830 value=self._has_elabftw(), 

831 id="elabftw-enabled", 

832 ) 

833 

834 enabled = self._has_elabftw() 

835 with Horizontal(classes="form-columns"): 

836 with Vertical(classes="form-column"): 

837 yield FormField( 

838 "NX_ELABFTW_URL", 

839 Input( 

840 value=self._get("NX_ELABFTW_URL"), 

841 placeholder="https://elabftw.example.com", 

842 id="nx-elabftw-url", 

843 disabled=not enabled, 

844 ), 

845 help_text=_fdesc("NX_ELABFTW_URL"), 

846 ) 

847 yield FormField( 

848 "NX_ELABFTW_API_KEY", 

849 Input( 

850 value=self._get("NX_ELABFTW_API_KEY"), 

851 placeholder="your-elabftw-api-key", 

852 password=True, 

853 id="nx-elabftw-api-key", 

854 disabled=not enabled, 

855 ), 

856 help_text=_fdesc("NX_ELABFTW_API_KEY"), 

857 ) 

858 with Vertical(classes="form-column"): 

859 yield FormField( 

860 "NX_ELABFTW_EXPERIMENT_CATEGORY (optional)", 

861 Input( 

862 value=self._get("NX_ELABFTW_EXPERIMENT_CATEGORY"), 

863 placeholder="(integer category ID)", 

864 id="nx-elabftw-category", 

865 disabled=not enabled, 

866 ), 

867 help_text=_fdesc("NX_ELABFTW_EXPERIMENT_CATEGORY"), 

868 ) 

869 yield FormField( 

870 "NX_ELABFTW_EXPERIMENT_STATUS (optional)", 

871 Input( 

872 value=self._get("NX_ELABFTW_EXPERIMENT_STATUS"), 

873 placeholder="(integer status ID)", 

874 id="nx-elabftw-status", 

875 disabled=not enabled, 

876 ), 

877 help_text=_fdesc("NX_ELABFTW_EXPERIMENT_STATUS"), 

878 ) 

879 

880 def _compose_labarchives(self) -> ComposeResult: 

881 with VerticalScroll(): 

882 yield Label( 

883 "Export experiment records to a LabArchives notebook", 

884 classes="tab-description", 

885 ) 

886 with Horizontal(classes="section-toggle-row", id="labarchives-toggle-row"): 

887 yield Label( 

888 "Enable LabArchives integration", 

889 classes="section-toggle-label", 

890 ) 

891 yield Switch( 

892 value=self._has_labarchives(), 

893 id="labarchives-enabled", 

894 ) 

895 

896 enabled = self._has_labarchives() 

897 _la_creds_ready = bool( 

898 self._get("NX_LABARCHIVES_URL") 

899 and self._get("NX_LABARCHIVES_ACCESS_KEY_ID") 

900 and self._get("NX_LABARCHIVES_ACCESS_PASSWORD") 

901 ) 

902 with Horizontal(classes="form-columns"): 

903 with Vertical(classes="form-column"): 

904 yield FormField( 

905 "NX_LABARCHIVES_URL", 

906 Input( 

907 value=self._get( 

908 "NX_LABARCHIVES_URL", 

909 _fdefault("NX_LABARCHIVES_URL"), 

910 ), 

911 placeholder="https://api.labarchives.com/api", 

912 id="nx-labarchives-url", 

913 disabled=not enabled, 

914 ), 

915 help_text=_fdesc("NX_LABARCHIVES_URL"), 

916 ) 

917 yield FormField( 

918 "NX_LABARCHIVES_ACCESS_KEY_ID", 

919 Input( 

920 value=self._get("NX_LABARCHIVES_ACCESS_KEY_ID"), 

921 placeholder="your-access-key-id", 

922 id="nx-labarchives-access-key-id", 

923 disabled=not enabled, 

924 ), 

925 help_text=_fdesc("NX_LABARCHIVES_ACCESS_KEY_ID"), 

926 ) 

927 with Vertical(classes="form-column"): 

928 yield FormField( 

929 "NX_LABARCHIVES_ACCESS_PASSWORD", 

930 Input( 

931 value=self._get("NX_LABARCHIVES_ACCESS_PASSWORD"), 

932 placeholder="your-access-password", 

933 password=True, 

934 id="nx-labarchives-access-password", 

935 disabled=not enabled, 

936 ), 

937 help_text=_fdesc("NX_LABARCHIVES_ACCESS_PASSWORD"), 

938 ) 

939 with Vertical(classes="form-field"): 

940 yield Label("NX_LABARCHIVES_USER_ID", classes="field-label") 

941 yield Static( 

942 _fdesc("NX_LABARCHIVES_USER_ID"), classes="field-help" 

943 ) 

944 with Horizontal(classes="field-with-button"): 

945 yield Input( 

946 value=self._get("NX_LABARCHIVES_USER_ID"), 

947 placeholder="your-uid", 

948 id="nx-labarchives-user-id", 

949 disabled=not enabled, 

950 ) 

951 yield Button( 

952 "Get My UID", 

953 id="labarchives-get-uid-btn", 

954 variant="primary", 

955 disabled=not (enabled and _la_creds_ready), 

956 ) 

957 yield Static( 

958 "", 

959 classes="field-error", 

960 id="nx-labarchives-user-id-error", 

961 ) 

962 yield FormField( 

963 "NX_LABARCHIVES_NOTEBOOK_ID (optional)", 

964 Input( 

965 value=self._get("NX_LABARCHIVES_NOTEBOOK_ID"), 

966 placeholder="(leave blank to use Inbox)", 

967 id="nx-labarchives-notebook-id", 

968 disabled=not enabled, 

969 ), 

970 help_text=_fdesc("NX_LABARCHIVES_NOTEBOOK_ID"), 

971 ) 

972 

973 def _compose_email(self) -> ComposeResult: 

974 with VerticalScroll(): 

975 yield Label( 

976 "Send notifications on record builder errors", 

977 classes="tab-description", 

978 ) 

979 with Horizontal(classes="section-toggle-row", id="email-toggle-row"): 

980 yield Label( 

981 "Enable email notifications", 

982 classes="section-toggle-label", 

983 ) 

984 yield Switch( 

985 value=self._has_email(), 

986 id="email-enabled", 

987 ) 

988 

989 enabled = self._has_email() 

990 with Horizontal(classes="form-columns"): 

991 with Vertical(classes="form-column"): 

992 yield FormField( 

993 "SMTP Host", 

994 Input( 

995 value=self._get("NX_EMAIL_SMTP_HOST"), 

996 placeholder="smtp.example.com", 

997 id="nx-email-smtp-host", 

998 disabled=not enabled, 

999 ), 

1000 help_text=_edesc("smtp_host"), 

1001 ) 

1002 yield FormField( 

1003 "SMTP Port", 

1004 Input( 

1005 value=self._get( 

1006 "NX_EMAIL_SMTP_PORT", _edefault("smtp_port") 

1007 ), 

1008 placeholder=_edefault("smtp_port"), 

1009 id="nx-email-smtp-port", 

1010 disabled=not enabled, 

1011 ), 

1012 help_text=_edesc("smtp_port"), 

1013 ) 

1014 yield FormField( 

1015 "SMTP Username (optional)", 

1016 Input( 

1017 value=self._get("NX_EMAIL_SMTP_USERNAME"), 

1018 placeholder="(leave blank if not required)", 

1019 id="nx-email-smtp-username", 

1020 disabled=not enabled, 

1021 ), 

1022 help_text=_edesc("smtp_username"), 

1023 ) 

1024 yield FormField( 

1025 "SMTP Password (optional)", 

1026 Input( 

1027 value=self._get("NX_EMAIL_SMTP_PASSWORD"), 

1028 placeholder="(leave blank if not required)", 

1029 password=True, 

1030 id="nx-email-smtp-password", 

1031 disabled=not enabled, 

1032 ), 

1033 help_text=_edesc("smtp_password"), 

1034 ) 

1035 with Vertical(classes="form-column"): 

1036 with Horizontal(classes="section-toggle-row"): 

1037 yield Label("Use TLS", classes="section-toggle-label") 

1038 yield Switch( 

1039 value=self._get_bool("NX_EMAIL_USE_TLS", default=True), 

1040 id="nx-email-use-tls", 

1041 disabled=not enabled, 

1042 ) 

1043 yield FormField( 

1044 "Sender Address", 

1045 Input( 

1046 value=self._get("NX_EMAIL_SENDER"), 

1047 placeholder="nexuslims@example.com", 

1048 id="nx-email-sender", 

1049 disabled=not enabled, 

1050 ), 

1051 help_text=_edesc("sender"), 

1052 ) 

1053 yield FormField( 

1054 "Recipients", 

1055 Input( 

1056 value=self._get("NX_EMAIL_RECIPIENTS"), 

1057 placeholder="admin@example.com, user2@example.com", 

1058 id="nx-email-recipients", 

1059 disabled=not enabled, 

1060 ), 

1061 help_text=_edesc("recipients"), 

1062 ) 

1063 

1064 def _compose_ssl(self) -> ComposeResult: 

1065 with VerticalScroll(): 

1066 yield Label("SSL / Certificate configuration", classes="tab-description") 

1067 yield FormField( 

1068 "NX_CERT_BUNDLE_FILE (optional)", 

1069 Input( 

1070 value=self._get("NX_CERT_BUNDLE_FILE"), 

1071 placeholder="/path/to/ca-bundle.crt", 

1072 id="nx-cert-bundle-file", 

1073 ), 

1074 help_text=_fdesc("NX_CERT_BUNDLE_FILE"), 

1075 ) 

1076 yield Label("NX_CERT_BUNDLE (optional)", classes="field-label") 

1077 yield Static( 

1078 _fdesc("NX_CERT_BUNDLE"), 

1079 classes="field-help", 

1080 ) 

1081 yield TextArea( 

1082 text=self._get("NX_CERT_BUNDLE"), 

1083 id="nx-cert-bundle", 

1084 ) 

1085 

1086 disable_ssl = self._get_bool("NX_DISABLE_SSL_VERIFY", default=False) 

1087 with Horizontal(classes="section-toggle-row ssl-verify-row"): 

1088 yield Label( 

1089 "NX_DISABLE_SSL_VERIFY", 

1090 classes="section-toggle-label", 

1091 ) 

1092 yield Switch( 

1093 value=disable_ssl, 

1094 id="nx-disable-ssl-verify", 

1095 ) 

1096 yield Static( 

1097 "WARNING: Disabling SSL verification is insecure. " 

1098 "Only use for local development with self-signed certificates.", 

1099 id="ssl-verify-warning", 

1100 classes="ssl-warning" + (" visible" if disable_ssl else ""), 

1101 ) 

1102 

1103 # ---------------------------------------------------------------------- # 

1104 # Lifecycle hooks # 

1105 # ---------------------------------------------------------------------- # 

1106 

1107 def on_mount(self) -> None: 

1108 """Populate NEMO harvester groups and configure toggle rows after mount.""" 

1109 container = self.query_one("#nemo-groups-container", Vertical) 

1110 for n, data in sorted(self._nemo_harvesters.items()): 

1111 container.mount(self._nemo_group_widget(n, data)) 

1112 self.query_one("#elabftw-toggle-row").set_class(self._has_elabftw(), "-on") 

1113 self.query_one("#labarchives-toggle-row").set_class( 

1114 self._has_labarchives(), "-on" 

1115 ) 

1116 self.query_one("#email-toggle-row").set_class(self._has_email(), "-on") 

1117 

1118 def _next_nemo_index(self) -> int: 

1119 """Return the next available NEMO harvester index.""" 

1120 existing = [ 

1121 int(w.id.split("-")[-1]) 

1122 for w in self.query(".nemo-group") 

1123 if w.id and w.id.startswith("nemo-group-") 

1124 ] 

1125 return max(existing, default=0) + 1 

1126 

1127 # ---------------------------------------------------------------------- # 

1128 # Event handlers # 

1129 # ---------------------------------------------------------------------- # 

1130 

1131 @on(Button.Pressed, "#config-save-btn") 

1132 def _on_save_btn(self) -> None: 

1133 self.action_save() 

1134 

1135 @on(Button.Pressed, "#config-cancel-btn") 

1136 def _on_cancel_btn(self) -> None: 

1137 self.action_cancel() 

1138 

1139 @on(Button.Pressed, "#nemo-add-btn") 

1140 def _on_nemo_add(self) -> None: 

1141 n = self._next_nemo_index() 

1142 container = self.query_one("#nemo-groups-container", Vertical) 

1143 container.mount(self._nemo_group_widget(n, {})) 

1144 self.app.notify(f"Added NEMO Harvester #{n}", timeout=2) 

1145 

1146 def on_button_pressed(self, event: Button.Pressed) -> None: 

1147 """Handle delete buttons on individual NEMO harvester groups.""" 

1148 if event.button.has_class("nemo-delete-btn"): 

1149 group = event.button.parent.parent # Button → header → group 

1150 if group is not None: 

1151 group.remove() 

1152 event.stop() 

1153 

1154 @on(Switch.Changed, "#elabftw-enabled") 

1155 def _on_elabftw_toggle(self, event: Switch.Changed) -> None: 

1156 enabled = event.value 

1157 self.query_one("#elabftw-toggle-row").set_class(enabled, "-on") 

1158 for field_id in ( 

1159 "nx-elabftw-url", 

1160 "nx-elabftw-api-key", 

1161 "nx-elabftw-category", 

1162 "nx-elabftw-status", 

1163 ): 

1164 with contextlib.suppress(Exception): 

1165 self.query_one(f"#{field_id}", Input).disabled = not enabled 

1166 

1167 @on(Switch.Changed, "#labarchives-enabled") 

1168 def _on_labarchives_toggle(self, event: Switch.Changed) -> None: 

1169 enabled = event.value 

1170 self.query_one("#labarchives-toggle-row").set_class(enabled, "-on") 

1171 for field_id in ( 

1172 "nx-labarchives-url", 

1173 "nx-labarchives-access-key-id", 

1174 "nx-labarchives-access-password", 

1175 "nx-labarchives-user-id", 

1176 "nx-labarchives-notebook-id", 

1177 ): 

1178 with contextlib.suppress(Exception): 

1179 self.query_one(f"#{field_id}", Input).disabled = not enabled 

1180 self._update_la_get_uid_btn() 

1181 

1182 @on(Input.Changed, "#nx-labarchives-url") 

1183 @on(Input.Changed, "#nx-labarchives-access-key-id") 

1184 @on(Input.Changed, "#nx-labarchives-access-password") 

1185 def _on_labarchives_cred_changed(self, _event: Input.Changed) -> None: 

1186 self._update_la_get_uid_btn() 

1187 

1188 def _update_la_get_uid_btn(self) -> None: 

1189 """Enable the 'Get My UID' button only when all three credentials are set.""" 

1190 with contextlib.suppress(Exception): 

1191 enabled = self.query_one("#labarchives-enabled", Switch).value 

1192 url = self.query_one("#nx-labarchives-url", Input).value.strip() 

1193 akid = self.query_one("#nx-labarchives-access-key-id", Input).value.strip() 

1194 pw = self.query_one("#nx-labarchives-access-password", Input).value.strip() 

1195 ready = enabled and bool(url and akid and pw) 

1196 self.query_one("#labarchives-get-uid-btn", Button).disabled = not ready 

1197 

1198 @on(Button.Pressed, "#labarchives-get-uid-btn") 

1199 def _on_labarchives_get_uid(self) -> None: 

1200 """Open the UID lookup dialog and populate the User ID field on success.""" 

1201 with contextlib.suppress(Exception): 

1202 url = self.query_one("#nx-labarchives-url", Input).value.strip() 

1203 akid = self.query_one("#nx-labarchives-access-key-id", Input).value.strip() 

1204 pw = self.query_one("#nx-labarchives-access-password", Input).value.strip() 

1205 self.app.push_screen( 

1206 LabArchivesGetUidDialog(base_url=url, akid=akid, access_password=pw), 

1207 self._on_uid_lookup_result, 

1208 ) 

1209 

1210 def _on_uid_lookup_result(self, uid: str | None) -> None: 

1211 """Populate the User ID input with the returned UID.""" 

1212 if uid: 

1213 with contextlib.suppress(Exception): 

1214 self.query_one("#nx-labarchives-user-id", Input).value = uid 

1215 self.app.notify( 

1216 f"LabArchives UID retrieved: {uid}", severity="information", timeout=5 

1217 ) 

1218 

1219 @on(Switch.Changed, "#email-enabled") 

1220 def _on_email_toggle(self, event: Switch.Changed) -> None: 

1221 enabled = event.value 

1222 self.query_one("#email-toggle-row").set_class(enabled, "-on") 

1223 for field_id in ( 

1224 "nx-email-smtp-host", 

1225 "nx-email-smtp-port", 

1226 "nx-email-smtp-username", 

1227 "nx-email-smtp-password", 

1228 "nx-email-use-tls", 

1229 "nx-email-sender", 

1230 "nx-email-recipients", 

1231 ): 

1232 with contextlib.suppress(Exception): 

1233 self.query_one(f"#{field_id}").disabled = not enabled 

1234 

1235 @on(Switch.Changed, "#nx-disable-ssl-verify") 

1236 def _on_ssl_verify_toggle(self, event: Switch.Changed) -> None: 

1237 warning = self.query_one("#ssl-verify-warning", Static) 

1238 if event.value: 

1239 warning.add_class("visible") 

1240 else: 

1241 warning.remove_class("visible") 

1242 

1243 # ---------------------------------------------------------------------- # 

1244 # Actions # 

1245 # ---------------------------------------------------------------------- # 

1246 

1247 def action_save(self) -> None: 

1248 """Validate all fields and write the .env file.""" 

1249 errors = self._validate_all() 

1250 if errors: 

1251 msg = f"Cannot save: {len(errors)} error(s). " + "; ".join(errors[:2]) 

1252 if len(errors) > 2: 

1253 msg += f" (and {len(errors) - 2} more)" 

1254 self.app.notify(msg, severity="error", timeout=6) 

1255 return 

1256 

1257 try: 

1258 config_dict = self._build_config_dict() 

1259 env_vars = _flatten_to_env(config_dict) 

1260 _write_env_file(env_vars, self._env_path) 

1261 self.app.notify( 

1262 f"Configuration saved to {self._env_path}", 

1263 severity="information", 

1264 timeout=4, 

1265 ) 

1266 self.app.exit() 

1267 except Exception as exc: 

1268 self.app.notify(f"Failed to save: {exc}", severity="error", timeout=6) 

1269 

1270 def _has_changes(self) -> bool: 

1271 """Return True if the current form state differs from the loaded env.""" 

1272 try: 

1273 current_env = _flatten_to_env(self._build_config_dict()) 

1274 except Exception: 

1275 return True 

1276 return current_env != self._initial_env 

1277 

1278 def action_cancel(self) -> None: 

1279 """Exit without saving, prompting if there are unsaved changes.""" 

1280 if not self._has_changes(): 

1281 self.app.exit() 

1282 return 

1283 self.app.push_screen( 

1284 ConfirmDialog( 

1285 "You have unsaved changes. Exit without saving?", 

1286 title="Unsaved Changes", 

1287 ), 

1288 self._on_cancel_confirmed, 

1289 ) 

1290 

1291 def _on_cancel_confirmed(self, confirmed: bool) -> None: 

1292 if confirmed: 

1293 self.app.exit() 

1294 

1295 def action_next_tab(self) -> None: 

1296 """Activate the next tab.""" 

1297 self.query_one(TabbedContent).query_one(Tabs).action_next_tab() 

1298 

1299 def action_previous_tab(self) -> None: 

1300 """Activate the previous tab.""" 

1301 self.query_one(TabbedContent).query_one(Tabs).action_previous_tab() 

1302 

1303 def _resolve_focused_field_detail(self, focused) -> tuple[str | None, str]: 

1304 """Return ``(field_name, detail)`` for the currently focused widget.""" 

1305 if isinstance(focused, Input): 

1306 return self._resolve_input_field_detail(focused) 

1307 if isinstance(focused, Switch): 

1308 if focused.id == "nx-disable-ssl-verify": 

1309 name = "NX_DISABLE_SSL_VERIFY" 

1310 return name, _fdetail(name) 

1311 elif isinstance(focused, TextArea): 

1312 if focused.id == "nx-cert-bundle": 

1313 name = "NX_CERT_BUNDLE" 

1314 return name, _fdetail(name) 

1315 elif isinstance(focused, Select): 

1316 return self._resolve_select_field_detail(focused) 

1317 return None, "" 

1318 

1319 def _resolve_input_field_detail(self, focused: Input) -> tuple[str | None, str]: 

1320 """Return ``(field_name, detail)`` for a focused Input widget.""" 

1321 input_id = focused.id or "" 

1322 mapping = _INPUT_ID_TO_FIELD.get(input_id) 

1323 if mapping: 

1324 model_class, field_name = mapping 

1325 detail = ( 

1326 _fdetail(field_name) 

1327 if model_class == "settings" 

1328 else _edetail(field_name) 

1329 ) 

1330 return field_name, detail 

1331 for prefix, nemo_field in _NEMO_INPUT_PREFIX_TO_FIELD.items(): 

1332 if input_id.startswith(prefix): 

1333 field_name = f"NX_NEMO_{nemo_field.upper()}_N" 

1334 return field_name, _ndetail(nemo_field) 

1335 return None, "" 

1336 

1337 def _resolve_select_field_detail(self, focused: Select) -> tuple[str | None, str]: 

1338 """Return ``(field_name, detail)`` for a focused Select widget.""" 

1339 select_id_map = { 

1340 "nx-file-strategy": "NX_FILE_STRATEGY", 

1341 "nx-export-strategy": "NX_EXPORT_STRATEGY", 

1342 } 

1343 name = select_id_map.get(focused.id or "") 

1344 if name: 

1345 return name, _fdetail(name) 

1346 return None, "" 

1347 

1348 def action_show_field_detail(self) -> None: 

1349 """Show extended help popup for the currently focused input or select.""" 

1350 field_name, detail = self._resolve_focused_field_detail(self.screen.focused) 

1351 

1352 if not field_name or not detail: 

1353 if field_name: 

1354 self.app.notify( 

1355 f"No extended help available for {field_name}.", 

1356 severity="information", 

1357 timeout=2, 

1358 ) 

1359 return 

1360 

1361 self.app.push_screen(FieldDetailScreen(field_name, detail)) 

1362 

1363 # ---------------------------------------------------------------------- # 

1364 # Validation helpers # 

1365 # ---------------------------------------------------------------------- # 

1366 

1367 def _validate_core_paths(self) -> list[str]: 

1368 errors: list[str] = [] 

1369 for field_id, label in [ 

1370 ("nx-instrument-data-path", "NX_INSTRUMENT_DATA_PATH"), 

1371 ("nx-data-path", "NX_DATA_PATH"), 

1372 ("nx-db-path", "NX_DB_PATH"), 

1373 ]: 

1374 val = self.query_one(f"#{field_id}", Input).value.strip() 

1375 ok, msg = validate_required(val, label) 

1376 if not ok: 

1377 errors.append(msg) 

1378 return errors 

1379 

1380 def _validate_cdcs(self) -> list[str]: 

1381 errors: list[str] = [] 

1382 cdcs_url = self.query_one("#nx-cdcs-url", Input).value.strip() 

1383 ok, msg = validate_url(cdcs_url, "NX_CDCS_URL") 

1384 if not ok: 

1385 errors.append(msg) 

1386 cdcs_token = self.query_one("#nx-cdcs-token", Input).value.strip() 

1387 ok, msg = validate_required(cdcs_token, "NX_CDCS_TOKEN") 

1388 if not ok: 

1389 errors.append(msg) 

1390 return errors 

1391 

1392 def _validate_file_processing(self) -> list[str]: 

1393 errors: list[str] = [] 

1394 ok, msg = validate_float_positive( 

1395 self.query_one("#nx-file-delay-days", Input).value.strip(), 

1396 "NX_FILE_DELAY_DAYS", 

1397 ) 

1398 if not ok: 

1399 errors.append(msg) 

1400 ok, msg = validate_float_nonneg( 

1401 self.query_one("#nx-clustering-sensitivity", Input).value.strip(), 

1402 "NX_CLUSTERING_SENSITIVITY", 

1403 ) 

1404 if not ok: 

1405 errors.append(msg) 

1406 return errors 

1407 

1408 def _validate_elabftw(self) -> list[str]: 

1409 if not self.query_one("#elabftw-enabled", Switch).value: 

1410 return [] 

1411 errors: list[str] = [] 

1412 url = self.query_one("#nx-elabftw-url", Input).value.strip() 

1413 ok, msg = validate_optional_url(url, "NX_ELABFTW_URL") 

1414 if not ok: 

1415 errors.append(msg) 

1416 ok, msg = validate_required( 

1417 self.query_one("#nx-elabftw-api-key", Input).value.strip(), 

1418 "NX_ELABFTW_API_KEY", 

1419 ) 

1420 if not ok: 

1421 errors.append(msg) 

1422 for field_id, label in [ 

1423 ("nx-elabftw-category", "NX_ELABFTW_EXPERIMENT_CATEGORY"), 

1424 ("nx-elabftw-status", "NX_ELABFTW_EXPERIMENT_STATUS"), 

1425 ]: 

1426 ok, msg = validate_optional_int( 

1427 self.query_one(f"#{field_id}", Input).value.strip(), label 

1428 ) 

1429 if not ok: 

1430 errors.append(msg) 

1431 return errors 

1432 

1433 def _validate_labarchives(self) -> list[str]: 

1434 if not self.query_one("#labarchives-enabled", Switch).value: 

1435 return [] 

1436 errors: list[str] = [] 

1437 url = self.query_one("#nx-labarchives-url", Input).value.strip() 

1438 ok, msg = validate_optional_url(url, "NX_LABARCHIVES_URL") 

1439 if not ok: 

1440 errors.append(msg) 

1441 for field_id, label in [ 

1442 ("nx-labarchives-access-key-id", "NX_LABARCHIVES_ACCESS_KEY_ID"), 

1443 ("nx-labarchives-access-password", "NX_LABARCHIVES_ACCESS_PASSWORD"), 

1444 ("nx-labarchives-user-id", "NX_LABARCHIVES_USER_ID"), 

1445 ]: 

1446 ok, msg = validate_required( 

1447 self.query_one(f"#{field_id}", Input).value.strip(), label 

1448 ) 

1449 if not ok: 

1450 errors.append(msg) 

1451 return errors 

1452 

1453 def _validate_email(self) -> list[str]: 

1454 if not self.query_one("#email-enabled", Switch).value: 

1455 return [] 

1456 errors: list[str] = [] 

1457 for field_id, label in [ 

1458 ("nx-email-smtp-host", "SMTP Host"), 

1459 ("nx-email-sender", "Email Sender"), 

1460 ("nx-email-recipients", "Email Recipients"), 

1461 ]: 

1462 ok, msg = validate_required( 

1463 self.query_one(f"#{field_id}", Input).value.strip(), label 

1464 ) 

1465 if not ok: 

1466 errors.append(msg) 

1467 ok, msg = validate_smtp_port( 

1468 self.query_one("#nx-email-smtp-port", Input).value.strip() 

1469 ) 

1470 if not ok: 

1471 errors.append(msg) 

1472 return errors 

1473 

1474 def _validate_nemo(self) -> list[str]: 

1475 errors: list[str] = [] 

1476 for group in self.query(".nemo-group"): 

1477 if group.id is None or not group.id.startswith("nemo-group-"): 

1478 continue 

1479 n = group.id.split("-")[-1] 

1480 address = self.query_one(f"#nemo-address-{n}", Input).value.strip() 

1481 ok, msg = validate_nemo_address(address) 

1482 if not ok: 

1483 errors.append(f"Harvester #{n} API Address: {msg}") 

1484 token = self.query_one(f"#nemo-token-{n}", Input).value.strip() 

1485 ok, msg = validate_required(token, f"Harvester #{n} API Token") 

1486 if not ok: 

1487 errors.append(msg) 

1488 tz_raw = self.query_one(f"#nemo-tz-{n}", Input).value.strip() 

1489 if tz_raw: 

1490 ok, msg = validate_optional_iana_timezone(tz_raw) 

1491 if not ok: 

1492 errors.append(f"Harvester #{n} Timezone: {msg}") 

1493 return errors 

1494 

1495 def _validate_all(self) -> list[str]: 

1496 return ( 

1497 self._validate_core_paths() 

1498 + self._validate_cdcs() 

1499 + self._validate_file_processing() 

1500 + self._validate_nemo() 

1501 + self._validate_elabftw() 

1502 + self._validate_labarchives() 

1503 + self._validate_email() 

1504 ) 

1505 

1506 # ---------------------------------------------------------------------- # 

1507 # Config dict builder helpers # 

1508 # ---------------------------------------------------------------------- # 

1509 

1510 def _build_paths_config(self) -> dict: 

1511 config: dict = {} 

1512 for field_id, key in [ 

1513 ("nx-instrument-data-path", "NX_INSTRUMENT_DATA_PATH"), 

1514 ("nx-data-path", "NX_DATA_PATH"), 

1515 ("nx-db-path", "NX_DB_PATH"), 

1516 ("nx-log-path", "NX_LOG_PATH"), 

1517 ("nx-records-path", "NX_RECORDS_PATH"), 

1518 ("nx-local-profiles-path", "NX_LOCAL_PROFILES_PATH"), 

1519 ]: 

1520 val = self.query_one(f"#{field_id}", Input).value.strip() 

1521 if val: 

1522 config[key] = val 

1523 return config 

1524 

1525 def _build_cdcs_config(self) -> dict: 

1526 config: dict = {} 

1527 for field_id, key in [ 

1528 ("nx-cdcs-url", "NX_CDCS_URL"), 

1529 ("nx-cdcs-token", "NX_CDCS_TOKEN"), 

1530 ]: 

1531 val = self.query_one(f"#{field_id}", Input).value.strip() 

1532 if val: 

1533 config[key] = val 

1534 return config 

1535 

1536 def _build_file_config(self) -> dict: 

1537 config: dict = {} 

1538 strategy_val = self.query_one("#nx-file-strategy", Select).value 

1539 if strategy_val and strategy_val is not Select.BLANK: 

1540 config["NX_FILE_STRATEGY"] = strategy_val 

1541 export_val = self.query_one("#nx-export-strategy", Select).value 

1542 if export_val and export_val is not Select.BLANK: 

1543 config["NX_EXPORT_STRATEGY"] = export_val 

1544 delay = self.query_one("#nx-file-delay-days", Input).value.strip() 

1545 if delay: 

1546 config["NX_FILE_DELAY_DAYS"] = float(delay) 

1547 sensitivity = self.query_one("#nx-clustering-sensitivity", Input).value.strip() 

1548 if sensitivity: 

1549 config["NX_CLUSTERING_SENSITIVITY"] = float(sensitivity) 

1550 patterns_raw = self.query_one("#nx-ignore-patterns", Input).value.strip() 

1551 if patterns_raw: 

1552 patterns_list = [p.strip() for p in patterns_raw.split(",") if p.strip()] 

1553 config["NX_IGNORE_PATTERNS"] = patterns_list 

1554 return config 

1555 

1556 def _build_elabftw_config(self) -> dict: 

1557 if not self.query_one("#elabftw-enabled", Switch).value: 

1558 return {} 

1559 config: dict = {} 

1560 for field_id, key in [ 

1561 ("nx-elabftw-url", "NX_ELABFTW_URL"), 

1562 ("nx-elabftw-api-key", "NX_ELABFTW_API_KEY"), 

1563 ]: 

1564 val = self.query_one(f"#{field_id}", Input).value.strip() 

1565 if val: 

1566 config[key] = val 

1567 for field_id, key in [ 

1568 ("nx-elabftw-category", "NX_ELABFTW_EXPERIMENT_CATEGORY"), 

1569 ("nx-elabftw-status", "NX_ELABFTW_EXPERIMENT_STATUS"), 

1570 ]: 

1571 val = self.query_one(f"#{field_id}", Input).value.strip() 

1572 if val: 

1573 config[key] = int(val) 

1574 return config 

1575 

1576 def _build_labarchives_config(self) -> dict: 

1577 if not self.query_one("#labarchives-enabled", Switch).value: 

1578 return {} 

1579 config: dict = {} 

1580 for field_id, key in [ 

1581 ("nx-labarchives-url", "NX_LABARCHIVES_URL"), 

1582 ("nx-labarchives-access-key-id", "NX_LABARCHIVES_ACCESS_KEY_ID"), 

1583 ("nx-labarchives-access-password", "NX_LABARCHIVES_ACCESS_PASSWORD"), 

1584 ("nx-labarchives-user-id", "NX_LABARCHIVES_USER_ID"), 

1585 ("nx-labarchives-notebook-id", "NX_LABARCHIVES_NOTEBOOK_ID"), 

1586 ]: 

1587 val = self.query_one(f"#{field_id}", Input).value.strip() 

1588 if val: 

1589 config[key] = val 

1590 return config 

1591 

1592 def _build_email_config(self) -> dict: 

1593 if not self.query_one("#email-enabled", Switch).value: 

1594 return {} 

1595 smtp_host = self.query_one("#nx-email-smtp-host", Input).value.strip() 

1596 smtp_port_str = self.query_one("#nx-email-smtp-port", Input).value.strip() 

1597 smtp_user = self.query_one("#nx-email-smtp-username", Input).value.strip() 

1598 smtp_pass = self.query_one("#nx-email-smtp-password", Input).value.strip() 

1599 use_tls = self.query_one("#nx-email-use-tls", Switch).value 

1600 sender = self.query_one("#nx-email-sender", Input).value.strip() 

1601 recipients_raw = self.query_one("#nx-email-recipients", Input).value.strip() 

1602 recipients = [r.strip() for r in recipients_raw.split(",") if r.strip()] 

1603 

1604 email_inner: dict = { 

1605 "smtp_host": smtp_host, 

1606 "smtp_port": int(smtp_port_str) if smtp_port_str else 587, 

1607 "use_tls": use_tls, 

1608 "sender": sender, 

1609 "recipients": recipients, 

1610 } 

1611 if smtp_user: 

1612 email_inner["smtp_username"] = smtp_user 

1613 if smtp_pass: 

1614 email_inner["smtp_password"] = smtp_pass 

1615 return {"email_config": email_inner} 

1616 

1617 def _build_ssl_config(self) -> dict: 

1618 config: dict = {} 

1619 cert_file = self.query_one("#nx-cert-bundle-file", Input).value.strip() 

1620 if cert_file: 

1621 config["NX_CERT_BUNDLE_FILE"] = cert_file 

1622 cert_bundle = self.query_one("#nx-cert-bundle", TextArea).text.strip() 

1623 if cert_bundle: 

1624 config["NX_CERT_BUNDLE"] = cert_bundle 

1625 config["NX_DISABLE_SSL_VERIFY"] = self.query_one( 

1626 "#nx-disable-ssl-verify", Switch 

1627 ).value 

1628 return config 

1629 

1630 def _build_nemo_config(self) -> dict: 

1631 """Build NEMO harvesters config from inline form groups.""" 

1632 harvesters: dict = {} 

1633 for i, group in enumerate(self.query(".nemo-group"), start=1): 

1634 if group.id is None or not group.id.startswith("nemo-group-"): 

1635 continue 

1636 n = group.id.split("-")[-1] 

1637 address = self.query_one(f"#nemo-address-{n}", Input).value.strip() 

1638 token = self.query_one(f"#nemo-token-{n}", Input).value.strip() 

1639 tz_raw = self.query_one(f"#nemo-tz-{n}", Input).value.strip() 

1640 strftime = self.query_one(f"#nemo-strftime-{n}", Input).value.strip() 

1641 strptime = self.query_one(f"#nemo-strptime-{n}", Input).value.strip() 

1642 hvst: dict = { 

1643 "address": address, 

1644 "token": token, 

1645 "strftime_fmt": strftime or _DEFAULT_STRFTIME, 

1646 "strptime_fmt": strptime or _DEFAULT_STRPTIME, 

1647 } 

1648 if tz_raw: 

1649 hvst["tz"] = tz_raw 

1650 harvesters[str(i)] = hvst 

1651 return {"nemo_harvesters": harvesters} if harvesters else {} 

1652 

1653 def _build_config_dict(self) -> dict: 

1654 """Build the nested config dict consumed by ``_flatten_to_env``.""" 

1655 config: dict = {} 

1656 config.update(self._build_paths_config()) 

1657 config.update(self._build_cdcs_config()) 

1658 config.update(self._build_file_config()) 

1659 config.update(self._build_nemo_config()) 

1660 config.update(self._build_elabftw_config()) 

1661 config.update(self._build_labarchives_config()) 

1662 config.update(self._build_email_config()) 

1663 config.update(self._build_ssl_config()) 

1664 return config