diff --git a/src/Model/WeatherParameters/WeatherParameters.py b/src/Model/WeatherParameters/WeatherParameters.py index 2f12943b..52650d60 100644 --- a/src/Model/WeatherParameters/WeatherParameters.py +++ b/src/Model/WeatherParameters/WeatherParameters.py @@ -540,6 +540,16 @@ class WeatherParameters(SQLSubModel): ) ) self.modified() + + def set_as_deleted_i(self, indexes): + for i in indexes: + self._data[i].set_as_deleted() + self.modified() + + def set_as_not_deleted_i(self, indexes): + for i in indexes: + self._data[i].set_as_not_deleted() + self.modified() def delete(self, els): self._data = list( diff --git a/src/Model/WeatherParameters/WeatherParametersTypes.py b/src/Model/WeatherParameters/WeatherParametersTypes.py index 3db87fd6..8bd4f815 100644 --- a/src/Model/WeatherParameters/WeatherParametersTypes.py +++ b/src/Model/WeatherParameters/WeatherParametersTypes.py @@ -76,7 +76,7 @@ class SpecificHumidity(WeatherParameters): ) self._type = "SH" - self._header = ["time", "discharge"] + self._header = ["time", "humidity percentage"] self._types = [SpecificHumidity.time_convert, float] @@ -89,7 +89,7 @@ class GlobalRadiation(WeatherParameters): ) self._type = "GR" - self._header = ["time", "discharge"] + self._header = ["time", "radiation"] self._types = [GlobalRadiation.time_convert, float] @@ -102,7 +102,7 @@ class ReferenceWindSpeed(WeatherParameters): ) self._type = "RWS" - self._header = ["time", "discharge"] + self._header = ["time", "wind speed"] self._types = [ReferenceWindSpeed.time_convert, float] @@ -115,7 +115,7 @@ class GroundwaterTemperature(WeatherParameters): ) self._type = "GT" - self._header = ["time", "discharge"] + self._header = ["time", "temperature"] self._types = [GroundwaterTemperature.time_convert, float] @@ -141,7 +141,7 @@ class Albedo(WeatherParameters): ) self._type = "ALB" - self._header = ["time", "discharge"] + self._header = ["time", "albedo"] self._types = [Albedo.time_convert, float] @@ -154,7 +154,7 @@ class ShadingCoefficient(WeatherParameters): ) self._type = "SC" - self._header = ["time", "discharge"] + self._header = ["time", "shading coefficient"] self._types = [ShadingCoefficient.time_convert, float] @@ -167,5 +167,5 @@ class CloudCoverFraction(WeatherParameters): ) self._type = "CCF" - self._header = ["time", "discharge"] + self._header = ["time", "cloud cover fraction"] self._types = [CloudCoverFraction.time_convert, float] diff --git a/src/View/WeatherParameters/Edit/Plot.py b/src/View/WeatherParameters/Edit/Plot.py new file mode 100644 index 00000000..31b25f9b --- /dev/null +++ b/src/View/WeatherParameters/Edit/Plot.py @@ -0,0 +1,104 @@ +# Plot.py -- Pamhyr +# Copyright (C) 2023-2025 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +import logging + +from datetime import datetime + +from tools import timer, trace +from View.Tools.PamhyrPlot import PamhyrPlot + +from PyQt5.QtCore import ( + QCoreApplication +) + +_translate = QCoreApplication.translate + +logger = logging.getLogger() + + +class Plot(PamhyrPlot): + def __init__(self, mode="time", data=None, + trad=None, canvas=None, toolbar=None, + parent=None): + super(Plot, self).__init__( + canvas=canvas, + trad=trad, + data=data, + toolbar=toolbar, + parent=parent + ) + + self._table_headers = self._trad.get_dict("table_headers") + + header = self.data.header + self.label_x = self._table_headers[header[0]] + self.label_y = self._table_headers[header[1]] + + self._mode = mode + self._isometric_axis = False + + self._auto_relim_update = True + self._autoscale_update = False + + def custom_ticks(self): + if self.data.header[0] != "time": + return + + self.set_ticks_time_formater() + + @timer + def draw(self): + self.init_axes() + + if len(self.data) == 0: + self._init = False + return + + self.draw_data() + self.custom_ticks() + + self.idle() + self._init = True + + def draw_data(self): + # Plot data + x = list(map(lambda v: v[0], self.data.data)) + y = list(map(lambda v: v[1], self.data.data)) + + self._line, = self.canvas.axes.plot( + x, y, + color=self.color_plot, + **self.plot_default_kargs + ) + + @timer + def update(self, ind=None): + if not self._init: + self.draw() + return + + self.update_data() + + self.update_idle() + + def update_data(self): + x = list(map(lambda v: v[0], self.data.data)) + y = list(map(lambda v: v[1], self.data.data)) + + self._line.set_data(x, y) diff --git a/src/View/WeatherParameters/Edit/Table.py b/src/View/WeatherParameters/Edit/Table.py new file mode 100644 index 00000000..996ef4b7 --- /dev/null +++ b/src/View/WeatherParameters/Edit/Table.py @@ -0,0 +1,218 @@ +# Table.py -- Pamhyr +# Copyright (C) 2023-2025 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +import logging +import traceback +from datetime import date, time, datetime, timedelta + +from tools import ( + trace, timer, + timestamp_to_old_pamhyr_date, + old_pamhyr_date_to_timestamp +) + +from View.Tools.PamhyrTable import PamhyrTableModel + +from PyQt5.QtCore import ( + Qt, QVariant, QAbstractTableModel, + QCoreApplication, QModelIndex, pyqtSlot, + QRect, QTime, QDateTime, +) + +from PyQt5.QtWidgets import ( + QTableView, QAbstractItemView, QSpinBox, + QTimeEdit, QDateTimeEdit, QItemDelegate, +) + +from Model.WeatherParameters.WeatherParametersTypes import ( + NotDefined, WeatherParam, AirTemperature, SpecificHumidity, + GlobalRadiation, ReferenceWindSpeed, GroundwaterTemperature, + GroundwaterFlowRate, Albedo, ShadingCoefficient, CloudCoverFraction +) + +from View.WeatherParameters.Edit.UndoCommand import ( + SetDataCommand, AddCommand, DelCommand, + SortCommand, MoveCommand, PasteCommand, +) +from View.WeatherParameters.Edit.translate import * + +_translate = QCoreApplication.translate + +logger = logging.getLogger() + + +class TableModel(PamhyrTableModel): + def data(self, index, role): + if role == Qt.TextAlignmentRole: + return Qt.AlignHCenter | Qt.AlignVCenter + + if role != Qt.ItemDataRole.DisplayRole: + return QVariant() + + row = index.row() + column = index.column() + + value = QVariant() + + if 0 <= column < 2: + v = self._data.get_i(row)[column] + if self._data.get_type_column(column) == float: + value = f"{v:.4f}" + elif self._data.header[column] == "time": + if self._opt_data == "time": + value = timestamp_to_old_pamhyr_date(int(v)) + else: + value = str(datetime.fromtimestamp(v)) + else: + value = f"{v}" + + return value + + def setData(self, index, value, role=Qt.EditRole): + if not index.isValid() or role != Qt.EditRole: + return False + + if self.is_same_data(index, value): + return False + + row = index.row() + column = index.column() + + try: + self._undo.push( + SetDataCommand( + self._data, row, column, value + ) + ) + except Exception as e: + logger.info(e) + logger.debug(traceback.format_exc()) + + self.update() + return True + + def add(self, row, parent=QModelIndex()): + self.beginInsertRows(parent, row, row - 1) + + self._undo.push( + AddCommand( + self._data, row + ) + ) + + self.endInsertRows() + self.update() + + def delete(self, rows, parent=QModelIndex()): + self.beginRemoveRows(parent, rows[0], rows[-1]) + + self._undo.push( + DelCommand( + self._data, rows + ) + ) + + self.endRemoveRows() + + def sort(self, _reverse, parent=QModelIndex()): + self.layoutAboutToBeChanged.emit() + + self._undo.push( + SortCommand( + self._data, _reverse + ) + ) + + self.layoutAboutToBeChanged.emit() + self.update() + + def move_up(self, row, parent=QModelIndex()): + if row <= 0: + return + + target = row + 2 + + self.beginMoveRows(parent, row - 1, row - 1, parent, target) + + self._undo_stack.push( + MoveCommand( + self._data, "up", row + ) + ) + + self.endMoveRows() + self.update() + + def move_down(self, index, parent=QModelIndex()): + row = index.row() + if row >= len(self._data): + return + + target = row + + self.beginMoveRows(parent, row + 1, row + 1, parent, target) + + self._undo_stack.push( + MoveCommand( + self._data, "down", row + ) + ) + + self.endMoveRows() + self.update() + + def paste(self, row, header, data): + if len(data) == 0: + return + + self.layoutAboutToBeChanged.emit() + + self._undo.push( + PasteCommand( + self._data, row, + list( + map( + lambda d: self._data.new_from_data(header, d), + data + ) + ) + ) + ) + + self.layoutAboutToBeChanged.emit() + self.update() + + def auto_sort(self): + self.layoutAboutToBeChanged.emit() + self._data.sort(key=lambda x: x[0]) + self.layoutAboutToBeChanged.emit() + + def update(self): + # self.auto_sort() + self.layoutChanged.emit() + + def get(self, row): + if row < 0 or row >= len(self._lst): + return None + return self._lst[row] + + def _global_row(self, row): + wp = self.get(row) + if wp is None: + return None + return self._data.index(wp) diff --git a/src/View/WeatherParameters/Edit/UndoCommand.py b/src/View/WeatherParameters/Edit/UndoCommand.py new file mode 100644 index 00000000..b9ddc8a6 --- /dev/null +++ b/src/View/WeatherParameters/Edit/UndoCommand.py @@ -0,0 +1,167 @@ +# UndoCommand.py -- Pamhyr +# Copyright (C) 2023-2025 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +from copy import deepcopy +from tools import trace, timer + +from PyQt5.QtWidgets import ( + QMessageBox, QUndoCommand, QUndoStack, +) + +from Model.WeatherParameters.WeatherParameters import WeatherParameters + + +class SetDataCommand(QUndoCommand): + def __init__(self, data, index, column, new_value): + QUndoCommand.__init__(self) + + self._data = data + self._index = index + self._column = column + self._old = self._data.get_i(self._index)[self._column] + _type = self._data.get_type_column(self._column) + self._new = _type(new_value) + + def undo(self): + self._data._set_i_c_v(self._index, self._column, self._old) + + def redo(self): + self._data._set_i_c_v(self._index, self._column, self._new) + + +class AddCommand(QUndoCommand): + def __init__(self, data, index): + QUndoCommand.__init__(self) + + self._data = data + self._index = index + self._new = None + + def undo(self): + self._data.delete_i([self._index]) + + def redo(self): + if self._new is None: + self._new = self._data.add(self._index) + else: + self._data.insert(self._index, self._new) + + +class DelCommand(QUndoCommand): + def __init__(self, data, rows): + QUndoCommand.__init__(self) + + self._data = data + self._rows = rows + + self._wp = [] + for row in rows: + self._wp.append((row, self._data.get_i(row))) + self._wp.sort() + + def undo(self): + self._data.set_as_not_deleted_i(self._rows) + + def redo(self): + self._data.set_as_deleted_i(self._rows) + + +class SortCommand(QUndoCommand): + def __init__(self, data, _reverse): + QUndoCommand.__init__(self) + + self._data = data + self._reverse = _reverse + + self._old = self._data.data + self._indexes = None + + def undo(self): + ll = self._data.data + self._data.sort( + key=lambda x: self._indexes[ll.index(x)] + ) + + def redo(self): + self._data.sort( + _reverse=self._reverse, + key=lambda x: x[0] + ) + if self._indexes is None: + self._indexes = list( + map( + lambda p: self._old.index(p), + self._data.data + ) + ) + self._old = None + + +class MoveCommand(QUndoCommand): + def __init__(self, data, up, i): + QUndoCommand.__init__(self) + + self._data = data + self._up = up == "up" + self._i = i + + def undo(self): + if self._up: + self._data.move_up(self._i) + else: + self._data.move_down(self._i) + + def redo(self): + if self._up: + self._data.move_up(self._i) + else: + self._data.move_down(self._i) + + +class PasteCommand(QUndoCommand): + def __init__(self, data, row, wps): + QUndoCommand.__init__(self) + + self._data = data + self._row = row + self._wps = wps + self._wps.reverse() + + def undo(self): + self._data.delete(self._wps) + + def redo(self): + for bc in self._wps: + self._data.insert(self._row, bc) + + +class DuplicateCommand(QUndoCommand): + def __init__(self, data, rows, bc): + QUndoCommand.__init__(self) + + self._data = data + self._rows = rows + self._wp = deepcopy(bc) + self._wp.reverse() + + def undo(self): + self._data.delete(self._wp) + + def redo(self): + for bc in self._wp: + self._data.insert(self._rows[0], bc) diff --git a/src/View/WeatherParameters/Edit/Window.py b/src/View/WeatherParameters/Edit/Window.py new file mode 100644 index 00000000..6aad1714 --- /dev/null +++ b/src/View/WeatherParameters/Edit/Window.py @@ -0,0 +1,232 @@ +# Window.py -- Pamhyr +# Copyright (C) 2023-2025 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +from tools import timer, trace + +from View.Tools.PamhyrWindow import PamhyrWindow +from View.Tools.PamhyrDelegate import PamhyrExTimeDelegate + +from PyQt5.QtGui import ( + QKeySequence, +) + +from PyQt5.QtCore import ( + Qt, QVariant, QAbstractTableModel, QCoreApplication, +) + +from PyQt5.QtWidgets import ( + QDialogButtonBox, QPushButton, QLineEdit, + QFileDialog, QTableView, QAbstractItemView, + QUndoStack, QShortcut, QAction, QItemDelegate, + QHeaderView, +) + +from View.Tools.Plot.PamhyrCanvas import MplCanvas +from View.Tools.Plot.PamhyrToolbar import PamhyrPlotToolbar + +from View.WeatherParameters.Edit.translate import WPETranslate +from View.WeatherParameters.Edit.Table import TableModel +from View.WeatherParameters.Edit.Plot import Plot + +_translate = QCoreApplication.translate + + +class EditWeatherParametersWindow(PamhyrWindow): + _pamhyr_ui = "EditWeatherParameters" + _pamhyr_name = "Edit weather parameters" + + def __init__(self, data=None, + study=None, config=None, + parent=None): + self._data = data + + trad = WPETranslate() + name = trad[self._pamhyr_name] + if self._data is not None: + edge_name = (self._data.reach.name if self._data.reach is not None + else trad['not_associated']) + name += ( + f" - {study.name} " + + f" - {self._data.name} ({self._data.id}) " + + # f"({trad.get_dict('long_types')[self._data.type]} - " + + f"{edge_name})" + ) + + super(EditWeatherParametersWindow, self).__init__( + title=name, + study=study, + config=config, + trad=trad, + parent=parent + ) + + self._hash_data.append(data) + + self.setup_table() + self.setup_plot() + self.setup_connections() + + def setup_table(self): + self._delegate_time = PamhyrExTimeDelegate( + data=self._data, + mode=self._study.time_system, + parent=self + ) + + headers = {} + table_headers = self._trad.get_dict("table_headers") + for h in self._data.header: + headers[h] = table_headers[h] + + if self._study.is_editable(): + editable_headers = self._data.header + else: + editable_headers = [] + + table = self.find(QTableView, "tableView") + self._table = TableModel( + table_view=table, + table_headers=headers, + editable_headers=editable_headers, + delegates={ + # "time": self._delegate_time, + }, + data=self._data, + undo=self._undo_stack, + trad=self._trad, + opt_data=self._study.time_system + ) + + table.setModel(self._table) + table.setSelectionBehavior(QAbstractItemView.SelectRows) + table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + table.setAlternatingRowColors(True) + + def setup_plot(self): + self.canvas = MplCanvas(width=5, height=4, dpi=100) + self.canvas.setObjectName("canvas") + self.toolbar = PamhyrPlotToolbar( + self.canvas, self + ) + self.verticalLayout.addWidget(self.toolbar) + self.verticalLayout.addWidget(self.canvas) + + self.plot = Plot( + canvas=self.canvas, + data=self._data, + mode=self._study.time_system, + toolbar=self.toolbar, + trad=self._trad, + parent=self + ) + self.plot.draw() + + def setup_connections(self): + if self._study.is_editable(): + self.find(QAction, "action_add").triggered.connect(self.add) + self.find(QAction, "action_del").triggered.connect(self.delete) + self.find(QAction, "action_sort").triggered.connect(self.sort) + + self._table.dataChanged.connect(self.update) + self._table.layoutChanged.connect(self.update) + + def update(self): + self.plot.update() + + def index_selected_row(self): + table = self.find(QTableView, "tableView") + return table.selectionModel()\ + .selectedRows()[0]\ + .row() + + def index_selected_rows(self): + table = self.find(QTableView, "tableView") + return list( + # Delete duplicate + set( + map( + lambda i: i.row(), + table.selectedIndexes() + ) + ) + ) + + def add(self): + rows = self.index_selected_rows() + if len(self._data) == 0 or len(rows) == 0: + self._table.add(0) + else: + self._table.add(rows[0]) + + self.plot.update() + + def delete(self): + rows = self.index_selected_rows() + if len(rows) == 0: + return + + self._table.delete(rows) + self.plot.update() + + def sort(self): + self._table.sort(False) + self.plot.update() + + def move_up(self): + row = self.index_selected_row() + self._table.move_up(row) + self.plot.update() + + def move_down(self): + row = self.index_selected_row() + self._table.move_down(row) + self.plot.update() + + def _copy(self): + rows = self.index_selected_rows() + + table = [] + table.append(self._data.header) + + data = self._data.data + for row in rows: + table.append(list(data[row])) + + self.copyTableIntoClipboard(table) + + def _paste(self): + header, data = self.parseClipboardTable() + + if len(data) == 0: + return + + row = 0 + rows = self.index_selected_rows() + if len(rows) != 0: + row = rows[0] + + self._table.paste(row, header, data) + self.plot.update() + + def _undo(self): + self._table.undo() + self.plot.update() + + def _redo(self): + self._table.redo() + self.plot.update() diff --git a/src/View/WeatherParameters/Edit/translate.py b/src/View/WeatherParameters/Edit/translate.py new file mode 100644 index 00000000..43ef63a3 --- /dev/null +++ b/src/View/WeatherParameters/Edit/translate.py @@ -0,0 +1,45 @@ +# translate.py -- Pamhyr +# Copyright (C) 2023-2025 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +from PyQt5.QtCore import QCoreApplication + +from View.Translate import MainTranslate +from View.WeatherParameters.translate import WeatherParametersTranslate + +_translate = QCoreApplication.translate + + +class WPETranslate(WeatherParametersTranslate): + def __init__(self): + super(WPETranslate, self).__init__() + self._dict["Edit weather parameters"] = _translate( + "WeatherParameters", "Edit weather parameters" + ) + + self._sub_dict["table_headers"] = { + "time": self._dict["time"], + "date": self._dict["date"], + "discharge": self._dict["unit_discharge"], + "temperature": _translate("WeatherParameters", "Temperature (°C)"), + "humidity percentage": _translate("WeatherParameters", "Humidity Percentage"), + "radiation": _translate("WeatherParameters", "Radiation (W/m²)"), + "wind speed": _translate("WeatherParameters", "Wind Speed (m/s)"), + "albedo": _translate("WeatherParameters", "Albedo"), + "shading coefficient": _translate("WeatherParameters", "Shading Coefficient"), + "cloud cover fraction": _translate("WeatherParameters", "Cloud Cover Fraction"), + } diff --git a/src/View/WeatherParameters/Window.py b/src/View/WeatherParameters/Window.py index d3563fb9..590ecb1f 100644 --- a/src/View/WeatherParameters/Window.py +++ b/src/View/WeatherParameters/Window.py @@ -57,6 +57,8 @@ from View.WeatherParameters.Table import ( from View.WeatherParameters.translate import WeatherParametersTranslate +from View.WeatherParameters.Edit.Window import EditWeatherParametersWindow + from Solver.Mage import Mage8 _translate = QCoreApplication.translate @@ -115,8 +117,13 @@ class WeatherParametersWindow(PamhyrWindow): if self._study.is_editable(): action_delete.triggered.connect(self.delete) + action_edit = QAction(self) + action_edit.setIcon(QIcon(os.path.join(path_icons, f"edit.png"))) + action_edit.triggered.connect(self.edit) + toolBar.addAction(action_add) toolBar.addAction(action_delete) + toolBar.addAction(action_edit) layout.addWidget(self.table_spec) @@ -346,3 +353,27 @@ class WeatherParametersWindow(PamhyrWindow): if len(rows) == 0: return self._table_spec.delete(rows) + + def edit(self): + rows = self.index_selected_rows_spec() + data_rows = { + id(wp): i for i, wp in enumerate(self._data.lst) + } + rows = [data_rows[id(self._table_spec._lst[r])] + for r in rows + if 0 <= r < len(self._table_spec._lst)] + for row in rows: + data = self._data.get(row) + + if self.sub_window_exists( + EditWeatherParametersWindow, + data=[self._study, None, data] + ): + continue + + win = EditWeatherParametersWindow( + data=data, + study=self._study, + parent=self + ) + win.show() diff --git a/src/View/ui/EditWeatherParameters.ui b/src/View/ui/EditWeatherParameters.ui new file mode 100644 index 00000000..2e739f88 --- /dev/null +++ b/src/View/ui/EditWeatherParameters.ui @@ -0,0 +1,130 @@ + + + MainWindow + + + + 0 + 0 + 800 + 450 + + + + + 0 + 0 + + + + MainWindow + + + + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + + + + + + + + + + + 0 + 0 + 800 + 22 + + + + + + toolBar + + + TopToolBarArea + + + false + + + + + + + + false + + + + ressources/add.pngressources/add.png + + + Add + + + Add a new point in boundary condition or lateral source + + + Ctrl+N + + + + + + ressources/del.pngressources/del.png + + + Delete + + + Delete current selected rows + + + Ctrl+D + + + + + + ressources/sort_A-Z.pngressources/sort_A-Z.png + + + Sort + + + Sort points + + + + + + ressources/import.pngressources/import.png + + + Import + + + Import from file + + + + + +