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/config/options.py b/datalab/config/options.py index 79b93f424..e54297214 100644 --- a/datalab/config/options.py +++ b/datalab/config/options.py @@ -227,6 +227,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/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 9bfa7a9fa..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 ObjectGroup, get_uuid +from datalab.objectmodel import ( + UUID_REGEX, + ObjectGroup, + get_uuid, + resolve_title_references, +) from datalab.plugins import ( PluginRegistry, discover_plugins, @@ -299,8 +304,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() @@ -340,11 +355,47 @@ 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 resolve_title_reference( + self, reference: str + ) -> tuple[str, SignalObj | ImageObj | ObjectGroup] | None: + """Resolve a title reference to ``(panel_str, object or group)``. + + Args: + reference: ```` (object) or ``g`` (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 an object title according to user settings.""" + if Conf.result_title_mode.get() != "title": + return 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 + + return resolve_title_references(title, resolve_source_title) + + def refresh_object_title_displays(self) -> None: + """Refresh object titles in trees and shown plot items.""" + for panel in (self.signalpanel, self.imagepanel): + panel.objview.update_tree() + panel.refresh_plot("selected", update_items=True, force=True) + @remote_controlled def get_object_uuids( self, @@ -1637,6 +1688,9 @@ def __configure_panels(self) -> None: panel.SIG_OBJECT_ADDED.connect(self.set_modified) panel.SIG_OBJECT_REMOVED.connect(self.set_modified) panel.SIG_OBJECT_MODIFIED.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) # Initializing common panel actions self.autorefresh_action.setChecked(Conf.auto_refresh.get(True)) self.showfirstonly_action.setChecked(Conf.show_first_only.get(False)) @@ -2314,9 +2368,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, @@ -2328,6 +2380,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. @@ -2441,6 +2494,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 7fac04a8d..4bc264aa3 100644 --- a/datalab/gui/objectview.py +++ b/datalab/gui/objectview.py @@ -45,15 +45,19 @@ 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, - get_short_id, + find_title_references, + get_title_reference, get_uuid, + resolve_title_references, ) from datalab.utils.qthelpers import block_signals -from datalab.widgets.titledelegate import ClickableTitleDelegate +from datalab.widgets.titledelegate import ( + TITLE_REFERENCE_ROLE, + ClickableTitleDelegate, +) if TYPE_CHECKING: from typing import Any @@ -103,8 +107,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_title_reference_display + ) + self.setItemDelegateForColumn(0, self.title_delegate) self.viewport().setMouseTracking(True) def __str__(self) -> str: @@ -210,35 +216,44 @@ 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) - if obj is None: + matches = self.objmodel.find_by_title_reference(reference) + return ("", matches[0]) if len(matches) == 1 else None + + 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 - return ("", obj) + _panel_str, obj = resolved + if Conf.result_title_mode.get() == "title": + return resolve_title_references(obj.title, self.get_source_title) + return reference - def _build_short_id_tooltip(self, text: str) -> str: + 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 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.""" rows: list[str] = [] seen: set[str] = set() - for idx, (start, _end, sid) in enumerate(matches): - if idx == 0 and start == 0: - # Skip the leading ":" prefix - continue - if sid in seen: + for _start, _end, reference in find_title_references(text): + 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 +264,10 @@ 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 "" + rows.append( + f"{reference} → {obj.title} ({kind}{suffix})" + ) if not rows: return "" title = _("Source objects") @@ -263,30 +280,31 @@ def __update_item( self, item: QW.QTreeWidgetItem, obj: SignalObj | ImageObj | ObjectGroup ) -> None: """Update item""" - text = f"{get_short_id(obj)}: {obj.title}" + 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)) + item.setData(0, TITLE_REFERENCE_ROLE, get_title_reference(obj)) - 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 +312,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() @@ -522,37 +540,21 @@ 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() 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: - obj = model.find_by_short_id(short_id) - 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_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 cb01b3b74..72e3b39a6 100644 --- a/datalab/gui/panel/base.py +++ b/datalab/gui/panel/base.py @@ -17,6 +17,7 @@ import warnings from collections.abc import Sequence from contextlib import ExitStack +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Generator, Generic, Literal, Type import guidata.dataset as gds @@ -91,13 +92,17 @@ clear_analysis_parameters, extract_analysis_parameters, extract_processing_parameters, + insert_processing_parameters, ) from datalab.gui.roieditor import TypeROIEditor from datalab.objectmodel import ( ObjectGroup, get_number, get_short_id, + get_short_uuid, + get_title_reference, get_uuid, + remap_title_references, set_number, set_uuid, ) @@ -1322,6 +1327,72 @@ def apply_recomputed_object_in_place( apply_processing_result(obj, new_obj, proc_params) +@dataclass +class H5ImportBatch: + """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], 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, title_remap) + for obj in self.objects: + obj.title = remap_title_references(obj.title, title_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 UUID remapping to imported batches.""" + reference_remap: dict[str, str] = {} + for batch in batches: + reference_remap.update(batch.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() + + class AbstractPanelMeta(type(QW.QSplitter), abc.ABCMeta): """Mixed metaclass to avoid conflicts""" @@ -1354,7 +1425,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: @@ -1377,14 +1449,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 @@ -1394,7 +1461,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: @@ -1468,11 +1535,15 @@ 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 [obj.title for obj in self.__objs] def on_button_click( self: SaveToDirectoryGUIParam, @@ -1560,15 +1631,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) @@ -1803,17 +1869,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 = 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 @@ -1988,12 +2049,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: @@ -2002,22 +2065,53 @@ def deserialize_from_hdf5( If False, regenerate only UUIDs that conflict with existing objects (object import). """ + batch = H5ImportBatch(self, {}, [], []) objects_added = False signals_blocked = self.blockSignals(True) try: 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 + ) + batch.groups.append(group) objects: list[TypeObj] = [] 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 + ) objects.append(obj) self._add_objects(objects, get_uuid(group), set_current=False) + batch.objects.extend(objects) objects_added = objects_added or bool(objects) finally: self.blockSignals(signals_blocked) @@ -2025,6 +2119,7 @@ def deserialize_from_hdf5( self.SIG_OBJECT_ADDED.emit() if not objects_added: self.selection_changed() + return batch def __len__(self) -> int: """Return number of objects""" @@ -2221,7 +2316,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 @@ -2332,17 +2427,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) try: self.objview.add_group_item(group) if select: @@ -2397,7 +2498,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) @@ -2826,6 +2927,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( @@ -3138,7 +3240,11 @@ 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"[{group.title}] {obj.title}") + guiparam = SaveToDirectoryGUIParam(objs, extensions, labels) # Restore settings from config saved_param = Conf.save_to_directory_settings.get( SaveToDirectoryParam() @@ -3365,6 +3471,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. @@ -3699,6 +3810,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) @@ -3725,7 +3838,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", ) @@ -3830,11 +3947,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) @@ -4160,17 +4280,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 references (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_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 last one: "s001, s002, ..., s010" + # Show first 2, "...", then the last reference. source_ids = ( - ", ".join(short_ids[: max_ids_to_show - 1]) + ", ".join(source_refs[: max_ids_to_show - 1]) + ", ..., " - + short_ids[-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 "" @@ -4209,8 +4329,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_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 ) @@ -4325,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/gui/plothandler.py b/datalab/gui/plothandler.py index a1c500213..1f42b7d77 100644 --- a/datalab/gui/plothandler.py +++ b/datalab/gui/plothandler.py @@ -298,6 +298,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) @@ -319,6 +321,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. @@ -488,6 +493,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/plugins/recipe_inputs.py b/datalab/gui/plugins/recipe_inputs.py index 4425add4f..300919661 100644 --- a/datalab/gui/plugins/recipe_inputs.py +++ b/datalab/gui/plugins/recipe_inputs.py @@ -14,7 +14,7 @@ from sigima.objects import ImageObj, SignalObj from datalab.config import _ -from datalab.objectmodel import get_short_id +from datalab.objectmodel import get_title_reference from datalab.plugins.recipe_binding import ( RecipeInputIssue, RecipeInputIssueCode, @@ -211,7 +211,7 @@ def __init__( def _candidate_label(self, slot: RecipeInputSlot, obj: DataObject) -> str: """Return the label of a candidate, naming its missing metadata.""" - label = f"{get_short_id(obj)}: {obj.title}" + label = f"{get_title_reference(obj)}: {obj.title}" missing = [ requirement.key for requirement in slot.metadata diff --git a/datalab/gui/processor/base.py b/datalab/gui/processor/base.py index 5a6c7ed04..4574e9af9 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_title_reference, get_uuid, patch_title_with_ids from datalab.utils.qthelpers import create_progress_bar, qt_try_except if TYPE_CHECKING: @@ -1654,7 +1654,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 @@ -1827,7 +1827,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) @@ -1855,7 +1855,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_title_reference(old_g)})" ) new_gids[old_gid] = new_gid self._add_object_to_appropriate_panel( @@ -2371,10 +2371,8 @@ def compute_n_to_1( ) if not valid: return - dst_gname = ( - f"{name}({','.join([get_short_id(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 @@ -2437,7 +2435,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) @@ -2471,7 +2469,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 @@ -2529,7 +2528,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) @@ -2574,6 +2573,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, @@ -2708,8 +2708,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) - grp_short_ids = [get_uuid(grp) for grp in (src_grp, grp2)] - dst_gname = f"{name}({','.join(grp_short_ids)})|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 @@ -2733,9 +2735,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) @@ -2866,7 +2866,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 e56e6018c..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, @@ -824,7 +824,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_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 # 2-tuples cannot be replayed deterministically). @@ -908,7 +910,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_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 10eead708..ff41cc6ca 100644 --- a/datalab/gui/roieditor.py +++ b/datalab/gui/roieditor.py @@ -270,8 +270,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 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 +307,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..f5aa0e83a 100644 --- a/datalab/gui/roigrideditor.py +++ b/datalab/gui/roigrideditor.py @@ -75,6 +75,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 obj.title + ) gridparam = gridparam or ROIGridParam() displayparam = displayparam or DisplayParam() self.gridparamwidget = gdq.DataSetEditGroupBox( @@ -96,7 +102,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 +193,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/gui/settings.py b/datalab/gui/settings.py index b20ed9b03..660a22c94 100644 --- a/datalab/gui/settings.py +++ b/datalab/gui/settings.py @@ -163,6 +163,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/h5/history.py b/datalab/h5/history.py index a3c8c80e5..7eb9c1d59 100644 --- a/datalab/h5/history.py +++ b/datalab/h5/history.py @@ -12,10 +12,7 @@ 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 @@ -28,11 +25,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 +108,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 +130,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 +156,17 @@ 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 + 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 +174,9 @@ 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 + data_panel.add_object(obj, new_group_uuid, set_current=False) + batch.objects.append(obj) data_panel.selection_changed() @@ -181,44 +192,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 +244,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/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/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 0ffbc70db..a4f410f3c 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -65,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" @@ -937,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" @@ -1153,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" @@ -1399,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" @@ -1486,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" @@ -1525,7 +1467,7 @@ msgstr "Écraser les fichiers existants" msgid "Preview" msgstr "Aperçu" -msgid "Add a new metadata item to the selected objects.

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.
Click the Help button for details on the pattern syntax.
" +msgid "Add a new metadata item to the selected objects.

The metadata key will be the same for all objects, but the value can use a pattern to generate different values.
Click the Help button for details on the pattern syntax.
" msgstr "Ajouter un nouvel élément de métadonnée aux objets sélectionnés.

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.
Cliquez sur le bouton Aide pour plus de détails sur la syntaxe du modèle.
" msgid "String" @@ -1577,12 +1519,12 @@ msgstr "Supprimer les métadonnées" msgid "Some selected objects have regions of interest.
Do you want to delete them as well?" msgstr "Certains objets sélectionnés ont des régions d'intérêt.
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\"" @@ -1827,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." @@ -1975,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" @@ -2059,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" @@ -2102,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" @@ -2111,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" @@ -2187,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 (.whl) or as a Python module (datalab_*.py). Wheels must be pure Python and rely only on packages already provided by DataLab." -msgstr "Installez un plugin partagé sous forme de fichier wheel (.whl) ou de module Python (datalab_*.py). 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 "Note: operation mode is pairwise: %s object(s) expected (i.e. as many as in the first group)" +msgstr "Note : le mode d'opération est pairwise : %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 "Note: operation mode is single operand: 1 object expected" +msgstr "Note : le mode d'opération est single : 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 DataLab plugin catalog. 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 catalogue de plugins DataLab. 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 "Note: operation mode is pairwise: %s object(s) expected (i.e. as many as in the first group)" -msgstr "Note : le mode d'opération est pairwise : %s objet(s) attendu(s) (c'est-à-dire autant que dans le premier groupe)" - -msgid "Note: operation mode is single operand: 1 object expected" -msgstr "Note : le mode d'opération est single : 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" @@ -2599,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" @@ -2698,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" @@ -2992,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" @@ -3620,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" @@ -3775,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 :" @@ -3902,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" @@ -4152,12 +3895,15 @@ 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 "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." @@ -4245,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" @@ -4433,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.

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.
Click the Help button for details on the pattern syntax.
" +msgstr "Ajouter un nouvel élément de métadonnée aux objets sélectionnés.

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.
Cliquez sur le bouton Aide pour plus de détails sur la syntaxe du modèle.
" -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 DataLab plugin catalog. 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 catalogue de plugins DataLab. 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 (.whl) or as a Python module (datalab_*.py). Wheels must be pure Python and rely only on packages already provided by DataLab." +msgstr "Installez un plugin partagé sous forme de fichier wheel (.whl) ou de module Python (datalab_*.py). 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" @@ -4633,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" @@ -4645,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)" @@ -4657,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)" @@ -4693,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)" @@ -4705,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" @@ -4798,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/datalab/objectmodel.py b/datalab/objectmodel.py index 6187885b0..e497cba00 100644 --- a/datalab/objectmodel.py +++ b/datalab/objectmodel.py @@ -36,8 +36,7 @@ from __future__ import annotations import re -from collections.abc import Iterator, Sequence -from typing import Callable +from collections.abc import Callable, Iterator, Sequence from uuid import uuid4 from sigima import ImageObj, SignalObj @@ -93,12 +92,94 @@ 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 +) +#: 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: + """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_title_reference(obj: SignalObj | ImageObj | ObjectGroup) -> str: + """Return the reference embedded in titles: ```` or ``g``.""" + 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 ``#`` form.""" + return f"#{get_title_reference(obj)}" + + +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, reference)`` tuples sorted by ``start``. + """ + return [ + (match.start(), match.end(), match.group(0)) + for match in TITLE_REFERENCE_REGEX.finditer(title) + ] + + +def render_title(title: str, resolver: Callable[[str], str | None]) -> str: + """Replace the title references that ``resolver`` resolves. + + Args: + title: Title containing object and group references + resolver: Callback returning a replacement for a reference, or None + + Returns: + Title with resolved references replaced, others left unchanged. + """ + for start, end, reference in reversed(find_title_references(title)): + replacement = resolver(reference) + if replacement: + title = title[:start] + replacement + title[end:] + 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], - id_func: Callable, ) -> None: - """Patch object title with short IDs of source objects + """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 @@ -107,9 +188,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_title_reference(obj) for obj in src_objs] title = dst_obj.title assert isinstance(title, str), "Title must be a string" try: @@ -120,22 +200,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") - - -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 remap_title_references(title: str, reference_remap: dict[str, str]) -> str: + """Replace the title references listed in ``reference_remap``.""" + return render_title(title, reference_remap.get) class ObjectGroup: @@ -145,12 +212,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 +269,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 +346,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""" @@ -320,27 +392,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: ```` (object) or ``g`` (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""" @@ -399,11 +463,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_uuid_display_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 +498,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 +536,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,7 +546,6 @@ 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""" @@ -509,44 +578,10 @@ def add_objects( raise ValueError( f"Object UUID already exists in model: {sorted(existing_ids)[0]}" ) - - tracked_objects = (*self._objects.values(), *objects) - tracked_titles = tuple( - (obj_or_group, obj_or_group.title) - for obj_or_group in (*self._groups, *tracked_objects) - ) - missing = object() - tracked_numbers = tuple( - (obj, obj.metadata.get("__number", missing)) for obj in tracked_objects - ) - tracked_group_numbers = tuple( - (tracked_group, tracked_group.number) for tracked_group in self._groups - ) - objects_before = self._objects.copy() - group_objects_before = group.get_object_ids() - - try: - self.replace_short_ids_by_uuids_in_titles(objects) - for obj, obj_uuid in zip(objects, object_ids): - self._objects[obj_uuid] = obj - group.append(obj) - self.reset_short_ids() - self.replace_uuids_by_short_ids_in_titles() - except Exception: - self._objects = objects_before - group.clear() - for obj_uuid in group_objects_before: - group.append(objects_before[obj_uuid]) - for obj_or_group, title in tracked_titles: - obj_or_group.title = title - for obj, number in tracked_numbers: - if number is missing: - obj.metadata.pop("__number", None) - else: - obj.metadata["__number"] = number - for tracked_group, number in tracked_group_numbers: - tracked_group.number = number - raise + for obj, obj_uuid in zip(objects, object_ids): + self._objects[obj_uuid] = obj + group.append(obj) + self.reset_short_ids() def remove_object(self, obj: SignalObj | ImageObj) -> None: """Remove object from model""" @@ -641,73 +676,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. @@ -715,14 +694,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. @@ -730,15 +703,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() diff --git a/datalab/tests/backbone/titledelegate_unit_test.py b/datalab/tests/backbone/titledelegate_unit_test.py new file mode 100644 index 000000000..195f846aa --- /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_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" + model = QG.QStandardItemModel() + item = QG.QStandardItem( + f"A deliberately long computed title referencing {referenced_uuid}" + ) + if with_item_uuid: + item.setData("87654321", TITLE_REFERENCE_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/tests/features/common/bulk_object_operations_test.py b/datalab/tests/features/common/bulk_object_operations_test.py index 5ca3dcfe6..500a1231c 100644 --- a/datalab/tests/features/common/bulk_object_operations_test.py +++ b/datalab/tests/features/common/bulk_object_operations_test.py @@ -7,7 +7,7 @@ import pytest from sigima.objects import create_signal -from datalab.objectmodel import ObjectModel, get_number, get_uuid +from datalab.objectmodel import ObjectModel, get_number, get_title_reference, get_uuid from datalab.tests import datalab_test_app_context @@ -17,21 +17,22 @@ def create_test_signal(title: str): def test_object_model_add_objects_preserves_order_and_references() -> None: - """Bulk insertion renumbers objects and updates short-ID references once.""" + """Bulk insertion renumbers objects and leaves title references unchanged.""" model = ObjectModel("gs") first_group = model.add_group("First") second_group = model.add_group("Second") source = create_test_signal("Source") model.add_object(source, get_uuid(second_group)) - derived = create_test_signal("derived(s001)") + derived = create_test_signal(f"derived({get_title_reference(source)})") model.add_object(derived, get_uuid(second_group)) + derived_title = derived.title inserted = (create_test_signal("A"), create_test_signal("B")) model.add_objects(inserted, get_uuid(first_group)) assert model.get_all_objects() == [*inserted, source, derived] assert [get_number(obj) for obj in model.get_all_objects()] == [1, 2, 3, 4] - assert derived.title == "derived(s003)" + assert derived.title == derived_title def test_object_model_add_objects_rejects_internal_duplicate() -> None: @@ -65,38 +66,6 @@ def test_object_model_add_objects_rejects_existing_uuid() -> None: assert "__number" not in collision.metadata -def test_object_model_add_objects_rolls_back_partial_mutation( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """An error after insertion restores objects, titles, and numbering.""" - model = ObjectModel("gs") - group = model.add_group("Group") - existing = create_test_signal("Existing") - model.add_object(existing, get_uuid(group)) - group.number = 17 - inserted = create_test_signal("derived(s001)") - - def fail_title_restoration() -> None: - raise RuntimeError("injected title failure") - - monkeypatch.setattr( - model, - "replace_uuids_by_short_ids_in_titles", - fail_title_restoration, - ) - - with pytest.raises(RuntimeError, match="injected title failure"): - model.add_objects((inserted,), get_uuid(group)) - - assert model.get_all_objects() == [existing] - assert group.get_objects() == [existing] - assert existing.title == "Existing" - assert get_number(existing) == 1 - assert group.number == 17 - assert inserted.title == "derived(s001)" - assert "__number" not in inserted.metadata - - def test_panel_add_objects_updates_tree_and_notifies_once( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/datalab/tests/features/common/history_model_unit_test.py b/datalab/tests/features/common/history_model_unit_test.py index 859a339d5..c8e314493 100644 --- a/datalab/tests/features/common/history_model_unit_test.py +++ b/datalab/tests/features/common/history_model_unit_test.py @@ -783,6 +783,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"]}, @@ -802,6 +804,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] @@ -810,6 +814,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( 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 diff --git a/datalab/tests/features/common/interactive_processing_test.py b/datalab/tests/features/common/interactive_processing_test.py index 596c6016a..b5f0c638b 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 @@ -394,10 +394,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..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_short_id +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_short_id(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_short_id(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) @@ -159,7 +161,7 @@ def test_pairwise_operations_mode_compute_n_to_1() -> None: 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) + 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) @@ -190,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_short_id(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) @@ -242,7 +244,8 @@ 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_title_reference(group[i_obj])}-" + f"{get_title_reference(group3[0])}", ) ) new_objs.append(obj) @@ -280,7 +283,8 @@ 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_title_reference(group[i_obj])}-" + f"{get_title_reference(group3[0])}", ) ) check_titles(f"Single operand mode Δ[{group.title}]", titles) @@ -339,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_short_id(obj1)}-{get_short_id(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 @@ -379,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_short_id(obj1)}-{get_short_id(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 f86f2ad5b..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,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_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 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 references of the selected " + f"source objects {expected_source_uuids}, " f"got '{result_signal.title}'" ) 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/tests/features/common/title_short_id_unit_test.py b/datalab/tests/features/common/title_short_id_unit_test.py index 23fe3e966..e1d2c68b0 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,155 @@ 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.params +import sigima.proc.signal as sips +from sigima.objects import create_signal +from sigima.tests.data import create_paracetamol_signal, create_sincos_image + +from datalab.config import Conf +from datalab.objectmodel import ( + ObjectModel, + find_title_references, + get_short_uuid, + get_title_reference, + get_uuid, + 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'Signal 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: & 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(" & friends") + assert html == "<not a tag> & friends" + + +def test_title_mode_follows_source_renames() -> None: + """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(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" diff --git a/datalab/tests/features/hdf5/h5workspace_unit_test.py b/datalab/tests/features/hdf5/h5workspace_unit_test.py index e8f4ec9da..84d5aa668 100644 --- a/datalab/tests/features/hdf5/h5workspace_unit_test.py +++ b/datalab/tests/features/hdf5/h5workspace_unit_test.py @@ -21,9 +21,12 @@ import enum import os.path as osp +from types import SimpleNamespace +from unittest.mock import Mock import h5py import pytest +import sigima.proc.signal as sips from guidata.io import JSONWriter from numpy import ma from plotpy.builder import make @@ -37,10 +40,56 @@ ) 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.objectmodel import get_title_reference, get_uuid from datalab.tests import datalab_test_app_context, helpers +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: + """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(monkeypatch: pytest.MonkeyPatch): """Test save_h5_workspace and load_h5_workspace methods""" with helpers.WorkdirRestoringTempDir() as tmpdir: @@ -128,19 +177,21 @@ def track_image_selection(*args, **kwargs) -> None: selection_calls[win.imagepanel] += 1 image_selection_changed(*args, **kwargs) - def track_signal_deserialize(*args, **kwargs) -> None: + def track_signal_deserialize(*args, **kwargs) -> H5ImportBatch: before = selection_calls[win.signalpanel] - signal_deserialize(*args, **kwargs) + batch = signal_deserialize(*args, **kwargs) deserialize_selection_calls[win.signalpanel].append( selection_calls[win.signalpanel] - before ) + return batch - def track_image_deserialize(*args, **kwargs) -> None: + def track_image_deserialize(*args, **kwargs) -> H5ImportBatch: before = selection_calls[win.imagepanel] - image_deserialize(*args, **kwargs) + batch = image_deserialize(*args, **kwargs) deserialize_selection_calls[win.imagepanel].append( selection_calls[win.imagepanel] - before ) + return batch monkeypatch.setattr(win.signalpanel, "_add_objects", track_signal_batch) monkeypatch.setattr(win.imagepanel, "_add_objects", track_image_batch) 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 0a70fc23a..2d5ee5429 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, @@ -288,19 +289,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 3b3db0d00..de3a156d7 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 5183f1d63..e0531012f 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), diff --git a/datalab/widgets/processingpreview.py b/datalab/widgets/processingpreview.py index 989ebea10..7c1088639 100644 --- a/datalab/widgets/processingpreview.py +++ b/datalab/widgets/processingpreview.py @@ -21,7 +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_id, patch_title_with_ids +from datalab.objectmodel import get_title_reference, patch_title_with_ids __all__ = [ "ProcessingPreviewDialog", @@ -113,7 +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_id(source)}: {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) @@ -286,7 +286,7 @@ def _show_result(self, output: CompOut, current: bool) -> None: original_title = result.title try: patch_title_with_ids( - result, [self.sources[self.source_combo.currentIndex()]], get_short_id + result, [self.sources[self.source_combo.currentIndex()]] ) self._render(result) except Exception as error: diff --git a/datalab/widgets/titledelegate.py b/datalab/widgets/titledelegate.py index b363b4d9f..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 """ @@ -15,50 +16,56 @@ 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 find_title_references if TYPE_CHECKING: pass #: URL scheme used in anchors emitted by :class:`ClickableTitleDelegate`. -SHORT_ID_URL_SCHEME = "dlb-shortid" +REFERENCE_URL_SCHEME = "dlb-ref" +#: Item data role holding the item's own title reference (````/``g``) +TITLE_REFERENCE_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, 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 ``": "`` 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 = find_short_ids_in_title(text) - if not matches: - return escape(text) out: list[str] = [] cursor = 0 - for idx, (start, end, sid) in enumerate(matches): + for start, end, reference in find_title_references(text): out.append(escape(text[cursor:start])) - if idx == 0 and start == 0: - # Leading "s001:" — keep as plain text - out.append(escape(sid)) + 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="{SHORT_ID_URL_SCHEME}:{sid}">{escape(sid)}</a>') + out.append( + f'<a href="{REFERENCE_URL_SCHEME}:{reference}">{escape(display)}</a>' + ) cursor = end out.append(escape(text[cursor:])) return "".join(out) @@ -69,6 +76,9 @@ def _make_text_document( option: QW.QStyleOptionViewItem, link_color: QG.QColor, text_color: QG.QColor | 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 inherited from ``option``. @@ -84,22 +94,40 @@ 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, 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 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 - 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``. """ + def __init__( + self, + parent: QW.QWidget, + reference_resolver: Callable[[str], str | None] | None = None, + ) -> None: + super().__init__(parent) + self.reference_resolver = reference_resolver + # pylint: disable=invalid-name def paint( self, @@ -109,7 +137,10 @@ 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_reference = index.data(TITLE_REFERENCE_ROLE) + if not isinstance(text, str) or not ( + find_title_references(text) or isinstance(item_reference, str) + ): super().paint(painter, option, index) return opt = QW.QStyleOptionViewItem(option) @@ -130,27 +161,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.reference_resolver, + item_reference if isinstance(item_reference, str) else None, + meta_color, + ) doc.setTextWidth(text_rect.width()) painter.save() painter.translate(text_rect.topLeft()) @@ -161,6 +189,39 @@ 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 identity line.""" + text = index.data(QC.Qt.DisplayRole) or "" + item_reference = index.data(TITLE_REFERENCE_ROLE) + if not isinstance(text, str) or not ( + find_title_references(text) or isinstance(item_reference, str) + ): + return super().sizeHint(option, index) + opt = QW.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + doc = _make_text_document( + text, + opt, + QG.QColor("black"), + 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) + 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, @@ -168,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. @@ -179,7 +240,8 @@ 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_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 @@ -189,10 +251,17 @@ 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"), + 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) - if href and href.startswith(f"{SHORT_ID_URL_SCHEME}:"): - return href[len(SHORT_ID_URL_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 17ea94ded..81b8960a6 100644 --- a/doc/features/common/settings.rst +++ b/doc/features/common/settings.rst @@ -115,6 +115,23 @@ 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. 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 the current signal's bounds. When disabled, default values are used. 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 27f524b1f..6d038e3f1 100644 --- a/doc/locale/fr/LC_MESSAGES/features/common/settings.po +++ b/doc/locale/fr/LC_MESSAGES/features/common/settings.po @@ -148,6 +148,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. 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, 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**" @@ -609,4 +624,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 d08d3611e..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 @@ -153,6 +153,27 @@ 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 "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" + +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" + +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" @@ -206,3 +227,15 @@ 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.04.md b/doc/release_notes/release_1.04.md index c366099df..4539b1080 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -62,6 +62,16 @@ * **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)). + +* 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 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 + ### 🔄 Changes ### **Guided tour:** @@ -93,3 +103,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))