From 91bc0274072255298a66b1847ec2ae1a9c11c722 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:20:39 +0200 Subject: [PATCH 1/2] feat(metadata): extract Add metadata values with a regular expression Bridge dialogs now resolve active states and callbacks while the Python call that opened them is pending. Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 5 + src/components/DialogBridge.tsx | 6 + src/runtime/bootstrap.py | 232 ++++++++++++++++++++-- src/runtime/runtime.ts | 21 +- tests/e2e/add_metadata_bridge.spec.ts | 75 +++++++ tests/python/test_metadata.py | 115 +++++++++++ tests/ts/components/DialogBridge.test.tsx | 83 ++++++++ 7 files changed, 513 insertions(+), 24 deletions(-) create mode 100644 tests/e2e/add_metadata_bridge.spec.ts create mode 100644 tests/ts/components/DialogBridge.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c04c5..8ce5960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) image-profile geometry, and is discarded on Cancel or Escape. For a single source, OK reuses a completed current preview once; stale, running and multi-selection previews fall back to normal processing. +- **Add metadata:** _Edit > Metadata > Add metadata…_ can now extract the value from the formatted text with a regular expression, for example an exposure time or a shot number read from object titles. Objects without a match are left unchanged unless you ask for an error, a scale factor converts numeric values (e.g. milliseconds to seconds), and a live preview shows the resulting values. Keys may contain dots and hyphens, as used by plugin keys, and a **Known keys** list copies a key already present on the selected objects. Invalid settings are reported and the dialog reopens with your entries. ### Changed @@ -26,6 +27,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) the `FloatArrayItem` sizing and numeric-parity hints required by browser editors. +### Fixed + +- **Plugin and macro dialogs:** fields enabled by another option, and values computed from other fields, now update while the dialog is open; they previously stayed frozen until the dialog was closed. + ## [0.9.0] - 2026-08-11 ### Added in 0.9.0 diff --git a/src/components/DialogBridge.tsx b/src/components/DialogBridge.tsx index b49a2c5..61b28c3 100644 --- a/src/components/DialogBridge.tsx +++ b/src/components/DialogBridge.tsx @@ -71,6 +71,12 @@ export function DialogBridge() { ? (currentValues) => runtime.resolveBridgeActive(currentValues) : undefined } + resolveCallbacks={ + runtime + ? (itemName, currentValues) => + runtime.resolveBridgeCallbacks(itemName, currentValues) + : undefined + } /> ); } diff --git a/src/runtime/bootstrap.py b/src/runtime/bootstrap.py index d7244b4..a590b12 100644 --- a/src/runtime/bootstrap.py +++ b/src/runtime/bootstrap.py @@ -924,6 +924,32 @@ def resolve_bridge_active(values: dict[str, Any] | None = None) -> dict[str, boo return resolve_dataset_active(probe) +def resolve_bridge_callbacks( + item_name: str, values: dict[str, Any] | None = None +) -> dict[str, Any]: + """Run the ``display.callback`` of *item_name* for the dataset currently + shown by the JS dialog bridge, and return the refreshed values. + + Counterpart of :func:`resolve_bridge_active`. A shallow copy keeps the + constructor state of the live instance (e.g. the objects an "Add + metadata" preview is computed from) without mutating it before submit. + Returns ``{}`` when no bridge dialog is open. + """ + import copy + + from guidata.dataset import resolve_dataset_callbacks, update_dataset + + instance = _BRIDGE_EDIT_INSTANCE + if instance is None: + return {} + if hasattr(values, "to_py"): + values = values.to_py() + probe = copy.copy(instance) + if values: + update_dataset(probe, values) + return resolve_dataset_callbacks(probe, item_name) + + async def _async_show_message( kind: str, message: str, title: str | None = None, **_kwargs: Any ) -> None: @@ -1895,11 +1921,40 @@ async def paste_object_metadata(oids: list[str]) -> bool: return True -def _add_metadata_param() -> Any: - """Build the "Add metadata" parameter set (mirrors Qt's class).""" +def _add_metadata_param(objs: list[Any] | None = None) -> Any: + """Build the "Add metadata" parameter set (mirrors Qt's class). + + Args: + objs: selected objects, used for the known keys and the preview + """ import guidata.dataset as gds from sigima.config import _ + objs = list(objs or []) + known_keys = _metadata_key_suggestions(objs) + + def update_preview(instance: Any, _item: Any = None, _value: Any = None) -> None: + try: + values = _build_metadata_values(objs, instance) + except ValueError as exc: + instance.preview = f"Invalid conversion:\n{exc}" + return + except (KeyError, TypeError) as exc: + instance.preview = f"Invalid pattern:\n{exc}" + return + lines = [] + for obj, value in zip(objs, values): + if value is None: + lines.append(f"{obj.title}: " + _("unchanged")) + else: + lines.append(f"{obj.title}: {instance.metadata_key} = {value!r}") + instance.preview = "\n".join(lines) + + def on_known_key_changed(instance: Any, _item: Any = None, value: Any = None): + if value: + instance.metadata_key = value + update_preview(instance) + class AddMetadataParam( gds.DataSet, title=_("Add metadata"), @@ -1914,7 +1969,12 @@ class AddMetadataParam( "{ylabel}, {yunit}, " "{metadata[key]}. Standard format specifiers apply, " "plus the upper/lower modifiers " - "(e.g. {title:20.20upper})." + "(e.g. {title:20.20upper}).

" + "An optional extraction pattern (regular expression) is searched " + "in the formatted value: its first group, or the whole match, " + "becomes the value. E.g. pattern {title}, extraction " + "([\\d.]+)\\s*ms, float conversion and scale factor " + "0.001 read 0.005 from 'Flat 5 ms 01'." ), ): """Add metadata parameters.""" @@ -1923,14 +1983,38 @@ class AddMetadataParam( _("Metadata key"), default="custom_key", notempty=True, - regexp=r"^[a-zA-Z_][a-zA-Z0-9_]*$", + regexp=r"^[a-zA-Z_][a-zA-Z0-9_.\-]*$", help=_("The key name for the metadata item"), - ) + ).set_prop("display", callback=update_preview) + known_key = gds.ChoiceItem( + _("Known keys"), + [("", _("Select a key..."))] + + [(key, f"{key} — {description}") for key, description in known_keys], + default="", + help=_("Copy a key found on the selected objects into the metadata key"), + ).set_prop("display", callback=on_known_key_changed) value_pattern = gds.StringItem( _("Value pattern"), default="{index}", help=_("Python format string. See description for details."), - ) + ).set_prop("display", callback=update_preview) + extraction_pattern = gds.StringItem( + _("Extraction pattern"), + default="", + help=_( + "Optional regular expression searched in the formatted value: " + "its first group, or the whole match, becomes the value" + ), + ).set_prop("display", callback=update_preview) + if_no_match = gds.ChoiceItem( + _("If no match"), + [ + ("skip", _("Leave the object unchanged")), + ("error", _("Report an error")), + ], + default="skip", + ).set_prop("display", callback=update_preview) + _prop_conversion = gds.GetAttrProp("conversion") conversion = gds.ChoiceItem( _("Conversion"), [ @@ -1940,17 +2024,111 @@ class AddMetadataParam( ("bool", _("Boolean")), ], default="string", + ).set_prop("display", store=_prop_conversion, callback=update_preview) + scale = gds.FloatItem( + _("Scale factor"), + default=1.0, + help=_("Multiplies numeric values, e.g. 0.001 to convert ms to s"), + ).set_prop( + "display", + active=gds.FuncProp( + _prop_conversion, lambda value: value in ("float", "int") + ), + callback=update_preview, + ) + preview = gds.TextItem(_("Preview"), default="").set_prop( + "display", readonly=True ) return AddMetadataParam() -def _convert_metadata_value(value_str: str, conversion: str) -> Any: +def _metadata_key_suggestions(objs: list[Any]) -> list[tuple[str, str]]: + """Return the user-visible scalar metadata keys of *objs*, with an example + value (mirrors Qt's ``collect_metadata_keys``).""" + from sigima.config import _ + from sigima.objects.base import ROI_KEY + + examples: dict[str, Any] = {} + for obj in objs: + for key, value in obj.metadata.items(): + if key in examples or key.startswith("_") or key == ROI_KEY: + continue + if not _metadata_visible(key): + continue + if isinstance(value, (str, bool, int, float, np.integer, np.floating)): + examples[key] = value + return [(key, _("e.g. %s") % repr(examples[key])) for key in sorted(examples)] + + +def _build_metadata_values(objs: list[Any], param: Any) -> list[Any]: + """Return one "Add metadata" value per object (mirrors Qt). + + ``None`` marks objects left unchanged because the extraction pattern does + not match them. + + Raises: + ValueError: invalid or non-matching extraction pattern (when asked to + report it), or a value that cannot be converted. + """ + import re + + from sigima.io.common.basename import format_basenames + + raw_values = format_basenames(objs, param.value_pattern) + regex = None + if param.extraction_pattern: + try: + regex = re.compile(param.extraction_pattern) + except re.error as exc: + raise ValueError(f"Invalid extraction pattern: {exc}") from exc + values: list[Any] = [] + for index, text in enumerate(raw_values, start=1): + if regex is not None: + match = regex.search(text) + extracted = None + if match is not None: + extracted = match.group(1) if regex.groups else match.group(0) + if extracted is None: + if param.if_no_match == "error": + raise ValueError( + f"No match for the value at index {index}: '{text}'" + ) + values.append(None) + continue + text = extracted + values.append( + _convert_metadata_value(text, param.conversion, param.scale, index) + ) + return values + + +def _convert_metadata_value( + value_str: str, conversion: str, scale: float = 1.0, index: int = 1 +) -> Any: """Convert a formatted string to the requested type (mirrors Qt).""" if conversion == "float": - return float(value_str) + try: + return float(value_str) * scale + except ValueError as exc: + raise ValueError( + f"Cannot convert value at index {index} to float: '{value_str}'" + ) from exc if conversion == "int": - return int(value_str) + try: + value = int(value_str) + except ValueError as exc: + raise ValueError( + f"Cannot convert value at index {index} to integer: '{value_str}'" + ) from exc + if scale == 1.0: + return value + scaled = value * scale + if not float(scaled).is_integer(): + raise ValueError( + f"Scaled value at index {index} is not an integer: {scaled}" + ) + return int(scaled) if conversion == "bool": return value_str.strip().lower() in ("true", "1", "yes", "on") return value_str @@ -1961,24 +2139,35 @@ async def add_object_metadata(oids: list[str]) -> bool: Mirrors DataLab desktop's ``add_metadata``: the value is produced from a Python format pattern (``{title}``, ``{index}``, ``{metadata[key]}``, - …) evaluated per object, then converted to the requested type. + …) evaluated per object, optionally extracted with a regular + expression, then converted to the requested type. Invalid settings are + reported and the dialog is shown again. + + Returns: + True if at least one object was modified """ + from sigima.config import _ + if hasattr(oids, "to_py"): oids = oids.to_py() if not oids: return False - param = _add_metadata_param() - if not await param.edit_async(): - return False - from sigima.io.common.basename import format_basenames - objs = [_MODEL.get(oid) for oid in oids] - raw_values = format_basenames(objs, param.value_pattern) - for obj, value_str in zip(objs, raw_values): - obj.metadata[param.metadata_key] = _convert_metadata_value( - value_str, param.conversion - ) - return True + param = _add_metadata_param(objs) + while True: + if not await param.edit_async(): + return False + try: + values = _build_metadata_values(objs, param) + break + except (ValueError, KeyError, TypeError) as exc: + await _async_show_message("error", str(exc), _("Add metadata")) + modified = False + for obj, value in zip(objs, values): + if value is not None: + obj.metadata[param.metadata_key] = value + modified = True + return modified def delete_object_metadata(oids: list[str], keep_roi: bool = False) -> bool: @@ -6786,6 +6975,7 @@ def plot_results( "plot_results", "set_dialog_bridge", "resolve_bridge_active", + "resolve_bridge_callbacks", "load_plugin_source", "load_plugin_file", "load_plugin_wheel", diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index bb925f3..1006384 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -1674,9 +1674,24 @@ await micropip.install([${JSON.stringify(SIGIMA_INSTALL_SPEC)}, ${JSON.stringify async resolveBridgeActive( values: Record, ): Promise> { - return (await this.callPy("resolve_bridge_active", { - values, - })) as Record; + // Not queued: the Python call that opened the dialog is suspended awaiting it. + return await this.invokePy>( + "resolve_bridge_active", + { values }, + ); + } + + /** Run an item's ``display`` callback for the dataset shown by the dialog + * bridge and return the refreshed values (``{}`` when none is open). */ + async resolveBridgeCallbacks( + itemName: string, + values: Record, + ): Promise> { + // Not queued, like resolveBridgeActive; it only computes on a dataset copy. + return await this.invokePy>( + "resolve_bridge_callbacks", + { item_name: itemName, values }, + ); } /** diff --git a/tests/e2e/add_metadata_bridge.spec.ts b/tests/e2e/add_metadata_bridge.spec.ts new file mode 100644 index 0000000..412faa9 --- /dev/null +++ b/tests/e2e/add_metadata_bridge.spec.ts @@ -0,0 +1,75 @@ +import { expect } from "@playwright/test"; +import { test, dismissAnyDialog } from "./fixtures-warm"; + +// Bridge dialogs resolve ``active`` states and display callbacks while the +// Python call that opened them is still pending (Add metadata as the case). +test("Add metadata previews and applies values extracted from titles", async ({ + warmPage: page, +}) => { + test.setTimeout(240_000); + await dismissAnyDialog(page); + const ids = await page.evaluate(async () => { + await window.runtime.resetAll(); + const out: string[] = []; + for (const title of ["Flat 5 ms 01", "Dark 01"]) { + out.push( + await window.runtime.addSignalFromArrays({ + title, + xdata: [0, 1, 2], + ydata: [0, 0, 0], + }), + ); + } + return out; + }); + await page.getByRole("tab", { name: "Images" }).click(); + await page.getByRole("tab", { name: "Signals" }).click(); + const items = page.locator(".object-tree-item"); + await items.filter({ hasText: "Flat 5 ms 01" }).first().click(); + await items + .filter({ hasText: "Dark 01" }) + .first() + .click({ modifiers: ["Control"] }); + + await page.getByRole("menuitem", { name: "Edit", exact: true }).click(); + await page + .locator(".menu-item-submenu") + .filter({ hasText: "Metadata" }) + .first() + .hover(); + await page.getByRole("menuitem", { name: "Add metadata…" }).click(); + const dialog = page.getByRole("dialog").filter({ + has: page.getByRole("heading", { name: "Add metadata" }), + }); + await expect(dialog).toBeVisible(); + const row = (label: string) => + dialog.locator(".dataset-form-row").filter({ + has: page.locator(".dataset-form-label", { hasText: label }), + }); + + const key = "plugin.org.example.cam.exposure_time_s"; + await row("Metadata key").locator("input").fill(key); + await row("Value pattern").locator("input").fill("{title}"); + await row("Extraction pattern").locator("input").fill("([\\d.]+)\\s*ms"); + const scale = row("Scale factor").locator("input"); + await expect(scale).toBeDisabled(); + await row("Conversion").locator("select").selectOption("float"); + await expect(scale).toBeEnabled({ timeout: 30_000 }); + await scale.fill("0.001"); + + const preview = row("Preview").locator("textarea, input"); + await expect(preview).toHaveValue(/Flat 5 ms 01: .*0\.005/, { + timeout: 30_000, + }); + await expect(preview).toHaveValue(/Dark 01: unchanged/); + await dialog.getByRole("button", { name: "OK" }).click(); + await expect(dialog).toBeHidden(); + + const metadata = await page.evaluate( + (oids) => + Promise.all(oids.map((oid) => window.runtime.listObjectMetadata(oid))), + ids, + ); + expect(metadata[0].find((entry) => entry.key === key)?.value).toBe("0.005"); + expect(metadata[1].some((entry) => entry.key === key)).toBe(false); +}); diff --git a/tests/python/test_metadata.py b/tests/python/test_metadata.py index 07a663c..2bedff4 100644 --- a/tests/python/test_metadata.py +++ b/tests/python/test_metadata.py @@ -4,7 +4,10 @@ from __future__ import annotations +import asyncio + import numpy as np +import pytest from sigima.objects.annotations import PointAnnotation @@ -156,6 +159,118 @@ def test_convert_metadata_value(fresh_bootstrap): assert bs._convert_metadata_value("true", "bool") is True assert bs._convert_metadata_value("off", "bool") is False assert bs._convert_metadata_value("text", "string") == "text" + assert bs._convert_metadata_value("5", "float", 0.001) == 0.005 + assert bs._convert_metadata_value("42", "int", 10.0) == 420 + with pytest.raises(ValueError, match="not an integer"): + bs._convert_metadata_value("3", "int", 0.5) + + +def _frames(bs) -> list[str]: + return [ + bs.add_signal_from_arrays(title, [0, 1], [0, 0]) + for title in ("Flat 5 ms 01", "Dark 01") + ] + + +def test_metadata_key_suggestions_skip_internal_keys(fresh_bootstrap): + bs = fresh_bootstrap + oids = _frames(bs) + bs.set_object_metadata_value(oids[0], "plugin.org.example.cam.gain", "number", "2") + obj = bs._MODEL.get(oids[1]) + obj.metadata["Geometry_fwhm_dict"] = {"title": "FWHM"} + obj.metadata["array"] = np.arange(3) + keys = [ + key + for key, _desc in bs._metadata_key_suggestions( + [bs._MODEL.get(oid) for oid in oids] + ) + ] + assert "plugin.org.example.cam.gain" in keys + assert not {"Geometry_fwhm_dict", "array"} & set(keys) + assert not any(key.startswith("_") for key in keys) + + +def test_add_object_metadata_extracts_values(fresh_bootstrap): + """Extraction, scale factor and unmatched objects left unchanged.""" + bs = fresh_bootstrap + oids = _frames(bs) + key = "plugin.org.example.cam.exposure_time_s" + received: list[dict] = [] + + async def bridge(kind: str, payload: dict) -> dict: + received.append(payload) + return { + "metadata_key": key, + "value_pattern": "{title}", + "extraction_pattern": r"([\d.]+)\s*ms", + "conversion": "float", + "scale": 0.001, + } + + bs.set_dialog_bridge(bridge) + try: + assert asyncio.run(bs.add_object_metadata(oids)) is True + finally: + bs.set_dialog_bridge(None) + + flat, dark = (bs._MODEL.get(oid) for oid in oids) + assert flat.metadata[key] == 0.005 + assert key not in dark.metadata + properties = received[0]["schema"]["properties"] + assert properties["known_key"]["x-guidata-has-callback"] is True + assert properties["metadata_key"]["pattern"] + + +def test_add_object_metadata_reports_invalid_settings(fresh_bootstrap): + """An invalid extraction pattern is reported and the dialog shown again.""" + bs = fresh_bootstrap + oids = _frames(bs) + answers = iter( + [ + {"value_pattern": "{title}", "extraction_pattern": "ms("}, + {"value_pattern": "{title}", "extraction_pattern": r"\d+"}, + ] + ) + kinds: list[str] = [] + + async def bridge(kind: str, payload: dict) -> dict | None: + kinds.append(kind) + return next(answers) if kind == "edit_dataset" else None + + bs.set_dialog_bridge(bridge) + try: + assert asyncio.run(bs.add_object_metadata(oids)) is True + finally: + bs.set_dialog_bridge(None) + + assert kinds == ["edit_dataset", "message", "edit_dataset"] + assert [bs._MODEL.get(oid).metadata["custom_key"] for oid in oids] == [ + "5", + "01", + ] + + +def test_resolve_bridge_callbacks_fills_key_and_preview(fresh_bootstrap): + """Known keys and preview callbacks run on a copy of the live dataset.""" + bs = fresh_bootstrap + oids = _frames(bs) + bs.set_object_metadata_value(oids[0], "plugin.org.example.cam.gain", "number", "2") + resolved: dict = {} + + async def bridge(kind: str, payload: dict) -> None: + values = dict(payload["values"], known_key="plugin.org.example.cam.gain") + resolved.update(bs.resolve_bridge_callbacks("known_key", values)) + return None + + assert bs.resolve_bridge_callbacks("known_key", {}) == {} + bs.set_dialog_bridge(bridge) + try: + assert asyncio.run(bs.add_object_metadata(oids)) is False + finally: + bs.set_dialog_bridge(None) + + assert resolved["metadata_key"] == "plugin.org.example.cam.gain" + assert "Flat 5 ms 01: plugin.org.example.cam.gain = '1'" in resolved["preview"] def test_delete_object_metadata(fresh_bootstrap): diff --git a/tests/ts/components/DialogBridge.test.tsx b/tests/ts/components/DialogBridge.test.tsx new file mode 100644 index 0000000..b156e21 --- /dev/null +++ b/tests/ts/components/DialogBridge.test.tsx @@ -0,0 +1,83 @@ +import { + fireEvent, + render, + screen, + waitFor, + act, +} from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { DialogBridge } from "../../../src/components/DialogBridge"; +import type { RuntimeApi } from "../../../src/runtime/RuntimeApi"; + +type Handler = (kind: string, payload: unknown) => Promise; + +const runtimeMock = vi.hoisted(() => ({ + handler: null as Handler | null, + setDialogHandler: vi.fn((handler: Handler | null) => { + runtimeMock.handler = handler; + }), + resolveBridgeActive: vi.fn(async () => ({})), + resolveBridgeCallbacks: vi.fn( + async (_name: string, values: Record) => ({ + ...values, + metadata_key: values.known_key, + }), + ), +})); + +vi.mock("../../../src/runtime/RuntimeContext", () => ({ + useRuntime: () => ({ runtime: runtimeMock as unknown as RuntimeApi }), +})); + +describe("DialogBridge", () => { + it("runs dataset display callbacks through the runtime", async () => { + render(); + let result: Promise | undefined; + act(() => { + result = runtimeMock.handler!("edit_dataset", { + title: "Add metadata", + schema: { + type: "object", + properties: { + metadata_key: { + type: "string", + "x-guidata-kind": "string", + "x-guidata-label": "Metadata key", + "x-guidata-name": "metadata_key", + }, + known_key: { + type: "string", + enum: ["", "gain"], + "x-guidata-choices": [ + { value: "", label: "Select a key..." }, + { value: "gain", label: "gain — e.g. 2" }, + ], + "x-guidata-kind": "choice", + "x-guidata-label": "Known keys", + "x-guidata-name": "known_key", + "x-guidata-has-callback": true, + }, + }, + "x-guidata-property-order": ["metadata_key", "known_key"], + }, + values: { metadata_key: "custom_key", known_key: "" }, + }); + }); + + const select = await screen.findByRole("combobox"); + fireEvent.change(select, { target: { value: "gain" } }); + + await waitFor(() => { + expect(runtimeMock.resolveBridgeCallbacks).toHaveBeenCalledWith( + "known_key", + expect.objectContaining({ known_key: "gain" }), + ); + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe( + "gain", + ); + }); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + await expect(result).resolves.toMatchObject({ metadata_key: "gain" }); + }); +}); From c9e68349780316bc55486e390ab83bfe407a7bfa Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:20:51 +0200 Subject: [PATCH 2/2] refactor(welcome): remove the release notes NEW badge Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/App.tsx | 5 +- .../releasenotes/ReleaseNotesDialog.tsx | 14 +--- src/components/welcome/WelcomeView.tsx | 16 +---- src/locales/fr.json | 1 - src/styles.css | 15 ----- src/utils/releaseNotes.ts | 65 ------------------- 7 files changed, 4 insertions(+), 113 deletions(-) delete mode 100644 src/utils/releaseNotes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce5960..b8c1544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ### Changed +- **Welcome page:** the "NEW" badge on the release notes entry has been removed. - **Histogram range editors:** generic parameter intervals now respect linked numeric constraints and read-only states. Brightness and contrast retains its specialized controls, including exact float64 window editing outside the source image's observed range. - **Guidata compatibility:** browser runtimes now require guidata 3.15 and use its native async DataSet backend and JSON Schema exporter. The former diff --git a/src/App.tsx b/src/App.tsx index 0c06d7d..aeb53b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5324,10 +5324,7 @@ export default function App() { /> )} {releaseNotesOpen && ( - setReleaseNotesOpen(false)} - /> + setReleaseNotesOpen(false)} /> )} {h5BrowserFiles !== null && ( diff --git a/src/components/releasenotes/ReleaseNotesDialog.tsx b/src/components/releasenotes/ReleaseNotesDialog.tsx index 5b7fda5..7986a19 100644 --- a/src/components/releasenotes/ReleaseNotesDialog.tsx +++ b/src/components/releasenotes/ReleaseNotesDialog.tsx @@ -5,15 +5,10 @@ * with the application bundle and always matches the running version. * Markdown is rendered with the shared :mod:`MarkdownView` (``marked`` * + ``DOMPurify``). - * - * Opening the dialog records the current version as "seen" via - * :func:`markReleaseSeen`, which clears the "NEW" badge surfaced by - * the Welcome page. */ import { useEffect } from "react"; import { MarkdownView } from "../AIAssistant/MarkdownView"; -import { markReleaseSeen } from "../../utils/releaseNotes"; import changelogMd from "../../../CHANGELOG.md?raw"; /** @@ -44,12 +39,10 @@ function stripEmptyUnreleased(md: string): string { const releaseNotesMd = stripEmptyUnreleased(changelogMd); interface Props { - /** Current application version (e.g. ``"0.2.0"``). */ - appVersion: string; onClose: () => void; } -export function ReleaseNotesDialog({ appVersion, onClose }: Props) { +export function ReleaseNotesDialog({ onClose }: Props) { // Esc closes the dialog (mirrors HelpDialog). useEffect(() => { const handler = (event: KeyboardEvent) => { @@ -59,11 +52,6 @@ export function ReleaseNotesDialog({ appVersion, onClose }: Props) { return () => window.removeEventListener("keydown", handler); }, [onClose]); - // Mark this version as seen as soon as the dialog is mounted. - useEffect(() => { - markReleaseSeen(appVersion); - }, [appVersion]); - return (
void; - /** Optional short text rendered as a coloured pill at the end of the - * row label (e.g. ``"NEW"`` to highlight the release notes entry on - * the first launch after an upgrade). */ - badge?: string; } /** Read/write helper for the "show welcome on startup" preference. */ @@ -110,7 +105,6 @@ export function WelcomeView({ onOpenReleaseNotes, onOpenAIAssistant, }: WelcomeViewProps) { - const releaseNotesUnseen = useUnseenRelease(appVersion); const startActions: QuickAction[] = [ { iconUrl: getRootIconUrl("signal.svg"), @@ -180,7 +174,6 @@ export function WelcomeView({ "Browse the full release notes and recent changes to DataLab-Web.", ), onClick: onOpenReleaseNotes, - badge: releaseNotesUnseen ? "NEW" : undefined, }, ]; @@ -310,14 +303,7 @@ function WelcomeActionRow({ /> )} - - {action.label} - {action.badge && ( - - {action.badge} - - )} - + {action.label} {action.description && ( {action.description} diff --git a/src/locales/fr.json b/src/locales/fr.json index 097c0a2..21654e2 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -638,7 +638,6 @@ "n": "n", "Name": "Nom", "Nearest": "Plus proche", - "New": "Nouveau", "New axis": "Nouvel axe", "New conversation": "Nouvelle conversation", "New group": "Nouveau groupe", diff --git a/src/styles.css b/src/styles.css index 69461ef..b39f9bf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -6296,21 +6296,6 @@ input { color: var(--text); } -.welcome-action-badge { - display: inline-block; - margin-left: 8px; - padding: 1px 6px; - border-radius: 8px; - background: var(--accent); - color: #fff; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.5px; - line-height: 1.4; - vertical-align: middle; - text-transform: uppercase; -} - .welcome-action-description { font-size: 12px; color: var(--text-dim); diff --git a/src/utils/releaseNotes.ts b/src/utils/releaseNotes.ts deleted file mode 100644 index d89577c..0000000 --- a/src/utils/releaseNotes.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Helpers tracking whether the user has seen the release notes for the - * currently running version of DataLab-Web. - * - * The version string is whatever is injected via - * ``import.meta.env.VITE_APP_VERSION`` (see :mod:`HelpDialog`); the - * "last seen" value is persisted in ``localStorage`` so the "NEW" hint - * on the Welcome page only fires once per upgrade per browser profile. - */ - -import { useCallback, useEffect, useState } from "react"; - -const LAST_SEEN_KEY = "datalab-web.releaseNotes.lastSeenVersion"; - -/** Storage event name dispatched on the window when the "last seen" - * value is updated within the same tab. ``storage`` events from the - * Web Storage API only fire across tabs, so we synthesise our own for - * intra-tab subscribers (e.g. the Welcome badge). */ -const CHANGE_EVENT = "datalab-web:releaseNotes:change"; - -export function readLastSeenVersion(): string | null { - try { - return window.localStorage.getItem(LAST_SEEN_KEY); - } catch { - return null; - } -} - -export function markReleaseSeen(version: string): void { - try { - window.localStorage.setItem(LAST_SEEN_KEY, version); - } catch { - /* ignore quota / privacy mode errors */ - } - window.dispatchEvent(new CustomEvent(CHANGE_EVENT)); -} - -export function hasUnseenRelease(currentVersion: string): boolean { - // A missing or invalid version string should never produce a stuck - // badge — treat it as "already seen". - if (!currentVersion || currentVersion === "dev") return false; - return readLastSeenVersion() !== currentVersion; -} - -/** React hook exposing a reactive "is there a new release the user - * hasn't acknowledged yet?" flag. Re-evaluates on mount and whenever - * another component calls :func:`markReleaseSeen`. */ -export function useUnseenRelease(currentVersion: string): boolean { - const compute = useCallback( - () => hasUnseenRelease(currentVersion), - [currentVersion], - ); - const [unseen, setUnseen] = useState(compute); - useEffect(() => { - setUnseen(compute()); - const handler = () => setUnseen(compute()); - window.addEventListener(CHANGE_EVENT, handler); - window.addEventListener("storage", handler); - return () => { - window.removeEventListener(CHANGE_EVENT, handler); - window.removeEventListener("storage", handler); - }; - }, [compute]); - return unseen; -}