From 542a0d4245fa741dad9e637a439045adebe5e383 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com>
Date: Wed, 7 Oct 2026 12:20:23 +0200
Subject: [PATCH] feat(metadata): extract Add metadata values with a regular
expression
Assisted-by: Claude Opus 5.5
---
datalab/gui/panel/base.py | 237 +++++++++++++++---
datalab/locale/fr/LC_MESSAGES/datalab.po | 40 ++-
.../features/common/add_metadata_app_test.py | 28 +++
.../features/common/add_metadata_unit_test.py | 79 +++++-
doc/features/image/menu_edit.rst | 11 +-
doc/features/signal/menu_edit.rst | 11 +-
.../LC_MESSAGES/features/image/menu_edit.po | 24 +-
.../LC_MESSAGES/features/signal/menu_edit.po | 24 +-
.../LC_MESSAGES/release_notes/release_1.04.po | 21 ++
doc/release_notes/release_1.04.md | 9 +
10 files changed, 435 insertions(+), 49 deletions(-)
diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py
index 698f5a631..be18e8398 100644
--- a/datalab/gui/panel/base.py
+++ b/datalab/gui/panel/base.py
@@ -109,6 +109,8 @@
from datalab.widgets.textimport import TextImportWizard
if TYPE_CHECKING:
+ from collections.abc import Sequence
+
from plotpy.items import CurveItem, LabelItem, MaskedXYImageItem
from sigima.io.image import ImageIORegistry
from sigima.io.signal import SignalIORegistry
@@ -131,6 +133,28 @@
}
+def collect_metadata_keys(objs: Sequence[TypeObj]) -> list[tuple[str, str]]:
+ """Return the user-visible scalar metadata keys of objects.
+
+ Args:
+ objs: objects to inspect
+
+ Returns:
+ Sorted ``(key, description)`` pairs, the description showing an example
+ value; internal options, ROIs and analysis results are excluded.
+ """
+ examples: dict[str, object] = {}
+ for obj in objs:
+ for key, value in obj.metadata.items():
+ if key in examples or key.startswith("_") or key == ROI_KEY:
+ continue
+ if GeometryAdapter.match(key, value) or TableAdapter.match(key, value):
+ continue
+ if isinstance(value, (str, bool, int, float, np.integer, np.floating)):
+ examples[key] = value
+ return [(key, _("e.g. %s") % repr(examples[key])) for key in sorted(examples)]
+
+
def is_plot_item_serializable(item: Any) -> bool:
"""Return True if plot item is serializable"""
try:
@@ -1577,15 +1601,26 @@ class AddMetadataParam(
comment=_(
"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.
"
+ "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.
"
),
):
- """Add metadata parameters"""
+ """Add metadata parameters
- def __init__(self, objs: list[TypeObj] | None = None) -> None:
+ Args:
+ objs: objects receiving the metadata item
+ known_keys: suggested ``(key, description)`` pairs
+ """
+
+ def __init__(
+ self,
+ objs: list[TypeObj] | None = None,
+ known_keys: Sequence[tuple[str, str]] | None = None,
+ ) -> None:
super().__init__()
self.__objs = objs or []
+ self.__known_keys = list(known_keys or [])
def on_help_button_click(
self: AddMetadataParam,
@@ -1635,11 +1670,52 @@ def on_help_button_click(
Title truncated to 20 characters, lower case |
+ """,
+ "",
+ "Extraction:",
+ """When an extraction pattern (Python regular expression) is set,
+ it is searched in the formatted value: its first group, or the
+ whole match if it has no group, becomes the value. Objects without
+ a match are left unchanged, unless 'If no match' asks to report an
+ error. Numeric values are then multiplied by the scale factor.""",
+ "",
+ """
+
+ | Pattern | Extraction | Conversion |
+ Scale | Result |
+
+ | {title} |
+ ([\\d.]+)\\s*ms |
+ Float |
+ 0.001 |
+ 'Flat 5 ms 01' → 0.005 'Dark 01' → unchanged |
+
+
+ | {title} |
+ shot\\s*(\\d+) |
+ Integer |
+ 1 |
+ 'CH1 shot 042' → 42 |
+
+
""",
]
)
NonModalInfoDialog(parent, _("Pattern help"), text).show()
+ def get_known_key_choices(self, _item=None, _value=None):
+ """Return the suggested metadata keys."""
+ return [("", _("Select a key..."), None)] + [
+ (key, f"{key} — {description}" if description else key, None)
+ for key, description in self.__known_keys
+ ]
+
+ def on_known_key_changed(self, _item=None, value=None) -> None:
+ """Copy the selected suggestion into the metadata key."""
+ if value:
+ self.metadata_key = value
+ self.update_preview()
+
def get_conversion_choices(self, _item=None, _value=None):
"""Return list of available conversion choices."""
return [
@@ -1651,42 +1727,73 @@ def get_conversion_choices(self, _item=None, _value=None):
def build_values(
self, objs: list[TypeObj] | None = None
- ) -> list[str | float | int | bool]:
+ ) -> list[str | float | int | bool | None]:
"""Build values according to current parameters.
+ Returns:
+ One value per object; ``None`` for objects left unchanged because
+ the extraction pattern does not match.
+
Raises:
- ValueError: If a value cannot be converted to the target type.
+ ValueError: If the extraction pattern is invalid or does not match
+ (when asked to report it), or if a value cannot be converted.
"""
objs = objs or self.__objs
# Generate values using the pattern
raw_values = format_basenames(objs, self.value_pattern)
+ regex = None
+ if self.extraction_pattern:
+ try:
+ regex = re.compile(self.extraction_pattern)
+ except re.error as exc:
+ raise ValueError(f"Invalid extraction pattern: {exc}") from exc
- # Convert values according to the selected conversion type
- converted_values = []
+ converted_values: list[str | float | int | bool | None] = []
for i, value_str in enumerate(raw_values, start=1):
- if self.conversion == "string":
- converted_values.append(value_str)
- elif self.conversion == "float":
- try:
- converted_values.append(float(value_str))
- except ValueError as exc:
- raise ValueError(
- f"Cannot convert value at index {i} to float: '{value_str}'"
- ) from exc
- elif self.conversion == "int":
- try:
- converted_values.append(int(value_str))
- except ValueError as exc:
- raise ValueError(
- f"Cannot convert value at index {i} to integer: '{value_str}'"
- ) from exc
- elif self.conversion == "bool":
- # Convert to boolean: "true", "1", "yes" -> True, others -> False
- lower_val = value_str.lower()
- converted_values.append(lower_val in ("true", "1", "yes", "on"))
-
+ if regex is not None:
+ match = regex.search(value_str)
+ extracted = None
+ if match is not None:
+ extracted = match.group(1) if regex.groups else match.group(0)
+ if extracted is None:
+ if self.if_no_match == "error":
+ raise ValueError(
+ f"No match for the value at index {i}: '{value_str}'"
+ )
+ converted_values.append(None)
+ continue
+ value_str = extracted
+ converted_values.append(self.__convert(i, value_str))
return converted_values
+ def __convert(self, index: int, value_str: str) -> str | float | int | bool:
+ """Convert one formatted value according to the selected conversion."""
+ if self.conversion == "float":
+ try:
+ return float(value_str) * self.scale
+ except ValueError as exc:
+ raise ValueError(
+ f"Cannot convert value at index {index} to float: '{value_str}'"
+ ) from exc
+ if self.conversion == "int":
+ try:
+ value = int(value_str)
+ except ValueError as exc:
+ raise ValueError(
+ f"Cannot convert value at index {index} to integer: '{value_str}'"
+ ) from exc
+ if self.scale == 1.0:
+ return value
+ scaled = value * self.scale
+ if not float(scaled).is_integer():
+ raise ValueError(
+ f"Scaled value at index {index} is not an integer: {scaled}"
+ )
+ return int(scaled)
+ if self.conversion == "bool":
+ return value_str.lower() in ("true", "1", "yes", "on")
+ return value_str
+
def update_preview(self, _item=None, _value=None) -> None:
"""Update preview."""
try:
@@ -1699,7 +1806,10 @@ def update_preview(self, _item=None, _value=None) -> None:
except (ValueError, KeyError):
# Fallback to simple index for objects not yet in panel
obj_id = str(i)
- preview_lines.append(f"{obj_id}: {self.metadata_key} = {value!r}")
+ if value is None:
+ preview_lines.append(f"{obj_id}: " + _("unchanged"))
+ else:
+ preview_lines.append(f"{obj_id}: {self.metadata_key} = {value!r}")
self.preview = "\n".join(preview_lines)
except ValueError as exc:
# Handle conversion errors
@@ -1712,10 +1822,17 @@ def update_preview(self, _item=None, _value=None) -> None:
_("Metadata key"),
default="custom_key",
notempty=True,
- regexp=r"^[a-zA-Z_][a-zA-Z0-9_]*$",
+ regexp=r"^[a-zA-Z_][a-zA-Z0-9_.\-]*$",
help=_("The key name for the metadata item"),
).set_prop("display", callback=update_preview)
+ known_key = gds.ChoiceItem(
+ _("Known keys"),
+ get_known_key_choices,
+ default="",
+ help=_("Copy a key found on the selected objects into the metadata key"),
+ ).set_prop("display", callback=on_known_key_changed)
+
value_pattern = gds.StringItem(
_("Value pattern"),
default="{index}",
@@ -1726,9 +1843,38 @@ def update_preview(self, _item=None, _value=None) -> None:
_("Help"), on_help_button_click, "MessageBoxInformation"
).set_pos(col=1)
+ extraction_pattern = gds.StringItem(
+ _("Extraction pattern"),
+ default="",
+ help=_(
+ "Optional regular expression searched in the formatted value: "
+ "its first group, or the whole match, becomes the value"
+ ),
+ ).set_prop("display", callback=update_preview)
+
+ if_no_match = gds.ChoiceItem(
+ _("If no match"),
+ [
+ ("skip", _("Leave the object unchanged")),
+ ("error", _("Report an error")),
+ ],
+ default="skip",
+ ).set_prop("display", callback=update_preview)
+
+ _prop_conversion = gds.GetAttrProp("conversion")
conversion = gds.ChoiceItem(
_("Conversion"), get_conversion_choices, default="string"
- ).set_prop("display", callback=update_preview)
+ ).set_prop("display", store=_prop_conversion, callback=update_preview)
+
+ scale = gds.FloatItem(
+ _("Scale factor"),
+ default=1.0,
+ help=_("Multiplies numeric values, e.g. 0.001 to convert ms to s"),
+ ).set_prop(
+ "display",
+ active=gds.FuncProp(_prop_conversion, lambda value: value in ("float", "int")),
+ callback=update_preview,
+ )
preview = gds.TextItem(_("Preview"), default="", regexp=r"^(?!Invalid).*").set_prop(
"display", readonly=True
@@ -2218,6 +2364,19 @@ def paste_metadata(self, param: PasteMetadataParam | None = None) -> None:
self.refresh_plot(
"selected", update_items=True, only_visible=False, only_existing=True
)
+ if sel_objects:
+ self.SIG_OBJECT_MODIFIED.emit()
+
+ def get_known_metadata_keys(self, objs: Sequence[TypeObj]) -> list[tuple[str, str]]:
+ """Return the metadata keys suggested by the Add metadata dialog.
+
+ Args:
+ objs: selected objects
+
+ Returns:
+ ``(key, description)`` pairs
+ """
+ return collect_metadata_keys(objs)
def add_metadata(self, param: AddMetadataParam | None = None) -> None:
"""Add metadata item to selected object(s)
@@ -2230,10 +2389,13 @@ def add_metadata(self, param: AddMetadataParam | None = None) -> None:
return
if param is None:
- param = AddMetadataParam(sel_objects)
+ param = AddMetadataParam(
+ sel_objects, self.get_known_metadata_keys(sel_objects)
+ )
# Restore settings from config
saved_param = Conf.add_metadata_settings.get(AddMetadataParam())
update_dataset(param, saved_param)
+ param.known_key = ""
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=gds.DataItemValidationWarning)
if not param.edit(parent=self.parentWidget(), wordwrap=False):
@@ -2253,14 +2415,19 @@ def add_metadata(self, param: AddMetadataParam | None = None) -> None:
# Build values for all selected objects
values = param.build_values(sel_objects)
- # Add metadata to each object
+ # Add metadata to each object, except those left unchanged by extraction
+ modified = False
for obj, value in zip(sel_objects, values):
- obj.metadata[param.metadata_key] = value
+ if value is not None:
+ obj.metadata[param.metadata_key] = value
+ modified = True
# Refresh the plot to update any changes
self.refresh_plot(
"selected", update_items=True, only_visible=False, only_existing=True
)
+ if modified:
+ self.SIG_OBJECT_MODIFIED.emit()
def copy_roi(self, roi_data=None) -> None:
"""Copy regions of interest
@@ -2438,6 +2605,8 @@ def delete_metadata(
self.refresh_plot(
"selected", update_items=True, only_visible=False, only_existing=True
)
+ if sel_objs:
+ self.SIG_OBJECT_MODIFIED.emit()
def add_annotations_from_items(
self, items: list, refresh_plot: bool = True
diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po
index 9f7ef6631..1e45a5763 100644
--- a/datalab/locale/fr/LC_MESSAGES/datalab.po
+++ b/datalab/locale/fr/LC_MESSAGES/datalab.po
@@ -1453,8 +1453,8 @@ 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.
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.
Cliquez sur le bouton Aide pour plus de détails sur la syntaxe du modèle.
"
+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 "String"
msgstr "Chaîne de caractères"
@@ -4245,3 +4245,39 @@ msgstr "Pour commencer"
msgid "Quick actions"
msgstr "Actions rapides"
+msgid "e.g. %s"
+msgstr "ex. %s"
+
+msgid "Select a key..."
+msgstr "Choisir une clé..."
+
+msgid "Known keys"
+msgstr "Clés connues"
+
+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 "Extraction pattern"
+msgstr "Motif d'extraction"
+
+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 "If no match"
+msgstr "Sans correspondance"
+
+msgid "Leave the object unchanged"
+msgstr "Laisser l'objet inchangé"
+
+msgid "Report an error"
+msgstr "Signaler une erreur"
+
+msgid "Scale factor"
+msgstr "Facteur d'échelle"
+
+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 "unchanged"
+msgstr "inchangé"
+
diff --git a/datalab/tests/features/common/add_metadata_app_test.py b/datalab/tests/features/common/add_metadata_app_test.py
index 28dcca078..bb86d9954 100644
--- a/datalab/tests/features/common/add_metadata_app_test.py
+++ b/datalab/tests/features/common/add_metadata_app_test.py
@@ -17,6 +17,8 @@
from typing import TYPE_CHECKING
import guidata.dataset as gds
+import numpy as np
+from sigima.objects import create_signal
from datalab.env import execenv
from datalab.gui.panel.base import AddMetadataParam
@@ -118,6 +120,32 @@ def test_add_metadata_to_images() -> None:
__add_metadata_to_images(win)
+def test_add_metadata_extraction_marks_workspace_modified() -> None:
+ """Test extracted values, unchanged objects and the modified state."""
+ with datalab_test_app_context() as win:
+ panel = win.signalpanel
+ for title in ("Flat 5 ms 01", "Dark 01"):
+ panel.add_object(
+ create_signal(title=title, x=np.arange(3.0), y=np.zeros(3))
+ )
+ panel.objview.select_objects([1, 2])
+ win.set_modified(False)
+
+ objs = panel.objview.get_sel_objects(include_groups=True)
+ param = AddMetadataParam(objs)
+ param.metadata_key = "plugin.org.example.camera.exposure_time_s"
+ param.value_pattern = "{title}"
+ param.extraction_pattern = r"([\d.]+)\s*ms"
+ param.conversion = "float"
+ param.scale = 0.001
+ panel.add_metadata(param)
+
+ flat, dark = objs
+ assert flat.metadata[param.metadata_key] == 0.005
+ assert param.metadata_key not in dark.metadata
+ assert win.is_modified()
+
+
def add_metadata_screenshots():
"""Generate add metadata screenshots."""
with execenv.context(unattended=True):
diff --git a/datalab/tests/features/common/add_metadata_unit_test.py b/datalab/tests/features/common/add_metadata_unit_test.py
index 1c0bfe89a..433b68595 100644
--- a/datalab/tests/features/common/add_metadata_unit_test.py
+++ b/datalab/tests/features/common/add_metadata_unit_test.py
@@ -19,7 +19,7 @@
from sigima.objects import create_image, create_signal
from datalab.env import execenv
-from datalab.gui.panel.base import AddMetadataParam
+from datalab.gui.panel.base import AddMetadataParam, collect_metadata_keys
if TYPE_CHECKING:
from sigima.objects import ImageObj, SignalObj
@@ -262,6 +262,83 @@ def test_error_handling(self) -> None:
execenv.print(f"{self.test_error_handling.__doc__}: OK")
+ def test_namespaced_key(self) -> None:
+ """Test that plugin-style namespaced keys are accepted."""
+ p = AddMetadataParam()
+ item = AddMetadataParam.metadata_key
+ for key in ("plugin.org.example.my-plugin.exposure_time_s", "custom_key"):
+ assert item.check_value(key), key
+ for key in ("1st_key", "bad key", ".hidden"):
+ assert not item.check_value(key), key
+ del p
+
+ def test_extraction_with_scale(self) -> None:
+ """Test extracting a numeric value from titles, with a scale factor."""
+ frames = [
+ create_signal(title=title, x=np.arange(3.0), y=np.zeros(3))
+ for title in ("Flat 5 ms 01", "Dark 01", "Flat 40ms 02")
+ ]
+ p = AddMetadataParam(frames)
+ p.metadata_key = "plugin.org.example.camera.exposure_time_s"
+ p.value_pattern = "{title}"
+ p.extraction_pattern = r"([\d.]+)\s*ms"
+ p.conversion = "float"
+ p.scale = 0.001
+
+ assert p.build_values() == [0.005, None, 0.04]
+ p.update_preview()
+ assert "unchanged" in p.preview and "0.005" in p.preview
+
+ p.if_no_match = "error"
+ with pytest.raises(ValueError, match="No match"):
+ p.build_values()
+ p.update_preview()
+ assert p.preview.startswith("Invalid")
+
+ def test_extraction_without_group_and_integer_scale(self) -> None:
+ """Test whole-match extraction, integer scaling and invalid patterns."""
+ shots = [
+ create_signal(title=title, x=np.arange(3.0), y=np.zeros(3))
+ for title in ("CH1 shot 042", "CH2 shot 7")
+ ]
+ p = AddMetadataParam(shots)
+ p.value_pattern = "{title}"
+ p.extraction_pattern = r"CH\d"
+ p.conversion = "string"
+ assert p.build_values() == ["CH1", "CH2"]
+
+ p.extraction_pattern = r"shot\s*(\d+)"
+ p.conversion = "int"
+ assert p.build_values() == [42, 7]
+ p.scale = 10.0
+ assert p.build_values() == [420, 70]
+ p.scale = 0.5
+ with pytest.raises(ValueError, match="not an integer"):
+ p.build_values()
+
+ p.extraction_pattern = "shot("
+ with pytest.raises(ValueError, match="Invalid extraction pattern"):
+ p.build_values()
+
+ def test_known_keys(self) -> None:
+ """Test suggested keys and their copy into the metadata key."""
+ signals = create_test_signals()
+ signals[0].metadata["__internal"] = 1
+ signals[0].metadata["array"] = np.arange(3)
+ known = collect_metadata_keys(signals)
+ assert [key for key, _description in known] == [
+ "frequency",
+ "time_constant",
+ "type",
+ ]
+
+ p = AddMetadataParam(signals, known)
+ choices = [choice[0] for choice in p.get_known_key_choices()]
+ assert choices == ["", "frequency", "time_constant", "type"]
+ p.on_known_key_changed(None, "frequency")
+ assert p.metadata_key == "frequency"
+ assert "frequency" in p.preview
+
if __name__ == "__main__":
pytest.main([__file__, "-v"])
diff --git a/doc/features/image/menu_edit.rst b/doc/features/image/menu_edit.rst
index d4c187904..62b179cbc 100644
--- a/doc/features/image/menu_edit.rst
+++ b/doc/features/image/menu_edit.rst
@@ -170,10 +170,14 @@ sample names, processing steps, or any other custom information.
When you select "Add metadata..." from the Edit menu, a dialog appears where you can:
-- **Metadata key**: Enter the name of the metadata field to add
+- **Metadata key**: Enter the name of the metadata field to add (letters, digits, ``_``, ``.`` and ``-``, e.g. a plugin key such as ``plugin.org.example.my-plugin.gain``)
+- **Known keys**: Pick a key already present on the selected images to copy it into the metadata key
- **Value pattern**: Define a pattern for the metadata value using Python format strings
+- **Extraction pattern**: Optionally, extract the value from the formatted text with a Python regular expression: its first group, or the whole match if it has no group, becomes the value
+- **If no match**: Leave images without a match unchanged (default), or report an error
- **Conversion**: Choose how to store the value (string, float, integer, or boolean)
-- **Preview**: See how the metadata will be added to each selected image
+- **Scale factor**: Multiply numeric values, e.g. ``0.001`` to convert milliseconds to seconds
+- **Preview**: See how the metadata will be added to each selected image, and which images are left unchanged
The value pattern supports the following placeholders:
@@ -200,6 +204,9 @@ You can also use format modifiers:
- Flag calibrated images: key=``is_calibrated``, pattern=``true``, conversion=bool
→ Sets ``is_calibrated=True`` for all selected images
+- Read exposure times from titles: key=``exposure_time_s``, pattern=``{title}``, extraction=``([\d.]+)\s*ms``, conversion=float, scale factor=``0.001``
+ → Sets ``exposure_time_s=0.005`` on an image titled "Flat 5 ms 01" and leaves "Dark 01" unchanged
+
Annotations
-----------
diff --git a/doc/features/signal/menu_edit.rst b/doc/features/signal/menu_edit.rst
index 001f848ee..3559e6ca5 100644
--- a/doc/features/signal/menu_edit.rst
+++ b/doc/features/signal/menu_edit.rst
@@ -165,10 +165,14 @@ sample names, processing steps, or any other custom information.
When you select "Add metadata..." from the Edit menu, a dialog appears where you can:
-- **Metadata key**: Enter the name of the metadata field to add
+- **Metadata key**: Enter the name of the metadata field to add (letters, digits, ``_``, ``.`` and ``-``, e.g. a plugin key such as ``plugin.org.example.my-plugin.gain``)
+- **Known keys**: Pick a key already present on the selected signals to copy it into the metadata key
- **Value pattern**: Define a pattern for the metadata value using Python format strings
+- **Extraction pattern**: Optionally, extract the value from the formatted text with a Python regular expression: its first group, or the whole match if it has no group, becomes the value
+- **If no match**: Leave signals without a match unchanged (default), or report an error
- **Conversion**: Choose how to store the value (string, float, integer, or boolean)
-- **Preview**: See how the metadata will be added to each selected signal
+- **Scale factor**: Multiply numeric values, e.g. ``0.001`` to convert milliseconds to seconds
+- **Preview**: See how the metadata will be added to each selected signal, and which signals are left unchanged
The value pattern supports the following placeholders:
@@ -195,6 +199,9 @@ You can also use format modifiers:
- Mark processed signals: key=``is_processed``, pattern=``true``, conversion=bool
→ Sets ``is_processed=True`` for all selected signals
+- Number shots from their titles: key=``shot``, pattern=``{title}``, extraction=``shot\s*(\d+)``, conversion=integer
+ → Sets ``shot=42`` on a signal titled "CH1 shot 042"
+
Annotations
-----------
diff --git a/doc/locale/fr/LC_MESSAGES/features/image/menu_edit.po b/doc/locale/fr/LC_MESSAGES/features/image/menu_edit.po
index 434463e48..2b05829c1 100644
--- a/doc/locale/fr/LC_MESSAGES/features/image/menu_edit.po
+++ b/doc/locale/fr/LC_MESSAGES/features/image/menu_edit.po
@@ -140,17 +140,29 @@ msgstr "Boîte de dialogue Ajouter des métadonnées."
msgid "When you select \"Add metadata...\" from the Edit menu, a dialog appears where you can:"
msgstr "Lorsque vous sélectionnez \"Ajouter des métadonnées...\" dans le menu Édition, une boîte de dialogue apparaît où vous pouvez :"
-msgid "**Metadata key**: Enter the name of the metadata field to add"
-msgstr "**Clé de métadonnées** : Entrez le nom du champ de métadonnées à ajouter"
+msgid "**Metadata key**: Enter the name of the metadata field to add (letters, digits, ``_``, ``.`` and ``-``, e.g. a plugin key such as ``plugin.org.example.my-plugin.gain``)"
+msgstr "**Clé de métadonnée** : Saisir le nom du champ de métadonnée à ajouter (lettres, chiffres, ``_``, ``.`` et ``-``, par exemple une clé de plugin telle que ``plugin.org.example.my-plugin.gain``)"
+
+msgid "**Known keys**: Pick a key already present on the selected images to copy it into the metadata key"
+msgstr "**Clés connues** : Choisir une clé déjà présente sur les images sélectionnées pour la copier dans la clé de métadonnée"
msgid "**Value pattern**: Define a pattern for the metadata value using Python format strings"
msgstr "**Modèle de valeur** : Définir un modèle pour la valeur des métadonnées en utilisant des chaînes de format Python"
+msgid "**Extraction pattern**: Optionally, extract the value from the formatted text with a Python regular expression: its first group, or the whole match if it has no group, becomes the value"
+msgstr "**Motif d'extraction** : Facultativement, extraire la valeur du texte formaté avec une expression régulière Python : son premier groupe, ou toute la correspondance si elle n'a pas de groupe, devient la valeur"
+
+msgid "**If no match**: Leave images without a match unchanged (default), or report an error"
+msgstr "**Sans correspondance** : Laisser inchangées les images sans correspondance (par défaut), ou signaler une erreur"
+
msgid "**Conversion**: Choose how to store the value (string, float, integer, or boolean)"
msgstr "**Conversion** : Choisissez comment stocker la valeur (chaîne, flottant, entier ou booléen)"
-msgid "**Preview**: See how the metadata will be added to each selected image"
-msgstr "**Aperçu** : Voir comment les métadonnées seront ajoutées à chaque image sélectionnée"
+msgid "**Scale factor**: Multiply numeric values, e.g. ``0.001`` to convert milliseconds to seconds"
+msgstr "**Facteur d'échelle** : Multiplier les valeurs numériques, par exemple ``0.001`` pour convertir des millisecondes en secondes"
+
+msgid "**Preview**: See how the metadata will be added to each selected image, and which images are left unchanged"
+msgstr "**Aperçu** : Voir comment les métadonnées seront ajoutées à chaque image sélectionnée, et quelles images restent inchangées"
msgid "The value pattern supports the following placeholders:"
msgstr "Le modèle de valeur prend en charge les espaces réservés suivants :"
@@ -204,6 +216,10 @@ msgstr "Ajouter le temps d'exposition : clé=``exposure_ms``, modèle=``{metadat
msgid "Flag calibrated images: key=``is_calibrated``, pattern=``true``, conversion=bool → Sets ``is_calibrated=True`` for all selected images"
msgstr "Identifier les images étalonnées : clé=``is_calibrated``, modèle=``true``, conversion=bool → Définit ``is_calibrated=True`` pour toutes les images sélectionnées."
+#, python-brace-format
+msgid "Read exposure times from titles: key=``exposure_time_s``, pattern=``{title}``, extraction=``([\\d.]+)\\s*ms``, conversion=float, scale factor=``0.001`` → Sets ``exposure_time_s=0.005`` on an image titled \"Flat 5 ms 01\" and leaves \"Dark 01\" unchanged"
+msgstr "Lire les temps d'exposition dans les titres : clé=``exposure_time_s``, modèle=``{title}``, extraction=``([\\d.]+)\\s*ms``, conversion=flottant, facteur d'échelle=``0.001`` → Définit ``exposure_time_s=0.005`` sur une image intitulée « Flat 5 ms 01 » et laisse « Dark 01 » inchangée"
+
msgid "Annotations"
msgstr "Annotations"
diff --git a/doc/locale/fr/LC_MESSAGES/features/signal/menu_edit.po b/doc/locale/fr/LC_MESSAGES/features/signal/menu_edit.po
index 801cddb30..dba3d52cd 100644
--- a/doc/locale/fr/LC_MESSAGES/features/signal/menu_edit.po
+++ b/doc/locale/fr/LC_MESSAGES/features/signal/menu_edit.po
@@ -137,17 +137,29 @@ msgstr "Boîte de dialogue Ajouter des métadonnées."
msgid "When you select \"Add metadata...\" from the Edit menu, a dialog appears where you can:"
msgstr "Lorsque vous sélectionnez \"Ajouter des métadonnées...\" dans le menu Édition, une boîte de dialogue apparaît où vous pouvez :"
-msgid "**Metadata key**: Enter the name of the metadata field to add"
-msgstr "**Clé de métadonnées** : Entrez le nom du champ de métadonnées à ajouter"
+msgid "**Metadata key**: Enter the name of the metadata field to add (letters, digits, ``_``, ``.`` and ``-``, e.g. a plugin key such as ``plugin.org.example.my-plugin.gain``)"
+msgstr "**Clé de métadonnée** : Saisir le nom du champ de métadonnée à ajouter (lettres, chiffres, ``_``, ``.`` et ``-``, par exemple une clé de plugin telle que ``plugin.org.example.my-plugin.gain``)"
+
+msgid "**Known keys**: Pick a key already present on the selected signals to copy it into the metadata key"
+msgstr "**Clés connues** : Choisir une clé déjà présente sur les signaux sélectionnés pour la copier dans la clé de métadonnée"
msgid "**Value pattern**: Define a pattern for the metadata value using Python format strings"
msgstr "**Modèle de valeur** : Définissez un modèle pour la valeur des métadonnées en utilisant des chaînes de format Python"
+msgid "**Extraction pattern**: Optionally, extract the value from the formatted text with a Python regular expression: its first group, or the whole match if it has no group, becomes the value"
+msgstr "**Motif d'extraction** : Facultativement, extraire la valeur du texte formaté avec une expression régulière Python : son premier groupe, ou toute la correspondance si elle n'a pas de groupe, devient la valeur"
+
+msgid "**If no match**: Leave signals without a match unchanged (default), or report an error"
+msgstr "**Sans correspondance** : Laisser inchangés les signaux sans correspondance (par défaut), ou signaler une erreur"
+
msgid "**Conversion**: Choose how to store the value (string, float, integer, or boolean)"
msgstr "**Conversion** : Choisissez comment stocker la valeur (chaîne, flottant, entier ou booléen)"
-msgid "**Preview**: See how the metadata will be added to each selected signal"
-msgstr "**Aperçu** : Voir comment les métadonnées seront ajoutées à chaque signal sélectionné."
+msgid "**Scale factor**: Multiply numeric values, e.g. ``0.001`` to convert milliseconds to seconds"
+msgstr "**Facteur d'échelle** : Multiplier les valeurs numériques, par exemple ``0.001`` pour convertir des millisecondes en secondes"
+
+msgid "**Preview**: See how the metadata will be added to each selected signal, and which signals are left unchanged"
+msgstr "**Aperçu** : Voir comment les métadonnées seront ajoutées à chaque signal sélectionné, et quels signaux restent inchangés"
msgid "The value pattern supports the following placeholders:"
msgstr "Le modèle de valeur prend en charge les espaces réservés suivants :"
@@ -201,6 +213,10 @@ msgstr "Ajouter la température de l'échantillon : clé=``temperature``, modèl
msgid "Mark processed signals: key=``is_processed``, pattern=``true``, conversion=bool → Sets ``is_processed=True`` for all selected signals"
msgstr "Marquer les signaux traités : clé=``is_processed``, modèle=``true``, conversion=bool → Définit ``is_processed=True`` pour tous les signaux sélectionnés"
+#, python-brace-format
+msgid "Number shots from their titles: key=``shot``, pattern=``{title}``, extraction=``shot\\s*(\\d+)``, conversion=integer → Sets ``shot=42`` on a signal titled \"CH1 shot 042\""
+msgstr "Numéroter des tirs à partir de leurs titres : clé=``shot``, modèle=``{title}``, extraction=``shot\\s*(\\d+)``, conversion=entier → Définit ``shot=42`` sur un signal intitulé « CH1 shot 042 »"
+
msgid "Annotations"
msgstr "Annotations"
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 b4f86cb74..ced4103a3 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
@@ -54,6 +54,15 @@ msgstr "Les aperçus terminés conservent leur processus dédié prêt pour les
msgid "Bounded numeric parameters now offer sliders alongside precise text entry. The Processing tab retains Apply and automatic re-processing, with updates deferred until slider release and valid input."
msgstr "Les paramètres numériques bornés proposent désormais des curseurs en complément d'une saisie textuelle précise. L'onglet Traitement conserve Appliquer et le retraitement automatique, avec des mises à jour différées jusqu'au relâchement du curseur et à une saisie valide."
+msgid "**Add metadata:**"
+msgstr "**Ajout de métadonnées :**"
+
+msgid "**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)"
+msgstr "**Édition > Métadonnées > Ajouter une métadonnée...** peut désormais extraire la valeur du texte formaté avec une expression régulière, par exemple un temps d'exposition ou un numéro de tir lu dans les titres des objets. Les objets sans correspondance restent inchangés, sauf si vous demandez une erreur, et un facteur d'échelle convertit les valeurs numériques (par exemple des millisecondes en secondes)"
+
+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 "🔄 Changes"
msgstr "🔄 Changements"
@@ -81,6 +90,18 @@ msgstr "DataLab nécessite désormais Sigima ≥ 1.3.0 et SigimaX ≥ 1.1.0"
msgid "🛠️ Bug Fixes"
msgstr "🛠️ Corrections de bugs"
+msgid "**Window layout:**"
+msgstr "**Disposition de la fenêtre :**"
+
+msgid "Fixed the width of the signal/image panels not being restored at startup: after narrowing the panels and restarting DataLab, they came back wider (up to their maximum width) instead of keeping the width chosen by the user"
+msgstr "Correction de la largeur des panneaux Signal/Image non restaurée au démarrage : après avoir réduit les panneaux puis redémarré DataLab, ils réapparaissaient plus larges (jusqu'à leur largeur maximale) au lieu de conserver la largeur choisie par l'utilisateur"
+
+msgid "**Metadata:**"
+msgstr "**Métadonnées :**"
+
+msgid "Adding, pasting or deleting metadata now marks the workspace as modified, so DataLab asks to save these changes before closing"
+msgstr "L'ajout, le collage ou la suppression de métadonnées marque désormais l'espace de travail comme modifié : DataLab propose donc d'enregistrer ces changements avant de se fermer"
+
msgid "**Plugins and History:**"
msgstr "**Plugins et historique :**"
diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md
index 3ae03a737..2d41d8ae1 100644
--- a/doc/release_notes/release_1.04.md
+++ b/doc/release_notes/release_1.04.md
@@ -21,6 +21,11 @@
* Completed previews keep their dedicated process ready for later dialogs, avoiding repeated process startup. Cancelling a preview that is still running stops its process so speculative work cannot continue in the background.
* Bounded numeric parameters now offer sliders alongside precise text entry. The Processing tab retains Apply and automatic re-processing, with updates deferred until slider release and valid input.
+**Add metadata:**
+
+* **Edit > Metadata > Add metadata...** can now extract the value from the formatted text with a regular expression, for example an exposure time or a shot number read from object titles. Objects without a match are left unchanged unless you ask for an error, 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
+
### 🔄 Changes ###
**Guided tour:**
@@ -40,6 +45,10 @@
* Fixed the width of the signal/image panels not being restored at startup: after narrowing the panels and restarting DataLab, they came back wider (up to their maximum width) instead of keeping the width chosen by the user
+**Metadata:**
+
+* Adding, pasting or deleting metadata now marks the workspace as modified, so DataLab asks to save these changes before closing
+
**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