diff --git a/python-tools/README.md b/python-tools/README.md index bc18b0a..c7656c5 100644 --- a/python-tools/README.md +++ b/python-tools/README.md @@ -37,6 +37,7 @@ for its web forms and its task order. It is parsed as data only and uses version ```json { "version": 1, + "label": "Example Tasks", "tasks": { "task.py": { "inputs": [ @@ -61,7 +62,7 @@ history. The form masks sensitive text, the database records `[redacted]`, and the original value is available only through a private execution input file. Tasks listed in the manifest appear in JSON order. A listed task may use `{}` when it only needs ordering; unlisted scripts appear afterward in alphabetical -order. +order. `label` is optional and overrides the group name displayed in the UI. Set `wait_for_result` to `true` for a short task whose form should wait for its final success or failure status. This disables the form and warns before leaving the page while the task is running; tasks without it keep the normal @@ -69,8 +70,8 @@ queued-run behavior. For a task with a review-first operation, declare `execution` with the input field and its review and execution values. The control panel generates the -dropdown. A successful review run offers a button that repeats the same saved -inputs using the execution value; it does not reuse a stale filesystem +mode selector attached to the submit button. A successful review run offers a +button that repeats the same saved inputs using the execution value; it does not reuse a stale filesystem snapshot, so the task must still validate its inputs before changing anything. The server validates every submitted value before a run is queued. It records diff --git a/python-tools/app/discovery.py b/python-tools/app/discovery.py index ea9f305..f880df5 100644 --- a/python-tools/app/discovery.py +++ b/python-tools/app/discovery.py @@ -12,10 +12,19 @@ from typing import Any FIELD_NAME = re.compile(r"[a-z][a-z0-9_]*\Z") FIELD_TYPES = frozenset({"text", "integer", "date", "datetime", "choice", "multi_choice", "file"}) +DISPLAY_ACRONYMS = { + "api": "API", "csv": "CSV", "iata": "IATA", "icao": "ICAO", "id": "ID", + "ids": "IDs", "url": "URL", "urls": "URLs", "uuid": "UUID", +} def display_name(value: str) -> str: - return value.replace("_", " ") + titled = value.replace("_", " ").title() + return re.sub( + r"\b[A-Za-z]+\b", + lambda match: DISPLAY_ACRONYMS.get(match.group().casefold(), match.group()), + titled, + ) @dataclass(frozen=True) @@ -104,12 +113,19 @@ class TaskCatalog: or not group_path.is_dir() ): continue - tasks = tuple(self._tasks_in_group(group_path)) + manifest = load_input_manifest( + group_path, + { + path.name for path in group_path.iterdir() + if path.is_file() and not path.is_symlink() and path.suffix == ".py" and not path.name.startswith((".", "_")) + }, + ) + tasks = tuple(self._tasks_in_group(group_path, manifest.declarations)) groups.append( Group( id=group_path.name, path=group_path, - display_name=display_name(group_path.name), + display_name=manifest.label or display_name(group_path.name), tasks=tasks, ) ) @@ -140,7 +156,11 @@ class TaskCatalog: tasks.extend(load_background_tasks(group_path)) return tuple(tasks) - def _tasks_in_group(self, group_path: Path) -> list[Task]: + def _tasks_in_group( + self, + group_path: Path, + declarations_by_filename: dict[str, "TaskDeclaration"] | None = None, + ) -> list[Task]: task_paths = [ path for path in sorted(group_path.iterdir(), key=lambda candidate: candidate.name) @@ -151,10 +171,11 @@ class TaskCatalog: or path.suffix != ".py" ) ] - declarations_by_filename = load_input_manifest( - group_path, - {path.name for path in task_paths}, - ) + if declarations_by_filename is None: + declarations_by_filename = load_input_manifest( + group_path, + {path.name for path in task_paths}, + ).declarations paths_by_filename = {path.name: path for path in task_paths} ordered_filenames = [ *declarations_by_filename, @@ -264,37 +285,49 @@ def read_description(path: Path) -> str | None: ) +@dataclass(frozen=True) +class GroupManifest: + label: str | None + declarations: dict[str, "TaskDeclaration"] + + def load_input_manifest( group_path: Path, task_filenames: set[str], -) -> dict[str, "TaskDeclaration"]: +) -> GroupManifest: """Read the optional data-only input declaration for one task group.""" manifest_path = group_path / "task-inputs.json" if not manifest_path.exists(): - return {} + return GroupManifest(label=None, declarations={}) if manifest_path.is_symlink(): raise ValueError(f"Input manifest cannot be a symlink: {manifest_path}") try: data = json.loads(manifest_path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: raise ValueError(f"Could not read input manifest {manifest_path}: {error}") from error - if not isinstance(data, dict) or set(data) != {"version", "tasks"}: + if not isinstance(data, dict) or set(data) - {"version", "label", "tasks"}: raise ValueError( - f"Input manifest {manifest_path} must contain only version and tasks." + f"Input manifest {manifest_path} has an invalid structure." ) - if data["version"] != 1 or not isinstance(data["tasks"], dict): + if data.get("version") != 1 or not isinstance(data.get("tasks"), dict): raise ValueError(f"Input manifest {manifest_path} has an unsupported structure.") + label = data.get("label") + if label is not None and (not isinstance(label, str) or not label.strip()): + raise ValueError(f"Input manifest {manifest_path} label must be non-empty text.") unknown_tasks = set(data["tasks"]) - task_filenames if unknown_tasks: names = ", ".join(sorted(unknown_tasks)) raise ValueError(f"Input manifest {manifest_path} names unknown tasks: {names}.") - return { - filename: parse_task_declaration(manifest_path, filename, declaration) - for filename, declaration in data["tasks"].items() - } + return GroupManifest( + label=label, + declarations={ + filename: parse_task_declaration(manifest_path, filename, declaration) + for filename, declaration in data["tasks"].items() + }, + ) @dataclass(frozen=True) diff --git a/python-tools/static/app.js b/python-tools/static/app.js index 4a3fff1..61928d3 100644 --- a/python-tools/static/app.js +++ b/python-tools/static/app.js @@ -51,6 +51,17 @@ for (const container of document.querySelectorAll("#run-artifacts")) { window.setInterval(() => refreshArtifacts(container), 2000); } +for (const control of document.querySelectorAll("select[data-execution-mode]")) { + const buttonLabel = control.closest("form")?.querySelector(".task-action .button-label"); + const updateButtonLabel = () => { + if (buttonLabel && control.selectedOptions[0]) { + buttonLabel.textContent = control.selectedOptions[0].textContent; + } + }; + control.addEventListener("change", updateButtonLabel); + updateButtonLabel(); +} + for (const form of document.querySelectorAll("form[data-wait-for-result]")) { const result = form.querySelector(".task-result"); const controls = [...form.querySelectorAll("input, select, button")]; diff --git a/python-tools/static/style.css b/python-tools/static/style.css index a5aa56c..f373021 100644 --- a/python-tools/static/style.css +++ b/python-tools/static/style.css @@ -31,8 +31,7 @@ a { padding: 1rem; } -.group-heading, -.tasks li { +.group-heading { align-items: center; display: flex; gap: 1rem; @@ -47,6 +46,9 @@ a { .tasks li { border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); + display: grid; + gap: 1rem; + grid-template-columns: minmax(14rem, 1fr) minmax(0, 3fr); padding: .8rem 0; } @@ -55,12 +57,17 @@ a { margin: .2rem 0; } -.tasks form { - min-width: 16rem; +.task-form { + align-items: end; + display: grid; + gap: .75rem; + grid-template-columns: repeat(5, minmax(0, 1fr)) auto; + min-width: 0; } .task-input { - margin: .5rem 0; + margin: 0; + min-width: 0; } .task-input label, @@ -75,6 +82,11 @@ a { max-width: 100%; } +.task-input input:not([type="checkbox"]), +.task-input select { + width: 100%; +} + .task-input fieldset { border: 0; margin: 0; @@ -97,6 +109,31 @@ button { padding: .4rem .7rem; } +.task-action { + align-items: stretch; + border-left: 1px solid color-mix(in srgb, currentColor 15%, transparent); + display: flex; + gap: .4rem; + grid-column: -1; + grid-row: 1; + margin-left: .25rem; + padding-left: .75rem; +} + +.task-action select, +.task-action button { + margin: 0; + white-space: nowrap; +} + +.visually-hidden { + height: 1px; + margin: -1px; + overflow: hidden; + position: absolute; + width: 1px; +} + button:disabled { cursor: wait; } @@ -118,6 +155,7 @@ button:disabled { } .task-result { + grid-column: 1 / -1; margin: .5rem 0 0; } @@ -133,6 +171,22 @@ button:disabled { to { transform: rotate(360deg); } } +@media (max-width: 58rem) { + .tasks li { + grid-template-columns: 1fr; + } +} + +@media (max-width: 42rem) { + .task-form { + grid-template-columns: minmax(0, 1fr) auto; + } + + .task-action { + grid-column: 2; + } +} + dt { font-weight: 700; } diff --git a/python-tools/templates/index.html b/python-tools/templates/index.html index d4cb05c..4ba6944 100644 --- a/python-tools/templates/index.html +++ b/python-tools/templates/index.html @@ -24,8 +24,8 @@
{{ task.description }}
{% endif %} {% if task_run %} @@ -37,10 +37,11 @@ {% endif %}