diff --git a/code-check-wrapper.sh b/code-check-wrapper.sh index b77d903de..500960424 100755 --- a/code-check-wrapper.sh +++ b/code-check-wrapper.sh @@ -104,6 +104,7 @@ for I in \ symbol_rule_set.cpp \ symbol_t.cpp \ symbol_tooltip.cpp \ + tag_remove_dialog.cpp \ tag_select_widget.cpp \ /template.cpp \ template_image.cpp \ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 10c595c63..5b7c31fbd 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -160,6 +160,7 @@ set(Mapper_Common_SRCS gui/select_crs_dialog.cpp gui/settings_dialog.cpp gui/simple_course_dialog.cpp + gui/tag_remove_dialog.cpp gui/task_dialog.cpp gui/text_browser_dialog.cpp gui/touch_cursor.cpp diff --git a/src/gui/map/map_editor.cpp b/src/gui/map/map_editor.cpp index 0725453b1..f27d87cd8 100644 --- a/src/gui/map/map_editor.cpp +++ b/src/gui/map/map_editor.cpp @@ -116,6 +116,7 @@ #include "gui/main_window.h" #include "gui/print_widget.h" #include "gui/simple_course_dialog.h" +#include "gui/tag_remove_dialog.h" #include "gui/text_browser_dialog.h" #include "gui/util_gui.h" #include "gui/map/map_dialog_scale.h" @@ -1037,6 +1038,7 @@ void MapEditorController::createActions() rotate_map_act = newAction("rotatemap", tr("Rotate map..."), this, SLOT(rotateMapClicked()), "tool-rotate.png", tr("Rotate the whole map"), "map_menu.html"); map_notes_act = newAction("mapnotes", tr("Map notes..."), this, SLOT(mapNotesClicked()), nullptr, QString{}, "map_menu.html"); map_info_act = newAction("mapinfo", tr("Map information..."), this, SLOT(mapInfoClicked()), "map-information.png", QString{}, "map_menu.html"); + remove_tags_act = newAction("removetags", tr("Remove object tags..."), this, SLOT(removeObjectTagsClicked()), "delete.png", QString{}, "map_menu.html"); template_window_act = newCheckAction("templatewindow", tr("Template setup window"), this, SLOT(showTemplateWindow(bool)), "templates.png", tr("Show/Hide the template window"), "templates_menu.html"); //QAction* template_config_window_act = newCheckAction("templateconfigwindow", tr("Template configurations window"), this, SLOT(showTemplateConfigurationsWindow(bool)), "window-new", tr("Show/Hide the template configurations window")); @@ -1266,6 +1268,7 @@ void MapEditorController::createMenuAndToolbars() map_menu->addAction(rotate_map_act); map_menu->addAction(map_notes_act); map_menu->addAction(map_info_act); + map_menu->addAction(remove_tags_act); map_menu->addSeparator(); updateMapPartsUI(); map_menu->addAction(mappart_add_act); @@ -2299,6 +2302,13 @@ void MapEditorController::mapInfoClicked() dialog.exec(); } +void MapEditorController::removeObjectTagsClicked() +{ + TagRemoveDialog dialog(window, map); + dialog.setWindowModality(Qt::WindowModal); + dialog.exec(); +} + void MapEditorController::createTemplateWindow() { Q_ASSERT(!template_dock_widget); @@ -2631,6 +2641,8 @@ void MapEditorController::updateObjectDependentActions() scale_act->setEnabled(have_selection); scale_act->setStatusTip(tr("Scale the selected objects.") + (scale_act->isEnabled() ? QString{} : QString(QLatin1Char(' ') + tr("Select at least one object to activate this tool.")))); mappart_move_menu->setEnabled(have_selection && have_multiple_parts); + remove_tags_act->setEnabled(have_selection); + remove_tags_act->setStatusTip(tr("Remove tags from the selected objects.") + (remove_tags_act->isEnabled() ? QString{} : QString(QLatin1Char(' ') + tr("Select at least one object to activate this tool.")))); // have_rotatable_pattern || have_rotatable_point rotate_pattern_act->setEnabled(have_rotatable_pattern || have_rotatable_object); diff --git a/src/gui/map/map_editor.h b/src/gui/map/map_editor.h index 142caecc2..fb793a964 100644 --- a/src/gui/map/map_editor.h +++ b/src/gui/map/map_editor.h @@ -352,6 +352,8 @@ public slots: void mapNotesClicked(); /** Shows the map information. */ void mapInfoClicked(); + /** Shows the TagRemoveDialog. */ + void removeObjectTagsClicked(); /** Shows or hides the template setup dock widget. */ void showTemplateWindow(bool show); @@ -753,6 +755,7 @@ protected slots: QAction* rotate_map_act = {}; QAction* map_notes_act = {}; QAction* map_info_act = {}; + QAction* remove_tags_act = {}; QAction* symbol_set_id_act = {}; std::unique_ptr symbol_report_feature; diff --git a/src/gui/tag_remove_dialog.cpp b/src/gui/tag_remove_dialog.cpp new file mode 100644 index 000000000..491f3ed00 --- /dev/null +++ b/src/gui/tag_remove_dialog.cpp @@ -0,0 +1,297 @@ +/* + * Copyright 2026 Matthias Kühlewein + * + * This file is part of OpenOrienteering. + * + * OpenOrienteering 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. + * + * OpenOrienteering 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 OpenOrienteering. If not, see . + */ + +#include "tag_remove_dialog.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/map.h" +#include "core/objects/object.h" +#include "gui/util_gui.h" +#include "undo/object_undo.h" + + +namespace { + +struct CompOpStruct { + const QString op; + const std::function fn; +}; + +static const CompOpStruct compare_operations[4] = { + { QCoreApplication::translate("OpenOrienteering::TagRemoveDialog", "is"), [](const QString& key, const QString& pattern) { return key == pattern; } }, + { QCoreApplication::translate("OpenOrienteering::TagRemoveDialog", "is not"), [](const QString& key, const QString& pattern) { return key != pattern; } }, + { QCoreApplication::translate("OpenOrienteering::TagRemoveDialog", "contains"), [](const QString& key, const QString& pattern) { return key.contains(pattern); } }, + { QCoreApplication::translate("OpenOrienteering::TagRemoveDialog", "contains not"), [](const QString& key, const QString& pattern) { return !key.contains(pattern); } } +}; + +} // namespace + + +namespace OpenOrienteering { + +TagRemoveDialog::TagRemoveDialog(QWidget* parent, Map* map) +: QDialog(parent, Qt::WindowSystemMenuHint | Qt::WindowTitleHint | Qt::WindowCloseButtonHint) +, map { map } +{ + setWindowTitle(tr("Remove Tags")); + + auto* search_operation_layout = new QHBoxLayout(); + search_operation_layout->addWidget(new QLabel(tr("Key"))); + compare_op = new QComboBox(); + for (auto& compare_operation : compare_operations) + { + compare_op->addItem(compare_operation.op); + } + search_operation_layout->addWidget(compare_op); + pattern_edit = new QLineEdit(); + pattern_select = new QComboBox(); + pattern_choice = new QStackedWidget(); + pattern_choice->addWidget(pattern_select); + pattern_choice->addWidget(pattern_edit); + + search_operation_layout->addWidget(pattern_choice); + + undo_check = new QCheckBox(tr("Add undo step")); + number_matching_objects = new QLabel(); + number_matching_keys = new QLabel(); + matching_keys_details = new QPlainTextEdit(); + matching_keys_details->setReadOnly(true); + + auto* button_box = new QDialogButtonBox(); + find_button = new QPushButton(tr("Find")); + find_button->setEnabled(false); + button_box->addButton(find_button, QDialogButtonBox::ActionRole); + button_box->addButton(QDialogButtonBox::Cancel); + remove_button = new QPushButton(QIcon(QLatin1String(":/images/delete.png")), tr("Remove")); + remove_button->setEnabled(false); + button_box->addButton(remove_button, QDialogButtonBox::ActionRole); + + auto* layout = new QVBoxLayout(); + layout->addWidget(new QLabel(tr("Remove tags from %n selected object(s)", nullptr, map->getNumSelectedObjects()))); + layout->addLayout(search_operation_layout); + layout->addWidget(undo_check); + layout->addItem(Util::SpacerItem::create(this)); + layout->addWidget(number_matching_objects); + layout->addWidget(number_matching_keys); + layout->addWidget(matching_keys_details); + layout->addWidget(button_box); + setLayout(layout); + + connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(find_button, &QAbstractButton::clicked, this, &TagRemoveDialog::findClicked); + connect(remove_button, &QAbstractButton::clicked, this, &TagRemoveDialog::removeClicked); + connect(pattern_edit, &QLineEdit::textChanged, this, &TagRemoveDialog::textChanged); + connect(compare_op, QOverload::of(&QComboBox::currentIndexChanged), this, &TagRemoveDialog::comboBoxChanged); + connect(pattern_select, QOverload::of(&QComboBox::currentIndexChanged), this, &TagRemoveDialog::findClicked); + + comboBoxChanged(); +} + +TagRemoveDialog::~TagRemoveDialog() = default; + + +// slot +void TagRemoveDialog::textChanged(const QString& text) +{ + find_button->setEnabled(!text.trimmed().isEmpty()); + reset(); +} + +// slot +void TagRemoveDialog::comboBoxChanged() +{ + reset(); + pattern_choice->setCurrentIndex(compare_op->currentIndex() <= 1 ? 0 : 1); + if (compare_op->currentIndex() >= 2) + { + find_button->setEnabled(!pattern_edit->text().trimmed().isEmpty()); + if (!pattern_edit->text().trimmed().isEmpty()) + findClicked(); + return; + } + + std::set all_keys; + findMatchingTags(map, QString(), 1 /* is not */, all_keys); // empty keys are not possible, so "QString(), 1" will match all keys + if (!all_keys.empty()) + { + QSignalBlocker block(pattern_select); + pattern_select->clear(); + if (compare_op->currentIndex() == 0) // is + { + pattern_select->addItem(tr("- any key -")); + } + for (const auto& key : all_keys) + pattern_select->addItem(key); + find_button->setEnabled(true); + } + else + { + find_button->setEnabled(false); + } + findClicked(); +} + +void TagRemoveDialog::reset() +{ + number_matching_objects->setText(QString()); + number_matching_keys->setText(QString()); + matching_keys_details->clear(); + remove_button->setEnabled(false); +} + +std::pair TagRemoveDialog::getPatternAndOperation() const +{ + QString pattern; + auto op = compare_op->currentIndex(); + + if (op <= 1) + { + if (op == 0 && pattern_select->currentIndex() == 0) // - any key - + op = 1; + else + pattern = pattern_select->currentText(); + } + else + pattern = pattern_edit->text(); + + return {pattern, op}; +} + +// slot +void TagRemoveDialog::findClicked() +{ + std::set matching_keys; + + const auto res = getPatternAndOperation(); + const auto objects_count = findMatchingTags(map, res.first, res.second, matching_keys); + + number_matching_objects->setText(tr("Number of matching objects: %1").arg(objects_count)); + number_matching_keys->setText(tr("%n matching key(s):", nullptr, matching_keys.size())); + matching_keys_details->clear(); + remove_button->setEnabled(!matching_keys.empty()); + + if (!matching_keys.empty()) + { + QString details = std::accumulate(begin(matching_keys), + end(matching_keys), + QString(), + [](const QString& a, const QString& b) -> QString { return a.isEmpty() ? b : a + QChar::LineFeed + b; } + ); + matching_keys_details->insertPlainText(details); + } +} + +// static +int TagRemoveDialog::findMatchingTags(const Map *map, const QString& pattern, int op, std::set& matching_keys) +{ + int objects_count = 0; + matching_keys.clear(); + + for (const auto& object : map->selectedObjects()) + { + auto object_matched = false; + for (const auto& tag : object->tags()) + { + if ((compare_operations[op].fn)(tag.key, pattern)) + { + matching_keys.insert(tag.key); + object_matched = true; + } + } + if (object_matched) + ++objects_count; + } + return objects_count; +} + +// slot +void TagRemoveDialog::removeClicked() +{ + const auto add_undo = undo_check->isChecked(); + + auto question = QString(tr("Do you really want to remove the object tags?")); + if (!add_undo) + question += QChar::LineFeed + QString(tr("This cannot be undone.")); + if (QMessageBox::question(this, tr("Remove object tags"), question, QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) + { + const auto res = getPatternAndOperation(); + removeMatchingTags(map, res.first, res.second, add_undo); + accept(); + } +} + +// static +void TagRemoveDialog::removeMatchingTags(Map *map, const QString& pattern, int op, bool add_undo) +{ + CombinedUndoStep* combined_step; + if (add_undo) + combined_step = new CombinedUndoStep(map); + + std::vector matching_keys; + for (const auto& object : map->selectedObjects()) + { + matching_keys.clear(); + for (const auto& tag : object->tags()) + { + if ((compare_operations[op].fn)(tag.key, pattern)) + { + matching_keys.push_back(tag.key); + } + } + if (add_undo && !matching_keys.empty()) + { + auto undo_step = new ObjectTagsUndoStep(map); + undo_step->addObject(map->getCurrentPart()->findObjectIndex(object)); + combined_step->push(undo_step); + } + for (const auto& key : matching_keys) + { + object->removeTag(key); + } + } + if (add_undo) + map->push(combined_step); +} + +} // namespace OpenOrienteering diff --git a/src/gui/tag_remove_dialog.h b/src/gui/tag_remove_dialog.h new file mode 100644 index 000000000..a0a5f71d3 --- /dev/null +++ b/src/gui/tag_remove_dialog.h @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Matthias Kühlewein + * + * This file is part of OpenOrienteering. + * + * OpenOrienteering 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. + * + * OpenOrienteering 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 OpenOrienteering. If not, see . + */ + +#ifndef OPENORIENTEERING_TAG_REMOVE_DIALOG_H +#define OPENORIENTEERING_TAG_REMOVE_DIALOG_H + +#include +#include + +#include +#include + +class QCheckBox; +class QComboBox; +class QLabel; +class QLineEdit; +class QPlainTextEdit; +class QPushButton; +class QStackedWidget; +class QString; +class QWidget; + + +namespace OpenOrienteering { + +class Map; + +class TagRemoveDialog : public QDialog +{ +Q_OBJECT +public: + /** + * Creates a new TagRemoveDialog object. + */ + TagRemoveDialog(QWidget* parent, Map* map); + + ~TagRemoveDialog() override; + + static int findMatchingTags(const Map *map, const QString& pattern, int op, std::set& matching_keys); + + static void removeMatchingTags(Map *map, const QString& pattern, int op, bool add_undo); + +private slots: + void textChanged(const QString& text); + void comboBoxChanged(); + void findClicked(); + void removeClicked(); + +private: + void reset(); + std::pair getPatternAndOperation() const; + + QPushButton* find_button; + QPushButton* remove_button; + QCheckBox* undo_check; + QComboBox* compare_op; + QLabel* number_matching_objects; + QLabel* number_matching_keys; + QLineEdit* pattern_edit; + QComboBox* pattern_select; + QStackedWidget* pattern_choice; + QPlainTextEdit* matching_keys_details; + + Map *map; +}; + + +} // namespace OpenOrienteering + +#endif // OPENORIENTEERING_TAG_REMOVE_DIALOG_H diff --git a/test/tools_t.cpp b/test/tools_t.cpp index 30da6c2ce..83b50fce3 100644 --- a/test/tools_t.cpp +++ b/test/tools_t.cpp @@ -1,7 +1,7 @@ /* * Copyright 2012, 2013 Thomas Schöps * Copyright 2015-2020, 2025 Kai Pastor - * Copyright 2025 Matthias Kühlewein + * Copyright 2025, 2026 Matthias Kühlewein * * This file is part of OpenOrienteering. * @@ -22,11 +22,15 @@ #include "tools_t.h" +#include +#include + #include #include #include #include #include +#include #include #include #include @@ -41,12 +45,14 @@ #include "core/symbols/point_symbol.h" #include "global.h" #include "gui/main_window.h" +#include "gui/tag_remove_dialog.h" #include "gui/map/map_editor.h" #include "gui/map/map_find_feature.h" #include "gui/map/map_widget.h" #include "templates/paint_on_template_feature.h" #include "tools/edit_point_tool.h" #include "tools/edit_tool.h" +#include "undo/undo_manager.h" using namespace OpenOrienteering; @@ -287,6 +293,55 @@ void ToolsTest::testFindObjects() } +void ToolsTest::testDeleteObjectTags() +{ + auto* map = new Map; + { + auto* point_symbol = new PointSymbol(); + map->addSymbol(point_symbol, 0); + + auto add_object = [map, point_symbol](bool add_to_selection, std::vector tags) { + auto* object = new PointObject(point_symbol); + for (const auto& key : tags) + object->setTag(key, QLatin1String("1")); + map->addObject(object); + if (add_to_selection) + map->addObjectToSelection(object, false); + }; + add_object(false, {QLatin1String("abc")}); // not added to selection + add_object(true, {QLatin1String("abc"), QLatin1String("bcd"), QLatin1String("cde")}); // added to selection + add_object(true, {QLatin1String("abc")}); // added to selection + add_object(true, {QLatin1String("cde"), QLatin1String("def"), QLatin1String("fgh")}); // added to selection + } + + TestMapEditor editor(map); // taking ownership + + std::set matching_keys; + + auto objects_count = TagRemoveDialog::findMatchingTags(map, QLatin1String("b"), 2 /* contains */, matching_keys); + QCOMPARE(map->getNumSelectedObjects(), 3); + QVERIFY(objects_count == 2 && matching_keys.size() == 2); + + TagRemoveDialog::removeMatchingTags(map, QLatin1String("b"), 2 /* contains */, true); // add removal as undo step + objects_count = TagRemoveDialog::findMatchingTags(map, QLatin1String("b"), 2 /* contains */, matching_keys); + QVERIFY(objects_count == 0 && matching_keys.size() == 0); + QVERIFY(map->undoManager().canUndo()); + + map->undoManager().undo(editor.window); + QVERIFY(!map->undoManager().canUndo()); + QVERIFY(map->undoManager().canRedo()); + objects_count = TagRemoveDialog::findMatchingTags(map, QLatin1String("b"), 2 /* contains */, matching_keys); + QVERIFY(objects_count == 2 && matching_keys.size() == 2); + + objects_count = TagRemoveDialog::findMatchingTags(map, QLatin1String("cde"), 0 /* is */, matching_keys); + QCOMPARE(map->getNumSelectedObjects(), 2); // 2 objects are restored => number of selected objects is now 2 + QVERIFY(objects_count == 1 && matching_keys.size() == 1); + + TagRemoveDialog::removeMatchingTags(map, QLatin1String("cde"), 0 /* is */, false); // don't add removal as undo step + QVERIFY(!map->undoManager().canUndo()); +} + + /* * We select a non-standard QPA because we don't need a real GUI window. * diff --git a/test/tools_t.h b/test/tools_t.h index 21832b333..ca3fbf61d 100644 --- a/test/tools_t.h +++ b/test/tools_t.h @@ -1,6 +1,7 @@ /* * Copyright 2012, 2013 Thomas Schöps * Copyright 2017, 2020, 2025 Kai Pastor + * Copyright 2025, 2026 Matthias Kühlewein * * This file is part of OpenOrienteering. * @@ -25,7 +26,7 @@ /** - * @test Tests the editing tools. + * @test Various tool tests. */ class ToolsTest : public QObject { @@ -38,6 +39,8 @@ private slots: void paintOnTemplateFeature(); void testFindObjects(); + + void testDeleteObjectTags(); }; -#endif +#endif // OPENORIENTEERING_TOOLS_T_H