normalize-tabular-data 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.
- normalize_tabular_data/__init__.py +18 -0
- normalize_tabular_data/__main__.py +4 -0
- normalize_tabular_data/app.py +644 -0
- normalize_tabular_data/io.py +187 -0
- normalize_tabular_data/ops.py +333 -0
- normalize_tabular_data/screens.py +568 -0
- normalize_tabular_data/widgets.py +227 -0
- normalize_tabular_data-0.1.0.dist-info/METADATA +164 -0
- normalize_tabular_data-0.1.0.dist-info/RECORD +12 -0
- normalize_tabular_data-0.1.0.dist-info/WHEEL +4 -0
- normalize_tabular_data-0.1.0.dist-info/entry_points.txt +3 -0
- normalize_tabular_data-0.1.0.dist-info/licenses/LICENSE +24 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""Modal screens: file open/save, operation parameters, date sample check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich.syntax import Syntax as RichSyntax
|
|
8
|
+
from textual import on
|
|
9
|
+
from textual.color import Color
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.containers import Horizontal, Vertical
|
|
12
|
+
from textual.screen import ModalScreen
|
|
13
|
+
from textual.widgets import (
|
|
14
|
+
Button,
|
|
15
|
+
Checkbox,
|
|
16
|
+
Input,
|
|
17
|
+
Label,
|
|
18
|
+
ListItem,
|
|
19
|
+
ListView,
|
|
20
|
+
Select,
|
|
21
|
+
SelectionList,
|
|
22
|
+
Static,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from normalize_tabular_data.io import SCRIPT_SUFFIX, WRITE_SUFFIXES
|
|
26
|
+
from normalize_tabular_data.ops import Operation
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ModalDialog(ModalScreen):
|
|
30
|
+
"""Shared chrome: centered bordered box; Escape cancels."""
|
|
31
|
+
|
|
32
|
+
DEFAULT_CSS = """
|
|
33
|
+
ModalDialog { align: center middle; }
|
|
34
|
+
ModalDialog > Vertical {
|
|
35
|
+
background: $surface;
|
|
36
|
+
border: round $accent;
|
|
37
|
+
padding: 1 2;
|
|
38
|
+
width: auto;
|
|
39
|
+
height: auto;
|
|
40
|
+
max-width: 96;
|
|
41
|
+
max-height: 90%;
|
|
42
|
+
}
|
|
43
|
+
.buttons { align-horizontal: right; margin-top: 1; height: auto; }
|
|
44
|
+
Button { margin-left: 1; width: auto; min-width: 6 !important; }
|
|
45
|
+
Label.help { color: $text-muted; }
|
|
46
|
+
#path_input, #save_input { width: 64; }
|
|
47
|
+
ListView { height: 12; width: 64; }
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
BINDINGS = [("escape", "cancel", "Cancel")]
|
|
51
|
+
|
|
52
|
+
dialog_title: str = "Dialog"
|
|
53
|
+
|
|
54
|
+
def compose(self) -> ComposeResult:
|
|
55
|
+
with Vertical():
|
|
56
|
+
yield Label(self.dialog_title)
|
|
57
|
+
yield from self.compose_body()
|
|
58
|
+
with Horizontal(classes="buttons"):
|
|
59
|
+
yield Button("OK", id="btn_ok", variant="primary")
|
|
60
|
+
yield Button("Cancel", id="btn_cancel")
|
|
61
|
+
|
|
62
|
+
def compose_body(self) -> ComposeResult:
|
|
63
|
+
return ()
|
|
64
|
+
yield NotImplemented
|
|
65
|
+
|
|
66
|
+
def post_result(self, value) -> None:
|
|
67
|
+
self.dismiss(value)
|
|
68
|
+
|
|
69
|
+
@on(Button.Pressed, "#btn_ok")
|
|
70
|
+
def _ok_pressed(self) -> None:
|
|
71
|
+
self.action_ok()
|
|
72
|
+
|
|
73
|
+
@on(Button.Pressed, "#btn_cancel")
|
|
74
|
+
def _cancel_pressed(self) -> None:
|
|
75
|
+
self.action_cancel()
|
|
76
|
+
|
|
77
|
+
def action_ok(self) -> None:
|
|
78
|
+
self.post_result(None)
|
|
79
|
+
|
|
80
|
+
def action_cancel(self) -> None:
|
|
81
|
+
self.post_result(None)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class OpenFileModal(ModalDialog):
|
|
85
|
+
"""Type a path or browse the current directory; choose a file."""
|
|
86
|
+
|
|
87
|
+
dialog_title = "Open file"
|
|
88
|
+
|
|
89
|
+
def __init__(self, start: Path | None = None) -> None:
|
|
90
|
+
super().__init__()
|
|
91
|
+
self.dir = (start or Path.cwd()).resolve()
|
|
92
|
+
|
|
93
|
+
def compose_body(self) -> ComposeResult:
|
|
94
|
+
yield Input(placeholder="type a path, then press Enter", id="path_input")
|
|
95
|
+
yield ListView(id="opendir")
|
|
96
|
+
|
|
97
|
+
def on_mount(self) -> None:
|
|
98
|
+
self._refresh_dir(self.dir)
|
|
99
|
+
self.query_one("#path_input", Input).focus()
|
|
100
|
+
|
|
101
|
+
def _refresh_dir(self, directory: Path) -> None:
|
|
102
|
+
self.dir = directory
|
|
103
|
+
input_widget = self.query_one("#path_input", Input)
|
|
104
|
+
input_widget.placeholder = f"path under {directory}"
|
|
105
|
+
listing = self.query_one("#opendir", ListView)
|
|
106
|
+
listing.clear()
|
|
107
|
+
listing.extend(self._entries(directory))
|
|
108
|
+
|
|
109
|
+
def _entries(self, directory: Path) -> list[ListItem]:
|
|
110
|
+
children = sorted(
|
|
111
|
+
(p for p in directory.iterdir() if not p.name.startswith(".")),
|
|
112
|
+
key=lambda p: (not p.is_dir(), p.name.lower()),
|
|
113
|
+
)
|
|
114
|
+
items = [ListItem(Label("../"), name=str(directory.parent.resolve()))]
|
|
115
|
+
items += [
|
|
116
|
+
ListItem(Label(p.name + ("/" if p.is_dir() else "")), name=str(p))
|
|
117
|
+
for p in children
|
|
118
|
+
]
|
|
119
|
+
return items
|
|
120
|
+
|
|
121
|
+
@on(ListView.Selected)
|
|
122
|
+
def on_list_selected(self, event: ListView.Selected) -> None:
|
|
123
|
+
if not event.item.name:
|
|
124
|
+
return
|
|
125
|
+
chosen = Path(event.item.name)
|
|
126
|
+
if chosen.is_dir():
|
|
127
|
+
self._refresh_dir(chosen)
|
|
128
|
+
return
|
|
129
|
+
self.post_result(chosen)
|
|
130
|
+
|
|
131
|
+
@on(Input.Submitted, "#path_input")
|
|
132
|
+
def on_input_submitted(self) -> None:
|
|
133
|
+
raw = self.query_one("#path_input", Input).value.strip()
|
|
134
|
+
if raw:
|
|
135
|
+
self.post_result(Path(raw).expanduser())
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ScriptPickerModal(OpenFileModal):
|
|
139
|
+
"""Browse for an operation script file to play against the open table.
|
|
140
|
+
|
|
141
|
+
As the browse list highlight moves (or a path is typed), a syntax-
|
|
142
|
+
highlighted preview of the `.ntd` file shows underneath."""
|
|
143
|
+
|
|
144
|
+
dialog_title = "Play script"
|
|
145
|
+
|
|
146
|
+
DEFAULT_CSS = """
|
|
147
|
+
ScriptPickerModal > Vertical > Static#script_preview {
|
|
148
|
+
height: 10;
|
|
149
|
+
width: 64;
|
|
150
|
+
margin-top: 1;
|
|
151
|
+
background: $boost;
|
|
152
|
+
}
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
PREVIEW_LINES = 40
|
|
156
|
+
|
|
157
|
+
preview_text: str = ""
|
|
158
|
+
preview_background: str = "#272727"
|
|
159
|
+
|
|
160
|
+
def compose_body(self) -> ComposeResult:
|
|
161
|
+
yield Input(placeholder="type a path, then press Enter", id="path_input")
|
|
162
|
+
yield ListView(id="opendir")
|
|
163
|
+
self.preview = Static(
|
|
164
|
+
RichSyntax("", lexer="python", background_color=self._preview_background()),
|
|
165
|
+
id="script_preview",
|
|
166
|
+
)
|
|
167
|
+
yield self.preview
|
|
168
|
+
|
|
169
|
+
def on_mount(self) -> None:
|
|
170
|
+
self._refresh_dir(self.dir)
|
|
171
|
+
# browse-first dialog: arrows move the highlight (and the preview),
|
|
172
|
+
# Enter on a file plays it, OK confirms the current selection —
|
|
173
|
+
# the list takes focus after the screen's default focus pass
|
|
174
|
+
self.call_after_refresh(self.query_one("#opendir", ListView).focus)
|
|
175
|
+
|
|
176
|
+
def _current_choice(self) -> Path | None:
|
|
177
|
+
"""The script the dialog would play right now: a typed path that
|
|
178
|
+
exists, else the currently highlighted list entry (file paths
|
|
179
|
+
only — directories are browsed, not played)."""
|
|
180
|
+
raw = self.query_one("#path_input", Input).value.strip()
|
|
181
|
+
if raw and Path(raw).expanduser().is_file():
|
|
182
|
+
return Path(raw).expanduser()
|
|
183
|
+
child = self.query_one("#opendir", ListView).highlighted_child
|
|
184
|
+
if child is not None and child.name:
|
|
185
|
+
chosen = Path(child.name)
|
|
186
|
+
if chosen.is_file():
|
|
187
|
+
return chosen
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def action_ok(self) -> None:
|
|
191
|
+
chosen = self._current_choice()
|
|
192
|
+
if chosen is None:
|
|
193
|
+
self.app.notify(
|
|
194
|
+
"Select a script file first (arrow keys highlight, Enter plays)",
|
|
195
|
+
severity="warning",
|
|
196
|
+
)
|
|
197
|
+
return
|
|
198
|
+
self.post_result(chosen)
|
|
199
|
+
|
|
200
|
+
def _update_preview(self, target: Path) -> None:
|
|
201
|
+
self.preview_text = self._script_text(target)
|
|
202
|
+
self.preview.update(
|
|
203
|
+
RichSyntax(
|
|
204
|
+
self.preview_text,
|
|
205
|
+
lexer="python",
|
|
206
|
+
background_color=self.preview_background,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _preview_background(self) -> str:
|
|
211
|
+
"""Hex color the preview strip paints behind the highlighted text.
|
|
212
|
+
|
|
213
|
+
The strip's CSS is `$boost` over the dialog's `$surface`; rich's
|
|
214
|
+
'default' background would leave those cells to the terminal's own
|
|
215
|
+
background color, which is not the box color. Composite the same
|
|
216
|
+
colors the compositor uses, and paint the text cells with the
|
|
217
|
+
result — foreground (monokai) colors are untouched."""
|
|
218
|
+
variables = self.app.get_css_variables()
|
|
219
|
+
boost = Color.parse(variables["boost"])
|
|
220
|
+
surface = Color.parse(variables["surface"])
|
|
221
|
+
alpha = boost.a # Color.a is already a 0..1 float
|
|
222
|
+
channels = tuple(
|
|
223
|
+
round(channel * alpha + base * (1 - alpha))
|
|
224
|
+
for channel, base in zip(boost.rgb, surface.rgb)
|
|
225
|
+
)
|
|
226
|
+
hex = f"#{channels[0]:02X}{channels[1]:02X}{channels[2]:02X}"
|
|
227
|
+
self.preview_background = hex
|
|
228
|
+
return hex
|
|
229
|
+
|
|
230
|
+
def _script_text(self, target: Path) -> str:
|
|
231
|
+
"""The script file's text (capped), or '' when target is not one."""
|
|
232
|
+
try:
|
|
233
|
+
if target.is_file() and target.suffix.lower() == SCRIPT_SUFFIX:
|
|
234
|
+
lines = target.read_text(
|
|
235
|
+
encoding="utf-8", errors="replace"
|
|
236
|
+
).splitlines()
|
|
237
|
+
head, note = lines[: self.PREVIEW_LINES], ""
|
|
238
|
+
if len(lines) > self.PREVIEW_LINES:
|
|
239
|
+
note = f"\n# … {len(lines) - self.PREVIEW_LINES} more lines"
|
|
240
|
+
return "\n".join(head) + note
|
|
241
|
+
except OSError:
|
|
242
|
+
pass
|
|
243
|
+
return ""
|
|
244
|
+
|
|
245
|
+
@on(ListView.Highlighted, "#opendir")
|
|
246
|
+
def _preview_highlighted(self, event: ListView.Highlighted) -> None:
|
|
247
|
+
"""As highlight moves through the listing, preview highlighted file."""
|
|
248
|
+
if event.item and event.item.name:
|
|
249
|
+
target = Path(event.item.name)
|
|
250
|
+
if target.is_file():
|
|
251
|
+
self._update_preview(target)
|
|
252
|
+
|
|
253
|
+
@on(Input.Changed, "#path_input")
|
|
254
|
+
def _preview_typed(self, event: Input.Changed) -> None:
|
|
255
|
+
raw = event.value.strip()
|
|
256
|
+
if raw:
|
|
257
|
+
self._update_preview(Path(raw).expanduser())
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class RenameColumnModal(ModalDialog):
|
|
261
|
+
"""Enter a new name for one column; dismisses with the name or None.
|
|
262
|
+
|
|
263
|
+
Docks in the sidebar and hugs its content: title + input + buttons."""
|
|
264
|
+
|
|
265
|
+
dialog_title = "Rename column"
|
|
266
|
+
|
|
267
|
+
DEFAULT_CSS = """
|
|
268
|
+
RenameColumnModal {
|
|
269
|
+
align: left middle;
|
|
270
|
+
}
|
|
271
|
+
RenameColumnModal > Vertical {
|
|
272
|
+
width: 38;
|
|
273
|
+
}
|
|
274
|
+
RenameColumnModal Input {
|
|
275
|
+
width: 32;
|
|
276
|
+
}
|
|
277
|
+
RenameColumnModal > Vertical > Horizontal.buttons {
|
|
278
|
+
height: auto;
|
|
279
|
+
}
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
def __init__(self, current: str) -> None:
|
|
283
|
+
super().__init__()
|
|
284
|
+
self.current = current
|
|
285
|
+
|
|
286
|
+
def compose_body(self) -> ComposeResult:
|
|
287
|
+
self.name_input = Input(value=self.current, id="rename_input")
|
|
288
|
+
yield self.name_input
|
|
289
|
+
|
|
290
|
+
def on_mount(self) -> None:
|
|
291
|
+
self.name_input.focus()
|
|
292
|
+
|
|
293
|
+
def action_ok(self) -> None:
|
|
294
|
+
raw = self.name_input.value.strip()
|
|
295
|
+
if not raw:
|
|
296
|
+
self.app.notify("Column name cannot be empty", severity="error")
|
|
297
|
+
return
|
|
298
|
+
self.post_result(raw)
|
|
299
|
+
|
|
300
|
+
@on(Input.Submitted, "#rename_input")
|
|
301
|
+
def _rename_input_submit(self) -> None:
|
|
302
|
+
self.action_ok()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class SheetPickerModal(ModalDialog):
|
|
306
|
+
"""Pick which worksheet to load from an xlsx."""
|
|
307
|
+
|
|
308
|
+
dialog_title = "Choose worksheet"
|
|
309
|
+
|
|
310
|
+
def __init__(self, sheet_names: list[str]) -> None:
|
|
311
|
+
super().__init__()
|
|
312
|
+
self.sheet_names = sheet_names
|
|
313
|
+
|
|
314
|
+
def compose_body(self) -> ComposeResult:
|
|
315
|
+
# Select.BLANK is just `False` in this Textual version; never pass it
|
|
316
|
+
# as `value` — omit `value` instead (default is the true blank sentinel)
|
|
317
|
+
self.sheet_choice = Select(
|
|
318
|
+
[(name, name) for name in self.sheet_names],
|
|
319
|
+
allow_blank=False,
|
|
320
|
+
id="sheet_pick",
|
|
321
|
+
)
|
|
322
|
+
yield self.sheet_choice
|
|
323
|
+
|
|
324
|
+
def action_ok(self) -> None:
|
|
325
|
+
self.post_result(self.sheet_choice.value)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class OpParamsModal(ModalDialog):
|
|
329
|
+
"""Collect parameters for an Operation; dismisses with the params dict.
|
|
330
|
+
|
|
331
|
+
Docks over the sidebar (like the op chooser), so the preview table in the
|
|
332
|
+
main pane keeps showing data rows while parameters are entered."""
|
|
333
|
+
|
|
334
|
+
dialog_title = "Parameters"
|
|
335
|
+
|
|
336
|
+
DEFAULT_CSS = """
|
|
337
|
+
OpParamsModal {
|
|
338
|
+
align: left middle;
|
|
339
|
+
}
|
|
340
|
+
OpParamsModal > Vertical {
|
|
341
|
+
width: 38;
|
|
342
|
+
}
|
|
343
|
+
OpParamsModal SelectionList,
|
|
344
|
+
OpParamsModal Select,
|
|
345
|
+
OpParamsModal Input {
|
|
346
|
+
width: 32;
|
|
347
|
+
}
|
|
348
|
+
/* a long column list must not push the OK/Cancel buttons and later
|
|
349
|
+
* fields out of the dialog: cap it and let it scroll internally */
|
|
350
|
+
OpParamsModal SelectionList {
|
|
351
|
+
max-height: 10;
|
|
352
|
+
}
|
|
353
|
+
OpParamsModal Label.help {
|
|
354
|
+
width: 32;
|
|
355
|
+
text-wrap: wrap;
|
|
356
|
+
}
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
def __init__(self, op: Operation) -> None:
|
|
360
|
+
super().__init__()
|
|
361
|
+
self.op = op
|
|
362
|
+
self.dialog_title = op.title
|
|
363
|
+
|
|
364
|
+
def compose_body(self) -> ComposeResult:
|
|
365
|
+
self.widgets: dict[str, object] = {}
|
|
366
|
+
candidates = self._date_candidates() if self.op.key == "date_normalize" else []
|
|
367
|
+
for spec in self.op.params:
|
|
368
|
+
if spec.kind == "column_multi":
|
|
369
|
+
sel = SelectionList(
|
|
370
|
+
*[(name, name, False) for name in self._available_columns()],
|
|
371
|
+
id="param_columns",
|
|
372
|
+
)
|
|
373
|
+
self.widgets[spec.name] = sel
|
|
374
|
+
yield sel
|
|
375
|
+
elif spec.kind == "column":
|
|
376
|
+
col_names = self._available_columns()
|
|
377
|
+
preselect = (
|
|
378
|
+
candidates[0] if candidates and candidates[0] in col_names else None
|
|
379
|
+
)
|
|
380
|
+
sel = Select(
|
|
381
|
+
[(name, name) for name in col_names],
|
|
382
|
+
allow_blank=True,
|
|
383
|
+
id="param_column",
|
|
384
|
+
)
|
|
385
|
+
self.preselect_column = preselect
|
|
386
|
+
self.widgets[spec.name] = sel
|
|
387
|
+
yield sel
|
|
388
|
+
elif spec.kind == "choice":
|
|
389
|
+
sel = Select(
|
|
390
|
+
[(c, c) for c in spec.choices or ()],
|
|
391
|
+
allow_blank=False,
|
|
392
|
+
value=spec.default,
|
|
393
|
+
id="param_choice",
|
|
394
|
+
)
|
|
395
|
+
self.widgets[spec.name] = sel
|
|
396
|
+
yield sel
|
|
397
|
+
elif spec.kind == "number":
|
|
398
|
+
inp = Input(
|
|
399
|
+
str(spec.default or ""),
|
|
400
|
+
type="integer",
|
|
401
|
+
id=f"param_number_{spec.name}",
|
|
402
|
+
classes="param_input",
|
|
403
|
+
)
|
|
404
|
+
self.widgets[spec.name] = inp
|
|
405
|
+
yield inp
|
|
406
|
+
elif spec.kind == "bool":
|
|
407
|
+
chk = Checkbox(spec.title, spec.default is True)
|
|
408
|
+
self.widgets[spec.name] = chk
|
|
409
|
+
yield chk
|
|
410
|
+
else:
|
|
411
|
+
default = spec.default if isinstance(spec.default, str) else ""
|
|
412
|
+
# a whitespace-only default (the separator's single space)
|
|
413
|
+
# looks empty anyway; start blank so the placeholder acts
|
|
414
|
+
# as visible shadow text
|
|
415
|
+
value = "" if not default.strip() else default
|
|
416
|
+
inp = Input(
|
|
417
|
+
value=value,
|
|
418
|
+
placeholder=spec.title,
|
|
419
|
+
id=f"param_text_{spec.name}",
|
|
420
|
+
classes="param_input",
|
|
421
|
+
)
|
|
422
|
+
self.widgets[spec.name] = inp
|
|
423
|
+
yield inp
|
|
424
|
+
if spec.help:
|
|
425
|
+
yield Label(spec.help, classes="help")
|
|
426
|
+
|
|
427
|
+
def on_mount(self) -> None:
|
|
428
|
+
# Select options are usable only after mount; apply any preselect here
|
|
429
|
+
preselect = getattr(self, "preselect_column", None)
|
|
430
|
+
if preselect is not None:
|
|
431
|
+
self.widgets["column"].value = preselect
|
|
432
|
+
|
|
433
|
+
def _available_columns(self) -> list[str]:
|
|
434
|
+
return list(getattr(self.app, "current_columns", []) or [])
|
|
435
|
+
|
|
436
|
+
def _date_candidates(self) -> list[str]:
|
|
437
|
+
analyzed = getattr(self.app, "analyzed", {}) or {}
|
|
438
|
+
return [name for name, info in analyzed.items() if info.date_candidate]
|
|
439
|
+
|
|
440
|
+
def collect_params(self) -> dict:
|
|
441
|
+
params: dict = {}
|
|
442
|
+
for spec in self.op.params:
|
|
443
|
+
widget = self.widgets[spec.name]
|
|
444
|
+
if spec.kind == "column_multi":
|
|
445
|
+
params[spec.name] = list(widget.selected)
|
|
446
|
+
elif spec.kind == "column":
|
|
447
|
+
params[spec.name] = (
|
|
448
|
+
None if widget.value is Select.NULL else widget.value
|
|
449
|
+
)
|
|
450
|
+
elif spec.kind == "number":
|
|
451
|
+
raw = widget.value.strip()
|
|
452
|
+
params[spec.name] = int(raw) if raw else None
|
|
453
|
+
elif spec.kind == "bool":
|
|
454
|
+
params[spec.name] = widget.value is True
|
|
455
|
+
else:
|
|
456
|
+
params[spec.name] = widget.value
|
|
457
|
+
return params
|
|
458
|
+
|
|
459
|
+
def action_ok(self) -> None:
|
|
460
|
+
pipeline = getattr(self.app, "pipeline", None)
|
|
461
|
+
if pipeline is None:
|
|
462
|
+
self.app.notify("No file loaded", severity="error")
|
|
463
|
+
return
|
|
464
|
+
params = self.collect_params()
|
|
465
|
+
problem = pipeline.validate(self.op, params)
|
|
466
|
+
if problem:
|
|
467
|
+
self.app.notify(problem, severity="error")
|
|
468
|
+
return
|
|
469
|
+
self.post_result(params)
|
|
470
|
+
|
|
471
|
+
@on(Input.Submitted, ".param_input")
|
|
472
|
+
def _input_submit_ok(self) -> None:
|
|
473
|
+
self.action_ok()
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class SaveModal(ModalDialog):
|
|
477
|
+
"""Path + format for writing the normalized table."""
|
|
478
|
+
|
|
479
|
+
dialog_title = "Save as"
|
|
480
|
+
|
|
481
|
+
def __init__(self, suggested: Path | None) -> None:
|
|
482
|
+
super().__init__()
|
|
483
|
+
self.suggested = suggested
|
|
484
|
+
|
|
485
|
+
def compose_body(self) -> ComposeResult:
|
|
486
|
+
path_stub = self.suggested or Path("normalized.csv")
|
|
487
|
+
self.path_input = Input(
|
|
488
|
+
placeholder=str(path_stub),
|
|
489
|
+
value=str(path_stub),
|
|
490
|
+
id="save_input",
|
|
491
|
+
)
|
|
492
|
+
self.overwrite = Checkbox(
|
|
493
|
+
"Allow overwrite if file exists", False, id="allow_overwrite"
|
|
494
|
+
)
|
|
495
|
+
self.save_script = Checkbox(
|
|
496
|
+
"Save sequence of operations?", False, id="save_script"
|
|
497
|
+
)
|
|
498
|
+
extensions = " ".join(sorted(WRITE_SUFFIXES))
|
|
499
|
+
yield self.path_input
|
|
500
|
+
yield Static(f"Extensions: {extensions}", classes="help")
|
|
501
|
+
yield self.overwrite
|
|
502
|
+
yield self.save_script
|
|
503
|
+
|
|
504
|
+
def action_ok(self) -> None:
|
|
505
|
+
raw = self.path_input.value.strip()
|
|
506
|
+
if not raw:
|
|
507
|
+
self.app.notify("Type a file name or path", severity="error")
|
|
508
|
+
return
|
|
509
|
+
target = Path(raw).expanduser()
|
|
510
|
+
fmt = WRITE_SUFFIXES.get(target.suffix.lower())
|
|
511
|
+
if fmt is None:
|
|
512
|
+
self.app.notify(
|
|
513
|
+
"Unsupported extension: use one of "
|
|
514
|
+
+ ", ".join(sorted(WRITE_SUFFIXES)),
|
|
515
|
+
severity="error",
|
|
516
|
+
)
|
|
517
|
+
return
|
|
518
|
+
if target.exists() and not self.overwrite.value:
|
|
519
|
+
self.app.notify(
|
|
520
|
+
"File exists: tick the overwrite box or pick another path",
|
|
521
|
+
severity="warning",
|
|
522
|
+
)
|
|
523
|
+
return
|
|
524
|
+
self.post_result((target, fmt, self.save_script.value is True))
|
|
525
|
+
|
|
526
|
+
@on(Input.Submitted, "#save_input")
|
|
527
|
+
def _save_input_submit(self) -> None:
|
|
528
|
+
self.action_ok()
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
class ScriptNameModal(ModalDialog):
|
|
532
|
+
"""Name the '.ntd' script file when saving a sequence of operations.
|
|
533
|
+
|
|
534
|
+
The default carries the .ntd extension; typing a different one saves
|
|
535
|
+
under that extension instead."""
|
|
536
|
+
|
|
537
|
+
dialog_title = "Save script"
|
|
538
|
+
|
|
539
|
+
def __init__(self, suggested: Path) -> None:
|
|
540
|
+
super().__init__()
|
|
541
|
+
self.suggested = suggested
|
|
542
|
+
|
|
543
|
+
def compose_body(self) -> ComposeResult:
|
|
544
|
+
self.path_input = Input(
|
|
545
|
+
placeholder=str(self.suggested),
|
|
546
|
+
value=str(self.suggested),
|
|
547
|
+
id="script_input",
|
|
548
|
+
)
|
|
549
|
+
yield self.path_input
|
|
550
|
+
yield Static(
|
|
551
|
+
f"Default extension: {SCRIPT_SUFFIX} — type another to override",
|
|
552
|
+
classes="help",
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
def action_ok(self) -> None:
|
|
556
|
+
raw = self.path_input.value.strip()
|
|
557
|
+
if not raw:
|
|
558
|
+
self.app.notify("Type a script file name or path", severity="error")
|
|
559
|
+
return
|
|
560
|
+
target = Path(raw).expanduser()
|
|
561
|
+
if target.exists():
|
|
562
|
+
self.app.notify("Script file exists: pick another name", severity="warning")
|
|
563
|
+
return
|
|
564
|
+
self.post_result(target)
|
|
565
|
+
|
|
566
|
+
@on(Input.Submitted, "#script_input")
|
|
567
|
+
def _script_input_submit(self) -> None:
|
|
568
|
+
self.action_ok()
|