From 47097318fa4aab7e01205916cf0550221532f3bf Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Thu, 10 Sep 2026 10:17:42 +0200 Subject: [PATCH 01/14] fix(history): stabilize UUID-backed title references Use canonical object UUIDs when formatting titles and adapting metadata and plotting objects. --- datalab/adapters_metadata/common.py | 8 +- datalab/adapters_plotpy/objects/adapters.py | 41 ++++ datalab/objectmodel.py | 235 +++++++++++--------- 3 files changed, 177 insertions(+), 107 deletions(-) diff --git a/datalab/adapters_metadata/common.py b/datalab/adapters_metadata/common.py index 3b6f364d0..45f087f0c 100644 --- a/datalab/adapters_metadata/common.py +++ b/datalab/adapters_metadata/common.py @@ -20,7 +20,7 @@ from datalab.adapters_metadata.geometry_adapter import GeometryAdapter from datalab.adapters_metadata.table_adapter import TableAdapter from datalab.config import Conf, _ -from datalab.objectmodel import get_short_id +from datalab.objectmodel import get_short_uuid if TYPE_CHECKING: from qtpy.QtWidgets import QWidget @@ -154,8 +154,8 @@ def append(self, adapter: BaseResultAdapter, obj: SignalObj | ImageObj) -> None: self.results.append(adapter) df = adapter.to_dataframe() for i_row_res in range(len(df)): - sid = get_short_id(obj) - ylabel = f"{adapter.func_name}({sid})" + short_uuid = get_short_uuid(obj) + ylabel = f"{adapter.func_name}({short_uuid})" if "roi_index" in df.columns: i_roi = int(df.iloc[i_row_res]["roi_index"]) roititle = "" @@ -163,7 +163,7 @@ def append(self, adapter: BaseResultAdapter, obj: SignalObj | ImageObj) -> None: roititle = obj.roi.get_single_roi_title(i_roi) ylabel += f"|{roititle}" self.ylabels.append(ylabel) - self.short_ids.append(sid) + self.short_ids.append(short_uuid) def have_results(objs: list[SignalObj | ImageObj]) -> bool: diff --git a/datalab/adapters_plotpy/objects/adapters.py b/datalab/adapters_plotpy/objects/adapters.py index 893260032..14307c69e 100644 --- a/datalab/adapters_plotpy/objects/adapters.py +++ b/datalab/adapters_plotpy/objects/adapters.py @@ -14,6 +14,7 @@ from collections.abc import Iterator from typing import Any +from plotpy.items import CurveItem, MaskedXYImageItem from sigimax.adapters_plotpy.objects.base import BaseObjPlotPyAdapter, TypePlotItem from sigimax.adapters_plotpy.objects.image import ( ImageObjPlotPyAdapter as SGMXImageObjPlotPyAdapter, @@ -29,6 +30,8 @@ SignalObjPlotPyAdapter as SGMXSignalObjPlotPyAdapter, ) +from datalab.objectmodel import shorten_uuids_in_title + __all__ = [ "CURVESTYLES", "BaseObjPlotPyAdapter", @@ -83,6 +86,24 @@ class SignalObjPlotPyAdapter(SGMXSignalObjPlotPyAdapter): Extends the SigimaX signal adapter with DataLab geometry-result rendering. """ + def make_item(self, update_from: CurveItem | None = None) -> CurveItem: + """Make a curve item with a display-safe title.""" + item = super().make_item(update_from) + item.param.label = shorten_uuids_in_title(self.obj.title) + item.param.update_item(item) + return item + + def update_item(self, item: CurveItem, data_changed: bool = True) -> None: + """Update a curve item and its displayed title.""" + super().update_item(item, data_changed) + item.param.label = shorten_uuids_in_title(self.obj.title) + item.param.update_item(item) + + def add_label_with_title(self, title: str | None = None) -> None: + """Add a title annotation with shortened UUID references.""" + title = self.obj.title if title is None else title + super().add_label_with_title(shorten_uuids_in_title(title)) + def iterate_metadata_shape_items( self, key: str, value: Any, fmt: str, lbl: bool ) -> Iterator: @@ -96,6 +117,26 @@ class ImageObjPlotPyAdapter(SGMXImageObjPlotPyAdapter): Extends the SigimaX image adapter with DataLab geometry-result rendering. """ + def make_item( + self, update_from: MaskedXYImageItem | None = None + ) -> MaskedXYImageItem: + """Make an image item with a display-safe title.""" + item = super().make_item(update_from) + item.param.label = shorten_uuids_in_title(self.obj.title) + item.param.update_item(item) + return item + + def update_item(self, item: MaskedXYImageItem, data_changed: bool = True) -> None: + """Update an image item and its displayed title.""" + super().update_item(item, data_changed) + item.param.label = shorten_uuids_in_title(self.obj.title) + item.param.update_item(item) + + def add_label_with_title(self, title: str | None = None) -> None: + """Add a title annotation with shortened UUID references.""" + title = self.obj.title if title is None else title + super().add_label_with_title(shorten_uuids_in_title(title)) + def iterate_metadata_shape_items( self, key: str, value: Any, fmt: str, lbl: bool ) -> Iterator: diff --git a/datalab/objectmodel.py b/datalab/objectmodel.py index 335af0d22..8f05eeccc 100644 --- a/datalab/objectmodel.py +++ b/datalab/objectmodel.py @@ -36,8 +36,7 @@ from __future__ import annotations import re -from collections.abc import Iterator -from typing import Callable +from collections.abc import Callable, Iterator from uuid import uuid4 from sigima import ImageObj, SignalObj @@ -93,12 +92,72 @@ def get_short_id(obj: SignalObj | ImageObj | ObjectGroup) -> str: return f"{obj.PREFIX}{get_number(obj):03d}" +UUID_DISPLAY_LENGTH = 8 +UUID_REGEX = re.compile( + r"\b[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\b", re.IGNORECASE +) + + +def get_short_uuid(obj_or_uuid: SignalObj | ImageObj | ObjectGroup | str) -> str: + """Return the eight-character display prefix of an UUID.""" + uuid = obj_or_uuid if isinstance(obj_or_uuid, str) else get_uuid(obj_or_uuid) + return uuid[:UUID_DISPLAY_LENGTH] + + +def get_uuid_display_id(obj: SignalObj | ImageObj | ObjectGroup) -> str: + """Return an object's own display identity in ``#`` form.""" + return f"#{get_short_uuid(obj)}" + + +def find_uuids_in_title(title: str) -> list[tuple[int, int, str]]: + """Return UUID references found in a canonical object title. + + Args: + title: Title string to scan + + Returns: + List of ``(start, end, uuid)`` tuples sorted by ``start``. + """ + return [ + (match.start(), match.end(), match.group(0)) + for match in UUID_REGEX.finditer(title) + ] + + +def render_title( + title: str, uuid_title_resolver: Callable[[str], str | None] | None = None +) -> str: + """Render UUID references as prefixes or resolved source titles. + + Args: + title: Canonical title containing full UUID references + uuid_title_resolver: Optional callback returning a source title for a UUID + + Returns: + Display title containing source titles or shortened UUID references. + """ + for start, end, uuid in reversed(find_uuids_in_title(title)): + replacement = None + if uuid_title_resolver is not None: + replacement = uuid_title_resolver(uuid) + if replacement: + replacement = shorten_uuids_in_title(replacement) + else: + replacement = get_short_uuid(uuid) + title = title[:start] + replacement + title[end:] + return title + + +def shorten_uuids_in_title(title: str) -> str: + """Render canonical UUID references as eight-character prefixes.""" + return render_title(title) + + def patch_title_with_ids( dst_obj: SignalObj | ImageObj, src_objs: list[SignalObj] | list[ImageObj], - id_func: Callable, ) -> None: - """Patch object title with short IDs of source objects + """Patch an object title with canonical source UUIDs. Destination object's title has been set to a string containing placeholders (e.g. "integral({0})"), by `sigima` computation function using a generic mecanism @@ -107,9 +166,8 @@ def patch_title_with_ids( Args: dst_obj: destination object src_objs: list of source objects - id_func: function to get ID from object (e.g. `short_id` or `get_uuid`) """ - ids = [id_func(obj) for obj in src_objs] + ids = [get_uuid(obj) for obj in src_objs] title = dst_obj.title assert isinstance(title, str), "Title must be a string" try: @@ -123,6 +181,7 @@ def patch_title_with_ids( #: Regex matching short IDs as embedded in computation titles #: (e.g. ``s001``, ``i012``, ``gs003``, ``gi007``). SHORT_ID_REGEX = re.compile(r"\b(g?[si])(\d{3})\b") +LEGACY_GROUP_SHORT_ID_REGEX = re.compile(r"\bg\d{3}\b") def find_short_ids_in_title(title: str) -> list[tuple[int, int, str]]: @@ -138,6 +197,36 @@ def find_short_ids_in_title(title: str) -> list[tuple[int, int, str]]: return [(m.start(), m.end(), m.group(0)) for m in SHORT_ID_REGEX.finditer(title)] +def find_legacy_group_short_ids_in_title( + title: str, +) -> list[tuple[int, int, str]]: + """Return legacy ``gNNN`` group references found in a title.""" + return [ + (match.start(), match.end(), match.group(0)) + for match in LEGACY_GROUP_SHORT_ID_REGEX.finditer(title) + ] + + +def remap_title_references(title: str, reference_remap: dict[str, str]) -> str: + """Replace known UUID and legacy short-ID references in a title. + + Args: + title: Canonical or legacy title to update + reference_remap: Mapping from serialized references to canonical UUIDs + + Returns: + Title with every known reference replaced by its canonical UUID. + """ + matches = find_short_ids_in_title(title) + matches.extend(find_legacy_group_short_ids_in_title(title)) + matches.extend(find_uuids_in_title(title)) + for start, end, reference in sorted(matches, reverse=True): + replacement = reference_remap.get(reference) + if replacement is not None: + title = title[:start] + replacement + title[end:] + return title + + class ObjectGroup: """Represents a DataLab object group @@ -145,12 +234,19 @@ class ObjectGroup: title: group title model: object model prefix: prefix for short ID ("gs" for signal groups, "gi" for image groups) + group_uuid: optional group UUID. If None, a new UUID is generated. """ - def __init__(self, title: str, model: ObjectModel, prefix: str) -> None: + def __init__( + self, + title: str, + model: ObjectModel, + prefix: str, + group_uuid: str | None = None, + ) -> None: self.model = model self.prefix = prefix # Instance-specific prefix - self.uuid: str = str(uuid4()) # Group uuid + self.uuid: str = group_uuid or str(uuid4()) # Group uuid self.number: int = 0 # Group number (used for short ID) self.__objects: list[str] = [] # list of object uuids self.__title: str = title @@ -195,17 +291,13 @@ def append(self, obj: SignalObj | ImageObj) -> None: def insert(self, index: int, obj: SignalObj | ImageObj) -> None: """Insert object at index""" - self.model.replace_short_ids_by_uuids_in_titles([obj]) self.__objects.insert(index, get_uuid(obj)) self.model.reset_short_ids() - self.model.replace_uuids_by_short_ids_in_titles() def remove(self, obj: SignalObj | ImageObj) -> None: """Remove object from group""" - self.model.replace_short_ids_by_uuids_in_titles() self.__objects.remove(get_uuid(obj)) self.model.reset_short_ids() - self.model.replace_uuids_by_short_ids_in_titles() def clear(self) -> None: """Clear group""" @@ -276,15 +368,17 @@ def __contains__(self, obj: SignalObj | ImageObj) -> bool: return get_uuid(obj) in self._objects def has_uuid(self, uuid: str) -> bool: - """Check if an object with the given UUID exists in the model + """Check if an object or group with the given UUID exists in the model. Args: uuid: UUID string to check Returns: - True if an object with this UUID exists, False otherwise + True if an object or group with this UUID exists, False otherwise """ - return uuid in self._objects + return uuid in self._objects or any( + group.uuid == uuid for group in self._groups + ) def clear(self) -> None: """Clear model""" @@ -399,11 +493,17 @@ def get_group_from_title(self, title: str) -> ObjectGroup: Raises: KeyError: if group with title not found + ValueError: if multiple groups have the same title """ - for group in self._groups: - if group.title == title: - return group - raise KeyError(f"Group with title '{title}' not found") + matches = [group for group in self._groups if group.title == title] + if not matches: + raise KeyError(f"Group with title '{title}' not found") + if len(matches) > 1: + match_ids = ", ".join(get_short_id(group) for group in matches) + raise ValueError( + f"Group title '{title}' is ambiguous; matches: {match_ids}" + ) + return matches[0] def get_group_from_object(self, obj: SignalObj | ImageObj) -> ObjectGroup: """Return group containing object @@ -428,16 +528,17 @@ def get_groups(self, uuids: list[str] | None = None) -> list[ObjectGroup]: return self._groups return [group for group in self._groups if get_uuid(group) in uuids] - def add_group(self, title: str) -> ObjectGroup: + def add_group(self, title: str, group_uuid: str | None = None) -> ObjectGroup: """Add group to model Args: title: group title + group_uuid: optional group UUID. If None, a new UUID is generated. Returns: Created group object """ - group = ObjectGroup(title, self, self._group_prefix) + group = ObjectGroup(title, self, self._group_prefix, group_uuid) self._groups.append(group) self.reset_short_ids() return group @@ -465,7 +566,6 @@ def get_group_object_ids(self, group_id: str) -> list[str]: def remove_group(self, group: ObjectGroup) -> None: """Remove group from model""" - self.replace_short_ids_by_uuids_in_titles() self._groups.remove(group) for obj in group: remove_obj = True @@ -476,11 +576,9 @@ def remove_group(self, group: ObjectGroup) -> None: if remove_obj: del self._objects[get_uuid(obj)] self.reset_short_ids() - self.replace_uuids_by_short_ids_in_titles() def add_object(self, obj: SignalObj | ImageObj, group_id: str) -> None: """Add object to model""" - self.replace_short_ids_by_uuids_in_titles([obj]) self._objects[get_uuid(obj)] = obj onb = 0 for group in self._groups: @@ -492,7 +590,6 @@ def add_object(self, obj: SignalObj | ImageObj, group_id: str) -> None: else: raise KeyError(f"Group with uuid '{group_id}' not found") self.reset_short_ids() - self.replace_uuids_by_short_ids_in_titles() def remove_object(self, obj: SignalObj | ImageObj) -> None: """Remove object from model""" @@ -587,73 +684,17 @@ def get_object_from_title(self, title: str) -> SignalObj | ImageObj: Raises: KeyError: if object with title not found + ValueError: if multiple objects have the same title """ - for obj in self._objects.values(): - if obj.title == title: - return obj - raise KeyError(f"Object with title '{title}' not found") - - def __get_group_object_mapping_to_shortid(self) -> dict[str, str]: - """Return dictionary mapping group/object uuids to their short ID""" - mapping = {} - for group in self._groups: - mapping[get_uuid(group)] = get_short_id(group) - for obj in group: - mapping[get_uuid(obj)] = get_short_id(obj) - return mapping - - def replace_short_ids_by_uuids_in_titles( - self, other_objects: tuple[SignalObj | ImageObj] | None = None - ) -> None: - """Replace short IDs by uuids in titles - - Args: - other_objects: tuple of other objects to consider for short ID replacement - - .. note:: - - This method is called before reorganizing groups or objects. It replaces the - short IDs in titles by the uuids. This is needed because the short IDs are - used to reflect in the title the operation performed on the object/group, - e.g. "fft(s001)" or "g001 + g002". But when reorganizing groups or objects, - the short IDs may change, so we need to replace them by the uuids, which are - stable. Once the reorganization is done, we will replace the uuids by the - new short IDs thanks to the `__replace_uuids_by_short_ids_in_titles` method. - """ - mapping = self.__get_group_object_mapping_to_shortid() - objs = self._objects.values() - if other_objects is not None: - objs = list(objs) + list(other_objects) - for obj in objs: - for obj_uuid, short_id in mapping.items(): - obj.title = obj.title.replace(short_id, obj_uuid) - for group in self._groups: - for grp_uuid, short_id in mapping.items(): - group.title = group.title.replace(short_id, grp_uuid) - - def replace_uuids_by_short_ids_in_titles(self) -> None: - """Replace uuids by short IDs in titles - - .. note:: - - This method is called after reorganizing groups or objects. It replaces - the uuids in titles by the short IDs. - """ - mapping = self.__get_group_object_mapping_to_shortid() - for obj in self._objects.values(): - for obj_uuid, short_id in mapping.items(): - obj.title = obj.title.replace(obj_uuid, short_id) - for group in self._groups: - for grp_uuid, short_id in mapping.items(): - group.title = group.title.replace(grp_uuid, short_id) - # Replace remaining UUIDs with f"{obj.PREFIX}xxx" - # (this may happen if groups or objects were removed in the meantime): - pattern = re.compile(r"\b[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}\b") - for obj in self._objects.values(): - obj.title = pattern.sub(f"{obj.PREFIX}xxx", obj.title) - for group in self._groups: - for obj in group: - obj.title = pattern.sub(f"{obj.PREFIX}xxx", obj.title) + matches = [obj for obj in self._objects.values() if obj.title == title] + if not matches: + raise KeyError(f"Object with title '{title}' not found") + if len(matches) > 1: + match_ids = ", ".join(get_uuid_display_id(obj) for obj in matches) + raise ValueError( + f"Object title '{title}' is ambiguous; matches: {match_ids}" + ) + return matches[0] def reorder_groups(self, group_ids: list[str]) -> None: """Reorder groups. @@ -661,14 +702,8 @@ def reorder_groups(self, group_ids: list[str]) -> None: Args: group_ids: list of group uuids """ - # Replace short IDs by uuids in titles: - self.replace_short_ids_by_uuids_in_titles() - # Reordering groups: self._groups = [self.get_group(group_id) for group_id in group_ids] - # Reset short IDs: self.reset_short_ids() - # Replace uuids by short IDs in titles: - self.replace_uuids_by_short_ids_in_titles() def reorder_objects(self, obj_ids: dict[str, list[str]]) -> None: """Reorder objects in groups. @@ -676,15 +711,9 @@ def reorder_objects(self, obj_ids: dict[str, list[str]]) -> None: Args: obj_ids: dict of group uuids and list of object uuids """ - # Replace short IDs by uuids in titles: - self.replace_short_ids_by_uuids_in_titles() - # Reordering objects in groups: for group_id, obj_uuids in obj_ids.items(): group = self.get_group(group_id) group.clear() for obj_uuid in obj_uuids: group.append(self._objects[obj_uuid]) - # Reset short IDs: self.reset_short_ids() - # Replace uuids by short IDs in titles: - self.replace_uuids_by_short_ids_in_titles() From 1458ac78d94ce9a017fe53935c520f97168361cc Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Thu, 10 Sep 2026 11:42:11 +0200 Subject: [PATCH 02/14] fix(history): remap action references during replay Keep history actions and their model assertions aligned when object UUIDs are restored or remapped. --- datalab/history/action.py | 23 ++++++++++++--- datalab/history/core.py | 4 ++- .../common/history_model_unit_test.py | 29 +++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/datalab/history/action.py b/datalab/history/action.py index 486195d4c..84f761639 100644 --- a/datalab/history/action.py +++ b/datalab/history/action.py @@ -147,6 +147,7 @@ class Descriptors: def __init__( self, title: str = "", + *, kind: str = KIND_UI, # --- compute-only -------------------------------------------------- panel_str: str | None = None, @@ -307,6 +308,13 @@ def copy_with_uuid_remap( A new independent :class:`HistoryAction` with supported UUID references remapped. """ + + def remap_owned_uuid(uuid: str) -> str: + matches = [pmap[uuid] for pmap in uuid_remap.values() if uuid in pmap] + if len(matches) > 1: + raise ValueError(f"Ambiguous object UUID in remap: {uuid}") + return matches[0] if matches else uuid + new_action = self.copy() # Rewrite state.selection for pstr, uuids in new_action.state.selection.items(): @@ -329,11 +337,18 @@ def copy_with_uuid_remap( new_action.kwargs["obj2_uuids"] = ( rewritten[0] if len(rewritten) == 1 else rewritten ) - # Rewrite output_uuids — they reference the target panel. + source_uuid = new_action.kwargs.get("source_uuid") + if isinstance(source_uuid, str): + pmap = uuid_remap.get(new_action.effective_panel_str(), {}) + new_action.kwargs["source_uuid"] = pmap.get(source_uuid, source_uuid) + output_uuid = new_action.kwargs.get("output_uuid") + if isinstance(output_uuid, str): + new_action.kwargs["output_uuid"] = remap_owned_uuid(output_uuid) + # Outputs may belong to another panel, for example image profiles. if new_action.output_uuids: - pstr = new_action.effective_panel_str() - pmap = uuid_remap.get(pstr, {}) - new_action.output_uuids = [pmap.get(u, u) for u in new_action.output_uuids] + new_action.output_uuids = [ + remap_owned_uuid(uuid) for uuid in new_action.output_uuids + ] # Rewrite target_uuids — mutated objects live in the target panel. if new_action.target_uuids: pstr = new_action.effective_panel_str() diff --git a/datalab/history/core.py b/datalab/history/core.py index 709e66c74..09ed1eda8 100644 --- a/datalab/history/core.py +++ b/datalab/history/core.py @@ -60,6 +60,8 @@ def numpy_to_json_safe(obj: Any) -> Any: """Recursively convert numpy arrays to lists for JSON serialization.""" if isinstance(obj, np.ndarray): return obj.tolist() + if isinstance(obj, np.generic): + return obj.item() if isinstance(obj, dict): return {k: numpy_to_json_safe(v) for k, v in obj.items()} if isinstance(obj, list): @@ -187,7 +189,7 @@ def decode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: ) decoded[key] = [] else: - decoded[key] = value + decoded[key] = numpy_to_json_safe(value) return decoded diff --git a/datalab/tests/features/common/history_model_unit_test.py b/datalab/tests/features/common/history_model_unit_test.py index 6fcbd5d66..f2bab8576 100644 --- a/datalab/tests/features/common/history_model_unit_test.py +++ b/datalab/tests/features/common/history_model_unit_test.py @@ -782,6 +782,8 @@ def test_recompute_in_place_skips_decode_failed_action() -> None: def test_action_copy_remaps_all_uuid_references() -> None: """Copy an action independently and rewrite every captured UUID field.""" action = build_history_action() + action.kwargs["source_uuid"] = "source-uuid" + action.kwargs["output_uuid"] = "output-uuid" action.plugin_origin = { "module": "example.plugin", "metadata": {"entry_points": ["difference"]}, @@ -801,6 +803,8 @@ def test_action_copy_remaps_all_uuid_references() -> None: "signal": {"new-source": {"shape": [10], "ndim": 1, "title": "Source"}} } assert copied.kwargs["obj2_uuids"] == "new-second" + assert copied.kwargs["source_uuid"] == "new-source" + assert copied.kwargs["output_uuid"] == "new-output" assert copied.output_uuids == ["new-output"] assert copied.plugin_origin == action.plugin_origin copied.state.object_metadata["signal"]["new-source"]["shape"] = [20] @@ -809,6 +813,31 @@ def test_action_copy_remaps_all_uuid_references() -> None: assert action.plugin_origin["metadata"]["entry_points"] == ["difference"] +def test_action_copy_remaps_cross_panel_outputs() -> None: + """Remap image processing outputs from their owning signal panel.""" + state = WorkspaceState() + state.selection = {"image": ["image-source"]} + action = HistoryAction( + title="Line profile", + kind=HistoryAction.KIND_COMPUTE, + panel_str="image", + func_name="line_profile", + pattern="1_to_1", + state=state, + ) + action.output_uuids = ["signal-output"] + + copied = action.copy_with_uuid_remap( + { + "image": {"image-source": "new-image-source"}, + "signal": {"signal-output": "new-signal-output"}, + } + ) + + assert copied.state.selection == {"image": ["new-image-source"]} + assert copied.output_uuids == ["new-signal-output"] + + def _make_analysis_action(obj_uuid: str) -> HistoryAction: """Build a 1-to-0 compute action analysing ``obj_uuid``.""" return HistoryAction( From cc27e3cb9541ab96b3cdb43394e72a2e46e23052 Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Thu, 10 Sep 2026 14:26:51 +0200 Subject: [PATCH 03/14] fix(hdf5): remap references on history import Rebuild cross-panel UUID mappings while loading history data into a non-empty workspace. --- datalab/h5/history.py | 97 ++++++++----------- .../features/hdf5/h5workspace_unit_test.py | 44 +++++++++ 2 files changed, 86 insertions(+), 55 deletions(-) diff --git a/datalab/h5/history.py b/datalab/h5/history.py index a3c8c80e5..3205df4a7 100644 --- a/datalab/h5/history.py +++ b/datalab/h5/history.py @@ -12,13 +12,14 @@ from qtpy.compat import getopenfilename, getsavefilename from datalab.config import Conf, _ -from datalab.gui.processor.base import ( - PROCESSING_PARAMETERS_OPTION, - ProcessingParameters, -) +from datalab.gui.panel.base import H5ImportBatch from datalab.h5.native import NativeH5Reader, NativeH5Writer from datalab.history import HistorySession -from datalab.objectmodel import get_uuid +from datalab.objectmodel import ( + LEGACY_GROUP_SHORT_ID_REGEX, + SHORT_ID_REGEX, + get_uuid, +) from datalab.utils.qthelpers import qt_try_loadsave_file, save_restore_stds if TYPE_CHECKING: @@ -28,11 +29,11 @@ @dataclass class HistoryImportRegistry: - """Imported objects and their old-to-new UUID mappings.""" + """Per-panel import batches and their old-to-new UUID mappings.""" panel_map: dict[str, BaseDataPanel] uuid_remap: dict[str, dict[str, str]] - imported_by_pstr: dict[str, list[Any]] + import_batches: dict[str, H5ImportBatch] def save_to_dlhist_file(panel: HistoryPanel, filename: str | None = None) -> bool: @@ -111,12 +112,15 @@ def open_dlhist_file(panel: HistoryPanel, filename: str | None = None) -> bool: else: # Pristine workspace: load directly, preserving original UUIDs # (reset_all=True) so that history references stay valid. - panel.mainwindow.signalpanel.deserialize_from_hdf5( - reader, reset_all=True - ) - panel.mainwindow.imagepanel.deserialize_from_hdf5( - reader, reset_all=True - ) + batches = [ + panel.mainwindow.signalpanel.deserialize_from_hdf5( + reader, reset_all=True + ), + panel.mainwindow.imagepanel.deserialize_from_hdf5( + reader, reset_all=True + ), + ] + H5ImportBatch.finalize_imports(batches, refresh_panels=True) panel.deserialize_from_hdf5(reader) return True @@ -130,7 +134,10 @@ def create_import_registry(panel: HistoryPanel) -> HistoryImportRegistry: return HistoryImportRegistry( panel_map=panel_map, uuid_remap={panel_str: {} for panel_str in panel_map}, - imported_by_pstr={panel_str: [] for panel_str in panel_map}, + import_batches={ + panel_str: H5ImportBatch(data_panel, {}, [], []) + for panel_str, data_panel in panel_map.items() + }, ) @@ -153,10 +160,22 @@ def read_imported_group( registry: HistoryImportRegistry, ) -> None: """Read one object group and register its regenerated UUIDs.""" + batch = registry.import_batches[panel_str] with reader.group(group_name): group = data_panel.add_group("") with reader.group("title"): group.title = reader.read_str() + new_group_uuid = get_uuid(group) + serialized_group_uuid = reader.read("uuid", default=None) + if isinstance(serialized_group_uuid, str) and serialized_group_uuid: + registry.uuid_remap[panel_str][serialized_group_uuid] = new_group_uuid + batch.reference_remap[serialized_group_uuid] = new_group_uuid + group_short_id = group_name.partition(":")[0] + if SHORT_ID_REGEX.fullmatch( + group_short_id + ) or LEGACY_GROUP_SHORT_ID_REGEX.fullmatch(group_short_id): + batch.reference_remap[group_short_id] = new_group_uuid + batch.groups.append(group) path = f"{data_panel.H5_PREFIX}/{group_name}" for object_name in reader.h5.get(path, []): obj = data_panel.deserialize_object_from_hdf5( @@ -164,8 +183,12 @@ def read_imported_group( ) old_uuid, new_uuid = assign_imported_uuid(obj) registry.uuid_remap[panel_str][old_uuid] = new_uuid - data_panel.add_object(obj, get_uuid(group), set_current=False) - registry.imported_by_pstr[panel_str].append(obj) + batch.reference_remap[old_uuid] = new_uuid + object_short_id = object_name.partition(":")[0] + if SHORT_ID_REGEX.fullmatch(object_short_id): + batch.reference_remap[object_short_id] = new_uuid + data_panel.add_object(obj, new_group_uuid, set_current=False) + batch.objects.append(obj) data_panel.selection_changed() @@ -181,44 +204,6 @@ def read_imported_objects( read_imported_group(reader, data_panel, panel_str, group_name, registry) -def remap_imported_object_sources(obj: Any, uuid_remap: dict[str, str]) -> None: - """Remap processing source UUIDs stored on one imported object.""" - try: - parameters_dict = obj.get_metadata_option(PROCESSING_PARAMETERS_OPTION) - except (AttributeError, ValueError): - return - if not parameters_dict: - return - try: - parameters = ProcessingParameters.from_dict(parameters_dict) - except (TypeError, ValueError, AttributeError): - return - changed = False - if parameters.source_uuid is not None and parameters.source_uuid in uuid_remap: - parameters.source_uuid = uuid_remap[parameters.source_uuid] - changed = True - if parameters.source_uuids is not None: - new_sources = [uuid_remap.get(uuid, uuid) for uuid in parameters.source_uuids] - if new_sources != parameters.source_uuids: - parameters.source_uuids = new_sources - changed = True - if changed: - try: - obj.set_metadata_option(PROCESSING_PARAMETERS_OPTION, parameters.to_dict()) - except (AttributeError, ValueError): - pass - - -def remap_imported_sources(registry: HistoryImportRegistry) -> None: - """Remap processing sources for all imported objects.""" - for panel_str, objects in registry.imported_by_pstr.items(): - uuid_remap = registry.uuid_remap.get(panel_str, {}) - if not uuid_remap: - continue - for obj in objects: - remap_imported_object_sources(obj, uuid_remap) - - def assemble_imported_sessions( panel: HistoryPanel, reader: NativeH5Reader, @@ -271,7 +256,9 @@ def import_dlhist_into_new_session(panel: HistoryPanel, reader: NativeH5Reader) """ registry = create_import_registry(panel) read_imported_objects(reader, registry) - remap_imported_sources(registry) + H5ImportBatch.finalize_imports( + list(registry.import_batches.values()), refresh_panels=True + ) sessions = assemble_imported_sessions(panel, reader, registry) if sessions is None: return diff --git a/datalab/tests/features/hdf5/h5workspace_unit_test.py b/datalab/tests/features/hdf5/h5workspace_unit_test.py index de244b442..18328f1f8 100644 --- a/datalab/tests/features/hdf5/h5workspace_unit_test.py +++ b/datalab/tests/features/hdf5/h5workspace_unit_test.py @@ -21,6 +21,8 @@ import enum import os.path as osp +from types import SimpleNamespace +from unittest.mock import Mock import h5py import pytest @@ -34,10 +36,52 @@ ) from datalab.adapters_metadata.table_adapter import TableAdapter +from datalab.gui.h5io import H5InputOutput from datalab.gui.newobject import extract_creation_parameters +from datalab.gui.panel.base import H5ImportBatch from datalab.tests import datalab_test_app_context, helpers +def test_finalize_imports_keeps_colliding_legacy_references_panel_local() -> None: + """Resolve legacy gNNN collisions in their owning panel batch.""" + signal_group = SimpleNamespace(title="derivative(g001)") + image_group = SimpleNamespace(title="normalize(g001)") + cross_panel_group = SimpleNamespace(title="profile(i001)") + signal_batch = H5ImportBatch( + SimpleNamespace(), + {"g001": "signal-group-uuid"}, + [], + [signal_group, cross_panel_group], + ) + image_batch = H5ImportBatch( + SimpleNamespace(), + {"g001": "image-group-uuid", "i001": "image-object-uuid"}, + [], + [image_group], + ) + + H5ImportBatch.finalize_imports([signal_batch, image_batch]) + + assert signal_group.title == "derivative(signal-group-uuid)" + assert image_group.title == "normalize(image-group-uuid)" + assert cross_panel_group.title == "profile(image-object-uuid)" + + +def test_open_file_headless_refreshes_reference_displays(monkeypatch) -> None: + """Refresh existing titles and legends after a headless HDF5 import.""" + panel = SimpleNamespace(refresh_after_reference_remap=Mock()) + batch = H5ImportBatch(panel, {}, [], []) + panel.deserialize_from_hdf5 = Mock(return_value=batch) + mainwindow = SimpleNamespace(panels=[panel], reset_all=Mock()) + reader = SimpleNamespace(close=Mock()) + monkeypatch.setattr("datalab.gui.h5io.NativeH5Reader", lambda _filename: reader) + + assert H5InputOutput(mainwindow).open_file_headless("workspace.h5", False) + + panel.refresh_after_reference_remap.assert_called_once_with() + reader.close.assert_called_once_with() + + def test_save_and_load_h5_workspace(): """Test save_h5_workspace and load_h5_workspace methods""" with helpers.WorkdirRestoringTempDir() as tmpdir: From ed0436922122d78c3e675c7ee315f3df17df28f0 Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Fri, 11 Sep 2026 10:14:37 +0200 Subject: [PATCH 04/14] fix(gui): refresh panels after history import Refresh object views, plots and ROI editors after replaying history into an existing workspace. --- datalab/gui/h5io.py | 13 +- datalab/gui/main.py | 42 ++++- datalab/gui/objectview.py | 120 +++++++----- datalab/gui/panel/base.py | 174 +++++++++++++++--- datalab/gui/plothandler.py | 7 + datalab/gui/roieditor.py | 11 +- datalab/gui/roigrideditor.py | 11 +- .../common/history_replay_fixes_test.py | 41 +++++ .../features/common/history_workflow_test.py | 52 ++++++ 9 files changed, 393 insertions(+), 78 deletions(-) diff --git a/datalab/gui/h5io.py b/datalab/gui/h5io.py index 8efd0a924..c6a060329 100644 --- a/datalab/gui/h5io.py +++ b/datalab/gui/h5io.py @@ -22,6 +22,7 @@ from datalab.config import _ from datalab.env import execenv +from datalab.gui.panel.base import H5ImportBatch from datalab.h5 import H5Importer from datalab.h5.native import NativeH5Reader, NativeH5Writer from datalab.utils.qthelpers import create_progress_bar, qt_try_loadsave_file @@ -73,8 +74,12 @@ def open_file_headless(self, filename: str, reset_all: bool) -> bool: reader = NativeH5Reader(filename) if reset_all: self.mainwindow.reset_all() + batches: list[H5ImportBatch] = [] for panel in self.mainwindow.panels: - panel.deserialize_from_hdf5(reader, reset_all) + batch = panel.deserialize_from_hdf5(reader, reset_all) + if isinstance(batch, H5ImportBatch): + batches.append(batch) + H5ImportBatch.finalize_imports(batches, refresh_panels=True) reader.close() return True except KeyError: @@ -87,15 +92,19 @@ def open_file(self, filename: str, import_all: bool, reset_all: bool) -> None: reader = NativeH5Reader(filename) if reset_all: self.mainwindow.reset_all() + batches: list[H5ImportBatch] = [] with create_progress_bar( self.mainwindow, self.__progbartitle(filename), 2 ) as progress: for idx, panel in enumerate(self.mainwindow.panels): progress.setValue(idx + 1) QW.QApplication.processEvents() - panel.deserialize_from_hdf5(reader, reset_all) + batch = panel.deserialize_from_hdf5(reader, reset_all) + if isinstance(batch, H5ImportBatch): + batches.append(batch) if progress.wasCanceled(): break + H5ImportBatch.finalize_imports(batches, refresh_panels=True) reader.close() except KeyError: if progress is not None: diff --git a/datalab/gui/main.py b/datalab/gui/main.py index 95a6282de..190aa4add 100644 --- a/datalab/gui/main.py +++ b/datalab/gui/main.py @@ -68,7 +68,7 @@ from datalab.gui.panel import base, history, image, macro, signal from datalab.gui.pluginconfig import PluginConfigDialog from datalab.gui.settings import AI_OPTION_NAMES, edit_settings -from datalab.objectmodel import ObjectGroup, get_uuid +from datalab.objectmodel import UUID_REGEX, ObjectGroup, get_uuid, render_title from datalab.plugins import PluginRegistry, discover_plugins, discover_v020_plugins from datalab.utils import qthelpers as qth from datalab.utils.qthelpers import configure_menu_about_to_show @@ -276,8 +276,18 @@ def get_object( Raises: KeyError: if object not found + ValueError: if multiple objects have the same title TypeError: if index_id_title type is invalid """ + if ( + isinstance(nb_id_title, str) + and panel is None + and UUID_REGEX.fullmatch(nb_id_title) + ): + obj = self.find_object_by_uuid(nb_id_title) + if obj is None or isinstance(obj, ObjectGroup): + raise KeyError(f"Invalid object index, id or title: {nb_id_title}") + return obj panelw = self.__get_datapanel(panel) if nb_id_title is None: return panelw.objview.get_current_object() @@ -317,11 +327,29 @@ def find_object_by_uuid( for panel in (self.signalpanel, self.imagepanel): if panel is not None: try: - return panel.objmodel[uuid] + return panel.objmodel.get_object_or_group(uuid) except KeyError: continue return None + def render_object_title(self, title: str) -> str: + """Render a canonical object title according to user settings.""" + + def resolve_uuid_title(uuid: str) -> str | None: + obj = self.find_object_by_uuid(uuid) + return None if obj is None else obj.title + + resolver = ( + resolve_uuid_title if Conf.result_title_mode.get() == "title" else None + ) + return render_title(title, resolver) + + def refresh_object_title_displays(self) -> None: + """Refresh object titles in trees and existing plot items.""" + for panel in (self.signalpanel, self.imagepanel): + panel.objview.update_tree() + panel.refresh_plot("existing", update_items=True, force=True) + @remote_controlled def get_object_uuids( self, @@ -1434,6 +1462,9 @@ def __configure_panels(self) -> None: for panel in self.panels: panel.SIG_OBJECT_ADDED.connect(self.set_modified) panel.SIG_OBJECT_REMOVED.connect(self.set_modified) + for panel in (self.signalpanel, self.imagepanel): + panel.SIG_OBJECT_MODIFIED.connect(self.refresh_object_title_displays) + panel.SIG_OBJECT_REMOVED.connect(self.refresh_object_title_displays) self.macropanel.SIG_OBJECT_MODIFIED.connect(self.set_modified) # Initializing common panel actions self.autorefresh_action.setChecked(Conf.auto_refresh.get(True)) @@ -2108,9 +2139,7 @@ def add_signal( ) return self.add_object(obj, group_id, set_current, new_session_behavior) - # This API mirrors the image metadata accepted by create_image, so the - # argument count is part of the stable public interface rather than noise. - def add_image( # pylint: disable=too-many-arguments + def add_image( self, title: str, data: np.ndarray, @@ -2122,6 +2151,7 @@ def add_image( # pylint: disable=too-many-arguments zlabel: str = "", group_id: str = "", set_current: bool = True, + *, new_session_behavior: SessionBehavior | None = None, ) -> bool: """Add image data to DataLab. @@ -2234,6 +2264,8 @@ def __edit_settings(self) -> None: # pylint: disable=too-many-branches,too-many "max_cols_in_label", ): refresh_signal_panel = refresh_image_panel = True + if option == "result_title_mode": + self.refresh_object_title_displays() if option == "show_result_label": for panel in (self.signalpanel, self.imagepanel): panel.acthandler.show_label_action.setChecked( diff --git a/datalab/gui/objectview.py b/datalab/gui/objectview.py index e3d0df391..c3854c1de 100644 --- a/datalab/gui/objectview.py +++ b/datalab/gui/objectview.py @@ -45,15 +45,18 @@ from qtpy import QtWidgets as QW from sigima.objects import ImageObj, SignalObj -from datalab.config import _ +from datalab.config import Conf, _ from datalab.objectmodel import ( ObjectGroup, find_short_ids_in_title, + find_uuids_in_title, get_short_id, + get_short_uuid, get_uuid, + shorten_uuids_in_title, ) from datalab.utils.qthelpers import block_signals -from datalab.widgets.titledelegate import ClickableTitleDelegate +from datalab.widgets.titledelegate import TITLE_UUID_ROLE, ClickableTitleDelegate if TYPE_CHECKING: from typing import Any @@ -103,8 +106,10 @@ def __init__(self, parent: QW.QWidget, objmodel: ObjectModel) -> None: self.itemDoubleClicked.connect(self.item_double_clicked) self.header().setSectionResizeMode(QW.QHeaderView.Interactive) self.itemChanged.connect(lambda item: self.resizeColumnToContents(0)) - self._title_delegate = ClickableTitleDelegate(self) - self.setItemDelegateForColumn(0, self._title_delegate) + self.title_delegate = ClickableTitleDelegate( + self, self.get_uuid_reference_display + ) + self.setItemDelegateForColumn(0, self.title_delegate) self.viewport().setMouseTracking(True) def __str__(self) -> str: @@ -210,35 +215,53 @@ def get_sel_groups(self) -> list[ObjectGroup]: """Return selected groups""" return self.objmodel.get_groups(self.get_sel_group_uuids()) - def _resolve_short_id( - self, short_id: str + def resolve_title_reference( + self, reference: str ) -> tuple[str, SignalObj | ImageObj | ObjectGroup] | None: - """Resolve a short ID embedded in a title to ``(panel_str, obj)``. + """Resolve a title reference to ``(panel_str, obj)``. Default implementation only looks up the tree's own model and returns an empty ``panel_str``. Subclasses with access to several panels should override this method. """ - obj = self.objmodel.find_by_short_id(short_id) + try: + obj = self.objmodel.get_object_or_group(reference) + except KeyError: + obj = self.objmodel.find_by_short_id(reference) if obj is None: return None return ("", obj) - def _build_short_id_tooltip(self, text: str) -> str: + def get_uuid_reference_display(self, uuid: str) -> str | None: + """Return the configured display text for an UUID reference.""" + resolved = self.resolve_title_reference(uuid) + if resolved is None: + return None + _panel_str, obj = resolved + if Conf.result_title_mode.get() == "title": + return shorten_uuids_in_title(obj.title) + return get_short_uuid(uuid) + + def build_title_reference_tooltip(self, text: str) -> str: """Return an HTML tooltip fragment listing the source objects - referenced by short IDs embedded in ``text``, or an empty string when - no such reference is found.""" - matches = find_short_ids_in_title(text) + referenced in ``text``, or an empty string when no such reference is + found.""" + short_id_matches = find_short_ids_in_title(text) + matches = short_id_matches.copy() + matches.extend(find_uuids_in_title(text)) + matches.sort(key=lambda match: match[0]) rows: list[str] = [] seen: set[str] = set() - for idx, (start, _end, sid) in enumerate(matches): - if idx == 0 and start == 0: + for start, _end, reference in matches: + if start == 0 and any( + reference == short_id for _start, _end, short_id in short_id_matches + ): # Skip the leading ":" prefix continue - if sid in seen: + if reference in seen: continue - seen.add(sid) - resolved = self._resolve_short_id(sid) + seen.add(reference) + resolved = self.resolve_title_reference(reference) if resolved is None: continue panel_str, obj = resolved @@ -249,8 +272,13 @@ def _build_short_id_tooltip(self, text: str) -> str: if isinstance(obj, SignalObj) else _("image") ) - suffix = f" \u00b7 {panel_str}" if panel_str else "" - rows.append(f"{sid} \u2192 {obj.title} ({kind}{suffix})") + suffix = f" · {panel_str}" if panel_str else "" + display_reference = self.get_uuid_reference_display( + reference + ) or shorten_uuids_in_title(reference) + rows.append( + f"{display_reference} → {obj.title} ({kind}{suffix})" + ) if not rows: return "" title = _("Source objects") @@ -263,30 +291,37 @@ def __update_item( self, item: QW.QTreeWidgetItem, obj: SignalObj | ImageObj | ObjectGroup ) -> None: """Update item""" - text = f"{get_short_id(obj)}: {obj.title}" + if isinstance(obj, ObjectGroup): + text = f"{get_short_id(obj)}: {obj.title}" + item.setData(0, TITLE_UUID_ROLE, None) + else: + text = obj.title item.setText(0, text) tooltip_parts: list[str] = [] - sid_tooltip = self._build_short_id_tooltip(text) - if sid_tooltip: - tooltip_parts.append(sid_tooltip) + reference_tooltip = self.build_title_reference_tooltip(text) + if reference_tooltip: + tooltip_parts.append(reference_tooltip) if isinstance(obj, (SignalObj, ImageObj)): meta_tooltip = metadata_to_html(obj.metadata) if meta_tooltip: tooltip_parts.append(meta_tooltip) item.setToolTip(0, "".join(tooltip_parts)) - item.setData(0, QC.Qt.UserRole, get_uuid(obj)) + uuid = get_uuid(obj) + item.setData(0, QC.Qt.UserRole, uuid) + if not isinstance(obj, ObjectGroup): + item.setData(0, TITLE_UUID_ROLE, uuid) - def _handle_short_id_click(self, short_id: str) -> None: - """Handle a click on a short-ID hyperlink. Default implementation + def handle_title_reference_click(self, reference: str) -> None: + """Handle a click on a title-reference hyperlink. Default implementation selects the matching object within the tree's own model.""" - resolved = self._resolve_short_id(short_id) + resolved = self.resolve_title_reference(reference) if resolved is None: return _panel_str, obj = resolved self.set_current_item_id(get_uuid(obj)) - def _short_id_at(self, pos: QC.QPoint) -> str | None: - """Return the short ID under viewport position ``pos``, or ``None`` + def title_reference_at(self, pos: QC.QPoint) -> str | None: + """Return the title reference under viewport position ``pos``, or ``None`` when the cursor is not over an anchor.""" index = self.indexAt(pos) if not index.isValid() or index.column() != 0: @@ -294,25 +329,25 @@ def _short_id_at(self, pos: QC.QPoint) -> str | None: rect = self.visualRect(index) option = self.viewOptions() option.rect = rect - return self._title_delegate.anchor_at(index, rect, pos, option) + return self.title_delegate.anchor_at(index, rect, pos, option) # pylint: disable=invalid-name def mousePressEvent(self, event: QG.QMouseEvent) -> None: """Reimplement Qt method to handle short-ID hyperlink clicks.""" if event.button() == QC.Qt.LeftButton: - short_id = self._short_id_at(event.pos()) - if short_id is not None: + reference = self.title_reference_at(event.pos()) + if reference is not None: event.accept() - self._handle_short_id_click(short_id) + self.handle_title_reference_click(reference) return super().mousePressEvent(event) # pylint: disable=invalid-name def mouseMoveEvent(self, event: QG.QMouseEvent) -> None: """Reimplement Qt method to update the cursor over short-ID anchors.""" - short_id = self._short_id_at(event.pos()) + reference = self.title_reference_at(event.pos()) viewport = self.viewport() - if short_id is not None: + if reference is not None: viewport.setCursor(QC.Qt.PointingHandCursor) else: viewport.unsetCursor() @@ -494,10 +529,10 @@ def __init__(self, parent: BaseDataPanel, objmodel: ObjectModel) -> None: self.__dragged_groups: list[QW.QListWidgetItem] = [] self.__dragged_expanded_states: dict[QW.QListWidgetItem, bool] = {} - def _resolve_short_id( - self, short_id: str + def resolve_title_reference( + self, reference: str ) -> tuple[str, SignalObj | ImageObj | ObjectGroup] | None: - """Resolve a short ID across the signal *and* image panels of the main + """Resolve a title reference across the signal and image panels of the main window, so titles can reference objects living in either panel. """ panel: BaseDataPanel = self.parent() @@ -517,14 +552,17 @@ def _resolve_short_id( own = panel.PANEL_STR_ID candidates.sort(key=lambda c: 0 if c[0] == own else 1) for panel_str, model in candidates: - obj = model.find_by_short_id(short_id) + try: + obj = model.get_object_or_group(reference) + except KeyError: + obj = model.find_by_short_id(reference) if obj is not None: return (panel_str, obj) return None - def _handle_short_id_click(self, short_id: str) -> None: + def handle_title_reference_click(self, reference: str) -> None: """Select the referenced object, switching active panel if needed.""" - resolved = self._resolve_short_id(short_id) + resolved = self.resolve_title_reference(reference) if resolved is None: return panel_str, obj = resolved diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py index 207bfe8fc..c3e46348c 100644 --- a/datalab/gui/panel/base.py +++ b/datalab/gui/panel/base.py @@ -15,6 +15,7 @@ import os.path as osp import re import warnings +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Generator, Generic, Literal, Type import guidata.dataset as gds @@ -91,11 +92,14 @@ ) from datalab.gui.roieditor import TypeROIEditor from datalab.objectmodel import ( + LEGACY_GROUP_SHORT_ID_REGEX, + SHORT_ID_REGEX, ObjectGroup, get_number, get_short_id, get_uuid, patch_title_with_ids, + remap_title_references, set_number, set_uuid, ) @@ -1287,7 +1291,7 @@ def apply_processing_parameters( report.success = True # --- Non-edit mode: create a new independent object --- - patch_title_with_ids(new_obj, [obj], get_short_id) + patch_title_with_ids(new_obj, [obj]) # Store processing metadata on the new object # pylint: disable=import-outside-toplevel @@ -1346,6 +1350,66 @@ def apply_recomputed_object_in_place( insert_processing_parameters(obj, proc_params) +@dataclass +class H5ImportBatch: + """References and objects created by one native HDF5 import.""" + + panel: BaseDataPanel + reference_remap: dict[str, str] + objects: list[TypeObj] + groups: list[ObjectGroup] + + def apply_reference_remap(self, reference_remap: dict[str, str]) -> None: + """Canonicalize title and processing references in this imported batch.""" + for group in self.groups: + group.title = remap_title_references(group.title, reference_remap) + for obj in self.objects: + obj.title = remap_title_references(obj.title, reference_remap) + for extract_parameters in ( + extract_processing_parameters, + extract_analysis_parameters, + ): + parameters = extract_parameters(obj) + if parameters is None: + continue + changed = False + if parameters.source_uuid in reference_remap: + parameters.source_uuid = reference_remap[parameters.source_uuid] + changed = True + if parameters.source_uuids is not None: + source_uuids = [ + reference_remap.get(uuid, uuid) + for uuid in parameters.source_uuids + ] + if source_uuids != parameters.source_uuids: + parameters.source_uuids = source_uuids + changed = True + if changed: + insert_processing_parameters(obj, parameters) + + @classmethod + def finalize_imports( + cls, batches: list[H5ImportBatch], refresh_panels: bool = False + ) -> None: + """Apply the combined signal/image reference map to imported batches.""" + remap_values: dict[str, set[str]] = {} + for batch in batches: + for reference, uuid in batch.reference_remap.items(): + remap_values.setdefault(reference, set()).add(uuid) + shared_remap = { + reference: next(iter(uuids)) + for reference, uuids in remap_values.items() + if len(uuids) == 1 + } + for batch in batches: + reference_remap = shared_remap.copy() + reference_remap.update(batch.reference_remap) + batch.apply_reference_remap(reference_remap) + if refresh_panels: + for batch in batches: + batch.panel.refresh_after_reference_remap() + + class AbstractPanelMeta(type(QW.QSplitter), abc.ABCMeta): """Mixed metaclass to avoid conflicts""" @@ -1378,7 +1442,8 @@ def __init__(self, parent): def get_serializable_name(self, obj: ObjItf) -> str: """Return serializable name of object""" title = re.sub("[^-a-zA-Z0-9_.() ]+", "", obj.title.replace("/", "_")) - name = f"{get_short_id(obj)}: {title}" + identifier = get_short_id(obj) + name = f"{identifier}: {title}" return name def serialize_object_to_hdf5(self, obj: ObjItf, writer: NativeH5Writer) -> None: @@ -1401,14 +1466,9 @@ def deserialize_object_from_hdf5( with reader.group(name): obj = self.create_object() obj.deserialize(reader) - # Only regenerate UUIDs when importing objects (reset_all=False). - # When reopening a workspace (reset_all=True), preserve original UUIDs - # so that processing parameter references (source_uuid, source_uuids) - # remain valid and features like "Show source" and "Recompute" work. - # When importing, only regenerate UUID if it conflicts with an existing one. - if not reset_all and isinstance(obj, (SignalObj, ImageObj, ObjectGroup)): - if self.objmodel.has_uuid(get_uuid(obj)): - set_uuid(obj) + # UUID collision handling is deferred until the complete imported batch + # can record old-to-new references for titles and processing metadata. + del reset_all return obj @abc.abstractmethod @@ -1418,7 +1478,7 @@ def serialize_to_hdf5(self, writer: NativeH5Writer) -> None: @abc.abstractmethod def deserialize_from_hdf5( self, reader: NativeH5Reader, reset_all: bool = False - ) -> None: + ) -> H5ImportBatch | None: """Deserialize whole panel from a HDF5 file Args: @@ -1890,12 +1950,14 @@ def serialize_to_hdf5(self, writer: NativeH5Writer) -> None: with writer.group(self.get_serializable_name(group)): with writer.group("title"): writer.write_str(group.title) + with writer.group("uuid"): + writer.write_str(get_uuid(group)) for obj in group.get_objects(): self.serialize_object_to_hdf5(obj, writer) def deserialize_from_hdf5( self, reader: NativeH5Reader, reset_all: bool = False - ) -> None: + ) -> H5ImportBatch: """Deserialize whole panel from a HDF5 file Args: @@ -1904,18 +1966,53 @@ def deserialize_from_hdf5( If False, regenerate only UUIDs that conflict with existing objects (object import). """ + batch = H5ImportBatch(self, {}, [], []) with reader.group(self.H5_PREFIX): for name in reader.h5.get(self.H5_PREFIX, []): with reader.group(name): - group = self.add_group("") with reader.group("title"): - group.title = reader.read_str() + group_title = reader.read_str() + serialized_group_uuid = reader.read("uuid", default=None) + if not isinstance(serialized_group_uuid, str): + serialized_group_uuid = None + group_uuid = None + if serialized_group_uuid and ( + reset_all or not self.objmodel.has_uuid(serialized_group_uuid) + ): + group_uuid = serialized_group_uuid + group = self.add_group(group_title, group_uuid=group_uuid) + if serialized_group_uuid: + batch.reference_remap[serialized_group_uuid] = get_uuid(group) + group_short_id = name.partition(":")[0] + if SHORT_ID_REGEX.fullmatch( + group_short_id + ) or LEGACY_GROUP_SHORT_ID_REGEX.fullmatch(group_short_id): + batch.reference_remap[group_short_id] = get_uuid(group) + batch.groups.append(group) for obj_name in reader.h5.get(f"{self.H5_PREFIX}/{name}", []): obj = self.deserialize_object_from_hdf5( reader, obj_name, reset_all ) + serialized_obj_uuid = obj.metadata.get("__uuid") + if ( + not isinstance(serialized_obj_uuid, str) + or not serialized_obj_uuid + ): + serialized_obj_uuid = None + if serialized_obj_uuid is None or ( + not reset_all + and self.objmodel.has_uuid(serialized_obj_uuid) + ): + set_uuid(obj) + if serialized_obj_uuid is not None: + batch.reference_remap[serialized_obj_uuid] = get_uuid(obj) + obj_short_id = obj_name.partition(":")[0] + if SHORT_ID_REGEX.fullmatch(obj_short_id): + batch.reference_remap[obj_short_id] = get_uuid(obj) self.add_object(obj, get_uuid(group), set_current=False) + batch.objects.append(obj) self.selection_changed() + return batch def __len__(self) -> int: """Return number of objects""" @@ -2012,7 +2109,7 @@ def set_object(self, obj: TypeObj) -> None: # Copy all public DataSet item values from obj to existing. # Skip computed (read-only) items such as ImageObj.xmin/xmax/ymin/ymax, # which would raise ValueError on assignment (see Issue #305). - for item in existing._items: # pylint: disable=protected-access + for item in existing.get_items(): name = item.get_name() if name.startswith("_"): continue @@ -2123,17 +2220,23 @@ def __popup_contextmenu(self, position: QC.QPoint) -> None: # pragma: no cover menu.popup(position) # ------Creating, adding, removing objects------------------------------------------ - def add_group(self, title: str, select: bool = False) -> objectmodel.ObjectGroup: + def add_group( + self, + title: str, + select: bool = False, + group_uuid: str | None = None, + ) -> objectmodel.ObjectGroup: """Add group Args: title: group title select: if True, select the group in the tree view. Defaults to False. + group_uuid: optional group UUID. If None, a new UUID is generated. Returns: Created group object """ - group = self.objmodel.add_group(title) + group = self.objmodel.add_group(title, group_uuid) self.objview.add_group_item(group) if select: self.objview.select_groups([group]) @@ -2581,6 +2684,7 @@ def rename_selected_object_or_group(self, new_name: str | None = None) -> None: return group.title = new_name self.objview.update_item(get_uuid(group)) + self.SIG_OBJECT_MODIFIED.emit() @abc.abstractmethod def get_newparam_from_current( @@ -3120,6 +3224,11 @@ def export_roi_to_file(self, filename: str | None = None) -> None: write_roi(filename, obj.roi) # ------Refreshing GUI-------------------------------------------------------------- + def refresh_after_reference_remap(self) -> None: + """Refresh cached presentation after imported references are remapped.""" + self.objview.update_tree() + self.selection_changed(update_items=True) + def selection_changed(self, update_items: bool = False) -> None: """Object selection changed: update object properties, refresh plot and update object view. @@ -3454,6 +3563,8 @@ def add_plot_items_to_dialog(self, dlg: PlotDialog, oids: list[str]) -> None: item = create_adapter_from_object(obj).make_item( update_from=existing_item ) + item.param.label = self.mainwindow.render_object_title(obj.title) + item.param.update_item(item) item.set_readonly(True) plot.add_item(item, z=0) plot.set_active_item(item) @@ -3480,7 +3591,11 @@ def open_separate_view( # Create a new dialog and add plot items to it dlg = self.create_new_dialog( - title=obj.title if len(oids) == 1 else None, + title=( + self.mainwindow.render_object_title(obj.title) + if len(oids) == 1 + else None + ), edit=True, name=f"{obj.PREFIX}_new_window", ) @@ -3582,11 +3697,14 @@ def view_images_side_by_side(self, oids: list[str] | None = None) -> None: col = idx % num_cols # Create plot with title - plot = BasePlot(options=BasePlotOptions(title=obj.title, type="image")) + rendered_title = self.mainwindow.render_object_title(obj.title) + plot = BasePlot(options=BasePlotOptions(title=rendered_title, type="image")) # Create plot item from object adapter = create_adapter_from_object(obj) item = adapter.make_item() + item.param.label = rendered_title + item.param.update_item(item) item.set_readonly(True) plot.add_item(item, z=0) @@ -3912,17 +4030,17 @@ def __plot_result( ) return obj = objs[0] - # Build a string with source object short IDs (max 3, then use "...") + # Build a string with source object UUIDs (max 3, then use "...") max_ids_to_show = 3 - short_ids = [get_short_id(obj) for obj in objs] - if len(short_ids) <= max_ids_to_show: - source_ids = ", ".join(short_ids) + source_uuids = [get_uuid(obj) for obj in objs] + if len(source_uuids) <= max_ids_to_show: + source_ids = ", ".join(source_uuids) else: - # Show first 2, "...", then last one: "s001, s002, ..., s010" + # Show first 2, "...", then the last UUID. source_ids = ( - ", ".join(short_ids[: max_ids_to_show - 1]) + ", ".join(source_uuids[: max_ids_to_show - 1]) + ", ..., " - + short_ids[-1] + + source_uuids[-1] ) for i_roi in all_roi_indexes[0]: roi_suffix = f"|ROI{int(i_roi + 1)}" if i_roi >= 0 else "" @@ -3961,8 +4079,8 @@ def __plot_result( else: x = roi_data[param.xaxis].values y = roi_data[param.yaxis].values - shid = get_short_id(objs[index]) - stitle = f"{title} ({shid}){roi_suffix}" + source_uuid = get_uuid(objs[index]) + stitle = f"{title} ({source_uuid}){roi_suffix}" self.__add_result_signal( x, y, stitle, param.xaxis, param.yaxis, result_group_id ) diff --git a/datalab/gui/plothandler.py b/datalab/gui/plothandler.py index c90998a01..8d4d368c5 100644 --- a/datalab/gui/plothandler.py +++ b/datalab/gui/plothandler.py @@ -289,6 +289,8 @@ def __add_item_to_plot(self, oid: str) -> TypePlotItem: obj = self.panel.objmodel[oid] self.__cached_hashes[obj] = calc_data_hash(obj) item: TypePlotItem = create_adapter_from_object(obj).make_item() + item.param.label = self.panel.mainwindow.render_object_title(obj.title) + item.param.update_item(item) item.set_readonly(True) self[oid] = item self.plot.add_item(item) @@ -310,6 +312,9 @@ def __update_item_on_plot(self, oid: str, just_show: bool = False) -> None: self.__cached_hashes[obj] = new_hash adapter = create_adapter_from_object(obj) adapter.update_item(self[oid], data_changed=data_changed) + item = self[oid] + item.param.label = self.panel.mainwindow.render_object_title(obj.title) + item.param.update_item(item) def set_auto_refresh(self, auto_refresh: bool) -> None: """Set auto refresh mode. @@ -447,6 +452,8 @@ def refresh_plot( # Collecting titles information for key in title_keys: title = getattr(obj, key, "") + if key == "title": + title = self.panel.mainwindow.render_object_title(title) value = titles_dict.get(key) if value is None: titles_dict[key] = title diff --git a/datalab/gui/roieditor.py b/datalab/gui/roieditor.py index 10eead708..58cf40aaa 100644 --- a/datalab/gui/roieditor.py +++ b/datalab/gui/roieditor.py @@ -68,6 +68,7 @@ ) from datalab.config import Conf, _ from datalab.env import execenv +from datalab.objectmodel import shorten_uuids_in_title if TYPE_CHECKING: from plotpy.plot import BasePlot @@ -270,8 +271,16 @@ def __init__( self.obj = obj self.mode = mode self.source_panel = source_panel + mainwindow = getattr(source_panel, "mainwindow", None) + rendered_title = ( + mainwindow.render_object_title(obj.title) + if mainwindow is not None + else shorten_uuids_in_title(obj.title) + ) if item is None: item = create_adapter_from_object(obj).make_item() + item.param.label = rendered_title + item.param.update_item(item) self.main_item = item self.__modified: bool | None = None self._tools: list[InteractiveTool] = [] @@ -299,7 +308,7 @@ def __init__( parent=parent, toolbar=mode != "define", options=options, - title=f"{roi_s} - {obj.title}", + title=f"{roi_s} - {rendered_title}", icon="DataLab.svg", edit=True, size=size, diff --git a/datalab/gui/roigrideditor.py b/datalab/gui/roigrideditor.py index 1904309b9..17e1f8b34 100644 --- a/datalab/gui/roigrideditor.py +++ b/datalab/gui/roigrideditor.py @@ -18,6 +18,7 @@ from datalab.adapters_plotpy import create_adapter_from_object from datalab.config import _ +from datalab.objectmodel import shorten_uuids_in_title from datalab.utils.qthelpers import block_signals @@ -75,6 +76,12 @@ def __init__( self.__is_ready = False self.obj = obj = obj.copy() # Avoid modifying the original object obj.roi = None # Clear the ROI to avoid conflicts with the editor + mainwindow = getattr(parent, "mainwindow", None) + self.rendered_title = ( + mainwindow.render_object_title(obj.title) + if mainwindow is not None + else shorten_uuids_in_title(obj.title) + ) gridparam = gridparam or ROIGridParam() displayparam = displayparam or DisplayParam() self.gridparamwidget = gdq.DataSetEditGroupBox( @@ -96,7 +103,7 @@ def __init__( parent=parent, toolbar=False, options=options, - title=f"{roi_s} - {obj.title}", + title=f"{roi_s} - {self.rendered_title}", icon="DataLab.svg", edit=True, size=size, @@ -187,6 +194,8 @@ def update_items(self) -> None: plot = self.get_plot() plot.del_all_items() item = create_adapter_from_object(obj).make_item() + item.param.label = self.rendered_title + item.param.update_item(item) item.set_mask_visible(dp.show_mask) item.set_selectable(False) item.set_readonly(True) diff --git a/datalab/tests/features/common/history_replay_fixes_test.py b/datalab/tests/features/common/history_replay_fixes_test.py index 378a01852..f7f40026b 100644 --- a/datalab/tests/features/common/history_replay_fixes_test.py +++ b/datalab/tests/features/common/history_replay_fixes_test.py @@ -10,6 +10,7 @@ import os import os.path as osp +import tempfile from unittest.mock import patch import numpy as np @@ -73,6 +74,46 @@ def test_replay_cross_panel_output_stays_in_destination_panel() -> None: assert len(spanel.objmodel) == signal_count +def test_nonempty_dlhist_double_import_remaps_cross_panel_outputs() -> None: + """Append each imported session once and remap profile outputs per import.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = osp.join(tmpdir, "profile.dlhist") + with datalab_test_app_context(history=True) as source: + source.historypanel.toggle_record_mode(True) + _action, original_output_uuid = record_line_profile(source) + exported_session_count = len(source.historypanel.history_sessions) + assert source.historypanel.save_to_dlhist_file(path) + + with datalab_test_app_context(history=True) as target: + target.signalpanel.add_object(create_paracetamol_signal()) + target.imagepanel.add_object(create_sincos_image()) + imported_output_sets = [] + for _import_index in range(2): + previous_session_count = len(target.historypanel.history_sessions) + assert target.historypanel.open_dlhist_file(path) + assert len(target.historypanel.history_sessions) == ( + previous_session_count + exported_session_count + ) + imported_sessions = target.historypanel.history_sessions[ + -exported_session_count: + ] + profile_actions = [ + action + for session in imported_sessions + for action in session.actions + if action.func_name == "line_profile" + ] + assert len(profile_actions) == 1 + profile_action = profile_actions[0] + image_source_uuid = profile_action.state.selection["image"][0] + output_uuid = profile_action.output_uuids[0] + assert target.imagepanel.objmodel.has_uuid(image_source_uuid) + assert target.signalpanel.objmodel.has_uuid(output_uuid) + assert output_uuid != original_output_uuid + imported_output_sets.append(set(profile_action.output_uuids)) + assert imported_output_sets[0].isdisjoint(imported_output_sets[1]) + + def test_replay_recreates_deleted_cross_panel_output_in_destination_panel() -> None: """Re-create a deleted cross-panel output in the destination panel.""" with datalab_test_app_context(history=True) as win: diff --git a/datalab/tests/features/common/history_workflow_test.py b/datalab/tests/features/common/history_workflow_test.py index 3106c8eeb..956360711 100644 --- a/datalab/tests/features/common/history_workflow_test.py +++ b/datalab/tests/features/common/history_workflow_test.py @@ -1588,3 +1588,55 @@ def fake_gaussian_dialog(x, y, parent=None): assert restored.method_name == "recompute_fit" assert restored.kwargs["fit_params"] == fit_params assert restored.kwargs["output_uuid"] == fitted_uuid + + +def test_nonempty_dlhist_import_remaps_interactive_fit_references() -> None: + """Replay an imported fit only against its cloned source and output.""" + + def fake_gaussian_dialog(x, y, parent=None): + del parent, y + fit_params = signal_fitting.create_fit_params( + "gaussian", + {"amplitude": 1200.0, "sigma": 20.0, "x0": 100.0, "y0": 5.0}, + interactive=True, + ) + return signal_fitting.evaluate_fit(x, **fit_params), [], fit_params + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "fit-import.dlhist") + with datalab_test_app_context(history=True) as source: + history, panel = source.historypanel, source.signalpanel + history.toggle_record_mode(True) + original_source_uuid = add_paracetamol_signals(panel, 1)[0] + panel.objview.select_objects([original_source_uuid]) + panel.processor.compute_fit("Gaussian fit", fake_gaussian_dialog) + original_action = history[len(history)] + original_output_uuid = original_action.output_uuids[0] + exported_session_count = len(history.history_sessions) + assert history.save_to_dlhist_file(path) + + with datalab_test_app_context(history=True) as target: + target.signalpanel.add_object(create_paracetamol_signal()) + assert target.historypanel.open_dlhist_file(path) + imported_sessions = target.historypanel.history_sessions[ + -exported_session_count: + ] + imported_action = next( + action + for session in imported_sessions + for action in session.actions + if action.method_name == "recompute_fit" + ) + imported_source_uuid = imported_action.kwargs["source_uuid"] + imported_output_uuid = imported_action.kwargs["output_uuid"] + assert imported_source_uuid != original_source_uuid + assert imported_output_uuid != original_output_uuid + assert imported_action.output_uuids == [imported_output_uuid] + assert target.signalpanel.objmodel.has_uuid(imported_source_uuid) + assert target.signalpanel.objmodel.has_uuid(imported_output_uuid) + + output = target.signalpanel.objmodel[imported_output_uuid] + object_count = len(target.signalpanel.objmodel) + imported_action.replay(target, restore_selection=True, edit=False) + assert len(target.signalpanel.objmodel) == object_count + assert target.signalpanel.objmodel[imported_output_uuid] is output From 6786eb6de8672c5ffbd209fb439789c9bf434d7e Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Fri, 11 Sep 2026 12:49:05 +0200 Subject: [PATCH 05/14] fix(processor): preserve UUID mappings across operations Keep result identity and operation modes consistent when processing creates or reconnects derived objects. --- datalab/gui/processor/base.py | 26 +++++++++---------- datalab/gui/processor/signal.py | 6 +++-- .../common/interactive_processing_test.py | 10 ++++--- .../common/operation_modes_app_test.py | 20 +++++++------- .../features/common/plot_results_app_test.py | 11 ++++---- 5 files changed, 37 insertions(+), 36 deletions(-) diff --git a/datalab/gui/processor/base.py b/datalab/gui/processor/base.py index 8856c6c29..b8e5df565 100644 --- a/datalab/gui/processor/base.py +++ b/datalab/gui/processor/base.py @@ -52,7 +52,7 @@ from datalab.config import Conf, _ from datalab.gui.processor.catcher import CompOut, wng_err_func from datalab.history.effects import capture_effects -from datalab.objectmodel import get_short_id, get_uuid, patch_title_with_ids +from datalab.objectmodel import get_uuid, patch_title_with_ids from datalab.utils.qthelpers import create_progress_bar, qt_try_except if TYPE_CHECKING: @@ -1530,7 +1530,7 @@ def recompute_1_to_1( if isinstance(new_obj, (SignalObj, ImageObj)): self._handle_keep_results(new_obj) - patch_title_with_ids(new_obj, [obj], get_short_id) + patch_title_with_ids(new_obj, [obj]) comp_out.result = new_obj return comp_out @@ -1683,7 +1683,7 @@ def _compute_1_to_1_subroutine( continue assert isinstance(new_obj, (SignalObj, ImageObj)) - patch_title_with_ids(new_obj, [obj], get_short_id) + patch_title_with_ids(new_obj, [obj]) # Handle keep_results logic for 1_to_1 operations self._handle_keep_results(new_obj) @@ -1711,7 +1711,7 @@ def _compute_1_to_1_subroutine( # Create a new group for each selected group old_g = self.panel.objmodel.get_group(old_gid) new_gid = self._create_group_for_result( - new_obj, f"{name}({get_short_id(old_g)})" + new_obj, f"{name}({get_uuid(old_g)})" ) new_gids[old_gid] = new_gid self._add_object_to_appropriate_panel( @@ -2137,8 +2137,7 @@ def compute_n_to_1( if not valid: return dst_gname = ( - f"{name}({','.join([get_short_id(grp) for grp in src_grps])})" - "|pairwise" + f"{name}({','.join(get_uuid(grp) for grp in src_grps)})|pairwise" ) group_exclusive = len(self.panel.objview.get_sel_groups()) != 0 if not group_exclusive: @@ -2202,7 +2201,7 @@ def compute_n_to_1( break assert isinstance(new_obj, (SignalObj, ImageObj)) - patch_title_with_ids(new_obj, src_objs_pair, get_short_id) + patch_title_with_ids(new_obj, src_objs_pair) # Handle keep_results and geometry result merging self._handle_keep_results(new_obj) @@ -2294,7 +2293,7 @@ def compute_n_to_1( assert isinstance(new_obj, (SignalObj, ImageObj)) group_id = dst_gid if dst_gid is not None else src_gid - patch_title_with_ids(new_obj, src_obj_list, get_short_id) + patch_title_with_ids(new_obj, src_obj_list) # Handle keep_results and geometry result merging self._handle_keep_results(new_obj) @@ -2339,6 +2338,7 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements | None, obj2_name: str, func: Callable, + *, param: gds.DataSet | None = None, paramclass: type[gds.DataSet] | None = None, title: str | None = None, @@ -2471,8 +2471,8 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements if group_exclusive: # This is a group exclusive selection src_grp = objmodel.get_group(src_gid) - grp_short_ids = [get_uuid(grp) for grp in (src_grp, grp2)] - dst_gname = f"{name}({','.join(grp_short_ids)})|pairwise" + group_uuids = [get_uuid(grp) for grp in (src_grp, grp2)] + dst_gname = f"{name}({','.join(group_uuids)})|pairwise" else: dst_gname = f"{name}[...]" # Delay group creation until after first result @@ -2496,9 +2496,7 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements assert isinstance(new_obj, (SignalObj, ImageObj)) # Use original objects for title generation - patch_title_with_ids( - new_obj, [orig_obj1, orig_obj2], get_short_id - ) + patch_title_with_ids(new_obj, [orig_obj1, orig_obj2]) # Handle keep_results logic for 2_to_1 operations self._handle_keep_results(new_obj) @@ -2629,7 +2627,7 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements group_id = objmodel.get_object_group_id(obj) # Use original objects for title generation - patch_title_with_ids(new_obj, [obj, orig_obj2], get_short_id) + patch_title_with_ids(new_obj, [obj, orig_obj2]) # Handle keep_results logic for 2_to_1 operations self._handle_keep_results(new_obj) diff --git a/datalab/gui/processor/signal.py b/datalab/gui/processor/signal.py index b0d2e4dbb..f6ed06109 100644 --- a/datalab/gui/processor/signal.py +++ b/datalab/gui/processor/signal.py @@ -844,7 +844,9 @@ def __row_compute_fit( pvalues[param.name] = param.value metadata = {fitdlgfunc.__name__: pvalues} # Creating new signal - signal = create_signal(f"{name}({obj.title})", obj.x, y, metadata=metadata) + signal = create_signal( + f"{name}({get_uuid(obj)})", obj.x, y, metadata=metadata + ) # Record a replayable history action when the dialog returned # canonical fit parameters (third-party dialogs returning legacy # 2-tuples cannot be replayed deterministically). @@ -928,7 +930,7 @@ def recompute_fit( if obj is None: return y = signal_fitting.evaluate_fit(obj.x, **fit_params) - title = f"{fit_name}({obj.title})" + title = f"{fit_name}({get_uuid(obj)})" if output_uuid is not None and self.panel.objmodel.has_uuid(output_uuid): # Update the recorded output in place (no duplicate on replay) target = self.panel.objmodel[output_uuid] diff --git a/datalab/tests/features/common/interactive_processing_test.py b/datalab/tests/features/common/interactive_processing_test.py index c4d486945..3ce0566a8 100644 --- a/datalab/tests/features/common/interactive_processing_test.py +++ b/datalab/tests/features/common/interactive_processing_test.py @@ -56,7 +56,7 @@ extract_processing_parameters, ) from datalab.gui.processor.catcher import CompOut -from datalab.objectmodel import get_short_id, get_uuid +from datalab.objectmodel import get_short_uuid, get_uuid from datalab.tests import datalab_test_app_context @@ -386,10 +386,12 @@ def fail_after_first_append(rdata, adapter, obj): assert extract_analysis_parameters(first) is None assert extract_analysis_parameters(second) is not None second_adapter = appended[1][0] - second_short_id = get_short_id(second) + second_short_uuid = get_short_uuid(second) assert result.results == [second_adapter] - assert result.ylabels == [f"{second_adapter.func_name}({second_short_id})"] - assert result.short_ids == [second_short_id] + assert result.ylabels == [ + f"{second_adapter.func_name}({second_short_uuid})" + ] + assert result.short_ids == [second_short_uuid] error_calls = [ call for call in show_error.call_args_list if call.args[1] == "error" ] diff --git a/datalab/tests/features/common/operation_modes_app_test.py b/datalab/tests/features/common/operation_modes_app_test.py index daba9176a..76da784d4 100644 --- a/datalab/tests/features/common/operation_modes_app_test.py +++ b/datalab/tests/features/common/operation_modes_app_test.py @@ -23,7 +23,7 @@ from datalab.config import Conf from datalab.env import execenv from datalab.gui.processor.base import is_pairwise_mode -from datalab.objectmodel import get_short_id +from datalab.objectmodel import get_uuid from datalab.tests import helpers from datalab.utils.qthelpers import datalab_app_context @@ -81,7 +81,7 @@ def test_single_operand_mode_compute_n_to_1() -> None: assert len(new_group) == 2 titles = [] for idx, obj in enumerate(new_group): - pfx_orig = ", ".join(get_short_id(obj) for obj in groups[idx].get_objects()) + pfx_orig = ", ".join(get_uuid(obj) for obj in groups[idx].get_objects()) titles.append((obj.title, f"Σ({pfx_orig})")) check_titles(f"Single operand mode Σ[{new_group.title}]", titles) @@ -108,7 +108,7 @@ def test_single_operand_mode_compute_n_to_1() -> None: assert len(model.get_groups()) == n_groups # no new group titles = [] for idx in range(1): - pfx_orig = ", ".join(get_short_id(obj) for obj in groups[idx][:2]) + pfx_orig = ", ".join(get_uuid(obj) for obj in groups[idx][:2]) titles.append((groups[idx][-1].title, f"Σ({pfx_orig})")) check_titles(f"Single operand mode Σ[{groups[1].title}]", titles) @@ -158,9 +158,7 @@ def test_pairwise_operations_mode_compute_n_to_1() -> None: titles = [] for idx in range(len(groups[0])): obj = new_group[idx] - pfx_orig = ", ".join( - get_short_id(obj) for obj in (grp[idx] for grp in groups) - ) + pfx_orig = ", ".join(get_uuid(obj) for obj in (grp[idx] for grp in groups)) titles.append((obj.title, f"Σ({pfx_orig})")) check_titles(f"Pairwise operations mode Σ[{new_group.title}]", titles) @@ -190,7 +188,7 @@ def test_pairwise_operations_mode_compute_n_to_1() -> None: assert len(new_group) == 2 # 2 signals were selected titles = [] for idx, obj in enumerate(new_group): - pfx_orig = ", ".join(get_short_id(obj) for obj in objs[idx::2]) + pfx_orig = ", ".join(get_uuid(obj) for obj in objs[idx::2]) titles.append((obj.title, f"Σ({pfx_orig})")) check_titles(f"Pairwise operations mode Σ[{new_group.title}]", titles) @@ -242,7 +240,7 @@ def test_single_operand_mode_compute_2_to_1() -> None: titles.append( ( obj.title, - f"{get_short_id(group[i_obj])}-{get_short_id(group3[0])}", + f"{get_uuid(group[i_obj])}-{get_uuid(group3[0])}", ) ) new_objs.append(obj) @@ -280,7 +278,7 @@ def test_single_operand_mode_compute_2_to_1() -> None: titles.append( ( obj.title, - f"{get_short_id(group[i_obj])}-{get_short_id(group3[0])}", + f"{get_uuid(group[i_obj])}-{get_uuid(group3[0])}", ) ) check_titles(f"Single operand mode Δ[{group.title}]", titles) @@ -339,7 +337,7 @@ def test_pairwise_operations_mode_compute_2_to_1() -> None: for idx in range(n_objects): obj = new_grp[idx] obj1, obj2 = groups[i_new_grp][idx], group3[idx] - titles.append((obj.title, f"{get_short_id(obj1)}-{get_short_id(obj2)}")) + titles.append((obj.title, f"{get_uuid(obj1)}-{get_uuid(obj2)}")) check_titles(f"Pairwise operations mode Δ[{new_grp.title}]", titles) # Remove new groups @@ -379,7 +377,7 @@ def test_pairwise_operations_mode_compute_2_to_1() -> None: obj = new_grp[idx] obj1, obj2 = objs[i_obj1], objs2[idx] i_obj1 += 1 - titles.append((obj.title, f"{get_short_id(obj1)}-{get_short_id(obj2)}")) + titles.append((obj.title, f"{get_uuid(obj1)}-{get_uuid(obj2)}")) check_titles(f"Pairwise operations mode Δ[{new_grp.title}]", titles) Conf.operation_mode.set(original_mode) diff --git a/datalab/tests/features/common/plot_results_app_test.py b/datalab/tests/features/common/plot_results_app_test.py index f86f2ad5b..ccc993d45 100644 --- a/datalab/tests/features/common/plot_results_app_test.py +++ b/datalab/tests/features/common/plot_results_app_test.py @@ -183,6 +183,8 @@ def test_plot_results_with_group_selection(): assert len(sel_groups) == 1, ( f"Expected 1 selected group, got {len(sel_groups)}" ) + source_uuids = ", ".join(get_uuid(obj) for obj in sel_groups[0]) + expected_source_uuids = f"({source_uuids})" # Plot results - this should create or reuse a "Results" group panel.plot_results(kind="one_curve_per_title", xaxis="indices", yaxis="Δx") @@ -206,12 +208,11 @@ def test_plot_results_with_group_selection(): "Results group should contain at least one result signal" ) - # Verify that the result signal title includes source object short IDs + # Verify that the result title includes the selected source object UUIDs result_signal = list(result_group)[0] - # Should contain all three source signal IDs: s001, s002, s003 - # (s000 is the default group, so signals start at s001) - assert "(s001, s002, s003)" in result_signal.title, ( - f"Result signal title should include source IDs (s001, s002, s003), " + assert expected_source_uuids in result_signal.title, ( + "Result signal title should include the full UUIDs of the selected " + f"source objects {expected_source_uuids}, " f"got '{result_signal.title}'" ) From 773a252df122e13d327ccb9c9e617174ab383524 Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Fri, 11 Sep 2026 16:07:28 +0200 Subject: [PATCH 06/14] fix(webapi): preserve object identity and metadata Resolve mutations by UUID while retaining titles and processing metadata across JSON and NPZ transfers. --- datalab/tests/webapi_extra_test.py | 191 ++++++++++++++++++++++--- datalab/tests/webapi_test.py | 16 ++- datalab/webapi/adapter.py | 220 ++++++++++++++--------------- datalab/webapi/routes.py | 55 ++++++-- datalab/webapi/schema.py | 3 +- datalab/webapi/serialization.py | 4 + 6 files changed, 330 insertions(+), 159 deletions(-) diff --git a/datalab/tests/webapi_extra_test.py b/datalab/tests/webapi_extra_test.py index f83c98fde..1390964f3 100644 --- a/datalab/tests/webapi_extra_test.py +++ b/datalab/tests/webapi_extra_test.py @@ -19,7 +19,9 @@ import io import json import zipfile +from types import SimpleNamespace from typing import Union +from unittest.mock import Mock import numpy as np import pytest @@ -27,6 +29,9 @@ from fastapi.testclient import TestClient from sigima import ImageObj, SignalObj +from datalab.gui.processor.base import PROCESSING_PARAMETERS_OPTION +from datalab.objectmodel import get_uuid +from datalab.webapi.adapter import WorkspaceAdapter from datalab.webapi.routes import ( router, set_adapter, @@ -68,6 +73,80 @@ def _make_image(title: str = "I", shape: tuple[int, int] = (16, 24)) -> ImageObj return obj +class ObjectListModel(list): + """Minimal object model used to exercise the real workspace adapter.""" + + def remove_object(self, obj: DataObject) -> None: + """Remove an object from the model.""" + self.remove(obj) + + +def make_panel(objects: list[DataObject]) -> SimpleNamespace: + """Build the panel surface required by :class:`WorkspaceAdapter`.""" + return SimpleNamespace( + objmodel=ObjectListModel(objects), + plothandler=SimpleNamespace(remove_item=Mock()), + objview=SimpleNamespace( + remove_item=Mock(), update_tree=Mock(), select_objects=Mock() + ), + SIG_OBJECT_REMOVED=SimpleNamespace(emit=Mock()), + SIG_OBJECT_MODIFIED=SimpleNamespace(emit=Mock()), + SIG_REFRESH_PLOT=SimpleNamespace(emit=Mock()), + ) + + +def test_workspace_adapter_resolves_uuid_and_rejects_ambiguous_titles() -> None: + """Use stable UUIDs for mutations and reject ambiguous title lookups.""" + signal = _make_signal("Duplicate", n=10) + image = _make_image("Duplicate") + signal_panel = make_panel([signal]) + image_panel = make_panel([image]) + adapter = WorkspaceAdapter( + SimpleNamespace(signalpanel=signal_panel, imagepanel=image_panel) + ) + signal_uuid = get_uuid(signal) + image_uuid = get_uuid(image) + processing_parameters = { + "func_name": "derivative", + "pattern": "1-to-1", + "source_uuid": signal_uuid, + } + signal.set_metadata_option(PROCESSING_PARAMETERS_OPTION, processing_parameters) + + assert adapter.get_object_panel(signal_uuid) == "signal" + fetched = adapter.get_object(signal_uuid) + assert fetched.get_metadata_option(PROCESSING_PARAMETERS_OPTION) == ( + processing_parameters + ) + with pytest.raises(ValueError, match="ambiguous"): + adapter.get_object_panel("Duplicate") + with pytest.raises(ValueError, match="ambiguous"): + adapter.select_objects(["Duplicate"]) + + selected, panel_str = adapter.select_objects([image_uuid], "image") + assert selected == [image_uuid] + assert panel_str == "image" + image_panel.objview.select_objects.assert_called_once_with([1]) + + assert adapter.update_metadata(signal_uuid, {"title": "Renamed"}) == signal_uuid + assert adapter.get_object(signal_uuid).title == "Renamed" + replacement = _make_signal("Ignored payload title", n=20) + replacement.set_metadata_option(PROCESSING_PARAMETERS_OPTION, processing_parameters) + assert adapter.set_object(signal_uuid, replacement) == signal_uuid + updated = adapter.get_object(signal_uuid) + assert updated.title == "Renamed" + assert updated.y.shape == (20,) + assert get_uuid(updated) == signal_uuid + assert updated.get_metadata_option(PROCESSING_PARAMETERS_OPTION) == ( + processing_parameters + ) + assert signal_panel.SIG_OBJECT_MODIFIED.emit.call_count == 2 + + adapter.remove_object(image_uuid) + assert image not in image_panel.objmodel + image_panel.SIG_OBJECT_REMOVED.emit.assert_called_once_with() + + # ============================================================================= # Serialization helpers — _to_python_scalar / _make_json_serializable # ============================================================================= @@ -297,22 +376,33 @@ def __init__(self) -> None: self._objects: dict[str, DataObject] = {} # --- queries ------------------------------------------------------------ - def list_objects(self) -> list[tuple[str, str]]: - """Return ``(name, type)`` pairs for every stored object.""" + def list_objects(self) -> list[tuple[str, str, str]]: + """Return ``(uuid, name, type)`` tuples for every stored object.""" return [ - (name, "signal" if isinstance(obj, SignalObj) else "image") + ( + get_uuid(obj), + name, + "signal" if isinstance(obj, SignalObj) else "image", + ) for name, obj in self._objects.items() ] def get_object(self, name: str) -> DataObject: - """Return the stored object for ``name`` or raise ``KeyError``.""" - if name not in self._objects: - raise KeyError(f"Object '{name}' not found") - return self._objects[name] + """Return the stored object for ``name`` or its UUID.""" + if name in self._objects: + return self._objects[name] + for obj in self._objects.values(): + if get_uuid(obj) == name: + return obj + raise KeyError(f"Object '{name}' not found") def object_exists(self, name: str) -> bool: """Return whether an object named ``name`` is stored.""" - return name in self._objects + try: + self.get_object(name) + return True + except KeyError: + return False # --- mutations ---------------------------------------------------------- def add_object(self, obj: DataObject, overwrite: bool = False) -> None: @@ -324,26 +414,33 @@ def add_object(self, obj: DataObject, overwrite: bool = False) -> None: def remove_object(self, name: str) -> None: """Remove the object stored under ``name`` or raise ``KeyError``.""" - if name not in self._objects: - raise KeyError(f"Object '{name}' not found") - del self._objects[name] + obj = self.get_object(name) + stored_name = next(key for key, value in self._objects.items() if value is obj) + del self._objects[stored_name] - def update_metadata(self, name: str, metadata: dict) -> None: + def update_metadata(self, name: str, metadata: dict) -> str: """Update non-``None`` attributes of the object stored under ``name``.""" - if name not in self._objects: - raise KeyError(f"Object '{name}' not found") - obj = self._objects[name] + obj = self.get_object(name) + stored_name = next(key for key, value in self._objects.items() if value is obj) for k, v in metadata.items(): if v is not None and hasattr(obj, k): setattr(obj, k, v) + if obj.title != stored_name: + del self._objects[stored_name] + self._objects[obj.title] = obj + return get_uuid(obj) - def set_object(self, name: str, obj: DataObject) -> None: + def set_object(self, name: str, obj: DataObject) -> str: """Replace the data of the object stored under ``name``.""" - if name not in self._objects: - raise KeyError(f"Object '{name}' not found") - # Replace the underlying data while keeping the entry. - obj.title = name - self._objects[name] = obj + existing = self.get_object(name) + stored_name = next( + key for key, value in self._objects.items() if value is existing + ) + object_uuid = get_uuid(existing) + obj.title = existing.title + obj.set_metadata_option("uuid", object_uuid) + self._objects[stored_name] = obj + return object_uuid @pytest.fixture(name="api_client") @@ -415,6 +512,17 @@ def test_metadata_known_object(self, api_client) -> None: assert body["name"] == "S1" assert body["type"] == "signal" + def test_metadata_by_uuid_returns_title_as_name(self, api_client) -> None: + """``GET`` by UUID keeps title and UUID response fields distinct.""" + client, token, adapter = api_client + obj = _make_signal("S1") + adapter.add_object(obj) + object_uuid = get_uuid(obj) + response = client.get(f"/api/v1/objects/{object_uuid}", headers=_auth(token)) + assert response.status_code == 200 + assert response.json()["name"] == "S1" + assert response.json()["uuid"] == object_uuid + def test_metadata_missing_object(self, api_client) -> None: """``GET /objects/{name}`` returns 404 for an unknown object.""" client, token, _adapter = api_client @@ -439,6 +547,19 @@ def test_patch_updates_known_field(self, api_client) -> None: assert body["xlabel"] == "Time" assert body["ylabel"] == "Volts" + def test_patch_rename_returns_updated_metadata(self, api_client) -> None: + """``PATCH`` reloads a renamed object through its stable UUID.""" + client, token, adapter = api_client + adapter.add_object(_make_signal("Old")) + response = client.patch( + "/api/v1/objects/Old/metadata", + json={"title": "New"}, + headers=_auth(token), + ) + assert response.status_code == 200 + assert response.json()["name"] == "New" + assert adapter.object_exists("New") + def test_patch_missing_object(self, api_client) -> None: """``PATCH`` on an unknown object returns 404.""" client, token, _adapter = api_client @@ -457,6 +578,17 @@ def test_delete_known(self, api_client) -> None: assert response.status_code == 204 assert not adapter.object_exists("S1") + def test_delete_by_uuid(self, api_client) -> None: + """``DELETE`` removes an object addressed by full UUID.""" + client, token, adapter = api_client + obj = _make_signal("S1") + adapter.add_object(obj) + response = client.delete( + f"/api/v1/objects/{get_uuid(obj)}", headers=_auth(token) + ) + assert response.status_code == 204 + assert not adapter.object_exists("S1") + def test_delete_missing(self, api_client) -> None: """``DELETE`` on an unknown object returns 404.""" client, token, _adapter = api_client @@ -579,6 +711,23 @@ def test_set_object_in_place(self, api_client) -> None: # Adapter now has the updated object assert adapter.get_object("Live").y.shape == (20,) + def test_set_object_by_uuid(self, api_client) -> None: + """``PUT /objects/{uuid}`` preserves title and stable identity.""" + client, token, adapter = api_client + original = _make_signal("Live", n=10) + adapter.add_object(original) + object_uuid = get_uuid(original) + payload = serialize_object_to_npz(_make_signal("Payload", n=20)) + response = client.put( + f"/api/v1/objects/{object_uuid}", + content=payload, + headers={**_auth(token), "Content-Type": "application/x-npz"}, + ) + assert response.status_code == 200 + assert response.json()["name"] == "Live" + assert response.json()["uuid"] == object_uuid + assert adapter.get_object(object_uuid).y.shape == (20,) + if __name__ == "__main__": pytest.main([__file__]) diff --git a/datalab/tests/webapi_test.py b/datalab/tests/webapi_test.py index a29fcc003..8efe09bec 100644 --- a/datalab/tests/webapi_test.py +++ b/datalab/tests/webapi_test.py @@ -19,6 +19,7 @@ from fastapi.testclient import TestClient from sigima import ImageObj, SignalObj +from datalab.objectmodel import get_uuid from datalab.webapi.routes import ( generate_auth_token, router, @@ -271,19 +272,22 @@ def add_object(self, name: str, obj: SignalObj | ImageObj) -> None: """Add an object to the mock workspace.""" self._objects[name] = obj - def list_objects(self) -> list[tuple[str, str]]: + def list_objects(self) -> list[tuple[str, str, str]]: """List all objects in the mock workspace.""" result = [] for name, obj in self._objects.items(): panel = "signal" if type(obj).__name__ == "SignalObj" else "image" - result.append((name, panel)) + result.append((get_uuid(obj), name, panel)) return result def get_object(self, name: str) -> SignalObj | ImageObj: - """Get an object by name.""" - if name not in self._objects: - raise KeyError(f"Object '{name}' not found") - return self._objects[name] + """Get an object by name or UUID.""" + if name in self._objects: + return self._objects[name] + for obj in self._objects.values(): + if get_uuid(obj) == name: + return obj + raise KeyError(f"Object '{name}' not found") class TestAPIEndpointsWithMock: diff --git a/datalab/webapi/adapter.py b/datalab/webapi/adapter.py index b1f76d846..ea2446850 100644 --- a/datalab/webapi/adapter.py +++ b/datalab/webapi/adapter.py @@ -34,7 +34,7 @@ from qtpy.QtCore import QCoreApplication, QObject, QThread, Signal, Slot -from datalab.objectmodel import get_uuid +from datalab.objectmodel import UUID_REGEX, get_uuid if TYPE_CHECKING: from sigima.objects import ImageObj, SignalObj @@ -170,13 +170,63 @@ def _ensure_main_window(self) -> None: # Read operations (marshaled to Qt main thread for thread safety) # ========================================================================= - def list_objects(self) -> list[tuple[str, str]]: + def find_object( + self, identifier: str, panel_str: str | None = None + ) -> tuple[str, Any, DataObject]: + """Find one object by UUID or unique title on the Qt main thread. + + Args: + identifier: Full object UUID or title. + panel_str: Optional panel restriction (``"signal"`` or ``"image"``). + + Returns: + Panel name, panel widget, and matching object. + + Raises: + KeyError: If no object matches. + ValueError: If a title matches multiple objects. + """ + panels = ( + ("signal", self._main_window.signalpanel), + ("image", self._main_window.imagepanel), + ) + if panel_str is not None: + panels = tuple(item for item in panels if item[0] == panel_str) + + matches = [] + if UUID_REGEX.fullmatch(identifier): + matches = [ + (name, panel, obj) + for name, panel in panels + if panel is not None + for obj in panel.objmodel + if get_uuid(obj) == identifier + ] + if not matches: + matches = [ + (name, panel, obj) + for name, panel in panels + if panel is not None + for obj in panel.objmodel + if obj.title == identifier + ] + + if not matches: + raise KeyError(f"Object '{identifier}' not found") + if len(matches) > 1: + match_ids = ", ".join(get_uuid(obj) for _name, _panel, obj in matches) + raise ValueError( + f"Object title '{identifier}' is ambiguous; matches: {match_ids}" + ) + return matches[0] + + def list_objects(self) -> list[tuple[str, str, str]]: """List all objects in the workspace. This operation is marshaled to the Qt main thread for thread safety. Returns: - List of (name, panel) tuples for all objects. + List of (uuid, title, panel) tuples for all objects. """ self._ensure_main_window() @@ -186,49 +236,39 @@ def do_list(): sig_panel = self._main_window.signalpanel if sig_panel is not None: for obj in sig_panel.objmodel: - result.append((obj.title, "signal")) + result.append((get_uuid(obj), obj.title, "signal")) # Access image panel img_panel = self._main_window.imagepanel if img_panel is not None: for obj in img_panel.objmodel: - result.append((obj.title, "image")) + result.append((get_uuid(obj), obj.title, "image")) return result return self._executor.run_on_main_thread(do_list) - def get_object(self, name: str) -> DataObject: - """Get an object by name. + def get_object(self, identifier: str) -> DataObject: + """Get an object by full UUID or canonical title. This operation is marshaled to the Qt main thread for thread safety. Args: - name: Object name/title. + identifier: Full object UUID or canonical title. Returns: The requested object. Raises: KeyError: If object not found. + ValueError: If multiple objects have the same title. """ self._ensure_main_window() def do_get(): - # Search in signal panel - sig_panel = self._main_window.signalpanel - if sig_panel is not None: - for obj in sig_panel.objmodel: - if obj.title == name: - return obj.copy() - - # Search in image panel - img_panel = self._main_window.imagepanel - if img_panel is not None: - for obj in img_panel.objmodel: - if obj.title == name: - return obj.copy() - - raise KeyError(f"Object '{name}' not found") + _panel_name, _panel, obj = self.find_object(identifier) + obj_copy = obj.copy(all_metadata=True) + obj_copy.set_metadata_option("uuid", get_uuid(obj)) + return obj_copy return self._executor.run_on_main_thread(do_get) @@ -247,36 +287,26 @@ def object_exists(self, name: str) -> bool: except KeyError: return False - def get_object_panel(self, name: str) -> str | None: + def get_object_panel(self, identifier: str) -> str | None: """Get the panel containing an object. This operation is marshaled to the Qt main thread for thread safety. Args: - name: Object name/title. + identifier: Full object UUID or unique title. Returns: "signal" or "image", or None if not found. """ self._ensure_main_window() - def do_lookup(): - sig_panel = self._main_window.signalpanel - if sig_panel is not None: - for obj in sig_panel.objmodel: - if obj.title == name: - return "signal" - - img_panel = self._main_window.imagepanel - if img_panel is not None: - for obj in img_panel.objmodel: - if obj.title == name: - return "image" - + try: + return self._executor.run_on_main_thread( + lambda: self.find_object(identifier)[0] + ) + except KeyError: return None - return self._executor.run_on_main_thread(do_lookup) - # ========================================================================= # Write operations (must be marshaled to Qt main thread) # ========================================================================= @@ -337,38 +367,14 @@ def remove_object(self, name: str) -> None: KeyError: If object not found. """ self._ensure_main_window() - - if not self.object_exists(name): - raise KeyError(f"Object '{name}' not found") - self._remove_object_sync(name) def _remove_object_sync(self, name: str) -> None: """Remove object (called from main thread or marshaled via executor).""" - panel_name = self.get_object_panel(name) - if panel_name is None: - return - - main_window = self._main_window # Use executor to run on main thread, including all panel access def do_remove(): - # All panel access happens inside the executor - if panel_name == "signal": - panel = main_window.signalpanel - else: - panel = main_window.imagepanel - - # Find the object - target_obj = None - for obj in panel.objmodel: - if obj.title == name: - target_obj = obj - break - - if target_obj is None: - return - + _panel_name, panel, target_obj = self.find_object(name) obj_uuid = get_uuid(target_obj) # Remove using the same approach as remove_all_objects but for single object @@ -385,7 +391,7 @@ def do_remove(): self._executor.run_on_main_thread(do_remove) - def update_metadata(self, name: str, metadata: dict) -> None: + def update_metadata(self, name: str, metadata: dict) -> str: """Update object metadata. This operation modifies Qt objects and should be marshaled to the @@ -400,29 +406,18 @@ def update_metadata(self, name: str, metadata: dict) -> None: """ self._ensure_main_window() - panel_name = self.get_object_panel(name) - if panel_name is None: - raise KeyError(f"Object '{name}' not found") + def do_update(): + _panel_name, panel, obj = self.find_object(name) + for key, value in metadata.items(): + if value is not None and hasattr(obj, key): + setattr(obj, key, value) + panel.SIG_REFRESH_PLOT.emit("selected", True) + panel.SIG_OBJECT_MODIFIED.emit() + return get_uuid(obj) - if panel_name == "signal": - panel = self._main_window.signalpanel - else: - panel = self._main_window.imagepanel + return self._executor.run_on_main_thread(do_update) - def do_update(): - # Find and update object - for obj in panel.objmodel: - if obj.title == name: - for key, value in metadata.items(): - if value is not None and hasattr(obj, key): - setattr(obj, key, value) - # Refresh display - panel.SIG_REFRESH_PLOT.emit("selected", True) - break - - self._executor.run_on_main_thread(do_update) - - def set_object(self, name: str, obj: DataObject) -> None: + def set_object(self, name: str, obj: DataObject) -> str: """Update an existing object in-place with new data. This operation replaces all data attributes of the existing object @@ -440,31 +435,22 @@ def set_object(self, name: str, obj: DataObject) -> None: """ self._ensure_main_window() - panel_name = self.get_object_panel(name) - if panel_name is None: - raise KeyError(f"Object '{name}' not found") - - if panel_name == "signal": - panel = self._main_window.signalpanel - else: - panel = self._main_window.imagepanel - def do_set(): - # Find the existing object by title - for existing in panel.objmodel: - if existing.title == name: - # Copy all public DataSet item values - for item in existing._items: # pylint: disable=protected-access - attr_name = item.get_name() - if not attr_name.startswith("_"): - setattr(existing, attr_name, getattr(obj, attr_name)) - # Refresh display - panel.objview.update_tree() - panel.SIG_REFRESH_PLOT.emit("selected", True) - return - raise KeyError(f"Object '{name}' not found") - - self._executor.run_on_main_thread(do_set) + _panel_name, panel, existing = self.find_object(name) + object_uuid = get_uuid(existing) + obj.title = existing.title + # Copy all public DataSet item values + for item in existing.get_items(): + attr_name = item.get_name() + if not attr_name.startswith("_"): + setattr(existing, attr_name, getattr(obj, attr_name)) + existing.set_metadata_option("uuid", object_uuid) + panel.objview.update_tree() + panel.SIG_REFRESH_PLOT.emit("selected", True) + panel.SIG_OBJECT_MODIFIED.emit() + return object_uuid + + return self._executor.run_on_main_thread(do_set) def clear(self) -> None: """Clear all objects from the workspace. @@ -508,13 +494,13 @@ def select_objects( def do_select(): # Determine panel for each object panels_found = set() + resolved = [] obj_indices = [] for name in names: - obj_panel = self.get_object_panel(name) - if obj_panel is None: - raise KeyError(f"Object '{name}' not found") + obj_panel, _panel_widget, obj = self.find_object(name, panel) panels_found.add(obj_panel) + resolved.append(obj) if len(panels_found) > 1: raise ValueError( @@ -535,10 +521,10 @@ def do_select(): else: panel_widget = self._main_window.imagepanel - # Find object indices (1-based) by name - for name in names: + # Find object indices (1-based) by stable identity + for resolved_obj in resolved: for idx, obj in enumerate(panel_widget.objmodel): - if obj.title == name: + if get_uuid(obj) == get_uuid(resolved_obj): obj_indices.append(idx + 1) # 1-based indexing break diff --git a/datalab/webapi/routes.py b/datalab/webapi/routes.py index dd9e5324b..74f54f27d 100644 --- a/datalab/webapi/routes.py +++ b/datalab/webapi/routes.py @@ -197,10 +197,10 @@ async def list_objects( objects_list = adapter.list_objects() result = [] - for name, _panel in objects_list: + for object_uuid, title, _panel in objects_list: try: - obj = adapter.get_object(name) - meta = object_to_metadata(obj, name) + obj = adapter.get_object(object_uuid) + meta = object_to_metadata(obj, title) result.append(ObjectMetadata(**meta)) except Exception: # pylint: disable=broad-exception-caught # Skip objects that can't be serialized @@ -239,7 +239,7 @@ async def get_object_metadata( """ try: obj = adapter.get_object(name) - meta = object_to_metadata(obj, name) + meta = object_to_metadata(obj, obj.title) return ObjectMetadata(**meta) except KeyError as e: @@ -247,6 +247,11 @@ async def get_object_metadata( status_code=status.HTTP_404_NOT_FOUND, detail=f"Object '{name}' not found", ) from e + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + ) from e except Exception as e: # pylint: disable=broad-exception-caught raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -280,6 +285,11 @@ async def delete_object( status_code=status.HTTP_404_NOT_FOUND, detail=f"Object '{name}' not found", ) from e + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + ) from e except Exception as e: # pylint: disable=broad-exception-caught raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -310,11 +320,11 @@ async def update_object_metadata( try: # Convert patch to dict, excluding None values updates = patch.model_dump(exclude_none=True) - adapter.update_metadata(name, updates) + object_uuid = adapter.update_metadata(name, updates) # Return updated metadata - obj = adapter.get_object(name) - meta = object_to_metadata(obj, name) + obj = adapter.get_object(object_uuid) + meta = object_to_metadata(obj, obj.title) return ObjectMetadata(**meta) except KeyError as e: @@ -322,6 +332,11 @@ async def update_object_metadata( status_code=status.HTTP_404_NOT_FOUND, detail=f"Object '{name}' not found", ) from e + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + ) from e # ============================================================================= @@ -387,6 +402,11 @@ async def get_object_data( status_code=status.HTTP_404_NOT_FOUND, detail=f"Object '{name}' not found", ) from e + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + ) from e except Exception as e: # pylint: disable=broad-exception-caught tb = traceback.format_exc() raise HTTPException( @@ -495,15 +515,22 @@ async def set_object( """ try: body = await request.body() - obj = deserialize_object_from_npz(body) - obj.title = name - adapter.set_object(name, obj) + try: + obj = deserialize_object_from_npz(body) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid NPZ format: {e}", + ) from e + object_uuid = adapter.set_object(name, obj) # Return updated metadata - updated = adapter.get_object(name) - meta = object_to_metadata(updated, name) + updated = adapter.get_object(object_uuid) + meta = object_to_metadata(updated, updated.title) return ObjectMetadata(**meta) + except HTTPException: + raise except KeyError as e: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -511,8 +538,8 @@ async def set_object( ) from e except ValueError as e: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Invalid NPZ format: {e}", + status_code=status.HTTP_409_CONFLICT, + detail=str(e), ) from e except Exception as e: # pylint: disable=broad-exception-caught raise HTTPException( diff --git a/datalab/webapi/schema.py b/datalab/webapi/schema.py index 07a531c8a..123f2595f 100644 --- a/datalab/webapi/schema.py +++ b/datalab/webapi/schema.py @@ -44,7 +44,8 @@ class ObjectMetadata(BaseModel): The actual data is transferred separately via the binary data plane. """ - name: str = Field(..., description="Unique object identifier/title") + name: str = Field(..., description="Object name/title") + uuid: Optional[str] = Field(None, description="Stable full object UUID") type: ObjectType = Field(..., description="Object type (signal or image)") shape: list[int] = Field(..., description="Array shape (e.g., [100] or [512, 512])") dtype: str = Field(..., description="NumPy dtype string (e.g., 'float64')") diff --git a/datalab/webapi/serialization.py b/datalab/webapi/serialization.py index 2fd404e45..80df76f51 100644 --- a/datalab/webapi/serialization.py +++ b/datalab/webapi/serialization.py @@ -42,6 +42,8 @@ import numpy as np from sigima.objects import ImageObj, SignalObj +from datalab.objectmodel import get_uuid + if TYPE_CHECKING: DataObject = Union[SignalObj, ImageObj] @@ -355,6 +357,7 @@ def object_to_metadata(obj: DataObject, name: str) -> dict: if obj_type == "SignalObj": return { "name": name, + "uuid": get_uuid(obj), "type": "signal", "shape": list(obj.y.shape), "dtype": str(obj.y.dtype), @@ -368,6 +371,7 @@ def object_to_metadata(obj: DataObject, name: str) -> dict: if obj_type == "ImageObj": return { "name": name, + "uuid": get_uuid(obj), "type": "image", "shape": list(obj.data.shape), "dtype": str(obj.data.dtype), From 59bf9f73bcac3e7c0644305c861850f559e36fa0 Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Mon, 14 Sep 2026 10:21:46 +0200 Subject: [PATCH 07/14] fix(gui): size wrapped title delegate correctly Keep painted title references and size hints aligned when labels wrap in narrow views. --- .../tests/backbone/titledelegate_unit_test.py | 40 +++++ datalab/widgets/titledelegate.py | 170 ++++++++++++++---- 2 files changed, 176 insertions(+), 34 deletions(-) create mode 100644 datalab/tests/backbone/titledelegate_unit_test.py diff --git a/datalab/tests/backbone/titledelegate_unit_test.py b/datalab/tests/backbone/titledelegate_unit_test.py new file mode 100644 index 000000000..b839a9509 --- /dev/null +++ b/datalab/tests/backbone/titledelegate_unit_test.py @@ -0,0 +1,40 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Unit tests for rich object-title sizing.""" + +from __future__ import annotations + +import pytest +from guidata.qthelpers import qt_app_context +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW + +from datalab.widgets.titledelegate import TITLE_UUID_ROLE, ClickableTitleDelegate + + +@pytest.mark.parametrize("with_item_uuid", [False, True]) +def test_size_hint_height_tracks_painted_wrapping(with_item_uuid: bool) -> None: + """Increase row height when the same rich title is painted more narrowly.""" + with qt_app_context(): + referenced_uuid = "12345678-1234-5678-1234-567812345678" + model = QG.QStandardItemModel() + item = QG.QStandardItem( + f"A deliberately long computed title referencing {referenced_uuid}" + ) + if with_item_uuid: + item.setData("87654321-4321-8765-4321-876543218765", TITLE_UUID_ROLE) + model.appendRow(item) + parent = QW.QWidget() + delegate = ClickableTitleDelegate(parent) + + wide_option = QW.QStyleOptionViewItem() + wide_option.widget = parent + wide_option.rect = QC.QRect(0, 0, 500, 200) + narrow_option = QW.QStyleOptionViewItem(wide_option) + narrow_option.rect = QC.QRect(0, 0, 120, 200) + + wide_height = delegate.sizeHint(wide_option, model.index(0, 0)).height() + narrow_height = delegate.sizeHint(narrow_option, model.index(0, 0)).height() + + assert narrow_height > wide_height diff --git a/datalab/widgets/titledelegate.py b/datalab/widgets/titledelegate.py index b363b4d9f..558f0536d 100644 --- a/datalab/widgets/titledelegate.py +++ b/datalab/widgets/titledelegate.py @@ -15,14 +15,20 @@ from __future__ import annotations +from collections.abc import Callable from html import escape +from math import ceil from typing import TYPE_CHECKING from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW -from datalab.objectmodel import find_short_ids_in_title +from datalab.objectmodel import ( + UUID_DISPLAY_LENGTH, + find_short_ids_in_title, + find_uuids_in_title, +) if TYPE_CHECKING: pass @@ -30,11 +36,16 @@ #: URL scheme used in anchors emitted by :class:`ClickableTitleDelegate`. SHORT_ID_URL_SCHEME = "dlb-shortid" +UUID_URL_SCHEME = "dlb-uuid" +TITLE_UUID_ROLE = QC.Qt.UserRole + 1 +TITLE_META_FONT_SIZE = "90%" +TITLE_ROW_VERTICAL_PADDING = 2 -def _build_html(text: str) -> str: - """Build the HTML representation of ``text`` with short IDs wrapped in - anchors. +def _build_html( + text: str, uuid_title_resolver: Callable[[str], str | None] | None = None +) -> str: + """Build the HTML representation of ``text`` with references as anchors. The first short ID occurrence is always rendered as plain text: object titles in DataLab tree views are formatted as ``": "`` and @@ -47,18 +58,36 @@ def _build_html(text: str) -> str: Returns: HTML string. """ - matches = find_short_ids_in_title(text) + matches: list[tuple[int, int, str, str | None, str]] = [ + (*match, SHORT_ID_URL_SCHEME, match[2]) + for match in find_short_ids_in_title(text) + ] + for match in find_uuids_in_title(text): + uuid = match[2] + display_reference = uuid[:UUID_DISPLAY_LENGTH] + resolved_display = ( + uuid_title_resolver(uuid) + if uuid_title_resolver is not None + else display_reference + ) + if resolved_display is not None: + display_reference = resolved_display + scheme = UUID_URL_SCHEME if resolved_display is not None else None + matches.append((*match, scheme, display_reference)) + matches.sort(key=lambda match: match[0]) if not matches: return escape(text) out: list[str] = [] cursor = 0 - for idx, (start, end, sid) in enumerate(matches): + for start, end, reference, scheme, display_reference in matches: out.append(escape(text[cursor:start])) - if idx == 0 and start == 0: + if scheme is None or (scheme == SHORT_ID_URL_SCHEME and start == 0): # Leading "s001:" — keep as plain text - out.append(escape(sid)) + out.append(escape(display_reference)) else: - out.append(f'<a href="{SHORT_ID_URL_SCHEME}:{sid}">{escape(sid)}</a>') + out.append( + f'<a href="{scheme}:{reference}">{escape(display_reference)}</a>' + ) cursor = end out.append(escape(text[cursor:])) return "".join(out) @@ -69,6 +98,9 @@ def _make_text_document( option: QW.QStyleOptionViewItem, link_color: QG.QColor, text_color: QG.QColor | None = None, + uuid_title_resolver: Callable[[str], str | None] | None = None, + item_uuid: str | None = None, + meta_color: QG.QColor | None = None, ) -> QG.QTextDocument: """Return a :class:`QTextDocument` rendering ``text`` with the styling inherited from ``option``. @@ -84,13 +116,24 @@ def _make_text_document( css_parts = [f"a {{ color: {link_color.name()}; text-decoration: underline; }}"] if text_color is not None: css_parts.append(f"body, p, span {{ color: {text_color.name()}; }}") + if meta_color is not None: + css_parts.append( + ".title-meta { " + f"color: {meta_color.name()}; font-size: {TITLE_META_FONT_SIZE}; " + "}" + ) doc.setDefaultStyleSheet(" ".join(css_parts)) - doc.setHtml(_build_html(text)) + title_html = _build_html(text, uuid_title_resolver) + if item_uuid: + short_uuid = escape(item_uuid[:UUID_DISPLAY_LENGTH]) + own_id_html = f'<span class="title-meta">#{short_uuid}</span>' + title_html = f"{title_html}<br>{own_id_html}" if title_html else own_id_html + doc.setHtml(title_html) return doc class ClickableTitleDelegate(QW.QStyledItemDelegate): - """Item delegate that renders object titles with clickable short IDs. + """Item delegate that renders object titles with clickable references. The delegate uses a :class:`QTextDocument` to render an HTML version of the item's display text in which each embedded short ID — apart from the @@ -100,6 +143,14 @@ class ClickableTitleDelegate(QW.QStyledItemDelegate): from the host view's ``mousePressEvent`` / ``mouseMoveEvent``. """ + def __init__( + self, + parent: QW.QWidget, + uuid_title_resolver: Callable[[str], str | None] | None = None, + ) -> None: + super().__init__(parent) + self.uuid_title_resolver = uuid_title_resolver + # pylint: disable=invalid-name def paint( self, @@ -109,7 +160,13 @@ def paint( ) -> None: """Reimplement Qt method to paint the item via a QTextDocument.""" text = index.data(QC.Qt.DisplayRole) or "" - if not isinstance(text, str) or not find_short_ids_in_title(text): + item_uuid = index.data(TITLE_UUID_ROLE) + has_references = bool( + find_short_ids_in_title(text) or find_uuids_in_title(text) + ) + if not isinstance(text, str) or not ( + has_references or isinstance(item_uuid, str) + ): super().paint(painter, option, index) return opt = QW.QStyleOptionViewItem(option) @@ -130,27 +187,24 @@ def paint( accent = palette.color(QG.QPalette.Active, QG.QPalette.Highlight) if selected: text_color = palette.color(QG.QPalette.Active, QG.QPalette.HighlightedText) - # On dark themes the selection background *is* the accent color, - # so a plain accent-colored link would vanish: blend it 50/50 - # with ``HighlightedText`` (typically white) to obtain a lighter - # tint that still reads as the same hue. On light themes the - # accent stays distinguishable on the highlight background, so - # we keep the unselected color for visual consistency. - base_is_light = ( - palette.color(QG.QPalette.Active, QG.QPalette.Base).lightness() > 128 - ) - if base_is_light: - link_color = accent - else: - link_color = QG.QColor( - (accent.red() + text_color.red()) // 2, - (accent.green() + text_color.green()) // 2, - (accent.blue() + text_color.blue()) // 2, - ) + # The selection background uses ``Highlight`` itself, so links must + # use the theme's contrasting text role. Underlining preserves the + # link affordance even when link and selected text share a color. + link_color = text_color + meta_color = text_color else: text_color = palette.color(QG.QPalette.Active, QG.QPalette.Text) link_color = accent - doc = _make_text_document(text, option, link_color, text_color) + meta_color = palette.color(QG.QPalette.Active, QG.QPalette.Mid) + doc = _make_text_document( + text, + option, + link_color, + text_color, + self.uuid_title_resolver, + item_uuid if isinstance(item_uuid, str) else None, + meta_color, + ) doc.setTextWidth(text_rect.width()) painter.save() painter.translate(text_rect.topLeft()) @@ -161,6 +215,42 @@ def paint( doc.documentLayout().draw(painter, ctx) painter.restore() + def sizeHint( + self, option: QW.QStyleOptionViewItem, index: QC.QModelIndex + ) -> QC.QSize: + """Return a size accommodating the title and own UUID metadata line.""" + text = index.data(QC.Qt.DisplayRole) or "" + item_uuid = index.data(TITLE_UUID_ROLE) + has_references = isinstance(text, str) and bool( + find_short_ids_in_title(text) or find_uuids_in_title(text) + ) + if not isinstance(text, str) or not ( + has_references or isinstance(item_uuid, str) + ): + return super().sizeHint(option, index) + opt = QW.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + doc = _make_text_document( + text, + opt, + QG.QColor("black"), + uuid_title_resolver=self.uuid_title_resolver, + item_uuid=item_uuid if isinstance(item_uuid, str) else None, + meta_color=QG.QColor("gray"), + ) + base_size = super().sizeHint(opt, index) + raw_text_width = opt.fontMetrics.horizontalAdvance(text) + horizontal_chrome = max(0, base_size.width() - raw_text_width) + width = max(base_size.width(), ceil(doc.idealWidth()) + horizontal_chrome) + style = opt.widget.style() if opt.widget else QW.QApplication.style() + text_rect = style.subElementRect(QW.QStyle.SE_ItemViewItemText, opt, opt.widget) + if text_rect.width() > 0: + doc.setTextWidth(text_rect.width()) + height = max( + base_size.height(), ceil(doc.size().height()) + TITLE_ROW_VERTICAL_PADDING + ) + return QC.QSize(width, height) + def anchor_at( self, index: QC.QModelIndex, @@ -179,7 +269,11 @@ def anchor_at( option: style option (already initialized for the item) """ text = index.data(QC.Qt.DisplayRole) or "" - if not isinstance(text, str) or not find_short_ids_in_title(text): + item_uuid = index.data(TITLE_UUID_ROLE) + has_references = bool( + find_short_ids_in_title(text) or find_uuids_in_title(text) + ) + if not isinstance(text, str) or not has_references: return None opt = QW.QStyleOptionViewItem(option) opt.rect = item_rect @@ -189,10 +283,18 @@ def anchor_at( if not text_rect.contains(pos): return None # Color does not influence hit-testing — pass any value. - doc = _make_text_document(text, option, QG.QColor("black")) + doc = _make_text_document( + text, + option, + QG.QColor("black"), + uuid_title_resolver=self.uuid_title_resolver, + item_uuid=item_uuid if isinstance(item_uuid, str) else None, + meta_color=QG.QColor("gray"), + ) doc.setTextWidth(text_rect.width()) local = QC.QPointF(pos - text_rect.topLeft()) href = doc.documentLayout().anchorAt(local) - if href and href.startswith(f"{SHORT_ID_URL_SCHEME}:"): - return href[len(SHORT_ID_URL_SCHEME) + 1 :] + for scheme in (SHORT_ID_URL_SCHEME, UUID_URL_SCHEME): + if href and href.startswith(f"{scheme}:"): + return href[len(scheme) + 1 :] return None From a2d99135cb3456507a89e7282052daf4a007d455 Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM <d.pham@codra.fr> Date: Mon, 14 Sep 2026 15:53:09 +0200 Subject: [PATCH 08/14] docs(settings): document history reference options Document the history reference settings, translations and the user-visible release note. --- datalab/config/options.py | 8 + datalab/gui/settings.py | 12 + datalab/locale/fr/LC_MESSAGES/datalab.po | 286 ++---------------- doc/features/common/settings.rst | 14 + .../LC_MESSAGES/features/common/settings.po | 15 + doc/release_notes/release_1.03.md | 10 +- 6 files changed, 82 insertions(+), 263 deletions(-) diff --git a/datalab/config/options.py b/datalab/config/options.py index 9c4591a5e..562511c54 100644 --- a/datalab/config/options.py +++ b/datalab/config/options.py @@ -175,6 +175,14 @@ def __init__(self) -> None: default=(configtools.MONOSPACE, 8, False), description="Monospace font used by history and analysis tabs.", ) + self.result_title_mode = EnumOptionField( + self, + "result_title_mode", + category="proc", + default="uuid", + choices=["uuid", "title"], + description="Reference display mode in generated object titles.", + ) self.history_auto_record = TypedOptionField( self, "history_auto_record", diff --git a/datalab/gui/settings.py b/datalab/gui/settings.py index e084d0ec3..c96a1e79b 100644 --- a/datalab/gui/settings.py +++ b/datalab/gui/settings.py @@ -144,6 +144,18 @@ class ProcSettings(gds.DataSet): ) % ("→", "→"), ) + result_title_mode = gds.ChoiceItem( + _("References in result titles"), + zip( + Conf.result_title_mode.choices, + [_("Source UUID"), _("Source title")], + ), + help=_( + "Display source references in generated titles as short UUIDs or " + "as the current source titles. This setting does not change the " + "stored UUID references." + ), + ) use_signal_bounds = gds.BoolItem( _("Use current signal bounds"), _("New signals"), diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index b063f0c20..2c2e2f8dc 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -4,7 +4,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -1519,12 +1518,12 @@ msgstr "Supprimer les métadonnées" msgid "Some selected objects have regions of interest.<br>Do you want to delete them as well?" msgstr "Certains objets sélectionnés ont des régions d'intérêt.<br>Souhaitez-vous les supprimer également ?" -msgid "Group name:" -msgstr "Nom du groupe :" - msgid "New group" msgstr "Nouveau groupe" +msgid "Group name:" +msgstr "Nom du groupe :" + #, python-format msgid "New group \"%s\"" msgstr "Nouveau groupe \"%s\"" @@ -3077,6 +3076,18 @@ msgstr "Mode d'opération" msgid "Operation mode for computations taking <i>N</i> inputs:<ul><li><b>single</b>: single operand mode</li><li><b>pairwise</b>: pairwise operation mode</li></ul><br>Computations taking <i>N</i> inputs are the ones where:<ul><li>N(>=2) objects in %s 1 object out</li><li>N(>=1) objects + 1 object in %s N objects out</li></ul>" msgstr "Mode d'opération pour les calculs prenant <i>N</i> entrées:<ul><li><b>single</b>: mode d'opération avec un seul opérande</li><li><b>pairwise</b>: mode d'opération par paire</li></ul><br>Les calculs prenant <i>N</i> entrées sont ceux où :<ul><li>N(>=2) objets en %s 1 objet en sortie</li><li>N(>=1) objets + 1 objet en %s N objets en sortie</li></ul>" +msgid "References in result titles" +msgstr "Références dans les titres de résultats" + +msgid "Source UUID" +msgstr "UUID source" + +msgid "Source title" +msgstr "Titre de la source" + +msgid "Display source references in generated titles as short UUIDs or as the current source titles. This setting does not change the stored UUID references." +msgstr "Afficher les références aux sources dans les titres générés sous forme d'UUID courts ou avec les titres actuels des sources. Ce réglage ne modifie pas les références UUID enregistrées." + msgid "Use current signal bounds" msgstr "Utiliser les bornes du signal courant" @@ -3761,18 +3772,12 @@ msgstr "" "\n" "Cela permettra aux applications externes de se connecter à DataLab et de le contrôler à distance via HTTP/JSON." -msgid "Connection to DataLab" -msgstr "Connexion à DataLab" - msgid "Show more" msgstr "Afficher plus" msgid "Show less" msgstr "Afficher moins" -msgid "Contents of file" -msgstr "Contenu du fichier" - msgid "Find" msgstr "Rechercher" @@ -3809,159 +3814,21 @@ msgstr "Rechercher :" msgid "Replace:" msgstr "Remplacer :" -msgid "Polymomial fit" -msgstr "Ajustement polynomial" - -msgid "Amplitude" -msgstr "Amplitude" - -msgid "Base line" -msgstr "Ligne de base" - -msgid "Std-dev" -msgstr "Écart-type" - -msgid "Mean" -msgstr "Moyenne" - -msgid "Y0" -msgstr "Y0" - -msgid "A coefficient" -msgstr "Coefficient A" - -msgid "B coefficient" -msgstr "Coefficient B" - -msgid "y0 constant" -msgstr "Constante y0" - -msgid "Frequency" -msgstr "Fréquence" - -msgid "Continuous component" -msgstr "Composante continue" - -msgid "Scale factor" -msgstr "Facteur d'échelle" - -msgid "Width factor" -msgstr "Facteur de largeur" - -msgid "Left amplitude" -msgstr "Amplitude gauche" - -msgid "Right amplitude" -msgstr "Amplitude droite" - -msgid "Left width" -msgstr "Largeur gauche" - -msgid "Right width" -msgstr "Largeur droite" - -msgid "Center" -msgstr "Centre" - -msgid "Left baseline" -msgstr "Ligne de base gauche" - -msgid "Right baseline" -msgstr "Ligne de base droite" - -msgid "Center position" -msgstr "Position du centre" - -msgid "Left rate" -msgstr "Taux gauche" - -msgid "Right rate" -msgstr "Taux droit" - -msgid "Collapse all" -msgstr "Replier tout" - -msgid "Expand all" -msgstr "Déplier tout" - -msgid "Restore" -msgstr "Restaurer" - -msgid "Restore original tree layout" -msgstr "Restaurer l'organisation initiale de l'arbre" - -msgid "Collapse selection" -msgstr "Replier la sélection" - -msgid "Expand selection" -msgstr "Déplier la sélection" - -msgid "HDF5 Browser" -msgstr "Explorateur HDF5" - -msgid "Size" -msgstr "Taille" - -msgid "Type" -msgstr "Type" - -msgid "Value" -msgstr "Valeur" - -msgid "Name" -msgstr "Nom" - -msgid "Unsupported data" -msgstr "Données non prises en charge" - -msgid "Group" -msgstr "Groupe" - -msgid "Attributes" -msgstr "Attributs" - -msgid "Show array" -msgstr "Afficher le tableau" - -msgid "Path" -msgstr "Chemin" - -msgid "Description" -msgstr "Description" - -msgid "Textual preview" -msgstr "Aperçu textuel" - -msgid "Select HDF5 file" -msgstr "Sélectionner un fichier HDF5" - -msgid "HDF5 files (*.h5 *.hdf5 *.hdf *.he5);;All files (*)" -msgstr "Fichiers HDF5 (*.h5 *.hdf5 *.hdf *.he5);;Tous les fichiers (*)" - -msgid "Check all" -msgstr "Cocher tout" - -msgid "Uncheck all" -msgstr "Décocher tout" - -msgid "Show only supported data" -msgstr "Afficher uniquement les données prises en charge" - -msgid "Show values" -msgstr "Afficher les valeurs" - msgid "Show details" msgstr "Afficher les détails" msgid "Hide details" msgstr "Masquer les détails" -msgid "Title" -msgstr "Titre" - msgid "Date and time" msgstr "Date et heure" +msgid "Description" +msgstr "Description" + +msgid "Title" +msgstr "Titre" + msgid "Action is compatible with the current workspace state." msgstr "L'action est compatible avec l'état actuel de l'espace de travail." @@ -3974,15 +3841,6 @@ msgstr "Cette étape est obsolète : rejouez-la pour actualiser son résultat." msgid "Active recording session." msgstr "Session d'enregistrement active." -msgid "Image background selection" -msgstr "Sélection de l'arrière-plan de l'image" - -msgid "Background area" -msgstr "Zone d'arrière-plan" - -msgid "Background value:" -msgstr "Valeur de l'arrière-plan :" - msgid "Installation configuration" msgstr "Configuration d'installation" @@ -3992,18 +3850,6 @@ msgstr "Configuration utilisateur" msgid "Plugins and I/O features" msgstr "Plugins et fonctionnalités d'entrée/sortie" -msgid "Log files" -msgstr "Journaux de bord" - -msgid "Log files were generated during current session." -msgstr "Des journaux de bord ont été générés lors de la session en cours." - -msgid "Log files were generated during last session." -msgstr "Des journaux de bord ont été générés lors de la dernière session." - -msgid "Log files are currently empty." -msgstr "Les journaux de bord sont vides." - msgid "Kernel / Mask preview" msgstr "Aperçu du noyau / masque" @@ -4022,33 +3868,6 @@ msgstr "±{n} lignes × ±{n} colonnes" msgid "This image uses an integer data type, so it cannot contain NaN or infinite values. Replace special values is therefore not applicable." msgstr "Cette image utilise un type de données entier, elle ne peut donc pas contenir de valeurs NaN ou infinies. Le remplacement des valeurs spéciales n'est donc pas applicable." -msgid "Signal baseline selection" -msgstr "Sélection de la ligne de base du signal" - -msgid "Select X value with cursor" -msgstr "Sélectionner la valeur X avec le curseur" - -msgid "Select Y value with cursor" -msgstr "Sélectionner la valeur Y avec le curseur" - -msgid "Apply" -msgstr "Appliquer" - -msgid "Apply cursor position" -msgstr "Appliquer la position du curseur" - -msgid "Cursor position" -msgstr "Position du curseur" - -msgid "Minimum distance:" -msgstr "Distance minimale :" - -msgid "Signal peak detection" -msgstr "Détection de pics 1D" - -msgid "Peaks:" -msgstr "Pics :" - msgid "Plugins:" msgstr "Plugins :" @@ -4073,12 +3892,12 @@ msgstr "Cliquer pour voir les infos de connexion" msgid "Source" msgstr "Source" -msgid "File" -msgstr "Fichier" - msgid "Clipboard" msgstr "Presse-papiers" +msgid "File" +msgstr "Fichier" + msgid "Source of the data" msgstr "Source des données" @@ -4261,63 +4080,6 @@ msgstr "Importer depuis :" msgid "DataLab Import Wizard" msgstr "Assistant d'importation de DataLab" -msgid "Error message" -msgstr "Message d'erreur" - -msgid "The following traceback may help to understand the problem:" -msgstr "Le traceback suivant peut aider à comprendre le problème :" - -msgid "Warning message" -msgstr "Message d'avertissement" - -msgid "Please take into account the following warning message:" -msgstr "Merci de prendre en compte le message d'avertissement suivant :" - -msgid "An error has occured during the following context:" -msgstr "Une erreur est survenue dans le contexte suivant:" - -msgid "Context" -msgstr "Contexte" - -msgid "Tip" -msgstr "Astuce" - -msgid "Please click on the 'Ignore' button to ignore this warning next time." -msgstr "Merci de cliquer sur le bouton 'Ignorer' pour ignorer cet avertissement la prochaine fois." - -msgid "Back" -msgstr "Précédent" - -msgid "Finish" -msgstr "Terminer" - -msgid "Welcome to the Example Wizard" -msgstr "Bienvenue dans l'assistant exemple" - -msgid "This wizard will guide you through the process of importing data." -msgstr "Cet assistant vous guidera à travers le processus d'importation de données." - -msgid "Select the Source of the Data" -msgstr "Sélectionner la source des données" - -msgid "Select the source of the data to be imported (clipboard or file)." -msgstr "Sélectionner la source des données à importer (presse-papiers ou fichier)." - -msgid "Browse..." -msgstr "Parcourir..." - -msgid "Select the File to Import" -msgstr "Sélectionner le fichier à importer" - -msgid "CSV Files (*.csv);;Text Files (*.txt);;All Files (*)" -msgstr "Fichiers CSV (*.csv);;Fichiers texte (*.txt);;Tous les fichiers (*)" - -msgid "Please select the file to import." -msgstr "Merci de sélectionner le fichier à importer." - -msgid "Example Wizard" -msgstr "Assistant exemple" - msgid "Signal" msgstr "Signal" diff --git a/doc/features/common/settings.rst b/doc/features/common/settings.rst index 7fb801271..89cecf33c 100644 --- a/doc/features/common/settings.rst +++ b/doc/features/common/settings.rst @@ -109,6 +109,20 @@ The Processing settings tab controls computation behavior and default parameters - New group 1: A-E, B-F - New group 2: C-E, D-F +**References in result titles** + Choose how source objects are represented in titles generated for computation + results: + + - **Source UUID** (default): use stable 8-character references derived from each + source object's UUID. + - **Source title**: use the source objects' live current titles, so references + follow later renames. + + This setting changes only the displayed reference. Each object's canonical + identity remains its full UUID. Object items in the signal and image trees always + show their own ``#UUID8`` reference, while group headers keep the ``gsNNN`` and + ``giNNN`` identifiers. + **Use signal bounds for new signals** When enabled, the xmin and xmax values for new signals are initialized from the current signal's bounds. When disabled, default values are used. diff --git a/doc/locale/fr/LC_MESSAGES/features/common/settings.po b/doc/locale/fr/LC_MESSAGES/features/common/settings.po index c923ed504..e1e57442b 100644 --- a/doc/locale/fr/LC_MESSAGES/features/common/settings.po +++ b/doc/locale/fr/LC_MESSAGES/features/common/settings.po @@ -136,6 +136,21 @@ msgstr "Nouveau groupe 1 : A-E, B-F" msgid "New group 2: C-E, D-F" msgstr "Nouveau groupe 2 : C-E, D-F" +msgid "**References in result titles**" +msgstr "**Références dans les titres de résultats**" + +msgid "Choose how source objects are represented in titles generated for computation results:" +msgstr "Choisir comment les objets sources sont représentés dans les titres générés pour les résultats des calculs :" + +msgid "**Source UUID** (default): use stable 8-character references derived from each source object's UUID." +msgstr "**UUID source** (par défaut) : utiliser des références stables de 8 caractères dérivées de l'UUID de chaque objet source." + +msgid "**Source title**: use the source objects' live current titles, so references follow later renames." +msgstr "**Titre de la source** : utiliser les titres actuels des objets sources, afin que les références suivent les renommages ultérieurs." + +msgid "This setting changes only the displayed reference. Each object's canonical identity remains its full UUID. Object items in the signal and image trees always show their own ``#UUID8`` reference, while group headers keep the ``gsNNN`` and ``giNNN`` identifiers." +msgstr "Ce réglage modifie uniquement la référence affichée. L'identité canonique de chaque objet reste son UUID complet. Les objets dans les arborescences des signaux et des images affichent toujours leur propre référence ``#UUID8``, tandis que les en-têtes de groupes conservent les identifiants ``gsNNN`` et ``giNNN``." + msgid "**Use signal bounds for new signals**" msgstr "**Utiliser les bornes du signal pour les nouveaux signaux**" diff --git a/doc/release_notes/release_1.03.md b/doc/release_notes/release_1.03.md index a34b7176d..db9e4c00c 100644 --- a/doc/release_notes/release_1.03.md +++ b/doc/release_notes/release_1.03.md @@ -94,7 +94,15 @@ easier to install, inspect and troubleshoot (implements **Object titles:** -* Short identifiers embedded in object titles (e.g. `s001`, `i012`) are now rendered as **clickable links** in the signal and image trees, so the source objects of a computation can be selected in a single click +DataLab now uses UUID-backed object references consistently across object trees, +result titles, plot legends, and HDF5 imports (implements [Issue #367](https://github.com/DataLab-Platform/DataLab/issues/367) and consolidates [Issue #149](https://github.com/DataLab-Platform/DataLab/issues/149), [Issue #323](https://github.com/DataLab-Platform/DataLab/issues/323), [Issue #357](https://github.com/DataLab-Platform/DataLab/issues/357), and [Issue #358](https://github.com/DataLab-Platform/DataLab/issues/358)). + +* Object items in the signal and image trees now show their current title followed by their own stable `#UUID8` reference; group headers keep the familiar `gsNNN` and `giNNN` identifiers +* Source references embedded in computed result titles are stable and clickable, so selecting the originating object no longer depends on its position or title +* The new **References in result titles** setting lets users choose between **Source UUID** (the default, using 8-character references) and **Source title** (using live current source titles), without changing the canonical full UUID identity +* Renaming an object now immediately updates its entry in the object tree and its plot legend, while existing source relationships remain intact +* Selected object entries now have stronger foreground and background contrast, keeping titles and references readable in both signal and image trees +* Loading legacy HDF5 files and appending projects now remap object identities and references safely; appending the same file twice also creates distinct identities, avoiding collisions and stale links **Replace special values processing (signal and image):** From 5630dd48ef1bd2d75fd61496066e24477bd5454d Mon Sep 17 00:00:00 2001 From: Thomas MALLET <t.mallet@codra.fr> Date: Wed, 7 Oct 2026 14:39:13 +0200 Subject: [PATCH 09/14] release note : put object reference dev note in the correct release note version --- .../LC_MESSAGES/release_notes/release_1.04.po | 29 +++++++++++++++++++ doc/release_notes/release_1.03.md | 10 +------ doc/release_notes/release_1.04.md | 15 ++++++++++ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po index ced4103a3..aa283c804 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po @@ -63,6 +63,24 @@ msgstr "**Édition > Métadonnées > Ajouter une métadonnée...** peut désorma msgid "Metadata keys may now contain dots and hyphens, as used by plugin keys, and a **Known keys** list copies a key already present on the selected objects" msgstr "Les clés de métadonnées peuvent désormais contenir des points et des tirets, comme les clés des plugins, et une liste **Clés connues** copie une clé déjà présente sur les objets sélectionnés" +msgid "**Object references in titles:**" +msgstr "**Références aux objets dans les titres :**" + +msgid "DataLab now uses stable UUID-based object references across object trees, result titles, plot legends and HDF5 workspaces (implements [Issue #367](https://github.com/DataLab-Platform/DataLab/issues/367) and [Issue #149](https://github.com/DataLab-Platform/DataLab/issues/149))." +msgstr "DataLab utilise désormais des références aux objets stables, fondées sur les UUID, dans les arborescences d'objets, les titres de résultats, les légendes des graphiques et les espaces de travail HDF5 (implémente [Issue #367](https://github.com/DataLab-Platform/DataLab/issues/367) et [Issue #149](https://github.com/DataLab-Platform/DataLab/issues/149))." + +msgid "Object items in the signal and image trees now show their current title followed by their own stable `#UUID8` reference; group headers keep the familiar `gsNNN` and `giNNN` identifiers" +msgstr "Les objets des arborescences signal et image affichent désormais leur titre courant suivi de leur propre référence stable `#UUID8` ; les en-têtes de groupe conservent les identifiants habituels `gsNNN` et `giNNN`" + +msgid "Source references embedded in result titles no longer change when objects are reordered, removed or renumbered, and remain clickable to select the source object" +msgstr "Les références aux sources intégrées dans les titres de résultats ne changent plus lorsque des objets sont réordonnés, supprimés ou renumérotés, et restent cliquables pour sélectionner l'objet source" + +msgid "The new **References in result titles** setting lets you display these references either as 8-character UUIDs (default) or as the current source titles, which follow later renames" +msgstr "Le nouveau paramètre **Références dans les titres de résultats** permet d'afficher ces références soit sous forme d'UUID à 8 caractères (par défaut), soit avec les titres actuels des sources, qui suivent les renommages ultérieurs" + +msgid "When a source object is deleted, its reference falls back to its 8-character UUID instead of becoming an anonymous placeholder" +msgstr "Lorsqu'un objet source est supprimé, sa référence est affichée sous la forme de son UUID à 8 caractères au lieu de devenir un espace réservé anonyme" + msgid "🔄 Changes" msgstr "🔄 Changements" @@ -108,3 +126,14 @@ msgstr "**Plugins et historique :**" msgid "Replaying a History action or re-processing a result created by a plugin feature that shares its name with a built-in feature now runs the plugin feature instead of the built-in one" msgstr "Le rejeu d'une action de l'historique ou le recalcul d'un résultat créé par une fonctionnalité de plugin portant le même nom qu'une fonctionnalité intégrée exécute désormais la fonctionnalité du plugin au lieu de la fonctionnalité intégrée" +msgid "**Object titles:**" +msgstr "**Titres des objets :**" + +msgid "Fixed plot legends still showing the original generated title after renaming an object, e.g. a profile extracted from an image (fixes [Issue #323](https://github.com/DataLab-Platform/DataLab/issues/323))" +msgstr "Correction des légendes des graphiques qui affichaient toujours le titre généré d'origine après le renommage d'un objet, par exemple un profil extrait d'une image (corrige [Issue #323](https://github.com/DataLab-Platform/DataLab/issues/323))" + +msgid "Fixed source references in titles being unreadable when the object is selected in the signal or image tree, notably on Linux (fixes [Issue #358](https://github.com/DataLab-Platform/DataLab/issues/358))" +msgstr "Correction des références aux sources illisibles dans les titres lorsque l'objet est sélectionné dans l'arborescence signal ou image, notamment sous Linux (corrige [Issue #358](https://github.com/DataLab-Platform/DataLab/issues/358))" + +msgid "Fixed result titles pointing to the wrong source objects after appending an HDF5 workspace to a non-empty session; appending the same workspace twice now also keeps each imported processing chain independent, so **Recompute** and **Select source objects** use the sources of the same import (fixes [Issue #357](https://github.com/DataLab-Platform/DataLab/issues/357))" +msgstr "Correction des titres de résultats qui désignaient de mauvais objets sources après l'ajout d'un espace de travail HDF5 à une session non vide ; ajouter deux fois le même espace de travail conserve désormais aussi des chaînes de traitement importées indépendantes, de sorte que **Retraiter** et **Sélectionner les objets source** utilisent les sources issues du même import (corrige [Issue #357](https://github.com/DataLab-Platform/DataLab/issues/357))" diff --git a/doc/release_notes/release_1.03.md b/doc/release_notes/release_1.03.md index db9e4c00c..a34b7176d 100644 --- a/doc/release_notes/release_1.03.md +++ b/doc/release_notes/release_1.03.md @@ -94,15 +94,7 @@ easier to install, inspect and troubleshoot (implements **Object titles:** -DataLab now uses UUID-backed object references consistently across object trees, -result titles, plot legends, and HDF5 imports (implements [Issue #367](https://github.com/DataLab-Platform/DataLab/issues/367) and consolidates [Issue #149](https://github.com/DataLab-Platform/DataLab/issues/149), [Issue #323](https://github.com/DataLab-Platform/DataLab/issues/323), [Issue #357](https://github.com/DataLab-Platform/DataLab/issues/357), and [Issue #358](https://github.com/DataLab-Platform/DataLab/issues/358)). - -* Object items in the signal and image trees now show their current title followed by their own stable `#UUID8` reference; group headers keep the familiar `gsNNN` and `giNNN` identifiers -* Source references embedded in computed result titles are stable and clickable, so selecting the originating object no longer depends on its position or title -* The new **References in result titles** setting lets users choose between **Source UUID** (the default, using 8-character references) and **Source title** (using live current source titles), without changing the canonical full UUID identity -* Renaming an object now immediately updates its entry in the object tree and its plot legend, while existing source relationships remain intact -* Selected object entries now have stronger foreground and background contrast, keeping titles and references readable in both signal and image trees -* Loading legacy HDF5 files and appending projects now remap object identities and references safely; appending the same file twice also creates distinct identities, avoiding collisions and stale links +* Short identifiers embedded in object titles (e.g. `s001`, `i012`) are now rendered as **clickable links** in the signal and image trees, so the source objects of a computation can be selected in a single click **Replace special values processing (signal and image):** diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index 2d41d8ae1..c7fd0fc2f 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -26,6 +26,15 @@ * **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, and a scale factor converts numeric values (e.g. milliseconds to seconds) * Metadata keys may now contain dots and hyphens, as used by plugin keys, and a **Known keys** list copies a key already present on the selected objects +**Object references in titles:** + +DataLab now uses stable UUID-based object references across object trees, result titles, plot legends and HDF5 workspaces (implements [Issue #367](https://github.com/DataLab-Platform/DataLab/issues/367) and [Issue #149](https://github.com/DataLab-Platform/DataLab/issues/149)). + +* Object items in the signal and image trees now show their current title followed by their own stable `#UUID8` reference; group headers keep the familiar `gsNNN` and `giNNN` identifiers +* Source references embedded in result titles no longer change when objects are reordered, removed or renumbered, and remain clickable to select the source object +* The new **References in result titles** setting lets you display these references either as 8-character UUIDs (default) or as the current source titles, which follow later renames +* When a source object is deleted, its reference falls back to its 8-character UUID instead of becoming an anonymous placeholder + ### 🔄 Changes ### **Guided tour:** @@ -52,3 +61,9 @@ **Plugins and History:** * Replaying a History action or re-processing a result created by a plugin feature that shares its name with a built-in feature now runs the plugin feature instead of the built-in one + +**Object titles:** + +* Fixed plot legends still showing the original generated title after renaming an object, e.g. a profile extracted from an image (fixes [Issue #323](https://github.com/DataLab-Platform/DataLab/issues/323)) +* Fixed source references in titles being unreadable when the object is selected in the signal or image tree, notably on Linux (fixes [Issue #358](https://github.com/DataLab-Platform/DataLab/issues/358)) +* Fixed result titles pointing to the wrong source objects after appending an HDF5 workspace to a non-empty session; appending the same workspace twice now also keeps each imported processing chain independent, so **Recompute** and **Select source objects** use the sources of the same import (fixes [Issue #357](https://github.com/DataLab-Platform/DataLab/issues/357)) From b4c59f3d5b852756925d536c04d80ae96ab2a687 Mon Sep 17 00:00:00 2001 From: Thomas MALLET <t.mallet@codra.fr> Date: Wed, 7 Oct 2026 14:46:04 +0200 Subject: [PATCH 10/14] fix(gui): replace remaining object short IDs with DataLab-Web labels Use UUID8 prefixes for the live preview source list and metadata clipboard result renaming, and titles (group-prefixed for Save to directory) in dialog previews. Assisted by : Claude Opus 5 --- datalab/gui/panel/base.py | 47 ++++++++++--------- .../features/common/save_to_dir_app_test.py | 4 +- .../features/common/save_to_dir_unit_test.py | 18 +++---- datalab/widgets/processingpreview.py | 11 ++++- 4 files changed, 46 insertions(+), 34 deletions(-) diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py index b936af7c8..9aa0dea04 100644 --- a/datalab/gui/panel/base.py +++ b/datalab/gui/panel/base.py @@ -98,10 +98,12 @@ ObjectGroup, get_number, get_short_id, + get_short_uuid, get_uuid, remap_title_references, set_number, set_uuid, + shorten_uuids_in_title, ) from datalab.utils.qthelpers import ( CallbackWorker, @@ -1525,11 +1527,17 @@ class SaveToDirectoryGUIParam(gds.DataSet, title=_("Save to directory")): """Save to directory parameters""" def __init__( - self, objs: list[TypeObj] | None = None, extensions: list[str] | None = None + self, + objs: list[TypeObj] | None = None, + extensions: list[str] | None = None, + labels: list[str] | None = None, ) -> None: super().__init__() self.__objs = objs or [] self.__extensions = extensions or [] + self.__labels = labels or [ + shorten_uuids_in_title(obj.title) for obj in self.__objs + ] def on_button_click( self: SaveToDirectoryGUIParam, @@ -1617,15 +1625,10 @@ def update_preview(self, _item=None, _value=None) -> None: """Update preview.""" try: filenames = self.build_filenames() - preview_lines = [] - for i, (obj, filename) in enumerate(zip(self.__objs, filenames), start=1): - # Try to get short ID if object has been added to panel - try: - obj_id = get_short_id(obj) - except (ValueError, KeyError): - # Fallback to simple index for objects not yet in panel - obj_id = str(i) - preview_lines.append(f"{obj_id}: {filename}") + preview_lines = [ + f"{label}: {filename}" + for label, filename in zip(self.__labels, filenames) + ] self.preview = "\n".join(preview_lines) except (ValueError, KeyError, TypeError) as exc: # Handle formatting errors gracefully (e.g., incomplete format string) @@ -1860,17 +1863,12 @@ def update_preview(self, _item=None, _value=None) -> None: try: values = self.build_values() preview_lines = [] - for i, (obj, value) in enumerate(zip(self.__objs, values), start=1): - # Try to get short ID if object has been added to panel - try: - obj_id = get_short_id(obj) - except (ValueError, KeyError): - # Fallback to simple index for objects not yet in panel - obj_id = str(i) + for obj, value in zip(self.__objs, values): + label = shorten_uuids_in_title(obj.title) if value is None: - preview_lines.append(f"{obj_id}: " + _("unchanged")) + preview_lines.append(f"{label}: " + _("unchanged")) else: - preview_lines.append(f"{obj_id}: {self.metadata_key} = {value!r}") + preview_lines.append(f"{label}: {self.metadata_key} = {value!r}") self.preview = "\n".join(preview_lines) except ValueError as exc: # Handle conversion errors @@ -2381,7 +2379,7 @@ def copy_metadata(self) -> None: self.metadata_clipboard = obj.metadata.copy() # Rename geometry results to avoid conflicts when pasting to same object type - new_pref = get_short_id(obj) + "_" + new_pref = get_short_uuid(obj) + "_" self._rename_results_in_clipboard(new_pref) # Update action states (e.g., "Paste metadata" should now be enabled) @@ -3115,7 +3113,14 @@ def save_to_directory(self, param: SaveToDirectoryParam | None = None) -> None: extensions = get_file_extensions(self.IO_REGISTRY.get_write_filters()) with warnings.catch_warnings(): warnings.simplefilter("ignore", category=gds.DataItemValidationWarning) - guiparam = SaveToDirectoryGUIParam(objs, extensions) + labels = [] + for obj in objs: + group = self.objmodel.get_group_from_object(obj) + labels.append( + f"[{shorten_uuids_in_title(group.title)}] " + f"{shorten_uuids_in_title(obj.title)}" + ) + guiparam = SaveToDirectoryGUIParam(objs, extensions, labels) # Restore settings from config saved_param = Conf.save_to_directory_settings.get( SaveToDirectoryParam() diff --git a/datalab/tests/features/common/save_to_dir_app_test.py b/datalab/tests/features/common/save_to_dir_app_test.py index ce4a7ccbd..e84468757 100644 --- a/datalab/tests/features/common/save_to_dir_app_test.py +++ b/datalab/tests/features/common/save_to_dir_app_test.py @@ -65,7 +65,7 @@ def __save_signals_to_directory(win: DLMainWindow, screenshot: bool = False) -> param.overwrite = False param.update_preview() execenv.print(f" Preview: {param.preview}") - assert "s001: 001_Sine Wave.csv" in param.preview + assert "Sine Wave: 001_Sine Wave.csv" in param.preview execenv.print(" ✓ Save to directory parameter configured correctly") # Edit the parameter to show the dialog @@ -117,7 +117,7 @@ def __save_images_to_directory(win: DLMainWindow, screenshot: bool = False) -> N param.overwrite = False param.update_preview() execenv.print(f" Preview: {param.preview}") - assert "i001: Random Noise_0001.png" in param.preview + assert "Random Noise: Random Noise_0001.png" in param.preview execenv.print(" ✓ Save to directory parameter configured correctly") # Edit the parameter to show the dialog diff --git a/datalab/tests/features/common/save_to_dir_unit_test.py b/datalab/tests/features/common/save_to_dir_unit_test.py index c65deeea2..d23b2df72 100644 --- a/datalab/tests/features/common/save_to_dir_unit_test.py +++ b/datalab/tests/features/common/save_to_dir_unit_test.py @@ -68,9 +68,9 @@ def test_preview_generation(self) -> None: # Check preview content expected_filenames = [ - "1: Sine Wave.csv", - "2: Cosine Wave.csv", - "3: Exponential Decay.csv", + "Sine Wave: Sine Wave.csv", + "Cosine Wave: Cosine Wave.csv", + "Exponential Decay: Exponential Decay.csv", ] preview_lines = p.preview.split("\n") @@ -91,9 +91,9 @@ def test_preview_generation(self) -> None: preview_lines = p.preview.split("\n") expected_filenames = [ - "1: 001_Sine Wave.csv", - "2: 002_Cosine Wave.csv", - "3: 003_Exponential Decay.csv", + "Sine Wave: 001_Sine Wave.csv", + "Cosine Wave: 002_Cosine Wave.csv", + "Exponential Decay: 003_Exponential Decay.csv", ] for i, expected in enumerate(expected_filenames): @@ -360,9 +360,9 @@ def test_with_images(self) -> None: preview_lines = p.preview.split("\n") expected_preview = [ - "1: 01_Random Noise.png", - "2: 02_Gaussian Pattern.png", - "3: 03_Checkerboard.png", + "Random Noise: 01_Random Noise.png", + "Gaussian Pattern: 02_Gaussian Pattern.png", + "Checkerboard: 03_Checkerboard.png", ] for i, expected in enumerate(expected_preview): diff --git a/datalab/widgets/processingpreview.py b/datalab/widgets/processingpreview.py index c56024fa6..3539eacb7 100644 --- a/datalab/widgets/processingpreview.py +++ b/datalab/widgets/processingpreview.py @@ -21,7 +21,12 @@ from datalab.config import _ from datalab.gui.processor.catcher import CompOut from datalab.gui.processor.preview import PreviewController -from datalab.objectmodel import get_short_id, patch_title_with_ids, render_title +from datalab.objectmodel import ( + get_short_uuid, + patch_title_with_ids, + render_title, + shorten_uuids_in_title, +) __all__ = [ "ProcessingPreviewDialog", @@ -113,7 +118,9 @@ def __init__( self.source_combo.setMinimumContentsLength(16) self.source_combo.setToolTip(_("Preview source")) for source in sources: - self.source_combo.addItem(f"{get_short_id(source)}: {source.title}") + self.source_combo.addItem( + f"{get_short_uuid(source)}: {shorten_uuids_in_title(source.title)}" + ) self.source_combo.setVisible(len(sources) > 1) self.source_combo.setEnabled(False) controls.addWidget(self.source_combo, 1) From 488db063a9dad0ae1c4dd2c360d7847a4a8b5890 Mon Sep 17 00:00:00 2001 From: Thomas MALLET <t.mallet@codra.fr> Date: Wed, 7 Oct 2026 16:52:57 +0200 Subject: [PATCH 11/14] refactor(titles): store UUID8 references in titles, drop short IDs Generated titles now embed 8-character UUID references (g-prefixed for groups) instead of full UUIDs, so raw titles are readable everywhere. Tree items show their own #UUID8 identity; short-ID links and legacy sNNN/gNNN remapping are removed; HDF5 imports remap colliding UUID8 references. Assisted by : Opus 5 --- datalab/adapters_plotpy/objects/adapters.py | 41 ------ datalab/gui/main.py | 34 +++-- datalab/gui/objectview.py | 86 +++--------- datalab/gui/panel/base.py | 81 +++++------ datalab/gui/processor/base.py | 18 +-- datalab/gui/processor/signal.py | 6 +- datalab/gui/roieditor.py | 3 +- datalab/gui/roigrideditor.py | 3 +- datalab/h5/history.py | 14 +- datalab/objectmodel.py | 126 +++++------------- .../tests/backbone/titledelegate_unit_test.py | 6 +- .../common/operation_modes_app_test.py | 34 +++-- .../features/common/plot_results_app_test.py | 6 +- .../common/title_short_id_unit_test.py | 117 +++++++++------- .../features/hdf5/h5workspace_unit_test.py | 51 +++---- datalab/widgets/processingpreview.py | 12 +- datalab/widgets/titledelegate.py | 117 ++++++---------- doc/features/common/settings.rst | 8 +- .../LC_MESSAGES/features/common/settings.po | 5 +- .../LC_MESSAGES/release_notes/release_1.04.po | 7 +- doc/release_notes/release_1.04.md | 3 +- 21 files changed, 322 insertions(+), 456 deletions(-) diff --git a/datalab/adapters_plotpy/objects/adapters.py b/datalab/adapters_plotpy/objects/adapters.py index 14307c69e..893260032 100644 --- a/datalab/adapters_plotpy/objects/adapters.py +++ b/datalab/adapters_plotpy/objects/adapters.py @@ -14,7 +14,6 @@ from collections.abc import Iterator from typing import Any -from plotpy.items import CurveItem, MaskedXYImageItem from sigimax.adapters_plotpy.objects.base import BaseObjPlotPyAdapter, TypePlotItem from sigimax.adapters_plotpy.objects.image import ( ImageObjPlotPyAdapter as SGMXImageObjPlotPyAdapter, @@ -30,8 +29,6 @@ SignalObjPlotPyAdapter as SGMXSignalObjPlotPyAdapter, ) -from datalab.objectmodel import shorten_uuids_in_title - __all__ = [ "CURVESTYLES", "BaseObjPlotPyAdapter", @@ -86,24 +83,6 @@ class SignalObjPlotPyAdapter(SGMXSignalObjPlotPyAdapter): Extends the SigimaX signal adapter with DataLab geometry-result rendering. """ - def make_item(self, update_from: CurveItem | None = None) -> CurveItem: - """Make a curve item with a display-safe title.""" - item = super().make_item(update_from) - item.param.label = shorten_uuids_in_title(self.obj.title) - item.param.update_item(item) - return item - - def update_item(self, item: CurveItem, data_changed: bool = True) -> None: - """Update a curve item and its displayed title.""" - super().update_item(item, data_changed) - item.param.label = shorten_uuids_in_title(self.obj.title) - item.param.update_item(item) - - def add_label_with_title(self, title: str | None = None) -> None: - """Add a title annotation with shortened UUID references.""" - title = self.obj.title if title is None else title - super().add_label_with_title(shorten_uuids_in_title(title)) - def iterate_metadata_shape_items( self, key: str, value: Any, fmt: str, lbl: bool ) -> Iterator: @@ -117,26 +96,6 @@ class ImageObjPlotPyAdapter(SGMXImageObjPlotPyAdapter): Extends the SigimaX image adapter with DataLab geometry-result rendering. """ - def make_item( - self, update_from: MaskedXYImageItem | None = None - ) -> MaskedXYImageItem: - """Make an image item with a display-safe title.""" - item = super().make_item(update_from) - item.param.label = shorten_uuids_in_title(self.obj.title) - item.param.update_item(item) - return item - - def update_item(self, item: MaskedXYImageItem, data_changed: bool = True) -> None: - """Update an image item and its displayed title.""" - super().update_item(item, data_changed) - item.param.label = shorten_uuids_in_title(self.obj.title) - item.param.update_item(item) - - def add_label_with_title(self, title: str | None = None) -> None: - """Add a title annotation with shortened UUID references.""" - title = self.obj.title if title is None else title - super().add_label_with_title(shorten_uuids_in_title(title)) - def iterate_metadata_shape_items( self, key: str, value: Any, fmt: str, lbl: bool ) -> Iterator: diff --git a/datalab/gui/main.py b/datalab/gui/main.py index fd367232c..30bc5f5b8 100644 --- a/datalab/gui/main.py +++ b/datalab/gui/main.py @@ -336,17 +336,35 @@ def find_object_by_uuid( continue return None + def resolve_title_reference( + self, reference: str + ) -> tuple[str, SignalObj | ImageObj | ObjectGroup] | None: + """Resolve a title reference to ``(panel_str, object or group)``. + + Args: + reference: ``<UUID8>`` (object) or ``g<UUID8>`` (group) reference + + Returns: + The unique match across signal and image panels, or None if the + reference matches nothing or is ambiguous. + """ + matches = [ + (panel.PANEL_STR_ID, obj) + for panel in (self.signalpanel, self.imagepanel) + for obj in panel.objmodel.find_by_title_reference(reference) + ] + return matches[0] if len(matches) == 1 else None + def render_object_title(self, title: str) -> str: - """Render a canonical object title according to user settings.""" + """Render an object title according to user settings.""" + if Conf.result_title_mode.get() != "title": + return title - def resolve_uuid_title(uuid: str) -> str | None: - obj = self.find_object_by_uuid(uuid) - return None if obj is None else obj.title + def resolve_source_title(reference: str) -> str | None: + resolved = self.resolve_title_reference(reference) + return None if resolved is None else resolved[1].title - resolver = ( - resolve_uuid_title if Conf.result_title_mode.get() == "title" else None - ) - return render_title(title, resolver) + return render_title(title, resolve_source_title) def refresh_object_title_displays(self) -> None: """Refresh object titles in trees and existing plot items.""" diff --git a/datalab/gui/objectview.py b/datalab/gui/objectview.py index c3854c1de..1159178c7 100644 --- a/datalab/gui/objectview.py +++ b/datalab/gui/objectview.py @@ -48,15 +48,15 @@ from datalab.config import Conf, _ from datalab.objectmodel import ( ObjectGroup, - find_short_ids_in_title, - find_uuids_in_title, - get_short_id, - get_short_uuid, + find_title_references, + get_title_reference, get_uuid, - shorten_uuids_in_title, ) from datalab.utils.qthelpers import block_signals -from datalab.widgets.titledelegate import TITLE_UUID_ROLE, ClickableTitleDelegate +from datalab.widgets.titledelegate import ( + TITLE_REFERENCE_ROLE, + ClickableTitleDelegate, +) if TYPE_CHECKING: from typing import Any @@ -107,7 +107,7 @@ def __init__(self, parent: QW.QWidget, objmodel: ObjectModel) -> None: self.header().setSectionResizeMode(QW.QHeaderView.Interactive) self.itemChanged.connect(lambda item: self.resizeColumnToContents(0)) self.title_delegate = ClickableTitleDelegate( - self, self.get_uuid_reference_display + self, self.get_title_reference_display ) self.setItemDelegateForColumn(0, self.title_delegate) self.viewport().setMouseTracking(True) @@ -224,40 +224,26 @@ def resolve_title_reference( an empty ``panel_str``. Subclasses with access to several panels should override this method. """ - try: - obj = self.objmodel.get_object_or_group(reference) - except KeyError: - obj = self.objmodel.find_by_short_id(reference) - if obj is None: - return None - return ("", obj) + matches = self.objmodel.find_by_title_reference(reference) + return ("", matches[0]) if len(matches) == 1 else None - def get_uuid_reference_display(self, uuid: str) -> str | None: - """Return the configured display text for an UUID reference.""" - resolved = self.resolve_title_reference(uuid) + def get_title_reference_display(self, reference: str) -> str | None: + """Return the configured display text for a resolvable title reference.""" + resolved = self.resolve_title_reference(reference) if resolved is None: return None _panel_str, obj = resolved if Conf.result_title_mode.get() == "title": - return shorten_uuids_in_title(obj.title) - return get_short_uuid(uuid) + return obj.title + return reference def build_title_reference_tooltip(self, text: str) -> str: """Return an HTML tooltip fragment listing the source objects referenced in ``text``, or an empty string when no such reference is found.""" - short_id_matches = find_short_ids_in_title(text) - matches = short_id_matches.copy() - matches.extend(find_uuids_in_title(text)) - matches.sort(key=lambda match: match[0]) rows: list[str] = [] seen: set[str] = set() - for start, _end, reference in matches: - if start == 0 and any( - reference == short_id for _start, _end, short_id in short_id_matches - ): - # Skip the leading "<short_id>:" prefix - continue + for _start, _end, reference in find_title_references(text): if reference in seen: continue seen.add(reference) @@ -273,11 +259,8 @@ def build_title_reference_tooltip(self, text: str) -> str: else _("image") ) suffix = f" · {panel_str}" if panel_str else "" - display_reference = self.get_uuid_reference_display( - reference - ) or shorten_uuids_in_title(reference) rows.append( - f"<b>{display_reference}</b> → {obj.title} <i>({kind}{suffix})</i>" + f"<b>{reference}</b> → {obj.title} <i>({kind}{suffix})</i>" ) if not rows: return "" @@ -291,11 +274,7 @@ def __update_item( self, item: QW.QTreeWidgetItem, obj: SignalObj | ImageObj | ObjectGroup ) -> None: """Update item""" - if isinstance(obj, ObjectGroup): - text = f"{get_short_id(obj)}: {obj.title}" - item.setData(0, TITLE_UUID_ROLE, None) - else: - text = obj.title + text = obj.title item.setText(0, text) tooltip_parts: list[str] = [] reference_tooltip = self.build_title_reference_tooltip(text) @@ -306,10 +285,8 @@ def __update_item( if meta_tooltip: tooltip_parts.append(meta_tooltip) item.setToolTip(0, "".join(tooltip_parts)) - uuid = get_uuid(obj) - item.setData(0, QC.Qt.UserRole, uuid) - if not isinstance(obj, ObjectGroup): - item.setData(0, TITLE_UUID_ROLE, uuid) + item.setData(0, QC.Qt.UserRole, get_uuid(obj)) + item.setData(0, TITLE_REFERENCE_ROLE, get_title_reference(obj)) def handle_title_reference_click(self, reference: str) -> None: """Handle a click on a title-reference hyperlink. Default implementation @@ -537,28 +514,9 @@ def resolve_title_reference( """ panel: BaseDataPanel = self.parent() mainwindow = getattr(panel, "mainwindow", None) - candidates: list[tuple[str, ObjectModel]] = [] - if mainwindow is not None: - sigpanel = getattr(mainwindow, "signalpanel", None) - if sigpanel is not None: - candidates.append(("signal", sigpanel.objmodel)) - imgpanel = getattr(mainwindow, "imagepanel", None) - if imgpanel is not None: - candidates.append(("image", imgpanel.objmodel)) - else: - candidates.append((panel.PANEL_STR_ID, self.objmodel)) - # Prefer the panel that owns this view (so a self-reference resolves - # locally), then fall back to the other panel. - own = panel.PANEL_STR_ID - candidates.sort(key=lambda c: 0 if c[0] == own else 1) - for panel_str, model in candidates: - try: - obj = model.get_object_or_group(reference) - except KeyError: - obj = model.find_by_short_id(reference) - if obj is not None: - return (panel_str, obj) - return None + if mainwindow is None: + return super().resolve_title_reference(reference) + return mainwindow.resolve_title_reference(reference) def handle_title_reference_click(self, reference: str) -> None: """Select the referenced object, switching active panel if needed.""" diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py index 9aa0dea04..ae56c0065 100644 --- a/datalab/gui/panel/base.py +++ b/datalab/gui/panel/base.py @@ -93,17 +93,15 @@ ) from datalab.gui.roieditor import TypeROIEditor from datalab.objectmodel import ( - LEGACY_GROUP_SHORT_ID_REGEX, - SHORT_ID_REGEX, ObjectGroup, get_number, get_short_id, get_short_uuid, + get_title_reference, get_uuid, remap_title_references, set_number, set_uuid, - shorten_uuids_in_title, ) from datalab.utils.qthelpers import ( CallbackWorker, @@ -1327,19 +1325,26 @@ def apply_recomputed_object_in_place( @dataclass class H5ImportBatch: - """References and objects created by one native HDF5 import.""" + """Objects and UUID remapping created by one native HDF5 import.""" panel: BaseDataPanel reference_remap: dict[str, str] objects: list[TypeObj] groups: list[ObjectGroup] - def apply_reference_remap(self, reference_remap: dict[str, str]) -> None: - """Canonicalize title and processing references in this imported batch.""" + def apply_reference_remap( + self, reference_remap: dict[str, str], title_remap: dict[str, str] + ) -> None: + """Remap title and processing references in this imported batch. + + Args: + reference_remap: serialized UUID to imported UUID mapping + title_remap: serialized title reference to imported title reference + """ for group in self.groups: - group.title = remap_title_references(group.title, reference_remap) + group.title = remap_title_references(group.title, title_remap) for obj in self.objects: - obj.title = remap_title_references(obj.title, reference_remap) + obj.title = remap_title_references(obj.title, title_remap) for extract_parameters in ( extract_processing_parameters, extract_analysis_parameters, @@ -1366,20 +1371,19 @@ def apply_reference_remap(self, reference_remap: dict[str, str]) -> None: def finalize_imports( cls, batches: list[H5ImportBatch], refresh_panels: bool = False ) -> None: - """Apply the combined signal/image reference map to imported batches.""" - remap_values: dict[str, set[str]] = {} + """Apply the combined signal/image UUID remapping to imported batches.""" + reference_remap: dict[str, str] = {} for batch in batches: - for reference, uuid in batch.reference_remap.items(): - remap_values.setdefault(reference, set()).add(uuid) - shared_remap = { - reference: next(iter(uuids)) - for reference, uuids in remap_values.items() - if len(uuids) == 1 - } - for batch in batches: - reference_remap = shared_remap.copy() reference_remap.update(batch.reference_remap) - batch.apply_reference_remap(reference_remap) + group_uuids = {get_uuid(group) for batch in batches for group in batch.groups} + title_remap: dict[str, str] = {} + for old_uuid, new_uuid in reference_remap.items(): + if old_uuid != new_uuid: + prefix = "g" if new_uuid in group_uuids else "" + old_reference = prefix + get_short_uuid(old_uuid) + title_remap[old_reference] = prefix + get_short_uuid(new_uuid) + for batch in batches: + batch.apply_reference_remap(reference_remap, title_remap) if refresh_panels: for batch in batches: batch.panel.refresh_after_reference_remap() @@ -1535,9 +1539,7 @@ def __init__( super().__init__() self.__objs = objs or [] self.__extensions = extensions or [] - self.__labels = labels or [ - shorten_uuids_in_title(obj.title) for obj in self.__objs - ] + self.__labels = labels or [obj.title for obj in self.__objs] def on_button_click( self: SaveToDirectoryGUIParam, @@ -1864,7 +1866,7 @@ def update_preview(self, _item=None, _value=None) -> None: values = self.build_values() preview_lines = [] for obj, value in zip(self.__objs, values): - label = shorten_uuids_in_title(obj.title) + label = obj.title if value is None: preview_lines.append(f"{label}: " + _("unchanged")) else: @@ -2076,11 +2078,6 @@ def deserialize_from_hdf5( group = self.add_group(group_title, group_uuid=group_uuid) if serialized_group_uuid: batch.reference_remap[serialized_group_uuid] = get_uuid(group) - group_short_id = name.partition(":")[0] - if SHORT_ID_REGEX.fullmatch( - group_short_id - ) or LEGACY_GROUP_SHORT_ID_REGEX.fullmatch(group_short_id): - batch.reference_remap[group_short_id] = get_uuid(group) batch.groups.append(group) for obj_name in reader.h5.get(f"{self.H5_PREFIX}/{name}", []): obj = self.deserialize_object_from_hdf5( @@ -2099,9 +2096,6 @@ def deserialize_from_hdf5( set_uuid(obj) if serialized_obj_uuid is not None: batch.reference_remap[serialized_obj_uuid] = get_uuid(obj) - obj_short_id = obj_name.partition(":")[0] - if SHORT_ID_REGEX.fullmatch(obj_short_id): - batch.reference_remap[obj_short_id] = get_uuid(obj) self.add_object(obj, get_uuid(group), set_current=False) batch.objects.append(obj) self.selection_changed() @@ -3116,10 +3110,7 @@ def save_to_directory(self, param: SaveToDirectoryParam | None = None) -> None: labels = [] for obj in objs: group = self.objmodel.get_group_from_object(obj) - labels.append( - f"[{shorten_uuids_in_title(group.title)}] " - f"{shorten_uuids_in_title(obj.title)}" - ) + labels.append(f"[{group.title}] {obj.title}") guiparam = SaveToDirectoryGUIParam(objs, extensions, labels) # Restore settings from config saved_param = Conf.save_to_directory_settings.get( @@ -4156,17 +4147,17 @@ def __plot_result( ) return obj = objs[0] - # Build a string with source object UUIDs (max 3, then use "...") + # Build a string with source object references (max 3, then use "...") max_ids_to_show = 3 - source_uuids = [get_uuid(obj) for obj in objs] - if len(source_uuids) <= max_ids_to_show: - source_ids = ", ".join(source_uuids) + source_refs = [get_title_reference(obj) for obj in objs] + if len(source_refs) <= max_ids_to_show: + source_ids = ", ".join(source_refs) else: - # Show first 2, "...", then the last UUID. + # Show first 2, "...", then the last reference. source_ids = ( - ", ".join(source_uuids[: max_ids_to_show - 1]) + ", ".join(source_refs[: max_ids_to_show - 1]) + ", ..., " - + source_uuids[-1] + + source_refs[-1] ) for i_roi in all_roi_indexes[0]: roi_suffix = f"|ROI{int(i_roi + 1)}" if i_roi >= 0 else "" @@ -4205,8 +4196,8 @@ def __plot_result( else: x = roi_data[param.xaxis].values y = roi_data[param.yaxis].values - source_uuid = get_uuid(objs[index]) - stitle = f"{title} ({source_uuid}){roi_suffix}" + source_ref = get_title_reference(objs[index]) + stitle = f"{title} ({source_ref}){roi_suffix}" self.__add_result_signal( x, y, stitle, param.xaxis, param.yaxis, result_group_id ) diff --git a/datalab/gui/processor/base.py b/datalab/gui/processor/base.py index c862e9d81..da3dba311 100644 --- a/datalab/gui/processor/base.py +++ b/datalab/gui/processor/base.py @@ -52,7 +52,7 @@ from datalab.config import Conf, _ from datalab.gui.processor.catcher import CompOut, wng_err_func from datalab.history.effects import capture_effects -from datalab.objectmodel import get_uuid, patch_title_with_ids +from datalab.objectmodel import get_title_reference, get_uuid, patch_title_with_ids from datalab.utils.qthelpers import create_progress_bar, qt_try_except if TYPE_CHECKING: @@ -1799,7 +1799,7 @@ def _compute_1_to_1_subroutine( # Create a new group for each selected group old_g = self.panel.objmodel.get_group(old_gid) new_gid = self._create_group_for_result( - new_obj, f"{name}({get_uuid(old_g)})" + new_obj, f"{name}({get_title_reference(old_g)})" ) new_gids[old_gid] = new_gid self._add_object_to_appropriate_panel( @@ -2283,9 +2283,8 @@ def compute_n_to_1( ) if not valid: return - dst_gname = ( - f"{name}({','.join(get_uuid(grp) for grp in src_grps)})|pairwise" - ) + src_refs = ",".join(get_title_reference(grp) for grp in src_grps) + dst_gname = f"{name}({src_refs})|pairwise" group_exclusive = len(self.panel.objview.get_sel_groups()) != 0 if not group_exclusive: # This is not a group exclusive selection @@ -2382,7 +2381,8 @@ def compute_n_to_1( if grps: # (Group exclusive selection) # At least one group is selected: create a new group - dst_gname = f"{name}({','.join([get_uuid(grp) for grp in grps])})" + grp_refs = ",".join(get_title_reference(grp) for grp in grps) + dst_gname = f"{name}({grp_refs})" # Delay group creation until after first result dst_gid = None dst_group_name = dst_gname # Store name for later use @@ -2617,8 +2617,10 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements if group_exclusive: # This is a group exclusive selection src_grp = objmodel.get_group(src_gid) - group_uuids = [get_uuid(grp) for grp in (src_grp, grp2)] - dst_gname = f"{name}({','.join(group_uuids)})|pairwise" + group_refs = [ + get_title_reference(grp) for grp in (src_grp, grp2) + ] + dst_gname = f"{name}({','.join(group_refs)})|pairwise" else: dst_gname = f"{name}[...]" # Delay group creation until after first result diff --git a/datalab/gui/processor/signal.py b/datalab/gui/processor/signal.py index 0cca4e5aa..560bb61c8 100644 --- a/datalab/gui/processor/signal.py +++ b/datalab/gui/processor/signal.py @@ -42,7 +42,7 @@ from datalab.config import _ from datalab.env import execenv from datalab.gui.processor.base import BaseProcessor, SourcePreparationTransaction -from datalab.objectmodel import get_uuid +from datalab.objectmodel import get_title_reference, get_uuid from datalab.utils.qthelpers import qt_try_except from datalab.widgets.replacespecialvalues import ( ReplaceSpecialValuesSignalParamDL, @@ -825,7 +825,7 @@ def __row_compute_fit( metadata = {fitdlgfunc.__name__: pvalues} # Creating new signal signal = create_signal( - f"{name}({get_uuid(obj)})", obj.x, y, metadata=metadata + f"{name}({get_title_reference(obj)})", obj.x, y, metadata=metadata ) # Record a replayable history action when the dialog returned # canonical fit parameters (third-party dialogs returning legacy @@ -910,7 +910,7 @@ def recompute_fit( if obj is None: return y = signal_fitting.evaluate_fit(obj.x, **fit_params) - title = f"{fit_name}({get_uuid(obj)})" + title = f"{fit_name}({get_title_reference(obj)})" if output_uuid is not None and self.panel.objmodel.has_uuid(output_uuid): # Update the recorded output in place (no duplicate on replay) target = self.panel.objmodel[output_uuid] diff --git a/datalab/gui/roieditor.py b/datalab/gui/roieditor.py index 58cf40aaa..ff41cc6ca 100644 --- a/datalab/gui/roieditor.py +++ b/datalab/gui/roieditor.py @@ -68,7 +68,6 @@ ) from datalab.config import Conf, _ from datalab.env import execenv -from datalab.objectmodel import shorten_uuids_in_title if TYPE_CHECKING: from plotpy.plot import BasePlot @@ -275,7 +274,7 @@ def __init__( rendered_title = ( mainwindow.render_object_title(obj.title) if mainwindow is not None - else shorten_uuids_in_title(obj.title) + else obj.title ) if item is None: item = create_adapter_from_object(obj).make_item() diff --git a/datalab/gui/roigrideditor.py b/datalab/gui/roigrideditor.py index 17e1f8b34..f5aa0e83a 100644 --- a/datalab/gui/roigrideditor.py +++ b/datalab/gui/roigrideditor.py @@ -18,7 +18,6 @@ from datalab.adapters_plotpy import create_adapter_from_object from datalab.config import _ -from datalab.objectmodel import shorten_uuids_in_title from datalab.utils.qthelpers import block_signals @@ -80,7 +79,7 @@ def __init__( self.rendered_title = ( mainwindow.render_object_title(obj.title) if mainwindow is not None - else shorten_uuids_in_title(obj.title) + else obj.title ) gridparam = gridparam or ROIGridParam() displayparam = displayparam or DisplayParam() diff --git a/datalab/h5/history.py b/datalab/h5/history.py index 3205df4a7..7eb9c1d59 100644 --- a/datalab/h5/history.py +++ b/datalab/h5/history.py @@ -15,11 +15,7 @@ from datalab.gui.panel.base import H5ImportBatch from datalab.h5.native import NativeH5Reader, NativeH5Writer from datalab.history import HistorySession -from datalab.objectmodel import ( - LEGACY_GROUP_SHORT_ID_REGEX, - SHORT_ID_REGEX, - get_uuid, -) +from datalab.objectmodel import get_uuid from datalab.utils.qthelpers import qt_try_loadsave_file, save_restore_stds if TYPE_CHECKING: @@ -170,11 +166,6 @@ def read_imported_group( if isinstance(serialized_group_uuid, str) and serialized_group_uuid: registry.uuid_remap[panel_str][serialized_group_uuid] = new_group_uuid batch.reference_remap[serialized_group_uuid] = new_group_uuid - group_short_id = group_name.partition(":")[0] - if SHORT_ID_REGEX.fullmatch( - group_short_id - ) or LEGACY_GROUP_SHORT_ID_REGEX.fullmatch(group_short_id): - batch.reference_remap[group_short_id] = new_group_uuid batch.groups.append(group) path = f"{data_panel.H5_PREFIX}/{group_name}" for object_name in reader.h5.get(path, []): @@ -184,9 +175,6 @@ def read_imported_group( old_uuid, new_uuid = assign_imported_uuid(obj) registry.uuid_remap[panel_str][old_uuid] = new_uuid batch.reference_remap[old_uuid] = new_uuid - object_short_id = object_name.partition(":")[0] - if SHORT_ID_REGEX.fullmatch(object_short_id): - batch.reference_remap[object_short_id] = new_uuid data_panel.add_object(obj, new_group_uuid, set_current=False) batch.objects.append(obj) data_panel.selection_changed() diff --git a/datalab/objectmodel.py b/datalab/objectmodel.py index 8f05eeccc..964d63d69 100644 --- a/datalab/objectmodel.py +++ b/datalab/objectmodel.py @@ -96,6 +96,8 @@ def get_short_id(obj: SignalObj | ImageObj | ObjectGroup) -> str: UUID_REGEX = re.compile( r"\b[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\b", re.IGNORECASE ) +#: Title references: ``1a2b3c4d`` for objects, ``g1a2b3c4d`` for groups +TITLE_REFERENCE_REGEX = re.compile(r"\bg?[0-9a-f]{8}\b") def get_short_uuid(obj_or_uuid: SignalObj | ImageObj | ObjectGroup | str) -> str: @@ -104,60 +106,54 @@ def get_short_uuid(obj_or_uuid: SignalObj | ImageObj | ObjectGroup | str) -> str return uuid[:UUID_DISPLAY_LENGTH] +def get_title_reference(obj: SignalObj | ImageObj | ObjectGroup) -> str: + """Return the reference embedded in titles: ``<UUID8>`` or ``g<UUID8>``.""" + prefix = "g" if isinstance(obj, ObjectGroup) else "" + return f"{prefix}{get_short_uuid(obj)}" + + def get_uuid_display_id(obj: SignalObj | ImageObj | ObjectGroup) -> str: - """Return an object's own display identity in ``#<UUID8>`` form.""" - return f"#{get_short_uuid(obj)}" + """Return an object's own display identity in ``#<reference>`` form.""" + return f"#{get_title_reference(obj)}" -def find_uuids_in_title(title: str) -> list[tuple[int, int, str]]: - """Return UUID references found in a canonical object title. +def find_title_references(title: str) -> list[tuple[int, int, str]]: + """Return object and group references found in a title. Args: title: Title string to scan Returns: - List of ``(start, end, uuid)`` tuples sorted by ``start``. + List of ``(start, end, reference)`` tuples sorted by ``start``. """ return [ (match.start(), match.end(), match.group(0)) - for match in UUID_REGEX.finditer(title) + for match in TITLE_REFERENCE_REGEX.finditer(title) ] -def render_title( - title: str, uuid_title_resolver: Callable[[str], str | None] | None = None -) -> str: - """Render UUID references as prefixes or resolved source titles. +def render_title(title: str, resolver: Callable[[str], str | None]) -> str: + """Replace the title references that ``resolver`` resolves. Args: - title: Canonical title containing full UUID references - uuid_title_resolver: Optional callback returning a source title for a UUID + title: Title containing object and group references + resolver: Callback returning a replacement for a reference, or None Returns: - Display title containing source titles or shortened UUID references. + Title with resolved references replaced, others left unchanged. """ - for start, end, uuid in reversed(find_uuids_in_title(title)): - replacement = None - if uuid_title_resolver is not None: - replacement = uuid_title_resolver(uuid) + for start, end, reference in reversed(find_title_references(title)): + replacement = resolver(reference) if replacement: - replacement = shorten_uuids_in_title(replacement) - else: - replacement = get_short_uuid(uuid) - title = title[:start] + replacement + title[end:] + title = title[:start] + replacement + title[end:] return title -def shorten_uuids_in_title(title: str) -> str: - """Render canonical UUID references as eight-character prefixes.""" - return render_title(title) - - def patch_title_with_ids( dst_obj: SignalObj | ImageObj, src_objs: list[SignalObj] | list[ImageObj], ) -> None: - """Patch an object title with canonical source UUIDs. + """Patch an object title with source references. Destination object's title has been set to a string containing placeholders (e.g. "integral({0})"), by `sigima` computation function using a generic mecanism @@ -167,7 +163,7 @@ def patch_title_with_ids( dst_obj: destination object src_objs: list of source objects """ - ids = [get_uuid(obj) for obj in src_objs] + ids = [get_title_reference(obj) for obj in src_objs] title = dst_obj.title assert isinstance(title, str), "Title must be a string" try: @@ -178,53 +174,9 @@ def patch_title_with_ids( ) from exc -#: Regex matching short IDs as embedded in computation titles -#: (e.g. ``s001``, ``i012``, ``gs003``, ``gi007``). -SHORT_ID_REGEX = re.compile(r"\b(g?[si])(\d{3})\b") -LEGACY_GROUP_SHORT_ID_REGEX = re.compile(r"\bg\d{3}\b") - - -def find_short_ids_in_title(title: str) -> list[tuple[int, int, str]]: - """Return a list of ``(start, end, short_id)`` tuples for every short ID - occurrence found in ``title``. - - Args: - title: title string to scan - - Returns: - List of ``(start, end, short_id)`` tuples, sorted by ``start``. - """ - return [(m.start(), m.end(), m.group(0)) for m in SHORT_ID_REGEX.finditer(title)] - - -def find_legacy_group_short_ids_in_title( - title: str, -) -> list[tuple[int, int, str]]: - """Return legacy ``gNNN`` group references found in a title.""" - return [ - (match.start(), match.end(), match.group(0)) - for match in LEGACY_GROUP_SHORT_ID_REGEX.finditer(title) - ] - - def remap_title_references(title: str, reference_remap: dict[str, str]) -> str: - """Replace known UUID and legacy short-ID references in a title. - - Args: - title: Canonical or legacy title to update - reference_remap: Mapping from serialized references to canonical UUIDs - - Returns: - Title with every known reference replaced by its canonical UUID. - """ - matches = find_short_ids_in_title(title) - matches.extend(find_legacy_group_short_ids_in_title(title)) - matches.extend(find_uuids_in_title(title)) - for start, end, reference in sorted(matches, reverse=True): - replacement = reference_remap.get(reference) - if replacement is not None: - title = title[:start] + replacement + title[end:] - return title + """Replace the title references listed in ``reference_remap``.""" + return render_title(title, reference_remap.get) class ObjectGroup: @@ -414,27 +366,19 @@ def get_object_or_group(self, uuid: str) -> SignalObj | ImageObj | ObjectGroup: return group raise KeyError(f"Object or group with uuid {uuid} not found") - def find_by_short_id( - self, short_id: str - ) -> SignalObj | ImageObj | ObjectGroup | None: - """Return the object or group whose short ID matches ``short_id``, - or ``None`` if no match is found in this model. + def find_by_title_reference( + self, reference: str + ) -> list[SignalObj | ImageObj | ObjectGroup]: + """Return the objects or groups matching a title reference. Args: - short_id: short ID to look up (e.g. ``"s001"``, ``"i012"``, - ``"gs003"`` or ``"gi007"``). + reference: ``<UUID8>`` (object) or ``g<UUID8>`` (group) reference Returns: - The matching :class:`sigima.SignalObj`, :class:`sigima.ImageObj` - or :class:`ObjectGroup` instance, or ``None``. + Matching objects or groups (more than one only on prefix collision). """ - for group in self._groups: - if get_short_id(group) == short_id: - return group - for obj in self._objects.values(): - if get_short_id(obj) == short_id: - return obj - return None + items = self._groups if reference.startswith("g") else self._objects.values() + return [item for item in items if get_title_reference(item) == reference] def get_group(self, uuid: str) -> ObjectGroup: """Return group with uuid""" @@ -499,7 +443,7 @@ def get_group_from_title(self, title: str) -> ObjectGroup: if not matches: raise KeyError(f"Group with title '{title}' not found") if len(matches) > 1: - match_ids = ", ".join(get_short_id(group) for group in matches) + match_ids = ", ".join(get_uuid_display_id(group) for group in matches) raise ValueError( f"Group title '{title}' is ambiguous; matches: {match_ids}" ) diff --git a/datalab/tests/backbone/titledelegate_unit_test.py b/datalab/tests/backbone/titledelegate_unit_test.py index b839a9509..195f846aa 100644 --- a/datalab/tests/backbone/titledelegate_unit_test.py +++ b/datalab/tests/backbone/titledelegate_unit_test.py @@ -10,20 +10,20 @@ from qtpy import QtGui as QG from qtpy import QtWidgets as QW -from datalab.widgets.titledelegate import TITLE_UUID_ROLE, ClickableTitleDelegate +from datalab.widgets.titledelegate import TITLE_REFERENCE_ROLE, ClickableTitleDelegate @pytest.mark.parametrize("with_item_uuid", [False, True]) def test_size_hint_height_tracks_painted_wrapping(with_item_uuid: bool) -> None: """Increase row height when the same rich title is painted more narrowly.""" with qt_app_context(): - referenced_uuid = "12345678-1234-5678-1234-567812345678" + referenced_uuid = "12345678" model = QG.QStandardItemModel() item = QG.QStandardItem( f"A deliberately long computed title referencing {referenced_uuid}" ) if with_item_uuid: - item.setData("87654321-4321-8765-4321-876543218765", TITLE_UUID_ROLE) + item.setData("87654321", TITLE_REFERENCE_ROLE) model.appendRow(item) parent = QW.QWidget() delegate = ClickableTitleDelegate(parent) diff --git a/datalab/tests/features/common/operation_modes_app_test.py b/datalab/tests/features/common/operation_modes_app_test.py index 76da784d4..c05d0b106 100644 --- a/datalab/tests/features/common/operation_modes_app_test.py +++ b/datalab/tests/features/common/operation_modes_app_test.py @@ -23,7 +23,7 @@ from datalab.config import Conf from datalab.env import execenv from datalab.gui.processor.base import is_pairwise_mode -from datalab.objectmodel import get_uuid +from datalab.objectmodel import get_title_reference from datalab.tests import helpers from datalab.utils.qthelpers import datalab_app_context @@ -81,7 +81,9 @@ def test_single_operand_mode_compute_n_to_1() -> None: assert len(new_group) == 2 titles = [] for idx, obj in enumerate(new_group): - pfx_orig = ", ".join(get_uuid(obj) for obj in groups[idx].get_objects()) + pfx_orig = ", ".join( + get_title_reference(obj) for obj in groups[idx].get_objects() + ) titles.append((obj.title, f"Σ({pfx_orig})")) check_titles(f"Single operand mode Σ[{new_group.title}]", titles) @@ -108,7 +110,7 @@ def test_single_operand_mode_compute_n_to_1() -> None: assert len(model.get_groups()) == n_groups # no new group titles = [] for idx in range(1): - pfx_orig = ", ".join(get_uuid(obj) for obj in groups[idx][:2]) + pfx_orig = ", ".join(get_title_reference(obj) for obj in groups[idx][:2]) titles.append((groups[idx][-1].title, f"Σ({pfx_orig})")) check_titles(f"Single operand mode Σ[{groups[1].title}]", titles) @@ -158,7 +160,9 @@ def test_pairwise_operations_mode_compute_n_to_1() -> None: titles = [] for idx in range(len(groups[0])): obj = new_group[idx] - pfx_orig = ", ".join(get_uuid(obj) for obj in (grp[idx] for grp in groups)) + pfx_orig = ", ".join( + get_title_reference(obj) for obj in (grp[idx] for grp in groups) + ) titles.append((obj.title, f"Σ({pfx_orig})")) check_titles(f"Pairwise operations mode Σ[{new_group.title}]", titles) @@ -188,7 +192,7 @@ def test_pairwise_operations_mode_compute_n_to_1() -> None: assert len(new_group) == 2 # 2 signals were selected titles = [] for idx, obj in enumerate(new_group): - pfx_orig = ", ".join(get_uuid(obj) for obj in objs[idx::2]) + pfx_orig = ", ".join(get_title_reference(obj) for obj in objs[idx::2]) titles.append((obj.title, f"Σ({pfx_orig})")) check_titles(f"Pairwise operations mode Σ[{new_group.title}]", titles) @@ -240,7 +244,8 @@ def test_single_operand_mode_compute_2_to_1() -> None: titles.append( ( obj.title, - f"{get_uuid(group[i_obj])}-{get_uuid(group3[0])}", + f"{get_title_reference(group[i_obj])}-" + f"{get_title_reference(group3[0])}", ) ) new_objs.append(obj) @@ -278,7 +283,8 @@ def test_single_operand_mode_compute_2_to_1() -> None: titles.append( ( obj.title, - f"{get_uuid(group[i_obj])}-{get_uuid(group3[0])}", + f"{get_title_reference(group[i_obj])}-" + f"{get_title_reference(group3[0])}", ) ) check_titles(f"Single operand mode Δ[{group.title}]", titles) @@ -337,7 +343,12 @@ def test_pairwise_operations_mode_compute_2_to_1() -> None: for idx in range(n_objects): obj = new_grp[idx] obj1, obj2 = groups[i_new_grp][idx], group3[idx] - titles.append((obj.title, f"{get_uuid(obj1)}-{get_uuid(obj2)}")) + titles.append( + ( + obj.title, + f"{get_title_reference(obj1)}-{get_title_reference(obj2)}", + ) + ) check_titles(f"Pairwise operations mode Δ[{new_grp.title}]", titles) # Remove new groups @@ -377,7 +388,12 @@ def test_pairwise_operations_mode_compute_2_to_1() -> None: obj = new_grp[idx] obj1, obj2 = objs[i_obj1], objs2[idx] i_obj1 += 1 - titles.append((obj.title, f"{get_uuid(obj1)}-{get_uuid(obj2)}")) + titles.append( + ( + obj.title, + f"{get_title_reference(obj1)}-{get_title_reference(obj2)}", + ) + ) check_titles(f"Pairwise operations mode Δ[{new_grp.title}]", titles) Conf.operation_mode.set(original_mode) diff --git a/datalab/tests/features/common/plot_results_app_test.py b/datalab/tests/features/common/plot_results_app_test.py index ccc993d45..bf310ba55 100644 --- a/datalab/tests/features/common/plot_results_app_test.py +++ b/datalab/tests/features/common/plot_results_app_test.py @@ -24,7 +24,7 @@ from datalab.config import _ from datalab.env import execenv -from datalab.objectmodel import get_uuid +from datalab.objectmodel import get_title_reference, get_uuid from datalab.tests import datalab_test_app_context @@ -183,7 +183,7 @@ def test_plot_results_with_group_selection(): assert len(sel_groups) == 1, ( f"Expected 1 selected group, got {len(sel_groups)}" ) - source_uuids = ", ".join(get_uuid(obj) for obj in sel_groups[0]) + source_uuids = ", ".join(get_title_reference(obj) for obj in sel_groups[0]) expected_source_uuids = f"({source_uuids})" # Plot results - this should create or reuse a "Results" group @@ -211,7 +211,7 @@ def test_plot_results_with_group_selection(): # Verify that the result title includes the selected source object UUIDs result_signal = list(result_group)[0] assert expected_source_uuids in result_signal.title, ( - "Result signal title should include the full UUIDs of the selected " + "Result signal title should include the references of the selected " f"source objects {expected_source_uuids}, " f"got '{result_signal.title}'" ) diff --git a/datalab/tests/features/common/title_short_id_unit_test.py b/datalab/tests/features/common/title_short_id_unit_test.py index 23fe3e966..1a2527aa2 100644 --- a/datalab/tests/features/common/title_short_id_unit_test.py +++ b/datalab/tests/features/common/title_short_id_unit_test.py @@ -1,12 +1,12 @@ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ -Short ID title link unit test. +Title reference unit test. -Validates the helpers underpinning the clickable short-ID feature in -:mod:`datalab.gui.objectview`: +Validates the helpers underpinning UUID8 title references: -- :func:`datalab.objectmodel.find_short_ids_in_title` +- :func:`datalab.objectmodel.find_title_references` +- :func:`datalab.objectmodel.remap_title_references` - :func:`datalab.widgets.titledelegate._build_html` """ @@ -14,49 +14,74 @@ from __future__ import annotations -from datalab.objectmodel import find_short_ids_in_title -from datalab.widgets.titledelegate import SHORT_ID_URL_SCHEME, _build_html - - -def test_find_short_ids_in_title_basic() -> None: - """``find_short_ids_in_title`` extracts every short ID with its bounds.""" - matches = find_short_ids_in_title("s003: average(s001, s002)") - assert [m[2] for m in matches] == ["s003", "s001", "s002"] - assert matches[0][0] == 0 # leading short ID starts at offset 0 - - -def test_find_short_ids_in_title_mixed_kinds() -> None: - """Image, group and signal short IDs are all detected.""" - matches = find_short_ids_in_title("i012: derived(s001, gi003)") - assert [m[2] for m in matches] == ["i012", "s001", "gi003"] - - -def test_find_short_ids_in_title_no_false_positives() -> None: - """Random ``letter+digits`` patterns are not mistaken for short IDs.""" - # `s12345` has too many digits; `s1` has too few. - assert find_short_ids_in_title("s12345 then s1") == [] - # Substrings inside a word must not match either. - assert find_short_ids_in_title("class s001abc") == [] - - -def test_build_html_skips_leading_short_id() -> None: - """The leading ``s001:`` part is rendered as plain text.""" - html = _build_html("s003: average(s001, s002)") - # Leading "s003" must NOT be wrapped in an anchor - assert html.startswith("s003") - assert f'href="{SHORT_ID_URL_SCHEME}:s003"' not in html - # But s001 and s002 inside the body must be anchors - assert f'href="{SHORT_ID_URL_SCHEME}:s001"' in html - assert f'href="{SHORT_ID_URL_SCHEME}:s002"' in html +import sigima.proc.signal as sips +from sigima.objects import create_signal +from sigima.tests.data import create_paracetamol_signal + +from datalab.config import Conf +from datalab.objectmodel import ( + ObjectModel, + find_title_references, + get_short_uuid, + get_title_reference, + patch_title_with_ids, + remap_title_references, +) +from datalab.tests import datalab_test_app_context +from datalab.widgets.titledelegate import REFERENCE_URL_SCHEME, _build_html + + +def test_title_references_use_uuid8() -> None: + """Objects are referenced by UUID8, groups by ``g`` + UUID8.""" + model = ObjectModel("gs") + group = model.add_group("Group") + src1, src2 = create_signal("A"), create_signal("B") + assert get_title_reference(src1) == get_short_uuid(src1) + assert get_title_reference(group) == f"g{get_short_uuid(group)}" + dst = create_signal("{0}-{1}") + patch_title_with_ids(dst, [src1, src2]) + assert dst.title == f"{get_short_uuid(src1)}-{get_short_uuid(src2)}" + + +def test_find_title_references() -> None: + """Object and group references are found; other words are ignored.""" + matches = find_title_references("avg(1a2b3c4d, g5e6f7a8b)|s001 x1a2b3c4d 123456789") + assert [match[2] for match in matches] == ["1a2b3c4d", "g5e6f7a8b"] + + +def test_remap_title_references_only_replaces_mapped_references() -> None: + """Unmapped tokens, legacy short IDs and user text are left unchanged.""" + title = "fft(1a2b3c4d) of g5e6f7a8b, run 20260107, s001" + remapped = remap_title_references( + title, {"1a2b3c4d": "9f8e7d6c", "g5e6f7a8b": "g0a1b2c3d"} + ) + assert remapped == "fft(9f8e7d6c) of g0a1b2c3d, run 20260107, s001" + + +def test_build_html_links_only_resolved_references() -> None: + """Resolved references become links, unresolved ones stay plain text.""" + html = _build_html("1a2b3c4d-20260107", {"1a2b3c4d": "Signal A"}.get) + assert f'<a href="{REFERENCE_URL_SCHEME}:1a2b3c4d">Signal A</a>' in html + assert html.endswith("-20260107") def test_build_html_escapes_text() -> None: """Surrounding text is HTML-escaped to avoid markup injection.""" - html = _build_html("s003: <not a tag> & friends") - assert "<not a tag>" in html - assert "&" in html - - -def test_build_html_no_short_ids_returns_plain_text() -> None: - """Without short IDs, the output is just the escaped text.""" - assert _build_html("Just a title") == "Just a title" + html = _build_html("<not a tag> & friends") + assert html == "<not a tag> & friends" + + +def test_title_mode_follows_source_renames() -> None: + """Source-title mode renders the current title of the referenced source.""" + with datalab_test_app_context(console=False) as win: + panel = win.signalpanel + source = create_paracetamol_signal() + panel.add_object(source) + panel.processor.run_feature(sips.derivative) + derived = panel.objview.get_current_object() + assert win.render_object_title(derived.title) == derived.title + with Conf.result_title_mode.context("title"): + source.title = "Renamed source" + assert win.render_object_title(derived.title) == ( + "derivative(Renamed source)" + ) diff --git a/datalab/tests/features/hdf5/h5workspace_unit_test.py b/datalab/tests/features/hdf5/h5workspace_unit_test.py index 411f3cca5..baa214ba2 100644 --- a/datalab/tests/features/hdf5/h5workspace_unit_test.py +++ b/datalab/tests/features/hdf5/h5workspace_unit_test.py @@ -26,6 +26,7 @@ import h5py import pytest +import sigima.proc.signal as sips from guidata.io import JSONWriter from numpy import ma from plotpy.builder import make @@ -42,32 +43,36 @@ from datalab.gui.h5io import H5InputOutput from datalab.gui.newobject import extract_creation_parameters from datalab.gui.panel.base import H5ImportBatch +from datalab.objectmodel import get_title_reference, get_uuid from datalab.tests import datalab_test_app_context, helpers -def test_finalize_imports_keeps_colliding_legacy_references_panel_local() -> None: - """Resolve legacy gNNN collisions in their owning panel batch.""" - signal_group = SimpleNamespace(title="derivative(g001)") - image_group = SimpleNamespace(title="normalize(g001)") - cross_panel_group = SimpleNamespace(title="profile(i001)") - signal_batch = H5ImportBatch( - SimpleNamespace(), - {"g001": "signal-group-uuid"}, - [], - [signal_group, cross_panel_group], - ) - image_batch = H5ImportBatch( - SimpleNamespace(), - {"g001": "image-group-uuid", "i001": "image-object-uuid"}, - [], - [image_group], - ) - - H5ImportBatch.finalize_imports([signal_batch, image_batch]) - - assert signal_group.title == "derivative(signal-group-uuid)" - assert image_group.title == "normalize(image-group-uuid)" - assert cross_panel_group.title == "profile(image-object-uuid)" +def test_h5_append_twice_remaps_title_references() -> None: + """Appending a workspace twice keeps each copy's title references local.""" + with helpers.WorkdirRestoringTempDir() as tmpdir: + with datalab_test_app_context(console=False) as win: + panel = win.signalpanel + src = create_paracetamol_signal() + src.title = "Run s001 20260107" + panel.add_object(src) + panel.processor.run_feature(sips.derivative) + panel.objview.select_groups([get_uuid(panel.objmodel.get_groups()[0])]) + panel.processor.run_feature(sips.derivative) + fname = osp.join(tmpdir, "references.h5") + win.save_h5_workspace(fname) + + for _index in range(2): + win.load_h5_workspace([fname], reset_all=False) + + groups = panel.objmodel.get_groups() + assert len(groups) == 6 + for src_group, result_group in (groups[2:4], groups[4:6]): + source, derived = src_group.get_objects() + assert source.title == "Run s001 20260107" + assert derived.title == f"derivative({get_title_reference(source)})" + assert result_group.title.endswith( + f"({get_title_reference(src_group)})" + ) def test_open_file_headless_refreshes_reference_displays(monkeypatch) -> None: diff --git a/datalab/widgets/processingpreview.py b/datalab/widgets/processingpreview.py index 3539eacb7..7c1088639 100644 --- a/datalab/widgets/processingpreview.py +++ b/datalab/widgets/processingpreview.py @@ -21,12 +21,7 @@ from datalab.config import _ from datalab.gui.processor.catcher import CompOut from datalab.gui.processor.preview import PreviewController -from datalab.objectmodel import ( - get_short_uuid, - patch_title_with_ids, - render_title, - shorten_uuids_in_title, -) +from datalab.objectmodel import get_title_reference, patch_title_with_ids __all__ = [ "ProcessingPreviewDialog", @@ -118,9 +113,7 @@ def __init__( self.source_combo.setMinimumContentsLength(16) self.source_combo.setToolTip(_("Preview source")) for source in sources: - self.source_combo.addItem( - f"{get_short_uuid(source)}: {shorten_uuids_in_title(source.title)}" - ) + self.source_combo.addItem(f"{get_title_reference(source)}: {source.title}") self.source_combo.setVisible(len(sources) > 1) self.source_combo.setEnabled(False) controls.addWidget(self.source_combo, 1) @@ -295,7 +288,6 @@ def _show_result(self, output: CompOut, current: bool) -> None: patch_title_with_ids( result, [self.sources[self.source_combo.currentIndex()]] ) - result.title = render_title(result.title) self._render(result) except Exception as error: self.controller.invalidate() diff --git a/datalab/widgets/titledelegate.py b/datalab/widgets/titledelegate.py index 558f0536d..739d167a1 100644 --- a/datalab/widgets/titledelegate.py +++ b/datalab/widgets/titledelegate.py @@ -6,7 +6,8 @@ The :mod:`datalab.widgets.titledelegate` module provides a :class:`QStyledItemDelegate` that renders object tree titles as rich text and -turns embedded short IDs (e.g. ``s001``, ``i012``) into clickable hyperlinks. +turns embedded object references (e.g. ``1a2b3c4d``, ``g1a2b3c4d``) into +clickable hyperlinks. .. autoclass:: ClickableTitleDelegate """ @@ -24,69 +25,46 @@ from qtpy import QtGui as QG from qtpy import QtWidgets as QW -from datalab.objectmodel import ( - UUID_DISPLAY_LENGTH, - find_short_ids_in_title, - find_uuids_in_title, -) +from datalab.objectmodel import find_title_references if TYPE_CHECKING: pass #: URL scheme used in anchors emitted by :class:`ClickableTitleDelegate`. -SHORT_ID_URL_SCHEME = "dlb-shortid" -UUID_URL_SCHEME = "dlb-uuid" -TITLE_UUID_ROLE = QC.Qt.UserRole + 1 +REFERENCE_URL_SCHEME = "dlb-ref" +#: Item data role holding the item's own title reference (``<UUID8>``/``g<UUID8>``) +TITLE_REFERENCE_ROLE = QC.Qt.UserRole + 1 TITLE_META_FONT_SIZE = "90%" TITLE_ROW_VERTICAL_PADDING = 2 def _build_html( - text: str, uuid_title_resolver: Callable[[str], str | None] | None = None + text: str, reference_resolver: Callable[[str], str | None] | None = None ) -> str: """Build the HTML representation of ``text`` with references as anchors. - The first short ID occurrence is always rendered as plain text: object - titles in DataLab tree views are formatted as ``"<short_id>: <title>"`` and - making the leading ``s001`` clickable would just re-select the current - item. + Only references resolved by ``reference_resolver`` become links; the others + are kept as plain text. Args: - text: raw item text (e.g. ``"s003: average(s001, s002)"``). + text: raw item text (e.g. ``"1a2b3c4d-5e6f7a8b"``). + reference_resolver: callback returning the display text of a reference, + or None when the reference cannot be resolved. Returns: HTML string. """ - matches: list[tuple[int, int, str, str | None, str]] = [ - (*match, SHORT_ID_URL_SCHEME, match[2]) - for match in find_short_ids_in_title(text) - ] - for match in find_uuids_in_title(text): - uuid = match[2] - display_reference = uuid[:UUID_DISPLAY_LENGTH] - resolved_display = ( - uuid_title_resolver(uuid) - if uuid_title_resolver is not None - else display_reference - ) - if resolved_display is not None: - display_reference = resolved_display - scheme = UUID_URL_SCHEME if resolved_display is not None else None - matches.append((*match, scheme, display_reference)) - matches.sort(key=lambda match: match[0]) - if not matches: - return escape(text) out: list[str] = [] cursor = 0 - for start, end, reference, scheme, display_reference in matches: + for start, end, reference in find_title_references(text): out.append(escape(text[cursor:start])) - if scheme is None or (scheme == SHORT_ID_URL_SCHEME and start == 0): - # Leading "s001:" — keep as plain text - out.append(escape(display_reference)) + display = None if reference_resolver is None else reference_resolver(reference) + if display is None: + out.append(escape(reference)) else: out.append( - f'<a href="{scheme}:{reference}">{escape(display_reference)}</a>' + f'<a href="{REFERENCE_URL_SCHEME}:{reference}">{escape(display)}</a>' ) cursor = end out.append(escape(text[cursor:])) @@ -98,8 +76,8 @@ def _make_text_document( option: QW.QStyleOptionViewItem, link_color: QG.QColor, text_color: QG.QColor | None = None, - uuid_title_resolver: Callable[[str], str | None] | None = None, - item_uuid: str | None = None, + reference_resolver: Callable[[str], str | None] | None = None, + item_reference: str | None = None, meta_color: QG.QColor | None = None, ) -> QG.QTextDocument: """Return a :class:`QTextDocument` rendering ``text`` with the styling @@ -123,10 +101,9 @@ def _make_text_document( "}" ) doc.setDefaultStyleSheet(" ".join(css_parts)) - title_html = _build_html(text, uuid_title_resolver) - if item_uuid: - short_uuid = escape(item_uuid[:UUID_DISPLAY_LENGTH]) - own_id_html = f'<span class="title-meta">#{short_uuid}</span>' + title_html = _build_html(text, reference_resolver) + if item_reference: + own_id_html = f'<span class="title-meta">#{escape(item_reference)}</span>' title_html = f"{title_html}<br>{own_id_html}" if title_html else own_id_html doc.setHtml(title_html) return doc @@ -136,8 +113,8 @@ class ClickableTitleDelegate(QW.QStyledItemDelegate): """Item delegate that renders object titles with clickable references. The delegate uses a :class:`QTextDocument` to render an HTML version of the - item's display text in which each embedded short ID — apart from the - leading one — is wrapped in an anchor pointing at ``dlb-shortid:<id>``. + item's display text in which each resolvable object or group reference is + wrapped in an anchor pointing at ``dlb-ref:<reference>``. Hit-testing is performed by :meth:`anchor_at`, which is meant to be called from the host view's ``mousePressEvent`` / ``mouseMoveEvent``. @@ -146,10 +123,10 @@ class ClickableTitleDelegate(QW.QStyledItemDelegate): def __init__( self, parent: QW.QWidget, - uuid_title_resolver: Callable[[str], str | None] | None = None, + reference_resolver: Callable[[str], str | None] | None = None, ) -> None: super().__init__(parent) - self.uuid_title_resolver = uuid_title_resolver + self.reference_resolver = reference_resolver # pylint: disable=invalid-name def paint( @@ -160,12 +137,9 @@ def paint( ) -> None: """Reimplement Qt method to paint the item via a QTextDocument.""" text = index.data(QC.Qt.DisplayRole) or "" - item_uuid = index.data(TITLE_UUID_ROLE) - has_references = bool( - find_short_ids_in_title(text) or find_uuids_in_title(text) - ) + item_reference = index.data(TITLE_REFERENCE_ROLE) if not isinstance(text, str) or not ( - has_references or isinstance(item_uuid, str) + find_title_references(text) or isinstance(item_reference, str) ): super().paint(painter, option, index) return @@ -201,8 +175,8 @@ def paint( option, link_color, text_color, - self.uuid_title_resolver, - item_uuid if isinstance(item_uuid, str) else None, + self.reference_resolver, + item_reference if isinstance(item_reference, str) else None, meta_color, ) doc.setTextWidth(text_rect.width()) @@ -218,14 +192,11 @@ def paint( def sizeHint( self, option: QW.QStyleOptionViewItem, index: QC.QModelIndex ) -> QC.QSize: - """Return a size accommodating the title and own UUID metadata line.""" + """Return a size accommodating the title and own identity line.""" text = index.data(QC.Qt.DisplayRole) or "" - item_uuid = index.data(TITLE_UUID_ROLE) - has_references = isinstance(text, str) and bool( - find_short_ids_in_title(text) or find_uuids_in_title(text) - ) + item_reference = index.data(TITLE_REFERENCE_ROLE) if not isinstance(text, str) or not ( - has_references or isinstance(item_uuid, str) + find_title_references(text) or isinstance(item_reference, str) ): return super().sizeHint(option, index) opt = QW.QStyleOptionViewItem(option) @@ -234,8 +205,8 @@ def sizeHint( text, opt, QG.QColor("black"), - uuid_title_resolver=self.uuid_title_resolver, - item_uuid=item_uuid if isinstance(item_uuid, str) else None, + reference_resolver=self.reference_resolver, + item_reference=item_reference if isinstance(item_reference, str) else None, meta_color=QG.QColor("gray"), ) base_size = super().sizeHint(opt, index) @@ -258,7 +229,7 @@ def anchor_at( pos: QC.QPoint, option: QW.QStyleOptionViewItem, ) -> str | None: - """Return the short ID under cursor position ``pos`` (in viewport + """Return the reference under cursor position ``pos`` (in viewport coordinates) for ``index``, or ``None`` if the cursor is not over any anchor. @@ -269,11 +240,8 @@ def anchor_at( option: style option (already initialized for the item) """ text = index.data(QC.Qt.DisplayRole) or "" - item_uuid = index.data(TITLE_UUID_ROLE) - has_references = bool( - find_short_ids_in_title(text) or find_uuids_in_title(text) - ) - if not isinstance(text, str) or not has_references: + item_reference = index.data(TITLE_REFERENCE_ROLE) + if not isinstance(text, str) or not find_title_references(text): return None opt = QW.QStyleOptionViewItem(option) opt.rect = item_rect @@ -287,14 +255,13 @@ def anchor_at( text, option, QG.QColor("black"), - uuid_title_resolver=self.uuid_title_resolver, - item_uuid=item_uuid if isinstance(item_uuid, str) else None, + reference_resolver=self.reference_resolver, + item_reference=item_reference if isinstance(item_reference, str) else None, meta_color=QG.QColor("gray"), ) doc.setTextWidth(text_rect.width()) local = QC.QPointF(pos - text_rect.topLeft()) href = doc.documentLayout().anchorAt(local) - for scheme in (SHORT_ID_URL_SCHEME, UUID_URL_SCHEME): - if href and href.startswith(f"{scheme}:"): - return href[len(scheme) + 1 :] + if href and href.startswith(f"{REFERENCE_URL_SCHEME}:"): + return href[len(REFERENCE_URL_SCHEME) + 1 :] return None diff --git a/doc/features/common/settings.rst b/doc/features/common/settings.rst index 14924ba41..6d25fb70c 100644 --- a/doc/features/common/settings.rst +++ b/doc/features/common/settings.rst @@ -121,10 +121,10 @@ The Processing settings tab controls computation behavior and default parameters - **Source title**: use the source objects' live current titles, so references follow later renames. - This setting changes only the displayed reference. Each object's canonical - identity remains its full UUID. Object items in the signal and image trees always - show their own ``#UUID8`` reference, while group headers keep the ``gsNNN`` and - ``giNNN`` identifiers. + This setting changes only the displayed reference. Result titles store + 8-character references (``g``-prefixed for groups), while each object's identity + remains its full UUID. Signal, image and group items in the trees show their own + ``#UUID8`` (or ``#gUUID8``) reference below their title. **Use signal bounds for new signals** When enabled, the xmin and xmax values for new signals are initialized from diff --git a/doc/locale/fr/LC_MESSAGES/features/common/settings.po b/doc/locale/fr/LC_MESSAGES/features/common/settings.po index db5ccc4b3..7ee1b4d6c 100644 --- a/doc/locale/fr/LC_MESSAGES/features/common/settings.po +++ b/doc/locale/fr/LC_MESSAGES/features/common/settings.po @@ -154,8 +154,8 @@ msgstr "**UUID source** (par défaut) : utiliser des références stables de 8 c msgid "**Source title**: use the source objects' live current titles, so references follow later renames." msgstr "**Titre de la source** : utiliser les titres actuels des objets sources, afin que les références suivent les renommages ultérieurs." -msgid "This setting changes only the displayed reference. Each object's canonical identity remains its full UUID. Object items in the signal and image trees always show their own ``#UUID8`` reference, while group headers keep the ``gsNNN`` and ``giNNN`` identifiers." -msgstr "Ce réglage modifie uniquement la référence affichée. L'identité canonique de chaque objet reste son UUID complet. Les objets dans les arborescences des signaux et des images affichent toujours leur propre référence ``#UUID8``, tandis que les en-têtes de groupes conservent les identifiants ``gsNNN`` et ``giNNN``." +msgid "This setting changes only the displayed reference. Result titles store 8-character references (``g``-prefixed for groups), while each object's identity remains its full UUID. Signal, image and group items in the trees show their own ``#UUID8`` (or ``#gUUID8``) reference below their title." +msgstr "Ce réglage modifie uniquement la référence affichée. Les titres de résultats stockent des références de 8 caractères (préfixées par ``g`` pour les groupes), tandis que l'identité de chaque objet reste son UUID complet. Les signaux, images et groupes des arborescences affichent leur propre référence ``#UUID8`` (ou ``#gUUID8``) sous leur titre." msgid "**Use signal bounds for new signals**" msgstr "**Utiliser les bornes du signal pour les nouveaux signaux**" @@ -618,4 +618,3 @@ msgstr "**Arguments de l'éditeur externe**" msgid "Command-line arguments to pass to the external editor." msgstr "Arguments de ligne de commande à passer à l'éditeur externe." - diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po index aa283c804..3b27106f6 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po @@ -69,8 +69,8 @@ msgstr "**Références aux objets dans les titres :**" msgid "DataLab now uses stable UUID-based object references across object trees, result titles, plot legends and HDF5 workspaces (implements [Issue #367](https://github.com/DataLab-Platform/DataLab/issues/367) and [Issue #149](https://github.com/DataLab-Platform/DataLab/issues/149))." msgstr "DataLab utilise désormais des références aux objets stables, fondées sur les UUID, dans les arborescences d'objets, les titres de résultats, les légendes des graphiques et les espaces de travail HDF5 (implémente [Issue #367](https://github.com/DataLab-Platform/DataLab/issues/367) et [Issue #149](https://github.com/DataLab-Platform/DataLab/issues/149))." -msgid "Object items in the signal and image trees now show their current title followed by their own stable `#UUID8` reference; group headers keep the familiar `gsNNN` and `giNNN` identifiers" -msgstr "Les objets des arborescences signal et image affichent désormais leur titre courant suivi de leur propre référence stable `#UUID8` ; les en-têtes de groupe conservent les identifiants habituels `gsNNN` et `giNNN`" +msgid "Signal, image and group items in the trees now show their current title followed by their own stable `#UUID8` reference (`#gUUID8` for groups), replacing the former `s001`, `i001` and `gs001` identifiers" +msgstr "Les signaux, images et groupes des arborescences affichent désormais leur titre courant suivi de leur propre référence stable `#UUID8` (`#gUUID8` pour les groupes), à la place des anciens identifiants `s001`, `i001` et `gs001`" msgid "Source references embedded in result titles no longer change when objects are reordered, removed or renumbered, and remain clickable to select the source object" msgstr "Les références aux sources intégrées dans les titres de résultats ne changent plus lorsque des objets sont réordonnés, supprimés ou renumérotés, et restent cliquables pour sélectionner l'objet source" @@ -81,6 +81,9 @@ msgstr "Le nouveau paramètre **Références dans les titres de résultats** per msgid "When a source object is deleted, its reference falls back to its 8-character UUID instead of becoming an anonymous placeholder" msgstr "Lorsqu'un objet source est supprimé, sa référence est affichée sous la forme de son UUID à 8 caractères au lieu de devenir un espace réservé anonyme" +msgid "Titles of workspaces saved by previous versions keep their former `s001`-style references as plain text" +msgstr "Les titres des espaces de travail enregistrés par les versions précédentes conservent leurs anciennes références de type `s001` sous forme de texte simple" + msgid "🔄 Changes" msgstr "🔄 Changements" diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index c7fd0fc2f..57065c517 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -30,10 +30,11 @@ DataLab now uses stable UUID-based object references across object trees, result titles, plot legends and HDF5 workspaces (implements [Issue #367](https://github.com/DataLab-Platform/DataLab/issues/367) and [Issue #149](https://github.com/DataLab-Platform/DataLab/issues/149)). -* Object items in the signal and image trees now show their current title followed by their own stable `#UUID8` reference; group headers keep the familiar `gsNNN` and `giNNN` identifiers +* Signal, image and group items in the trees now show their current title followed by their own stable `#UUID8` reference (`#gUUID8` for groups), replacing the former `s001`, `i001` and `gs001` identifiers * Source references embedded in result titles no longer change when objects are reordered, removed or renumbered, and remain clickable to select the source object * The new **References in result titles** setting lets you display these references either as 8-character UUIDs (default) or as the current source titles, which follow later renames * When a source object is deleted, its reference falls back to its 8-character UUID instead of becoming an anonymous placeholder +* Titles of workspaces saved by previous versions keep their former `s001`-style references as plain text ### 🔄 Changes ### From 4c6f23e3a7cd5402cc32c35b3a0394d6b2182844 Mon Sep 17 00:00:00 2001 From: Thomas MALLET <t.mallet@codra.fr> Date: Wed, 14 Oct 2026 11:38:59 +0200 Subject: [PATCH 12/14] feat(titles): resolve nested source titles and fix plot title refresh In source-title mode, references are resolved recursively (cycle-safe), including in tree links and "Add object title to plot" annotations. Refresh only shown plot items when title settings change so the plot title no longer disappears. --- datalab/gui/main.py | 13 +++++++--- datalab/gui/objectview.py | 8 +++++- datalab/gui/panel/base.py | 7 ++++- datalab/objectmodel.py | 26 +++++++++++++++++++ .../common/title_short_id_unit_test.py | 5 ++-- 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/datalab/gui/main.py b/datalab/gui/main.py index a14f0dee7..1fd986d43 100644 --- a/datalab/gui/main.py +++ b/datalab/gui/main.py @@ -74,7 +74,12 @@ from datalab.gui.processor.preview import PreviewExecutorCache from datalab.gui.settings import AI_OPTION_NAMES, edit_settings from datalab.gui.welcome import WelcomePanel -from datalab.objectmodel import UUID_REGEX, ObjectGroup, get_uuid, render_title +from datalab.objectmodel import ( + UUID_REGEX, + ObjectGroup, + get_uuid, + resolve_title_references, +) from datalab.plugins import ( PluginRegistry, discover_plugins, @@ -383,13 +388,13 @@ def resolve_source_title(reference: str) -> str | None: resolved = self.resolve_title_reference(reference) return None if resolved is None else resolved[1].title - return render_title(title, resolve_source_title) + return resolve_title_references(title, resolve_source_title) def refresh_object_title_displays(self) -> None: - """Refresh object titles in trees and existing plot items.""" + """Refresh object titles in trees and shown plot items.""" for panel in (self.signalpanel, self.imagepanel): panel.objview.update_tree() - panel.refresh_plot("existing", update_items=True, force=True) + panel.refresh_plot("selected", update_items=True, force=True) @remote_controlled def get_object_uuids( diff --git a/datalab/gui/objectview.py b/datalab/gui/objectview.py index 982b9af55..4bc264aa3 100644 --- a/datalab/gui/objectview.py +++ b/datalab/gui/objectview.py @@ -51,6 +51,7 @@ find_title_references, get_title_reference, get_uuid, + resolve_title_references, ) from datalab.utils.qthelpers import block_signals from datalab.widgets.titledelegate import ( @@ -234,9 +235,14 @@ def get_title_reference_display(self, reference: str) -> str | None: return None _panel_str, obj = resolved if Conf.result_title_mode.get() == "title": - return obj.title + return resolve_title_references(obj.title, self.get_source_title) return reference + def get_source_title(self, reference: str) -> str | None: + """Return the title of the object or group a reference resolves to.""" + resolved = self.resolve_title_reference(reference) + return None if resolved is None else resolved[1].title + def build_title_reference_tooltip(self, text: str) -> str: """Return an HTML tooltip fragment listing the source objects referenced in ``text``, or an empty string when no such reference is diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py index e5bbf0800..72e3b39a6 100644 --- a/datalab/gui/panel/base.py +++ b/datalab/gui/panel/base.py @@ -4445,7 +4445,12 @@ def add_label_with_title( ) objs = self.objview.get_sel_objects(include_groups=True) for obj in objs: - create_adapter_from_object(obj).add_label_with_title(title=title) + label = ( + self.mainwindow.render_object_title(obj.title) + if title is None + else title + ) + create_adapter_from_object(obj).add_label_with_title(title=label) if ( not Conf.ignore_title_insertion_msg.get(False) and not ignore_msg diff --git a/datalab/objectmodel.py b/datalab/objectmodel.py index d942fc99c..e497cba00 100644 --- a/datalab/objectmodel.py +++ b/datalab/objectmodel.py @@ -149,6 +149,32 @@ def render_title(title: str, resolver: Callable[[str], str | None]) -> str: return title +def resolve_title_references( + title: str, + source_title: Callable[[str], str | None], + _visited: frozenset[str] = frozenset(), +) -> str: + """Replace references by source titles, resolving nested references too. + + Args: + title: Title containing object and group references + source_title: Callback returning the title of a referenced source, or None + + Returns: + Title with every resolvable reference replaced, at any nesting depth. + """ + + def resolve(reference: str) -> str | None: + if reference in _visited: + return None + source = source_title(reference) + if source is None: + return None + return resolve_title_references(source, source_title, _visited | {reference}) + + return render_title(title, resolve) + + def patch_title_with_ids( dst_obj: SignalObj | ImageObj, src_objs: list[SignalObj] | list[ImageObj], diff --git a/datalab/tests/features/common/title_short_id_unit_test.py b/datalab/tests/features/common/title_short_id_unit_test.py index 1a2527aa2..a8e65750f 100644 --- a/datalab/tests/features/common/title_short_id_unit_test.py +++ b/datalab/tests/features/common/title_short_id_unit_test.py @@ -72,16 +72,17 @@ def test_build_html_escapes_text() -> None: def test_title_mode_follows_source_renames() -> None: - """Source-title mode renders the current title of the referenced source.""" + """Source-title mode renders current source titles, at any nesting depth.""" with datalab_test_app_context(console=False) as win: panel = win.signalpanel source = create_paracetamol_signal() panel.add_object(source) panel.processor.run_feature(sips.derivative) + panel.processor.run_feature(sips.derivative) derived = panel.objview.get_current_object() assert win.render_object_title(derived.title) == derived.title with Conf.result_title_mode.context("title"): source.title = "Renamed source" assert win.render_object_title(derived.title) == ( - "derivative(Renamed source)" + "derivative(derivative(Renamed source))" ) From d24b78c7513a1980c05a25fdd7bb7b019f9d18d8 Mon Sep 17 00:00:00 2001 From: Thomas MALLET <t.mallet@codra.fr> Date: Wed, 14 Oct 2026 11:43:36 +0200 Subject: [PATCH 13/14] test(titles): cover source-title mode across operations and refreshes --- .../common/title_short_id_unit_test.py | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/datalab/tests/features/common/title_short_id_unit_test.py b/datalab/tests/features/common/title_short_id_unit_test.py index a8e65750f..e1d2c68b0 100644 --- a/datalab/tests/features/common/title_short_id_unit_test.py +++ b/datalab/tests/features/common/title_short_id_unit_test.py @@ -14,9 +14,10 @@ from __future__ import annotations +import sigima.params import sigima.proc.signal as sips from sigima.objects import create_signal -from sigima.tests.data import create_paracetamol_signal +from sigima.tests.data import create_paracetamol_signal, create_sincos_image from datalab.config import Conf from datalab.objectmodel import ( @@ -24,6 +25,7 @@ find_title_references, get_short_uuid, get_title_reference, + get_uuid, patch_title_with_ids, remap_title_references, ) @@ -86,3 +88,81 @@ def test_title_mode_follows_source_renames() -> None: assert win.render_object_title(derived.title) == ( "derivative(derivative(Renamed source))" ) + + +def test_title_mode_covers_common_operations() -> None: + """Source-title mode resolves 2-to-1, group n-to-1 and cross-panel results.""" + with datalab_test_app_context(console=False) as win: + panel = win.signalpanel + sig_a, sig_b = create_paracetamol_signal(), create_paracetamol_signal() + sig_a.title, sig_b.title = "Signal A", "Signal B" + panel.add_object(sig_a) + panel.add_object(sig_b) + panel.objview.select_objects([sig_a]) + panel.processor.run_feature("difference", sig_b) + difference = panel.objview.get_current_object() + group = panel.objmodel.get_group_from_object(sig_a) + group.title = "Campaign" + panel.objview.select_groups([group]) + panel.processor.run_feature(sips.average) + average_group = panel.objmodel.get_groups()[-1] + image = create_sincos_image() + image.title = "Image A" + win.imagepanel.add_object(image) + param = sigima.params.LineProfileParam.create( + direction="horizontal", row=100, col=100 + ) + win.imagepanel.processor.run_feature("line_profile", param) + profile = win.signalpanel.objview.get_current_object() + assert get_title_reference(sig_b) in difference.title + assert get_title_reference(group) in average_group.title + assert get_title_reference(image) in profile.title + + with Conf.result_title_mode.context("title"): + assert win.render_object_title(difference.title) == ( + difference.title.replace( + get_title_reference(sig_a), "Signal A" + ).replace(get_title_reference(sig_b), "Signal B") + ) + assert win.render_object_title(average_group.title) == ( + average_group.title.replace(get_title_reference(group), "Campaign") + ) + assert win.render_object_title(profile.title) == ( + profile.title.replace(get_title_reference(image), "Image A") + ) + + +def test_title_mode_falls_back_to_reference_of_deleted_source() -> None: + """A deleted source keeps its UUID8 reference, rendered as plain text.""" + with datalab_test_app_context(console=False) as win: + panel = win.signalpanel + source = create_paracetamol_signal() + panel.add_object(source) + panel.processor.run_feature(sips.derivative) + derived = panel.objview.get_current_object() + panel.objview.select_objects([source]) + panel.remove_object(force=True) + with Conf.result_title_mode.context("title"): + assert win.render_object_title(derived.title) == derived.title + reference = get_title_reference(source) + assert panel.objview.get_title_reference_display(reference) is None + + +def test_title_mode_refreshes_legend_and_plot_title() -> None: + """Mode changes and renames refresh the shown legend and plot title.""" + with datalab_test_app_context(console=False) as win: + panel = win.signalpanel + source = create_paracetamol_signal() + source.title = "Source" + panel.add_object(source) + panel.processor.run_feature(sips.derivative) + derived = panel.objview.get_current_object() + panel.objview.select_objects([source]) + panel.objview.select_objects([derived]) + item = panel.plothandler[get_uuid(derived)] + with Conf.result_title_mode.context("title"): + win.refresh_object_title_displays() + assert item.param.label == "derivative(Source)" + assert panel.plothandler.plot.get_title() == "derivative(Source)" + panel.rename_selected_object_or_group("My derivative") + assert item.param.label == "My derivative" From 46c98de84b4652fe328245fac91f7b30fcf03305 Mon Sep 17 00:00:00 2001 From: Thomas MALLET <t.mallet@codra.fr> Date: Wed, 14 Oct 2026 11:50:10 +0200 Subject: [PATCH 14/14] docs(titles): document nested source-title resolution and display scope update french translation --- datalab/locale/fr/LC_MESSAGES/datalab.po | 1286 +++++++++-------- doc/features/common/settings.rst | 15 +- .../LC_MESSAGES/features/advanced/plugins.po | 267 ++-- .../LC_MESSAGES/features/common/settings.po | 8 +- .../LC_MESSAGES/release_notes/release_1.04.po | 4 +- doc/release_notes/release_1.04.md | 2 +- 6 files changed, 838 insertions(+), 744 deletions(-) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index e2fadcfa2..a4f410f3c 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -4,6 +4,7 @@ # msgid "" msgstr "" +"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -64,6 +65,9 @@ msgstr "Assistant IA prêt. Configurez votre fournisseur dans le menu Edition > msgid "You" msgstr "Vous" +msgid "Assistant" +msgstr "Assistant" + msgid "Save to Macros" msgstr "Enregistrer dans les macros" @@ -936,81 +940,6 @@ msgstr "Afficher le panneau de contraste" msgid "Show or hide contrast adjustment panel" msgstr "Afficher ou cacher le panneau de réglage du contraste" -msgid "Start analysis" -msgstr "Lancer l'analyse" - -msgid "Documentation" -msgstr "Documentation" - -#, python-format -msgid "Plugin ID: %s" -msgstr "ID du plugin : %s" - -#, python-format -msgid "Version: %s" -msgstr "Version : %s" - -msgid "Recipes" -msgstr "Méthodes" - -msgid "No recipes declared" -msgstr "Aucune méthode déclarée" - -msgid "Examples" -msgstr "Exemples" - -msgid "No examples declared" -msgstr "Aucun exemple déclaré" - -msgid "Show on welcome page" -msgstr "Afficher sur la page d'accueil" - -msgid "Pin to the welcome page" -msgstr "Épingler sur la page d'accueil" - -msgid "Search applications..." -msgstr "Rechercher des applications..." - -msgid "Applications" -msgstr "Applications" - -msgid "No application plugins are currently loaded." -msgstr "Aucun plugin d'application n'est actuellement chargé." - -#, python-format -msgid "Starting analysis '%s'" -msgstr "Lancement de l'analyse '%s'" - -#, python-format -msgid "Opening example '%s'" -msgstr "Ouverture de l'exemple '%s'" - -msgid "Browse all applications..." -msgstr "Parcourir toutes les applications..." - -msgid "More actions" -msgstr "Plus d'actions" - -#, python-format -msgid "More applications (%d)" -msgstr "Autres applications (%d)" - -msgid "Browse all applications in the catalog." -msgstr "Parcourir toutes les applications du catalogue." - -msgid "Unpin" -msgstr "Désépingler" - -msgid "Pin to the top" -msgstr "Épingler en tête" - -msgid "Hide from welcome page" -msgstr "Masquer de la page d'accueil" - -#, python-format -msgid "Launching '%s'" -msgstr "Lancement de '%s'" - msgid "Command palette" msgstr "Palette de commandes" @@ -1152,9 +1081,6 @@ msgstr "Afficher les titres des objets graphiques" msgid "Show or hide ROI and other graphical object titles or subtitles" msgstr "Afficher ou cacher les titres (et sous-titres) des régions d'intérêt et autres objets graphiques" -msgid "Browse application plugins, recipes and examples" -msgstr "Parcourir les plugins d'application, les méthodes et les exemples" - msgid "Reload plugins" msgstr "Recharger les plugins" @@ -1398,6 +1324,9 @@ msgstr "Créé : paramètres de pic historiques" msgid "Created: invalid creation parameters" msgstr "Créé : paramètres de création invalides" +msgid "Created" +msgstr "Créé" + msgid "Original object" msgstr "Objet original" @@ -1485,6 +1414,20 @@ msgstr "L'objet source n'existe plus." msgid "The object that was used to create this processed object has been deleted and cannot be used for reprocessing." msgstr "L'objet qui a été utilisé pour créer cet objet traité a été supprimé et ne peut pas être utilisé pour le retraitement." +#, python-format +msgid "" +"Failed to reprocess object:\n" +"%s" +msgstr "" +"Échec du retraitement de l'objet :\n" +"%s" + +msgid "Processing was cancelled." +msgstr "Le traitement a été annulé." + +msgid "Failed to reprocess object." +msgstr "Échec du retraitement de l'objet." + msgid "Geometry results" msgstr "Résultats géométriques" @@ -1524,7 +1467,7 @@ msgstr "Écraser les fichiers existants" msgid "Preview" msgstr "Aperçu" -msgid "Add a new metadata item to the selected objects.<br><br>The metadata key will be the same for all objects, but the value can use a pattern to generate different values, optionally extracted with a regular expression.<br>Click the <b>Help</b> button for details on the pattern syntax.<br>" +msgid "Add a new metadata item to the selected objects.<br><br>The metadata key will be the same for all objects, but the value can use a pattern to generate different values.<br>Click the <b>Help</b> button for details on the pattern syntax.<br>" msgstr "Ajouter un nouvel élément de métadonnée aux objets sélectionnés.<br><br>La clé de métadonnée sera la même pour tous les objets, mais la valeur peut utiliser un modèle pour générer des valeurs différentes, éventuellement extraites par une expression régulière.<br>Cliquez sur le bouton <b>Aide</b> pour plus de détails sur la syntaxe du modèle.<br>" msgid "String" @@ -1576,12 +1519,12 @@ msgstr "Supprimer les métadonnées" msgid "Some selected objects have regions of interest.<br>Do you want to delete them as well?" msgstr "Certains objets sélectionnés ont des régions d'intérêt.<br>Souhaitez-vous les supprimer également ?" -msgid "New group" -msgstr "Nouveau groupe" - msgid "Group name:" msgstr "Nom du groupe :" +msgid "New group" +msgstr "Nouveau groupe" + #, python-format msgid "New group \"%s\"" msgstr "Nouveau groupe \"%s\"" @@ -1826,6 +1769,9 @@ msgstr "L'action %s a été ignorée : ses paramètres enregistrés n'ont pas pu msgid "History panel" msgstr "Panneau d'historique" +msgid "History files" +msgstr "Enregistrer un fichier d'historique" + #, python-format msgid "Action %s: target panel not found — skipping." msgstr "Action %s : panneau cible introuvable — ignorée." @@ -1974,6 +1920,9 @@ msgstr "Effacer toutes les macros récentes ?" msgid "Macro panel" msgstr "Panneau des macros" +msgid "Python files" +msgstr "Ouvrir le fichier" + msgid "Clear console" msgstr "Effacer la console" @@ -2058,15 +2007,6 @@ msgstr "Création des formes géométriques" msgid "Creating plot items" msgstr "Création des objets graphiques" -msgid "Input/output" -msgstr "Entrée/sortie" - -msgid "Visualization" -msgstr "Visualisation" - -msgid "Application" -msgstr "Application" - msgid "today" msgstr "aujourd'hui" @@ -2101,6 +2041,9 @@ msgstr "Modifier le répertoire" msgid "Remove directory" msgstr "Supprimer le répertoire" +msgid "from" +msgstr "depuis" + msgid "Plugin Configuration" msgstr "Configuration des plugins" @@ -2110,9 +2053,6 @@ msgstr "Activer/désactiver les plugins" msgid "Plugin settings" msgstr "Paramètres des plugins" -msgid "Install plugins" -msgstr "Installer des plugins" - msgid "Apply and reload plugins" msgstr "Appliquer et recharger les plugins" @@ -2186,290 +2126,111 @@ msgstr "Recharger les plugins" msgid "Plugin configuration has been saved. Do you want to reload plugins now to apply changes?" msgstr "La configuration des plugins a été enregistrée. Voulez-vous recharger les plugins maintenant pour appliquer les modifications ?" -msgid "Install plugin" -msgstr "Installer un plugin" - -msgid "Plugin:" -msgstr "Plugin :" - -msgid "Version:" -msgstr "Version :" +msgid "Failed to deserialize processing parameters from JSON." +msgstr "Échec de la désérialisation des paramètres de traitement depuis le format JSON." -msgid "Description:" -msgstr "Description :" +msgid "DataLab relies on various libraries to perform the computation. During the computation, errors may occur because of the data (e.g. division by zero, unexpected data type, etc.) or because of the libraries (e.g. memory error, etc.). If you encounter an error, before reporting it, please ensure that the computation is correct, by checking the data and the parameters." +msgstr "DataLab utilise plusieurs bibliothèques pour réaliser les calculs. Lors des calculs, des erreurs peuvent survenir en raison de la nature des données (par exemple division par zéro, type de données inattendu, etc.) ou à cause de limitations ou d'erreurs dans les bibliothèques (par exemple erreur mémoire, etc.). Si vous rencontrez une erreur, avant de la signaler, assurez-vous que le calcul est correct, en vérifiant les données et les paramètres." -msgid "Plugin classes:" -msgstr "Classes de plugin :" +msgid "X-array incompatibility" +msgstr "Incompatibilité des tableaux des X" -msgid "not applicable" -msgstr "non applicable" +msgid "" +"The selected signals have different X arrays.\n" +"\n" +"To perform the computation, signals need to be interpolated to match a common X array.\n" +"\n" +"Do you want to continue with automatic interpolation?" +msgstr "" +"Les signaux sélectionnés ont des tableaux des X différents.\n" +"\n" +"Pour effectuer le calcul, les signaux doivent être interpolés pour correspondre à un tableau des X commun.\n" +"\n" +"Souhaitez-vous continuer avec une interpolation automatique ?" -msgid "Dependencies:" -msgstr "Dépendances :" +msgid "Yes to All" +msgstr "Oui à tout" -msgid "Plugin module:" -msgstr "Module de plugin :" +msgid "Processing metadata is incomplete (missing source UUID)." +msgstr "Les métadonnées de traitement sont incomplètes (UUID source manquant)." -msgid "File:" -msgstr "Fichier :" +msgid "Signal was reprocessed." +msgstr "Le signal a été retraité." -msgid "Size:" -msgstr "Taille :" +msgid "Image was reprocessed." +msgstr "L'image a été retraitée." -msgid "SHA-256:" -msgstr "SHA-256 :" +msgid "Recomputing..." +msgstr "Retraitement en cours..." -msgid "A plugin runs with the same rights as DataLab: it may read and modify your files. Install only plugins from authors you trust." -msgstr "Un plugin s'exécute avec les mêmes droits que DataLab : il peut lire et modifier vos fichiers. N'installez que des plugins provenant d'auteurs de confiance." +msgid "Processing object with updated parameters..." +msgstr "Traitement de l'objet avec les paramètres mis à jour..." -msgid "Install" -msgstr "Installer" +msgid "Recomputing" +msgstr "Retraitement" #, python-format -msgid "Installed on %s from %s" -msgstr "Installé le %s depuis %s" +msgid "Computing: %s" +msgstr "Calcul en cours : %s" -msgid "Uninstall" -msgstr "Désinstaller" +msgid "In pairwise mode, you need to select objects in at least two groups." +msgstr "En mode 'pairwise', vous devez sélectionner des objets dans au moins deux groupes." -msgid "Install a plugin shared as a wheel file (<code>.whl</code>) or as a Python module (<code>datalab_*.py</code>). Wheels must be pure Python and rely only on packages already provided by DataLab." -msgstr "Installez un plugin partagé sous forme de fichier wheel (<code>.whl</code>) ou de module Python (<code>datalab_*.py</code>). Les wheels doivent être en Python pur et ne dépendre que de paquets déjà fournis par DataLab." +msgid "In pairwise mode, you need to select the same number of objects in each group." +msgstr "En mode 'pairwise', vous devez sélectionner le même nombre d'objets dans chaque groupe." -msgid "Install from file..." -msgstr "Installer depuis un fichier..." +#, python-format +msgid "Calculating: %s" +msgstr "Calcul : %s" -msgid "Plugins installed from a file" -msgstr "Plugins installés depuis un fichier" +#, python-format +msgid "Select %s" +msgstr "Sélectionner %s" -msgid "No plugin has been installed from a file." -msgstr "Aucun plugin n'a été installé depuis un fichier." +#, python-format +msgid "<u>Note:</u> operation mode is <i>pairwise</i>: %s object(s) expected (i.e. as many as in the first group)" +msgstr "<u>Note :</u> le mode d'opération est <i>pairwise</i> : %s objet(s) attendu(s) (c'est-à-dire autant que dans le premier groupe)" -msgid "Install plugin from file" -msgstr "Installer un plugin depuis un fichier" +msgid "<u>Note:</u> operation mode is <i>single operand</i>: 1 object expected" +msgstr "<u>Note :</u> le mode d'opération est <i>single</i> : 1 objet attendu" -msgid "DataLab plugins" -msgstr "Plugins DataLab" +msgid "Regions of Interest" +msgstr "Régions d'intérêt" -msgid "This file cannot be installed as a plugin:" -msgstr "Ce fichier ne peut pas être installé comme plugin :" +msgid "No ROI selected for editing." +msgstr "Aucune ROI sélectionnée pour l'édition." -#, python-format -msgid "%s will be loaded at the next start of DataLab." -msgstr "%s sera chargé au prochain démarrage de DataLab." +msgid "Remove ROI" +msgstr "Supprimer la ROI" #, python-format -msgid "%s has been installed." -msgstr "%s a été installé." - -msgid "Uninstall plugin" -msgstr "Désinstaller le plugin" +msgid "Are you sure you want to remove ROI '%s'?" +msgstr "Êtes-vous sûr de vouloir supprimer la ROI '%s' ?" #, python-format -msgid "Do you want to uninstall %s?" -msgstr "Voulez-vous désinstaller %s ?" +msgid "Remove ROI '%s'" +msgstr "Supprimer la ROI '%s'" -#, python-format -msgid "%s has been uninstalled." -msgstr "%s a été désinstallé." +msgid "Sum" +msgstr "Addition" -msgid "Do you want to reload plugins now?" -msgstr "Voulez-vous recharger les plugins maintenant ?" +msgid "Average" +msgstr "Moyenne" -msgid "Source:" -msgstr "Source :" +msgid "Standard deviation" +msgstr "Écart-type" -msgid "Available plugins" -msgstr "Plugins disponibles" +msgid "Difference" +msgstr "Soustraction" -msgid "Official" -msgstr "Officiel" +msgid "image to subtract" +msgstr "image à soustraire" -msgid "Community" -msgstr "Communauté" +msgid "Quadratic Difference" +msgstr "Différence quadratique" -msgid "Source code" -msgstr "Code source" - -#, python-format -msgid "Withdrawn: %s" -msgstr "Retiré : %s" - -msgid "Not available for DataLab desktop" -msgstr "Non disponible pour DataLab desktop" - -#, python-format -msgid "Not available for Python %s" -msgstr "Non disponible pour Python %s" - -#, python-format -msgid "Installed in the Python environment (%s)" -msgstr "Installé dans l'environnement Python (%s)" - -msgid "Installed" -msgstr "Installé" - -#, python-format -msgid "Deprecated: %s" -msgstr "Obsolète : %s" - -#, python-format -msgid "Installed: %s" -msgstr "Installé : %s" - -#, python-format -msgid "Update to %s" -msgstr "Mettre à jour vers %s" - -#, python-format -msgid "Plugins listed in the <a href=\"%s\">DataLab plugin catalog</a>. Official plugins are maintained by the DataLab team, community plugins by their authors: install only plugins from authors you trust." -msgstr "Plugins répertoriés dans le <a href=\"%s\">catalogue de plugins DataLab</a>. Les plugins officiels sont maintenus par l'équipe DataLab, les plugins de la communauté par leurs auteurs : n'installez que des plugins provenant d'auteurs de confiance." - -msgid "Search plugins" -msgstr "Rechercher des plugins" - -msgid "No plugin catalog is configured." -msgstr "Aucun catalogue de plugins n'est configuré." - -msgid "Loading the plugin catalog..." -msgstr "Chargement du catalogue de plugins..." - -#, python-format -msgid "Downloading %s..." -msgstr "Téléchargement de %s..." - -#, python-format -msgid "Plugins in the catalog: %d" -msgstr "Plugins dans le catalogue : %d" - -msgid "The plugin catalog cannot be loaded:" -msgstr "Le catalogue de plugins ne peut pas être chargé :" - -#, python-format -msgid "DataLab plugin catalog (%s)" -msgstr "Catalogue de plugins DataLab (%s)" - -msgid "Failed to deserialize processing parameters from JSON." -msgstr "Échec de la désérialisation des paramètres de traitement depuis le format JSON." - -msgid "DataLab relies on various libraries to perform the computation. During the computation, errors may occur because of the data (e.g. division by zero, unexpected data type, etc.) or because of the libraries (e.g. memory error, etc.). If you encounter an error, before reporting it, please ensure that the computation is correct, by checking the data and the parameters." -msgstr "DataLab utilise plusieurs bibliothèques pour réaliser les calculs. Lors des calculs, des erreurs peuvent survenir en raison de la nature des données (par exemple division par zéro, type de données inattendu, etc.) ou à cause de limitations ou d'erreurs dans les bibliothèques (par exemple erreur mémoire, etc.). Si vous rencontrez une erreur, avant de la signaler, assurez-vous que le calcul est correct, en vérifiant les données et les paramètres." - -msgid "X-array incompatibility" -msgstr "Incompatibilité des tableaux des X" - -msgid "" -"The selected signals have different X arrays.\n" -"\n" -"To perform the computation, signals need to be interpolated to match a common X array.\n" -"\n" -"Do you want to continue with automatic interpolation?" -msgstr "" -"Les signaux sélectionnés ont des tableaux des X différents.\n" -"\n" -"Pour effectuer le calcul, les signaux doivent être interpolés pour correspondre à un tableau des X commun.\n" -"\n" -"Souhaitez-vous continuer avec une interpolation automatique ?" - -msgid "Yes to All" -msgstr "Oui à tout" - -msgid "Processing metadata is incomplete (missing source UUID)." -msgstr "Les métadonnées de traitement sont incomplètes (UUID source manquant)." - -#, python-format -msgid "" -"Failed to reprocess object:\n" -"%s" -msgstr "" -"Échec du retraitement de l'objet :\n" -"%s" - -msgid "Processing was cancelled." -msgstr "Le traitement a été annulé." - -msgid "Failed to reprocess object." -msgstr "Échec du retraitement de l'objet." - -msgid "Signal was reprocessed." -msgstr "Le signal a été retraité." - -msgid "Image was reprocessed." -msgstr "L'image a été retraitée." - -msgid "Recomputing..." -msgstr "Retraitement en cours..." - -msgid "Processing object with updated parameters..." -msgstr "Traitement de l'objet avec les paramètres mis à jour..." - -msgid "Recomputing" -msgstr "Retraitement" - -#, python-format -msgid "Computing: %s" -msgstr "Calcul en cours : %s" - -msgid "In pairwise mode, you need to select objects in at least two groups." -msgstr "En mode 'pairwise', vous devez sélectionner des objets dans au moins deux groupes." - -msgid "In pairwise mode, you need to select the same number of objects in each group." -msgstr "En mode 'pairwise', vous devez sélectionner le même nombre d'objets dans chaque groupe." - -msgid "A preview source was removed. The processing was cancelled." -msgstr "Un objet source de l'aperçu a été supprimé. Le traitement a été annulé." - -#, python-format -msgid "Calculating: %s" -msgstr "Calcul : %s" - -#, python-format -msgid "Select %s" -msgstr "Sélectionner %s" - -#, python-format -msgid "<u>Note:</u> operation mode is <i>pairwise</i>: %s object(s) expected (i.e. as many as in the first group)" -msgstr "<u>Note :</u> le mode d'opération est <i>pairwise</i> : %s objet(s) attendu(s) (c'est-à-dire autant que dans le premier groupe)" - -msgid "<u>Note:</u> operation mode is <i>single operand</i>: 1 object expected" -msgstr "<u>Note :</u> le mode d'opération est <i>single</i> : 1 objet attendu" - -msgid "Regions of Interest" -msgstr "Régions d'intérêt" - -msgid "No ROI selected for editing." -msgstr "Aucune ROI sélectionnée pour l'édition." - -msgid "Remove ROI" -msgstr "Supprimer la ROI" - -#, python-format -msgid "Are you sure you want to remove ROI '%s'?" -msgstr "Êtes-vous sûr de vouloir supprimer la ROI '%s' ?" - -#, python-format -msgid "Remove ROI '%s'" -msgstr "Supprimer la ROI '%s'" - -msgid "Sum" -msgstr "Addition" - -msgid "Average" -msgstr "Moyenne" - -msgid "Standard deviation" -msgstr "Écart-type" - -msgid "Difference" -msgstr "Soustraction" - -msgid "image to subtract" -msgstr "image à soustraire" - -msgid "Quadratic Difference" -msgstr "Différence quadratique" - -msgid "Product" -msgstr "Produit" +msgid "Product" +msgstr "Produit" msgid "Division" msgstr "Division" @@ -2598,6 +2359,9 @@ msgstr "" " • y' = a0 + a1*y + a2*y^2 + ...\n" " • z' = a0 + a1*z + a2*z^2 + ..." +msgid "Swap X/Y axes" +msgstr "Permuter les axes X/Y" + msgid "Normalize" msgstr "Normaliser" @@ -2697,9 +2461,6 @@ msgstr "Seuillage Triangle" msgid "Yen thresholding" msgstr "Seuillage Yen" -msgid "Brightness and contrast" -msgstr "Luminosité et contraste" - msgid "Gamma correction" msgstr "Correction gamma" @@ -2991,9 +2752,6 @@ msgstr "" " • x' = ax + b\n" " • y' = ay + b" -msgid "Swap X/Y axes" -msgstr "Permuter les axes X/Y" - msgid "Reverse X-axis" msgstr "Inverser l'axe des X" @@ -3319,18 +3077,6 @@ msgstr "Mode d'opération" msgid "Operation mode for computations taking <i>N</i> inputs:<ul><li><b>single</b>: single operand mode</li><li><b>pairwise</b>: pairwise operation mode</li></ul><br>Computations taking <i>N</i> inputs are the ones where:<ul><li>N(>=2) objects in %s 1 object out</li><li>N(>=1) objects + 1 object in %s N objects out</li></ul>" msgstr "Mode d'opération pour les calculs prenant <i>N</i> entrées:<ul><li><b>single</b>: mode d'opération avec un seul opérande</li><li><b>pairwise</b>: mode d'opération par paire</li></ul><br>Les calculs prenant <i>N</i> entrées sont ceux où :<ul><li>N(>=2) objets en %s 1 objet en sortie</li><li>N(>=1) objets + 1 objet en %s N objets en sortie</li></ul>" -msgid "References in result titles" -msgstr "Références dans les titres de résultats" - -msgid "Source UUID" -msgstr "UUID source" - -msgid "Source title" -msgstr "Titre de la source" - -msgid "Display source references in generated titles as short UUIDs or as the current source titles. This setting does not change the stored UUID references." -msgstr "Afficher les références aux sources dans les titres générés sous forme d'UUID courts ou avec les titres actuels des sources. Ce réglage ne modifie pas les références UUID enregistrées." - msgid "Use current signal bounds" msgstr "Utiliser les bornes du signal courant" @@ -3631,6 +3377,9 @@ msgstr "État d'activation de la console" msgid "General" msgstr "Général" +msgid "Visualization" +msgstr "Visualisation" + msgid "I/O" msgstr "Entrée/sortie" @@ -3786,123 +3535,112 @@ msgstr "C'est la fin de la visite guidée !" msgid "You can show the tour again, or close this dialog box." msgstr "Vous pouvez afficher la visite guidée à nouveau, ou fermer cette boîte de dialogue." -msgid "Save history file" -msgstr "Enregistrer un fichier d'historique" - -msgid "Open history file" -msgstr "Ouvrir un fichier d'historique" +msgid "Welcome" +msgstr "Accueil" -msgid "Imported" -msgstr "Importé" +msgid "Show welcome page when the current panel is empty" +msgstr "Afficher la page d'accueil lorsque le panneau courant est vide" #, python-format -msgid "Failed to deserialize history DataSet kwarg %r." -msgstr "Échec de la désérialisation de l'argument DataSet de l'historique %r." +msgid "Version %s" +msgstr "Version %s" -#, python-format -msgid "Failed to deserialize history ROI kwarg %r." -msgstr "Échec de la désérialisation de l'argument ROI de l'historique %r." +msgid "Create..." +msgstr "Créer..." -#, python-format -msgid "Failed to deserialize history DataSet-list kwarg %r." -msgstr "Échec de la désérialisation de l'argument DataSet-list de l'historique %r." +msgid "Generate a 1D signal or 2D image from a template." +msgstr "Générez un signal 1D ou une image 2D à partir d'un modèle." -msgid "Plugin name must be a non-empty single line" -msgstr "Le nom du plugin doit être une ligne non vide" +msgid "Open file..." +msgstr "Ouvrir un fichier..." -msgid "Package name must start with a lowercase letter and contain only lowercase letters, digits, and underscores" -msgstr "Le nom du package doit commencer par une lettre minuscule et ne contenir que des lettres minuscules, des chiffres et des tirets bas" +msgid "Load a signal or image from your computer." +msgstr "Chargez un signal ou une image depuis votre ordinateur." -msgid "Package name must not be a Python keyword" -msgstr "Le nom du package ne doit pas être un mot-clé Python" +msgid "Browse HDF5 file..." +msgstr "Parcourir un fichier HDF5..." -msgid "Plugin ID must be a lowercase dotted identifier such as 'org.example.my-plugin'" -msgstr "L'ID du plugin doit être un identifiant en minuscules séparé par des points, comme 'org.example.mon-plugin'" +msgid "Inspect any HDF5 file and import selected datasets as signals or images." +msgstr "Inspectez n'importe quel fichier HDF5 et importez les jeux de données choisis comme signaux ou images." -msgid "Description must be a single line" -msgstr "La description doit tenir sur une seule ligne" +msgid "Open HDF5 workspace..." +msgstr "Ouvrir un espace de travail HDF5..." -msgid "Plugin capabilities are invalid" -msgstr "Les capacités du plugin ne sont pas valides" +msgid "Resume a previously saved DataLab workspace." +msgstr "Reprenez un espace de travail DataLab précédemment enregistré." -msgid "Plugin capabilities must be unique" -msgstr "Les capacités du plugin doivent être uniques" +msgid "Import text data..." +msgstr "Importer des données texte..." -msgid "Object kind must be 'signal' or 'image'" -msgstr "Le type d'objet doit être 'signal' ou 'image'" +msgid "Bring in CSV / TSV / column data with the wizard." +msgstr "Importez des données CSV / TSV / en colonnes avec l'assistant." -msgid "The minimal template currently supports BSD-3-Clause" -msgstr "Le modèle minimal prend actuellement en charge BSD-3-Clause" +msgid "Walkthroughs" +msgstr "Visites guidées" -msgid "GitHub account must be a GitHub user or organization" -msgstr "Le compte GitHub doit être un utilisateur ou une organisation GitHub" +msgid "Ask the AI assistant" +msgstr "Interroger l'assistant IA" -msgid "Plugin name must contain letters or digits" -msgstr "Le nom du plugin doit contenir des lettres ou des chiffres" +msgid "Chat with the built-in assistant to inspect, create and process your data." +msgstr "Dialoguez avec l'assistant intégré pour inspecter, créer et traiter vos données." -#, python-format -msgid "Destination already exists: %s" -msgstr "La destination existe déjà : %s" +msgid "Take the guided tour" +msgstr "Suivre la visite guidée" -#, python-format -msgid "%s is required" -msgstr "%s est requis" +msgid "A short interactive walk-through of the DataLab workspace." +msgstr "Une courte visite interactive de l'espace de travail DataLab." -msgid "Plugin name" -msgstr "Nom du plugin" +msgid "Run the demo" +msgstr "Lancer la démo" -msgid "GitHub account (optional)" -msgstr "Compte GitHub (facultatif)" +msgid "Watch DataLab create, process and analyze signals and images." +msgstr "Regardez DataLab créer, traiter et analyser des signaux et des images." -msgid "Package name" -msgstr "Nom du package" +msgid "Read the documentation" +msgstr "Lire la documentation" -msgid "Plugin ID" -msgstr "ID du plugin" +msgid "Open the online user guide, tutorials and reference." +msgstr "Ouvrez le guide utilisateur en ligne, les tutoriels et la référence." #, python-format -msgid "%s plugin for DataLab" -msgstr "Plugin %s pour DataLab" - -msgid "Description" -msgstr "Description" - -msgid "Destination" -msgstr "Destination" +msgid "What's new in version %s" +msgstr "Nouveautés de la version %s" -msgid "Create and maintain DataLab plugin projects." -msgstr "Créer et maintenir des projets de plugins DataLab." +msgid "Browse the release notes of this version." +msgstr "Consultez les nouveautés et corrections de cette version." -msgid "Create a layered installable plugin project." -msgstr "Créer un projet de plugin installable structuré en couches." +msgid "Image" +msgstr "Image" -msgid "Plugin display name" -msgstr "Nom d'affichage du plugin" +msgid "Signal" +msgstr "Signal" -msgid "Python import package name" -msgstr "Nom du package d'import Python" +msgid "Get started" +msgstr "Pour commencer" -msgid "Stable reverse-domain ID" -msgstr "ID stable en notation de domaine inversé" +msgid "Quick actions" +msgstr "Actions rapides" -msgid "One-line description" -msgstr "Description sur une ligne" +msgid "Save history file" +msgstr "Enregistrer un fichier d'historique" -msgid "GitHub user or organization hosting the project" -msgstr "Utilisateur ou organisation GitHub hébergeant le projet" +msgid "Open history file" +msgstr "Ouvrir un fichier d'historique" -msgid "Plugin capability; may be repeated" -msgstr "Capacité du plugin ; l'option peut être répétée" +msgid "Imported" +msgstr "Importé" -msgid "Object type used by the sample processing" -msgstr "Type d'objet utilisé par le traitement d'exemple" +#, python-format +msgid "Failed to deserialize history DataSet kwarg %r." +msgstr "Échec de la désérialisation de l'argument DataSet de l'historique %r." -msgid "Project license" -msgstr "Licence du projet" +#, python-format +msgid "Failed to deserialize history ROI kwarg %r." +msgstr "Échec de la désérialisation de l'argument ROI de l'historique %r." #, python-format -msgid "Created DataLab plugin project at %s" -msgstr "Projet de plugin DataLab créé dans %s" +msgid "Failed to deserialize history DataSet-list kwarg %r." +msgstr "Échec de la désérialisation de l'argument DataSet-list de l'historique %r." msgid "Registered plugins:" msgstr "Plugins enregistrés :" @@ -3913,12 +3651,6 @@ msgstr "Aucun plugin disponible" msgid "Plugins are disabled (see DataLab settings)" msgstr "Les plugins sont désactivés (voir les préférences de DataLab)" -msgid "Opening this example replaces the current workspace. Continue?" -msgstr "L'ouverture de cet exemple remplace l'espace de travail actuel. Continuer ?" - -msgid "Open example" -msgstr "Ouvrir l'exemple" - msgid "Warning" msgstr "Attention" @@ -4163,14 +3895,14 @@ msgstr "Afficher les détails" msgid "Hide details" msgstr "Masquer les détails" -msgid "Date and time" -msgstr "Date et heure" - msgid "Title" msgstr "Titre" -msgid "Title" -msgstr "Titre" +msgid "Description" +msgstr "Description" + +msgid "Date and time" +msgstr "Date et heure" msgid "Action is compatible with the current workspace state." msgstr "L'action est compatible avec l'état actuel de l'espace de travail." @@ -4259,12 +3991,12 @@ msgstr "Cliquer pour voir les infos de connexion" msgid "Source" msgstr "Source" -msgid "Clipboard" -msgstr "Presse-papiers" - msgid "File" msgstr "Fichier" +msgid "Clipboard" +msgstr "Presse-papiers" + msgid "Source of the data" msgstr "Source des données" @@ -4447,199 +4179,328 @@ msgstr "Importer depuis :" msgid "DataLab Import Wizard" msgstr "Assistant d'importation de DataLab" -msgid "Signal" -msgstr "Signal" - msgid "Shape" msgstr "Forme" -msgid "Image" -msgstr "Image" - msgid "Dimensions" msgstr "Dimensions" -msgid "Minimum value" -msgstr "Valeur minimum" +#, python-format +msgid "Opening tool '%s'" +msgstr "Ouverture de l'outil '%s'" -msgid "Maximum value" -msgstr "Valeur maximum" +msgid "Browse application plugins, recipes and examples" +msgstr "Parcourir les plugins d'application, les méthodes et les exemples" -msgid "Step between levels" -msgstr "Pas entre les niveaux" +msgid "Welcome page" +msgstr "Page d'accueil" -msgid "Overlay image" -msgstr "Image de fond" +#, python-format +msgid "e.g. %s" +msgstr "ex. %s" -msgid "Show level labels" -msgstr "Afficher les étiquettes de niveaux" +msgid "Add a new metadata item to the selected objects.<br><br>The metadata key will be the same for all objects, but the value can use a pattern to generate different values, optionally extracted with a regular expression.<br>Click the <b>Help</b> button for details on the pattern syntax.<br>" +msgstr "Ajouter un nouvel élément de métadonnée aux objets sélectionnés.<br><br>La clé de métadonnée sera la même pour tous les objets, mais la valeur peut utiliser un modèle pour générer des valeurs différentes, éventuellement extraites par une expression régulière.<br>Cliquez sur le bouton <b>Aide</b> pour plus de détails sur la syntaxe du modèle.<br>" -msgid "Contour isoline plot" -msgstr "Tracé de lignes de contour" +msgid "Select a key..." +msgstr "Choisir une clé..." -msgid "Display isolines (contour lines) overlaid on the selected image, with configurable level range, step, and optional value labels" -msgstr "Afficher les isolignes (lignes de contour) superposées à l'image sélectionnée, avec une plage de niveaux configurable, un pas entre les niveaux, et des étiquettes de valeurs optionnelles" +msgid "unchanged" +msgstr "inchangé" -msgid "Annotation" -msgstr "Annotation" +msgid "Known keys" +msgstr "Clés connues" -msgid "Contour" -msgstr "Contour" +msgid "Copy a key found on the selected objects into the metadata key" +msgstr "Copier dans la clé de métadonnée une clé présente sur les objets sélectionnés" -msgid "Select a single image first." -msgstr "Sélectionnez d'abord une seule image." +msgid "Extraction pattern" +msgstr "Motif d'extraction" -msgid "Selected image does not contain finite data." -msgstr "L'image sélectionnée ne contient pas de données finies." +msgid "Optional regular expression searched in the formatted value: its first group, or the whole match, becomes the value" +msgstr "Expression régulière facultative recherchée dans la valeur formatée : son premier groupe, ou toute la correspondance, devient la valeur" -msgid "Selected image is constant: no contour can be drawn." -msgstr "L'image sélectionnée est constante : aucun contour ne peut être tracé." +msgid "If no match" +msgstr "Sans correspondance" -msgid "Contour plot parameters" -msgstr "Paramètres du tracé de contours" +msgid "Leave the object unchanged" +msgstr "Laisser l'objet inchangé" -msgid "Maximum value must be strictly greater than minimum." -msgstr "La valeur maximale doit être strictement supérieure à la valeur minimale." +msgid "Report an error" +msgstr "Signaler une erreur" -msgid "No contour was found for the selected level range." -msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée." +msgid "Scale factor" +msgstr "Facteur d'échelle" -msgid "Show contour plot..." -msgstr "Afficher le tracé de contours..." +msgid "Multiplies numeric values, e.g. 0.001 to convert ms to s" +msgstr "Multiplie les valeurs numériques, par ex. 0.001 pour convertir des ms en s" -msgid "Show welcome page when the current panel is empty" -msgstr "Afficher la page d'accueil lorsque le panneau courant est vide" +#, python-format +msgid "Version: %s" +msgstr "Version : %s" -msgid "The welcome page gathers the main actions to get started (create or open data, guided tour, documentation...). It is shown at startup and whenever the current signal or image panel is empty." -msgstr "La page d'accueil rassemble les principales actions pour bien démarrer (créer ou ouvrir des données, visite guidée, documentation...). Elle est affichée au démarrage et chaque fois que le panneau signal ou image courant est vide." +msgid "Expected inputs" +msgstr "Entrées attendues" -msgid "Welcome page application rows" -msgstr "Lignes d'applications de la page d'accueil" +msgid "Run on selection..." +msgstr "Exécuter sur la sélection..." -msgid "Maximum number of application tile rows on the welcome page. Other applications remain available from the Applications catalog." -msgstr "Nombre maximal de lignes de tuiles d'applications sur la page d'accueil. Les autres applications restent accessibles depuis le catalogue des applications." +msgid "Examples designed for this method" +msgstr "Exemples conçus pour cette méthode" -msgid "Welcome" -msgstr "Accueil" +msgid "Try with this example" +msgstr "Essayer avec cet exemple" -msgid "Welcome page" -msgstr "Page d'accueil" +msgid "The current selection could not be assessed" +msgstr "La sélection courante n'a pas pu être évaluée" -#, python-format -msgid "Version %s" -msgstr "Version %s" +msgid "Show on welcome page" +msgstr "Afficher sur la page d'accueil" -msgid "Create..." -msgstr "Créer..." +msgid "Pin to the welcome page" +msgstr "Épingler sur la page d'accueil" -msgid "Generate a 1D signal or 2D image from a template." -msgstr "Générez un signal 1D ou une image 2D à partir d'un modèle." +msgid "Documentation" +msgstr "Documentation" -msgid "Open file..." -msgstr "Ouvrir un fichier..." +#, python-format +msgid "Plugin ID: %s" +msgstr "ID du plugin : %s" -msgid "Load a signal or image from your computer." -msgstr "Chargez un signal ou une image depuis votre ordinateur." +msgid "No methods declared" +msgstr "Aucune méthode déclarée" -msgid "Browse HDF5 file..." -msgstr "Parcourir un fichier HDF5..." +msgid "Open tool" +msgstr "Ouvrir l'outil" -msgid "Inspect any HDF5 file and import selected datasets as signals or images." -msgstr "Inspectez n'importe quel fichier HDF5 et importez les jeux de données choisis comme signaux ou images." +#, python-format +msgid "Tools (%d)" +msgstr "Outils (%d)" -msgid "Open HDF5 workspace..." -msgstr "Ouvrir un espace de travail HDF5..." +msgid "Open dataset" +msgstr "Ouvrir le jeu de données" -msgid "Resume a previously saved DataLab workspace." -msgstr "Reprenez un espace de travail DataLab précédemment enregistré." +#, python-format +msgid "Datasets (%d)" +msgstr "Jeux de données (%d)" -msgid "Import text data..." -msgstr "Importer des données texte..." +#, python-format +msgid "Created %d objects" +msgstr "%d objets créés" -msgid "Bring in CSV / TSV / column data with the wizard." -msgstr "Importez des données CSV / TSV / en colonnes avec l'assistant." +msgid "Search applications..." +msgstr "Rechercher des applications..." -msgid "Walkthroughs" -msgstr "Visites guidées" +msgid "Hide the application list" +msgstr "Masquer la liste des applications" -msgid "Ask the AI assistant" -msgstr "Interroger l'assistant IA" +msgid "Show the application list" +msgstr "Afficher la liste des applications" -msgid "Chat with the built-in assistant to inspect, create and process your data." -msgstr "Dialoguez avec l'assistant intégré pour inspecter, créer et traiter vos données." +msgid "No application plugins are currently loaded." +msgstr "Aucun plugin d'application n'est actuellement chargé." -msgid "Take the guided tour" -msgstr "Suivre la visite guidée" +#, python-format +msgid "Starting analysis '%s'" +msgstr "Lancement de l'analyse '%s'" -msgid "A short interactive walk-through of the DataLab workspace." -msgstr "Une courte visite interactive de l'espace de travail DataLab." +#, python-format +msgid "Trying example '%s'" +msgstr "Essai de l'exemple '%s'" -msgid "Run the demo" -msgstr "Lancer la démo" +#, python-format +msgid "Opening example '%s'" +msgstr "Ouverture de l'exemple '%s'" -msgid "Watch DataLab create, process and analyze signals and images." -msgstr "Regardez DataLab créer, traiter et analyser des signaux et des images." +msgid "Official" +msgstr "Officiel" -msgid "Read the documentation" -msgstr "Lire la documentation" +msgid "Community" +msgstr "Communauté" -msgid "Open the online user guide, tutorials and reference." -msgstr "Ouvrez le guide utilisateur en ligne, les tutoriels et la référence." +msgid "Source code" +msgstr "Code source" #, python-format -msgid "What's new in version %s" -msgstr "Nouveautés de la version %s" +msgid "Withdrawn: %s" +msgstr "Retiré : %s" -msgid "Browse the release notes of this version." -msgstr "Consultez les nouveautés et corrections de cette version." +#, python-format +msgid "Not available for Python %s" +msgstr "Non disponible pour Python %s" -msgid "Get started" -msgstr "Pour commencer" +msgid "Not available for DataLab desktop" +msgstr "Non disponible pour DataLab desktop" -msgid "Quick actions" -msgstr "Actions rapides" +#, python-format +msgid "Installed in the Python environment (%s)" +msgstr "Installé dans l'environnement Python (%s)" -msgid "Expected inputs" -msgstr "Entrées attendues" +msgid "Installed" +msgstr "Installé" -msgid "Run on selection..." -msgstr "Exécuter sur la sélection..." +#, python-format +msgid "Deprecated: %s" +msgstr "Obsolète : %s" -msgid "Examples designed for this method" -msgstr "Exemples conçus pour cette méthode" +#, python-format +msgid "Installed: %s" +msgstr "Installé : %s" -msgid "Try with this example" -msgstr "Essayer avec cet exemple" +#, python-format +msgid "Update to %s" +msgstr "Mettre à jour vers %s" -msgid "The current selection could not be assessed" -msgstr "La sélection courante n'a pas pu être évaluée" +msgid "Install" +msgstr "Installer" -msgid "Methods" -msgstr "Méthodes" +#, python-format +msgid "Plugins listed in the <a href=\"%s\">DataLab plugin catalog</a>. Official plugins are maintained by the DataLab team, community plugins by their authors: install only plugins from authors you trust." +msgstr "Plugins répertoriés dans le <a href=\"%s\">catalogue de plugins DataLab</a>. Les plugins officiels sont maintenus par l'équipe DataLab, les plugins de la communauté par leurs auteurs : n'installez que des plugins provenant d'auteurs de confiance." -msgid "No methods declared" -msgstr "Aucune méthode déclarée" +msgid "Search plugins" +msgstr "Rechercher des plugins" -msgid "Tools" -msgstr "Outils" +msgid "No plugin catalog is configured." +msgstr "Aucun catalogue de plugins n'est configuré." -msgid "Open tool" -msgstr "Ouvrir l'outil" +msgid "Loading the plugin catalog..." +msgstr "Chargement du catalogue de plugins..." -msgid "Datasets" -msgstr "Jeux de données" +#, python-format +msgid "Downloading %s..." +msgstr "Téléchargement de %s..." -msgid "Open dataset" -msgstr "Ouvrir le jeu de données" +#, python-format +msgid "Plugins in the catalog: %d" +msgstr "Plugins dans le catalogue : %d" -msgid "Created %d objects" -msgstr "%d objets créés" +msgid "The plugin catalog cannot be loaded:" +msgstr "Le catalogue de plugins ne peut pas être chargé :" -msgid "Trying example '%s'" -msgstr "Essai de l'exemple '%s'" +#, python-format +msgid "DataLab plugin catalog (%s)" +msgstr "Catalogue de plugins DataLab (%s)" -msgid "Opening tool '%s'" -msgstr "Ouverture de l'outil '%s'" +msgid "Install plugin" +msgstr "Installer un plugin" + +msgid "Input/output" +msgstr "Entrée/sortie" + +msgid "Application" +msgstr "Application" + +msgid "Available plugins" +msgstr "Plugins disponibles" + +msgid "Install plugins" +msgstr "Installer des plugins" + +msgid "Source:" +msgstr "Source :" + +msgid "Plugin:" +msgstr "Plugin :" + +msgid "Version:" +msgstr "Version :" + +msgid "Description:" +msgstr "Description :" + +msgid "Plugin classes:" +msgstr "Classes de plugin :" + +msgid "not applicable" +msgstr "non applicable" + +msgid "Dependencies:" +msgstr "Dépendances :" + +msgid "Plugin module:" +msgstr "Module de plugin :" + +msgid "File:" +msgstr "Fichier :" + +msgid "Size:" +msgstr "Taille :" + +msgid "SHA-256:" +msgstr "SHA-256 :" + +msgid "A plugin runs with the same rights as DataLab: it may read and modify your files. Install only plugins from authors you trust." +msgstr "Un plugin s'exécute avec les mêmes droits que DataLab : il peut lire et modifier vos fichiers. N'installez que des plugins provenant d'auteurs de confiance." + +#, python-format +msgid "Installed on %s from %s" +msgstr "Installé le %s depuis %s" + +msgid "Uninstall" +msgstr "Désinstaller" + +msgid "Install a plugin shared as a wheel file (<code>.whl</code>) or as a Python module (<code>datalab_*.py</code>). Wheels must be pure Python and rely only on packages already provided by DataLab." +msgstr "Installez un plugin partagé sous forme de fichier wheel (<code>.whl</code>) ou de module Python (<code>datalab_*.py</code>). Les wheels doivent être en Python pur et ne dépendre que de paquets déjà fournis par DataLab." + +msgid "Install from file..." +msgstr "Installer depuis un fichier..." + +msgid "Plugins installed from a file" +msgstr "Plugins installés depuis un fichier" + +msgid "No plugin has been installed from a file." +msgstr "Aucun plugin n'a été installé depuis un fichier." + +msgid "Install plugin from file" +msgstr "Installer un plugin depuis un fichier" + +msgid "DataLab plugins" +msgstr "Plugins DataLab" + +msgid "This file cannot be installed as a plugin:" +msgstr "Ce fichier ne peut pas être installé comme plugin :" + +#, python-format +msgid "%s will be loaded at the next start of DataLab." +msgstr "%s sera chargé au prochain démarrage de DataLab." + +#, python-format +msgid "%s has been installed." +msgstr "%s a été installé." + +msgid "Uninstall plugin" +msgstr "Désinstaller le plugin" + +#, python-format +msgid "Do you want to uninstall %s?" +msgstr "Voulez-vous désinstaller %s ?" + +#, python-format +msgid "%s has been uninstalled." +msgstr "%s a été désinstallé." + +msgid "Do you want to reload plugins now?" +msgstr "Voulez-vous recharger les plugins maintenant ?" + +msgid "Live" +msgstr "En direct" + +msgid "Refresh the view continuously" +msgstr "Rafraîchir la vue en continu" + +msgid "Acquire" +msgstr "Acquérir" + +msgid "Add an acquisition to the workspace, in a new group" +msgstr "Ajouter une acquisition à l'espace de travail, dans un nouveau groupe" + +msgid "Invalid settings" +msgstr "Réglages non valides" + +#, python-format +msgid "%d objects added to group '%s'" +msgstr "%d objets ajoutés au groupe '%s'" msgid "One signal" msgstr "Un signal" @@ -4647,9 +4508,11 @@ msgstr "Un signal" msgid "One image" msgstr "Une image" +#, python-format msgid "At least %d signals" msgstr "Au moins %d signaux" +#, python-format msgid "At least %d images" msgstr "Au moins %d images" @@ -4659,6 +4522,7 @@ msgstr "Un ou plusieurs signaux" msgid "One or more images" msgstr "Une ou plusieurs images" +#, python-format msgid "%s (optional)" msgstr "%s (facultatif)" @@ -4671,24 +4535,31 @@ msgstr "indicative" msgid "No input data" msgstr "Aucune donnée d'entrée" +#, python-format msgid "%(slot)s: no object assigned" msgstr "%(slot)s : aucun objet affecté" +#, python-format msgid "%(slot)s: choose the objects to use" msgstr "%(slot)s : choisir les objets à utiliser" +#, python-format msgid "%(slot)s: %(count)d object(s) assigned, at least %(min_count)d required" msgstr "%(slot)s : %(count)d objet(s) affecté(s), au moins %(min_count)d requis" +#, python-format msgid "%(slot)s: accepts only one object (%(count)d assigned)" msgstr "%(slot)s : un seul objet accepté (%(count)d affectés)" +#, python-format msgid "%(slot)s: %(count)d object(s) of the wrong type (%(titles)s)" msgstr "%(slot)s : %(count)d objet(s) de type incorrect (%(titles)s)" +#, python-format msgid "%(slot)s: metadata '%(key)s' missing on %(count)d object(s) (%(titles)s)" msgstr "%(slot)s : métadonnée '%(key)s' absente de %(count)d objet(s) (%(titles)s)" +#, python-format msgid "%(slot)s: %(count)d object(s) assigned twice (%(titles)s)" msgstr "%(slot)s : %(count)d objet(s) affecté(s) deux fois (%(titles)s)" @@ -4707,9 +4578,14 @@ msgstr "La sélection courante ne peut pas être analysée" msgid "Select the input data in the workspace" msgstr "Sélectionner les données d'entrée dans l'espace de travail" +msgid "To set it, use Edit > Metadata > Add metadata..." +msgstr "Pour la définir, utilisez Édition > Métadonnées > Ajouter des métadonnées..." + +#, python-format msgid "Inputs of '%s'" msgstr "Entrées de '%s'" +#, python-format msgid "(missing: %s)" msgstr "(manquant : %s)" @@ -4719,71 +4595,79 @@ msgstr "Non assigné" msgid "No compatible object" msgstr "Aucun objet compatible" +#, python-format msgid "No selected object can be used by '%s'." msgstr "Aucun objet sélectionné n'est utilisable par '%s'." msgid "Expected inputs:" msgstr "Entrées attendues :" +#, python-format msgid "Running '%s'" msgstr "Exécution de '%s'" -msgid "Hide the application list" -msgstr "Masquer la liste des applications" +msgid "A preview source was removed. The processing was cancelled." +msgstr "Un objet source de l'aperçu a été supprimé. Le traitement a été annulé." -msgid "Show the application list" -msgstr "Afficher la liste des applications" +msgid "Brightness and contrast" +msgstr "Luminosité et contraste" -msgid "Tools (%d)" -msgstr "Outils (%d)" +msgid "The welcome page gathers the main actions to get started (create or open data, guided tour, documentation...). It is shown at startup and whenever the current signal or image panel is empty." +msgstr "La page d'accueil rassemble les principales actions pour bien démarrer (créer ou ouvrir des données, visite guidée, documentation...). Elle est affichée au démarrage et chaque fois que le panneau signal ou image courant est vide." -msgid "Datasets (%d)" -msgstr "Jeux de données (%d)" +msgid "Welcome page application rows" +msgstr "Lignes d'applications de la page d'accueil" -msgid "Scientific signal and image processing — version %s" -msgstr "Traitement scientifique du signal et de l'image — version %s" +msgid "Maximum number of application tile rows on the welcome page. Other applications remain available from the Applications catalog." +msgstr "Nombre maximal de lignes de tuiles d'applications sur la page d'accueil. Les autres applications restent accessibles depuis le catalogue des applications." -msgid "Desktop edition of DataLab" -msgstr "Édition Desktop de DataLab" +msgid "References in result titles" +msgstr "Références dans les titres de résultats" -msgid "e.g. %s" -msgstr "ex. %s" +msgid "Source UUID" +msgstr "UUID source" -msgid "Select a key..." -msgstr "Choisir une clé..." +msgid "Source title" +msgstr "Titre de la source" -msgid "Known keys" -msgstr "Clés connues" +msgid "Display source references in generated titles as short UUIDs or as the current source titles. This setting does not change the stored UUID references." +msgstr "Afficher les références aux sources dans les titres générés sous forme d'UUID courts ou avec les titres actuels des sources. Ce réglage ne modifie pas les références UUID enregistrées." -msgid "Copy a key found on the selected objects into the metadata key" -msgstr "Copier dans la clé de métadonnée une clé présente sur les objets sélectionnés" +msgid "More actions" +msgstr "Plus d'actions" -msgid "Extraction pattern" -msgstr "Motif d'extraction" +#, python-format +msgid "More applications (%d)" +msgstr "Autres applications (%d)" -msgid "Optional regular expression searched in the formatted value: its first group, or the whole match, becomes the value" -msgstr "Expression régulière facultative recherchée dans la valeur formatée : son premier groupe, ou toute la correspondance, devient la valeur" +msgid "Browse all applications in the catalog." +msgstr "Parcourir toutes les applications du catalogue." -msgid "If no match" -msgstr "Sans correspondance" +msgid "Browse all applications..." +msgstr "Parcourir toutes les applications..." -msgid "Leave the object unchanged" -msgstr "Laisser l'objet inchangé" +msgid "Applications" +msgstr "Applications" -msgid "Report an error" -msgstr "Signaler une erreur" +msgid "Pin to the top" +msgstr "Épingler en tête" -msgid "Scale factor" -msgstr "Facteur d'échelle" +msgid "Unpin" +msgstr "Désépingler" -msgid "Multiplies numeric values, e.g. 0.001 to convert ms to s" -msgstr "Multiplie les valeurs numériques, par ex. 0.001 pour convertir des ms en s" +msgid "Hide from welcome page" +msgstr "Masquer de la page d'accueil" -msgid "unchanged" -msgstr "inchangé" +#, python-format +msgid "Launching '%s'" +msgstr "Lancement de '%s'" -msgid "To set it, use Edit > Metadata > Add metadata..." -msgstr "Pour la définir, utilisez Édition > Métadonnées > Ajouter des métadonnées..." +#, python-format +msgid "Scientific signal and image processing — version %s" +msgstr "Traitement scientifique du signal et de l'image — version %s" + +msgid "Desktop edition of DataLab" +msgstr "Édition Desktop de DataLab" msgid "Select one signal" msgstr "Sélectionnez un signal" @@ -4812,20 +4696,150 @@ msgstr "Sélectionnez au moins deux images" msgid "Select at least two objects" msgstr "Sélectionnez au moins deux objets" -msgid "Live" -msgstr "En direct" +msgid "Opening this example replaces the current workspace. Continue?" +msgstr "L'ouverture de cet exemple remplace l'espace de travail actuel. Continuer ?" -msgid "Refresh the view continuously" -msgstr "Rafraîchir la vue en continu" +msgid "Open example" +msgstr "Ouvrir l'exemple" -msgid "Acquire" -msgstr "Acquérir" +msgid "Plugin name must be a non-empty single line" +msgstr "Le nom du plugin doit être une ligne non vide" -msgid "Add an acquisition to the workspace, in a new group" -msgstr "Ajouter une acquisition à l'espace de travail, dans un nouveau groupe" +msgid "Package name must start with a lowercase letter and contain only lowercase letters, digits, and underscores" +msgstr "Le nom du package doit commencer par une lettre minuscule et ne contenir que des lettres minuscules, des chiffres et des tirets bas" -msgid "Invalid settings" -msgstr "Réglages non valides" +msgid "Package name must not be a Python keyword" +msgstr "Le nom du package ne doit pas être un mot-clé Python" -msgid "%d objects added to group '%s'" -msgstr "%d objets ajoutés au groupe '%s'" +msgid "Plugin ID must be a lowercase dotted identifier such as 'org.example.my-plugin'" +msgstr "L'ID du plugin doit être un identifiant en minuscules séparé par des points, comme 'org.example.mon-plugin'" + +msgid "Description must be a single line" +msgstr "La description doit tenir sur une seule ligne" + +msgid "Plugin capabilities are invalid" +msgstr "Les capacités du plugin ne sont pas valides" + +msgid "Plugin capabilities must be unique" +msgstr "Les capacités du plugin doivent être uniques" + +msgid "Object kind must be 'signal' or 'image'" +msgstr "Le type d'objet doit être 'signal' ou 'image'" + +msgid "The minimal template currently supports BSD-3-Clause" +msgstr "Le modèle minimal prend actuellement en charge BSD-3-Clause" + +msgid "GitHub account must be a GitHub user or organization" +msgstr "Le compte GitHub doit être un utilisateur ou une organisation GitHub" + +msgid "Plugin name must contain letters or digits" +msgstr "Le nom du plugin doit contenir des lettres ou des chiffres" + +#, python-format +msgid "Destination already exists: %s" +msgstr "La destination existe déjà : %s" + +#, python-format +msgid "%s is required" +msgstr "%s est requis" + +msgid "Plugin name" +msgstr "Nom du plugin" + +msgid "GitHub account (optional)" +msgstr "Compte GitHub (facultatif)" + +msgid "Package name" +msgstr "Nom du package" + +msgid "Plugin ID" +msgstr "ID du plugin" + +#, python-format +msgid "%s plugin for DataLab" +msgstr "Plugin %s pour DataLab" + +msgid "Destination" +msgstr "Destination" + +msgid "Create and maintain DataLab plugin projects." +msgstr "Créer et maintenir des projets de plugins DataLab." + +msgid "Create a layered installable plugin project." +msgstr "Créer un projet de plugin installable structuré en couches." + +msgid "Plugin display name" +msgstr "Nom d'affichage du plugin" + +msgid "Python import package name" +msgstr "Nom du package d'import Python" + +msgid "Stable reverse-domain ID" +msgstr "ID stable en notation de domaine inversé" + +msgid "One-line description" +msgstr "Description sur une ligne" + +msgid "GitHub user or organization hosting the project" +msgstr "Utilisateur ou organisation GitHub hébergeant le projet" + +msgid "Plugin capability; may be repeated" +msgstr "Capacité du plugin ; l'option peut être répétée" + +msgid "Object type used by the sample processing" +msgstr "Type d'objet utilisé par le traitement d'exemple" + +msgid "Project license" +msgstr "Licence du projet" + +#, python-format +msgid "Created DataLab plugin project at %s" +msgstr "Projet de plugin DataLab créé dans %s" + +msgid "Minimum value" +msgstr "Valeur minimum" + +msgid "Maximum value" +msgstr "Valeur maximum" + +msgid "Step between levels" +msgstr "Pas entre les niveaux" + +msgid "Overlay image" +msgstr "Image de fond" + +msgid "Show level labels" +msgstr "Afficher les étiquettes de niveaux" + +msgid "Contour isoline plot" +msgstr "Tracé de lignes de contour" + +msgid "Display isolines (contour lines) overlaid on the selected image, with configurable level range, step, and optional value labels" +msgstr "Afficher les isolignes (lignes de contour) superposées à l'image sélectionnée, avec une plage de niveaux configurable, un pas entre les niveaux, et des étiquettes de valeurs optionnelles" + +msgid "Annotation" +msgstr "Annotation" + +msgid "Contour" +msgstr "Contour" + +msgid "Select a single image first." +msgstr "Sélectionnez d'abord une seule image." + +msgid "Selected image does not contain finite data." +msgstr "L'image sélectionnée ne contient pas de données finies." + +msgid "Selected image is constant: no contour can be drawn." +msgstr "L'image sélectionnée est constante : aucun contour ne peut être tracé." + +msgid "Contour plot parameters" +msgstr "Paramètres du tracé de contours" + +msgid "Maximum value must be strictly greater than minimum." +msgstr "La valeur maximale doit être strictement supérieure à la valeur minimale." + +msgid "No contour was found for the selected level range." +msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée." + +msgid "Show contour plot..." +msgstr "Afficher le tracé de contours..." diff --git a/doc/features/common/settings.rst b/doc/features/common/settings.rst index 2d2c80f28..81b8960a6 100644 --- a/doc/features/common/settings.rst +++ b/doc/features/common/settings.rst @@ -122,12 +122,15 @@ The Processing settings tab controls computation behavior and default parameters - **Source UUID** (default): use stable 8-character references derived from each source object's UUID. - **Source title**: use the source objects' live current titles, so references - follow later renames. - - This setting changes only the displayed reference. Result titles store - 8-character references (``g``-prefixed for groups), while each object's identity - remains its full UUID. Signal, image and group items in the trees show their own - ``#UUID8`` (or ``#gUUID8``) reference below their title. + follow later renames. References nested in processing chains are resolved + too, e.g. ``derivative(derivative(My signal))``. + + This setting changes only the displayed reference, in the object trees, plot + legends, plot titles and labels added with *Add object title to plot*. The + title edited in the Properties panel keeps its stored references. Result titles + store 8-character references (``g``-prefixed for groups), while each object's + identity remains its full UUID. Signal, image and group items in the trees show + their own ``#UUID8`` (or ``#gUUID8``) reference below their title. **Use signal bounds for new signals** When enabled, the xmin and xmax values for new signals are initialized from diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po index 50bc98c1e..5921691d2 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po @@ -541,95 +541,63 @@ msgstr ":class:`PluginBase`, qui est la classe de base pour tous les plugins" msgid "Plugins may also extends DataLab I/O features by providing new image or signal formats. To do so, they must provide a subclass of :class:`ImageFormatBase` or :class:`SignalFormatBase`, in which format information is defined using the :class:`FormatInfo` class." msgstr "Les plugins peuvent également étendre les fonctionnalités d'entrée/sortie de DataLab en fournissant de nouveaux formats d'image ou de signal. Pour ce faire, ils doivent fournir une sous-classe de :class:`ImageFormatBase` ou :class:`SignalFormatBase`, dans laquelle les informations de format sont définies à l'aide de la classe :class:`FormatInfo`." -msgid "Metaclass for registering plugins" -msgstr "Métaclasse pour l'enregistrement des plugins" - -msgid "Return plugin classes" -msgstr "Retourne les classes de plugins" - -msgid "Return plugin instances" -msgstr "Retourne les instances de plugins" - -msgid "Return a plugin by stable ID, legacy name, or class." -msgstr "Renvoie un plugin à partir de son identifiant stable, de son ancien nom ou de sa classe." - -msgid "Legacy display names are accepted only when they identify a single plugin." -msgstr "Les anciens noms d'affichage ne sont acceptés que s'ils désignent un seul plugin." - -msgid "Register plugin" -msgstr "Enregistrer le plugin" - -msgid "Unregister plugin" -msgstr "Désenregistrer le plugin" - -msgid "Unregister all plugins" -msgstr "Désenregistrer tous les plugins" - -msgid "Clear registered plugin classes." -msgstr "Effacer les classes de plugins enregistrées" - -msgid "This is mainly useful when reloading plugin modules at runtime." -msgstr "Ceci est principalement utile lors du rechargement des modules de plugins à l'exécution." +msgid "Object representing classic image file types" +msgstr "" -msgid "Record an error traceback that occurred during plugin discovery." -msgstr "Enregistrer une trace d'erreur survenue lors de la découverte des plugins." +msgid "Read data and return it" +msgstr "" msgid "Parameters" msgstr "Paramètres" -msgid "Formatted traceback string" -msgstr "Chaîne de trace d'erreur formatée" - -msgid "Return error tracebacks collected during plugin discovery." -msgstr "Retourner les traces d'erreur collectées lors de la découverte des plugins." +msgid "File name" +msgstr "" msgid "Returns" msgstr "Retourne" -msgid "List of formatted traceback strings (may be empty)" -msgstr "Liste des chaînes de trace d'erreur formatées (peut être vide)" - -msgid "Clear recorded discovery errors." -msgstr "Effacer les erreurs de découverte enregistrées." +msgid "Image array data" +msgstr "" -msgid "Record a plugin that failed to load or instantiate." -msgstr "Enregistrer un plugin qui n'a pas pu être chargé ou instancié." +msgid "Write data to file" +msgstr "" -msgid "Module or plugin class name" -msgstr "Nom du module ou de la classe du plugin" +msgid "Information about a plugin that failed to load or instantiate." +msgstr "Informations sur un plugin qui n'a pas pu être chargé ou instancié." -msgid "File path of the plugin module" -msgstr "Chemin d'accès du module du plugin" +msgid "Format info" +msgstr "" -msgid "Discovery source description" -msgstr "Description de la source de découverte" +msgid "Return reStructuredText table row for format info (table `.. list-table::` format, with 5 columns)" +msgstr "" -msgid "Return structured info about plugins that failed to load." -msgstr "Renvoie des informations structurées sur les plugins qui n'ont pas pu être chargés." +msgid "Base image format object." +msgstr "" -msgid "List of FailedPluginInfo objects (may be empty)" -msgstr "Liste des objets FailedPluginInfo (peut être vide)" +msgid "This class is used to define the interface for image I/O formats. It is an abstract base class that defines the methods that must be implemented by any image format class." +msgstr "" -msgid "Clear recorded failed plugin info." -msgstr "Effacer les informations sur les plugins ayant échoué." +msgid "Read list of image objects from file" +msgstr "" -msgid "Return plugin information (names, versions, descriptions) in html format" -msgstr "Retourne les informations sur les plugins (noms, versions, descriptions) au format html" +msgid "Callback worker object" +msgstr "" -msgid "return html formatted text (default: True)" -msgstr "retourner du texte formaté en html (par défaut : True)" +msgid "List of image objects" +msgstr "" -msgid "Information about a plugin that failed to load or instantiate." -msgstr "Informations sur un plugin qui n'a pas pu être chargé ou instancié." +msgid "file name" +msgstr "" -msgid "Capability exposed by a plugin to DataLab consumers." -msgstr "Capacité exposée par un plugin aux consommateurs de DataLab." +#, fuzzy +msgid "native object (signal or image)" +msgstr "signaux et images générés" -msgid "Plugin info" -msgstr "Informations sur le plugin" +msgid "Raises" +msgstr "Lève" -msgid "Mixed metaclass to avoid conflicts" -msgstr "Métaclasse mixte pour éviter les conflits" +msgid "if format is not supported" +msgstr "" msgid "Plugin base class" msgstr "Classe de base du plugin" @@ -691,9 +659,6 @@ msgstr "Ouvre un exemple, puis lance l'une des recettes pour lesquelles il est c msgid "Return validated tools listed in the Applications catalog." msgstr "Renvoie les outils validés listés dans le catalogue Applications." -msgid "Return the message telling which objects to select to open a tool." -msgstr "Renvoie le message indiquant quels objets sélectionner pour ouvrir un outil." - msgid "Return one tool by its plugin-local ID." msgstr "Renvoie un outil à partir de son identifiant local au plugin." @@ -706,9 +671,6 @@ msgstr "Message indiquant quels objets sélectionner, ou None si l'outil peut ê msgid "Open a plugin tool: call its launcher, or show its instrument." msgstr "Ouvre un outil du plugin : appelle sa méthode de lancement, ou affiche son instrument." -msgid "Raises" -msgstr "Lève" - msgid "if the selection does not allow opening the tool" msgstr "si la sélection ne permet pas d'ouvrir l'outil" @@ -805,6 +767,12 @@ msgstr "Nouveau jeu de paramètres d'image (ou None si annulé)" msgid "Return True if plugin is registered" msgstr "Retourne True si le plugin est enregistré" +msgid "Register plugin" +msgstr "Enregistrer le plugin" + +msgid "Unregister plugin" +msgstr "Désenregistrer le plugin" + msgid "Remove this plugin's computing features from available processors." msgstr "Supprime les fonctionnalités de calcul de ce plugin des processeurs disponibles." @@ -832,20 +800,111 @@ msgstr "Crée les actions." msgid "Called immediately after :meth:`register_computations`, inside the panels' PLUGINS action category: actions created here populate the *Plugins* menu." msgstr "Appelée juste après :meth:`register_computations`, dans la catégorie d'actions PLUGINS des panneaux : les actions créées ici alimentent le menu *Plugins*." -msgid "Migrate enabled plugin display names to stable IDs." -msgstr "Convertit les noms d'affichage des plugins activés en identifiants stables." +msgid "Mixed metaclass to avoid conflicts" +msgstr "Métaclasse mixte pour éviter les conflits" -msgid "Unknown entries are retained so temporarily unavailable plugins do not lose their activation state. A legacy name shared by multiple plugins enables all matching IDs because the old setting cannot distinguish between them." -msgstr "Les entrées inconnues sont conservées afin que les plugins temporairement indisponibles ne perdent pas leur état d'activation. Un ancien nom partagé par plusieurs plugins active tous les identifiants correspondants, car l'ancien paramètre ne permet pas de les distinguer." +msgid "Capability exposed by a plugin to DataLab consumers." +msgstr "Capacité exposée par un plugin aux consommateurs de DataLab." -msgid "Configured stable IDs or legacy display names" -msgstr "Identifiants stables ou anciens noms d'affichage configurés" +msgid "Plugin info" +msgstr "Informations sur le plugin" -msgid "Classes available for migration (registry by default)" -msgstr "Classes disponibles pour la conversion (registre par défaut)" +msgid "Metaclass for registering plugins" +msgstr "Métaclasse pour l'enregistrement des plugins" -msgid "Enabled stable IDs and retained unknown entries, or None for all plugins" -msgstr "Identifiants stables activés et entrées inconnues conservées, ou None pour tous les plugins" +msgid "Return plugin classes" +msgstr "Retourne les classes de plugins" + +msgid "Return plugin instances" +msgstr "Retourne les instances de plugins" + +msgid "Return a plugin by stable ID, legacy name, or class." +msgstr "Renvoie un plugin à partir de son identifiant stable, de son ancien nom ou de sa classe." + +msgid "Legacy display names are accepted only when they identify a single plugin." +msgstr "Les anciens noms d'affichage ne sont acceptés que s'ils désignent un seul plugin." + +msgid "Unregister all plugins" +msgstr "Désenregistrer tous les plugins" + +msgid "Clear registered plugin classes." +msgstr "Effacer les classes de plugins enregistrées" + +msgid "This is mainly useful when reloading plugin modules at runtime." +msgstr "Ceci est principalement utile lors du rechargement des modules de plugins à l'exécution." + +msgid "Record an error traceback that occurred during plugin discovery." +msgstr "Enregistrer une trace d'erreur survenue lors de la découverte des plugins." + +msgid "Formatted traceback string" +msgstr "Chaîne de trace d'erreur formatée" + +msgid "Return error tracebacks collected during plugin discovery." +msgstr "Retourner les traces d'erreur collectées lors de la découverte des plugins." + +msgid "List of formatted traceback strings (may be empty)" +msgstr "Liste des chaînes de trace d'erreur formatées (peut être vide)" + +msgid "Clear recorded discovery errors." +msgstr "Effacer les erreurs de découverte enregistrées." + +msgid "Record a plugin that failed to load or instantiate." +msgstr "Enregistrer un plugin qui n'a pas pu être chargé ou instancié." + +msgid "Module or plugin class name" +msgstr "Nom du module ou de la classe du plugin" + +msgid "File path of the plugin module" +msgstr "Chemin d'accès du module du plugin" + +msgid "Discovery source description" +msgstr "Description de la source de découverte" + +msgid "Return structured info about plugins that failed to load." +msgstr "Renvoie des informations structurées sur les plugins qui n'ont pas pu être chargés." + +msgid "List of FailedPluginInfo objects (may be empty)" +msgstr "Liste des objets FailedPluginInfo (peut être vide)" + +msgid "Clear recorded failed plugin info." +msgstr "Effacer les informations sur les plugins ayant échoué." + +msgid "Return plugin information (names, versions, descriptions) in html format" +msgstr "Retourne les informations sur les plugins (noms, versions, descriptions) au format html" + +msgid "return html formatted text (default: True)" +msgstr "retourner du texte formaté en html (par défaut : True)" + +msgid "Class representing a signal file type" +msgstr "" + +msgid "Create empty object" +msgstr "" + +msgid "Index of object in file" +msgstr "" + +#, fuzzy +msgid "Signal object" +msgstr "objets sélectionnés" + +msgid "Create signal object from xydata and filename." +msgstr "" + +msgid "XY data" +msgstr "" + +msgid "Create signal objects from xydata and filename" +msgstr "" + +msgid "List of signal objects" +msgstr "" + +msgid "Read list of signal objects from file" +msgstr "" + +msgid "Read data and metadata from file, write metadata to object, return xydata" +msgstr "" msgid "Discover plugins through package entry points and naming convention." msgstr "Découvrir les plugins via les points d'entrée de packages et la convention de nommage." @@ -863,6 +922,36 @@ msgstr "Cette fonction modifie ``sys.modules`` et le registre des plugins sans s msgid "Imported/reloaded modules containing discovered plugins" msgstr "Modules importés ou rechargés contenant les plugins découverts" +msgid "Discover v0.20 plugins (with ``cdl_`` prefix) without importing them" +msgstr "Découvrir les plugins v0.20 (avec le préfixe ``cdl_``) sans les importer" + +msgid "List of tuples (plugin_name, directory_path) for discovered v0.20 plugins" +msgstr "Liste des tuples (nom_du_plugin, chemin_du_répertoire) pour les plugins v0.20 découverts" + +msgid "Return the message telling which objects to select to open a tool." +msgstr "Renvoie le message indiquant quels objets sélectionner pour ouvrir un outil." + +msgid "Instantiate and get available plugins" +msgstr "Instancier et obtenir les plugins disponibles" + +msgid "List of available plugins (as instances)" +msgstr "Liste des plugins disponibles (en tant qu'instances)" + +msgid "Migrate enabled plugin display names to stable IDs." +msgstr "Convertit les noms d'affichage des plugins activés en identifiants stables." + +msgid "Unknown entries are retained so temporarily unavailable plugins do not lose their activation state. A legacy name shared by multiple plugins enables all matching IDs because the old setting cannot distinguish between them." +msgstr "Les entrées inconnues sont conservées afin que les plugins temporairement indisponibles ne perdent pas leur état d'activation. Un ancien nom partagé par plusieurs plugins active tous les identifiants correspondants, car l'ancien paramètre ne permet pas de les distinguer." + +msgid "Configured stable IDs or legacy display names" +msgstr "Identifiants stables ou anciens noms d'affichage configurés" + +msgid "Classes available for migration (registry by default)" +msgstr "Classes disponibles pour la conversion (registre par défaut)" + +msgid "Enabled stable IDs and retained unknown entries, or None for all plugins" +msgstr "Identifiants stables activés et entrées inconnues conservées, ou None pour tous les plugins" + msgid "Reload plugin modules and reset plugin classes." msgstr "Recharger les modules de plugins et réinitialiser les classes de plugins." @@ -881,18 +970,6 @@ msgstr "Recharge ou importe tous les modules correspondant à la convention de n msgid "Like :func:`discover_plugins`, this is not thread-safe: call it from the Qt main thread only." msgstr "Comme :func:`discover_plugins`, cette fonction n'est pas thread-safe : ne l'appelez que depuis le thread principal Qt." -msgid "Discover v0.20 plugins (with ``cdl_`` prefix) without importing them" -msgstr "Découvrir les plugins v0.20 (avec le préfixe ``cdl_``) sans les importer" - -msgid "List of tuples (plugin_name, directory_path) for discovered v0.20 plugins" -msgstr "Liste des tuples (nom_du_plugin, chemin_du_répertoire) pour les plugins v0.20 découverts" - -msgid "Instantiate and get available plugins" -msgstr "Instancier et obtenir les plugins disponibles" - -msgid "List of available plugins (as instances)" -msgstr "Liste des plugins disponibles (en tant qu'instances)" - msgid "Headless contracts for plugin-provided scientific recipes." msgstr "Contrats sans interface graphique pour les recettes scientifiques fournies par les plugins." diff --git a/doc/locale/fr/LC_MESSAGES/features/common/settings.po b/doc/locale/fr/LC_MESSAGES/features/common/settings.po index a9c827238..6d038e3f1 100644 --- a/doc/locale/fr/LC_MESSAGES/features/common/settings.po +++ b/doc/locale/fr/LC_MESSAGES/features/common/settings.po @@ -157,11 +157,11 @@ msgstr "Choisir comment les objets sources sont représentés dans les titres g msgid "**Source UUID** (default): use stable 8-character references derived from each source object's UUID." msgstr "**UUID source** (par défaut) : utiliser des références stables de 8 caractères dérivées de l'UUID de chaque objet source." -msgid "**Source title**: use the source objects' live current titles, so references follow later renames." -msgstr "**Titre de la source** : utiliser les titres actuels des objets sources, afin que les références suivent les renommages ultérieurs." +msgid "**Source title**: use the source objects' live current titles, so references follow later renames. References nested in processing chains are resolved too, e.g. ``derivative(derivative(My signal))``." +msgstr "**Titre de la source** : utiliser les titres actuels des objets sources, afin que les références suivent les renommages ultérieurs. Les références imbriquées dans les chaînes de traitement sont également résolues, par exemple ``derivative(derivative(Mon signal))``." -msgid "This setting changes only the displayed reference. Result titles store 8-character references (``g``-prefixed for groups), while each object's identity remains its full UUID. Signal, image and group items in the trees show their own ``#UUID8`` (or ``#gUUID8``) reference below their title." -msgstr "Ce réglage modifie uniquement la référence affichée. Les titres de résultats stockent des références de 8 caractères (préfixées par ``g`` pour les groupes), tandis que l'identité de chaque objet reste son UUID complet. Les signaux, images et groupes des arborescences affichent leur propre référence ``#UUID8`` (ou ``#gUUID8``) sous leur titre." +msgid "This setting changes only the displayed reference, in the object trees, plot legends, plot titles and labels added with *Add object title to plot*. The title edited in the Properties panel keeps its stored references. Result titles store 8-character references (``g``-prefixed for groups), while each object's identity remains its full UUID. Signal, image and group items in the trees show their own ``#UUID8`` (or ``#gUUID8``) reference below their title." +msgstr "Ce réglage modifie uniquement la référence affichée, dans les arborescences d'objets, les légendes et titres des graphiques et les étiquettes ajoutées avec *Ajouter le titre de l'objet au graphique*. Le titre modifié dans le panneau Propriétés conserve ses références enregistrées. Les titres de résultats stockent des références de 8 caractères (préfixées par ``g`` pour les groupes), tandis que l'identité de chaque objet reste son UUID complet. Les signaux, images et groupes des arborescences affichent leur propre référence ``#UUID8`` (ou ``#gUUID8``) sous leur titre." msgid "**Use signal bounds for new signals**" msgstr "**Utiliser les bornes du signal pour les nouveaux signaux**" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po index a3b6816ec..71693d081 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po @@ -165,8 +165,8 @@ msgstr "Les signaux, images et groupes des arborescences affichent désormais le msgid "Source references embedded in result titles no longer change when objects are reordered, removed or renumbered, and remain clickable to select the source object" msgstr "Les références aux sources intégrées dans les titres de résultats ne changent plus lorsque des objets sont réordonnés, supprimés ou renumérotés, et restent cliquables pour sélectionner l'objet source" -msgid "The new **References in result titles** setting lets you display these references either as 8-character UUIDs (default) or as the current source titles, which follow later renames" -msgstr "Le nouveau paramètre **Références dans les titres de résultats** permet d'afficher ces références soit sous forme d'UUID à 8 caractères (par défaut), soit avec les titres actuels des sources, qui suivent les renommages ultérieurs" +msgid "The new **References in result titles** setting lets you display these references either as 8-character UUIDs (default) or as the current source titles, which follow later renames and are resolved through whole processing chains (e.g. `derivative(derivative(My signal))`)" +msgstr "Le nouveau paramètre **Références dans les titres de résultats** permet d'afficher ces références soit sous forme d'UUID à 8 caractères (par défaut), soit avec les titres actuels des sources, qui suivent les renommages ultérieurs et sont résolus sur l'ensemble des chaînes de traitement (par exemple `derivative(derivative(Mon signal))`)" msgid "When a source object is deleted, its reference falls back to its 8-character UUID instead of becoming an anonymous placeholder" msgstr "Lorsqu'un objet source est supprimé, sa référence est affichée sous la forme de son UUID à 8 caractères au lieu de devenir un espace réservé anonyme" diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index 4873bfece..4539b1080 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -68,7 +68,7 @@ DataLab now uses stable UUID-based object references across object trees, result * Signal, image and group items in the trees now show their current title followed by their own stable `#UUID8` reference (`#gUUID8` for groups), replacing the former `s001`, `i001` and `gs001` identifiers * Source references embedded in result titles no longer change when objects are reordered, removed or renumbered, and remain clickable to select the source object -* The new **References in result titles** setting lets you display these references either as 8-character UUIDs (default) or as the current source titles, which follow later renames +* The new **References in result titles** setting lets you display these references either as 8-character UUIDs (default) or as the current source titles, which follow later renames and are resolved through whole processing chains (e.g. `derivative(derivative(My signal))`) * When a source object is deleted, its reference falls back to its 8-character UUID instead of becoming an anonymous placeholder * Titles of workspaces saved by previous versions keep their former `s001`-style references as plain text