diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index 4ca387959d..8ac9847816 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -1659,6 +1659,22 @@ Backup database located at %2 Key not transformed. This is a bug, please report it to the developers. + + The tag name cannot be empty. + + + + Both tag names are the same. + + + + The tag "%1" already exists. + + + + The tag "%1" was not found. + + Recycle Bin @@ -10473,10 +10489,22 @@ This option is deprecated, use --set-key-file instead. Remove Search + + Rename Tag + + Remove Tag + + New tag name for "%1": + + + + Error + + Confirm Remove Tag diff --git a/src/core/Database.cpp b/src/core/Database.cpp index 87a03d30f2..5aeb260b01 100644 --- a/src/core/Database.cpp +++ b/src/core/Database.cpp @@ -878,6 +878,60 @@ void Database::removeTag(const QString& tag) } } +bool Database::hasTag(const QString& tag) +{ + if (!m_rootGroup) { + return false; + } + + for (auto entry : m_rootGroup->entriesRecursive()) { + if (entry->hasTag(tag)) { + return true; + } + } + return false; +} + +bool Database::renameTag(const QString& oldTag, const QString& newTag, QString* error) +{ + const QString cleanOldTag = oldTag.trimmed(); + const QString cleanNewTag = newTag.trimmed(); + + if (cleanOldTag.isEmpty() || cleanNewTag.isEmpty()) { + if (error) { + *error = tr("The tag name cannot be empty."); + } + return false; + } + + if (cleanOldTag.compare(cleanNewTag, Qt::CaseInsensitive) == 0) { + if (error) { + *error = tr("Both tag names are the same."); + } + return false; + } + + if (hasTag(cleanNewTag)) { + if (error) { + *error = tr("The tag \"%1\" already exists.").arg(cleanNewTag); + } + return false; + } + + bool renamed = false; + for (auto entry : m_rootGroup->entriesRecursive()) { + renamed |= entry->renameTag(oldTag, newTag); + } + + if (!renamed) { + if (error) { + *error = tr("The tag \"%1\" was not found.").arg(cleanOldTag); + } + } + + return renamed; +} + const QUuid& Database::cipher() const { return m_data.cipher; diff --git a/src/core/Database.h b/src/core/Database.h index ebecbbe6b3..8e65649cc1 100644 --- a/src/core/Database.h +++ b/src/core/Database.h @@ -146,6 +146,8 @@ class Database : public ModifiableObject const QStringList& customAttributeKeys() const; const QStringList& tagList() const; void removeTag(const QString& tag); + bool hasTag(const QString& tag); + bool renameTag(const QString& oldTag, const QString& newTag, QString* error); QSharedPointer key() const; bool setKey(const QSharedPointer& key, diff --git a/src/core/Entry.cpp b/src/core/Entry.cpp index 121e4d02bc..624e2af769 100644 --- a/src/core/Entry.cpp +++ b/src/core/Entry.cpp @@ -752,6 +752,46 @@ void Entry::removeTag(const QString& tag) } } +bool Entry::hasTag(const QString& tag) +{ + auto cleanTag = tag.trimmed(); + cleanTag.remove(TagDelimiterRegex); + + auto tagList = m_data.tags; + for (const auto& t : tagList) { + if (t.compare(cleanTag, Qt::CaseInsensitive) == 0) { + return true; + } + } + return false; +} + +bool Entry::renameTag(const QString& oldTag, const QString& newTag) +{ + beginUpdate(); + auto cleanOldTag = oldTag.trimmed(); + cleanOldTag.remove(TagDelimiterRegex); + + auto cleanNewTag = newTag.trimmed(); + cleanNewTag.remove(TagDelimiterRegex); + + auto tagList = m_data.tags; + bool renamed = false; + for (int i = 0; i < tagList.size(); i++) { + if (tagList[i].compare(cleanOldTag, Qt::CaseInsensitive) == 0) { + tagList[i] = cleanNewTag; + renamed = true; + break; + } + } + if (renamed) { + tagList.sort(); + set(m_data.tags, tagList); + } + endUpdate(); + return renamed; +} + void Entry::setTimeInfo(const TimeInfo& timeInfo) { m_data.timeInfo = timeInfo; diff --git a/src/core/Entry.h b/src/core/Entry.h index be140e91f0..061a171b4a 100644 --- a/src/core/Entry.h +++ b/src/core/Entry.h @@ -169,6 +169,8 @@ class Entry : public ModifiableObject void addTag(const QString& tag); void removeTag(const QString& tag); + bool hasTag(const QString& tag); + bool renameTag(const QString& oldTag, const QString& newTag); QList historyItems(); const QList& historyItems() const; diff --git a/src/gui/tag/TagView.cpp b/src/gui/tag/TagView.cpp index 0330612920..a755a8d116 100644 --- a/src/gui/tag/TagView.cpp +++ b/src/gui/tag/TagView.cpp @@ -24,6 +24,7 @@ #include "gui/Icons.h" #include "gui/MessageBox.h" +#include #include #include #include @@ -86,17 +87,37 @@ void TagView::contextMenuRequested(const QPoint& pos) m_db->metadata()->deleteSavedSearch(index.data(Qt::DisplayRole).toString()); } } else if (type == TagModel::TAG) { - // Allow removing tags from all entries in a database + // Allow removing and renaming tags from all entries in a database QMenu menu; - auto action = menu.exec({new QAction(icons()->icon("trash"), tr("Remove Tag"), nullptr)}, mapToGlobal(pos)); + auto renameAction = menu.addAction(icons()->icon("entry-edit"), tr("Rename Tag")); + auto removeAction = menu.addAction(icons()->icon("trash"), tr("Remove Tag")); + + auto action = menu.exec(mapToGlobal(pos)); if (action) { auto tag = index.data(Qt::DisplayRole).toString(); - auto ans = MessageBox::question(this, - tr("Confirm Remove Tag"), - tr("Remove tag \"%1\" from all entries in this database?").arg(tag), - MessageBox::Remove | MessageBox::Cancel); - if (ans == MessageBox::Remove) { - m_db->removeTag(tag); + if (action == renameAction) { + bool ok = false; + QString newTag = QInputDialog::getText(this, + tr("Rename Tag"), + tr("New tag name for \"%1\":").arg(tag), + QLineEdit::Normal, + tag, + &ok).trimmed(); + + if (ok && newTag != tag) { + QString error; + if (!m_db->renameTag(tag, newTag, &error)) { + MessageBox::warning(this, tr("Error"), error); + } + } + } else if (action == removeAction) { + auto ans = MessageBox::question(this, + tr("Confirm Remove Tag"), + tr("Remove tag \"%1\" from all entries in this database?").arg(tag), + MessageBox::Remove | MessageBox::Cancel); + if (ans == MessageBox::Remove) { + m_db->removeTag(tag); + } } } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 44d4cabeea..6e926c37ac 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -154,6 +154,9 @@ add_unit_test(NAME testsharing SOURCES TestSharing.cpp add_unit_test(NAME testdatabase SOURCES TestDatabase.cpp LIBS testsupport ${TEST_LIBRARIES}) +add_unit_test(NAME testtags SOURCES TestTags.cpp + LIBS ${TEST_LIBRARIES}) + add_unit_test(NAME testtools SOURCES TestTools.cpp LIBS testsupport ${TEST_LIBRARIES}) diff --git a/tests/TestTags.cpp b/tests/TestTags.cpp new file mode 100644 index 0000000000..6cb7d4b1e9 --- /dev/null +++ b/tests/TestTags.cpp @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2026 Brais Couce + * + * 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 2 or (at your option) + * version 3 of the License. + * + * 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 . + */ + +#include "TestTags.h" + +#include + +#include "core/Database.h" +#include "core/Entry.h" +#include "core/Group.h" +#include "crypto/Crypto.h" + +QTEST_GUILESS_MAIN(TestTags) + +void TestTags::initTestCase() +{ + QVERIFY(Crypto::init()); + QLocale::setDefault(QLocale::c()); +} + +void TestTags::testRenameTag() +{ + QScopedPointer db(new Database()); + QVERIFY(db); + + auto* entry1 = new Entry(); + db->rootGroup()->addEntry(entry1); + entry1->setTags("tag1, tag 2"); + QCOMPARE(entry1->historyItems().size(), 0); + + auto* entry2 = new Entry(); + db->rootGroup()->addEntry(entry2); + entry2->setTags("TaG 2, tag3"); + QCOMPARE(entry2->historyItems().size(), 0); + + QString error; + QVERIFY(db->renameTag("tag 2", "tag2_ren", &error)); + QVERIFY(error.isEmpty()); + + QCOMPARE(entry1->tagList(), QStringList({"tag1", "tag2_ren"})); + QCOMPARE(entry1->historyItems().size(), 1); + QCOMPARE(entry2->tagList(), QStringList({"tag2_ren", "tag3"})); + QCOMPARE(entry2->historyItems().size(), 1); +} + +void TestTags::renameEmptyTag() +{ + QScopedPointer db(new Database()); + QVERIFY(db); + + QString error; + + QVERIFY(!db->renameTag("tag1", " ", &error)); + QCOMPARE(error, QObject::tr("The tag name cannot be empty.")); + error.clear(); + + QVERIFY(!db->renameTag(" ", "tag1_ren", &error)); + QCOMPARE(error, QObject::tr("The tag name cannot be empty.")); + error.clear(); +} + +void TestTags::renameExistingTag() +{ + QScopedPointer db(new Database()); + QVERIFY(db); + + auto* entry = new Entry(); + db->rootGroup()->addEntry(entry); + entry->setTags("tag1, tag2"); + + QString error; + QVERIFY(!db->renameTag("tag1", "tag2", &error)); + QCOMPARE(error, QObject::tr("The tag \"%1\" already exists.").arg("tag2")); +} + +void TestTags::testRenameNotExistingTag() +{ + QScopedPointer db(new Database()); + QVERIFY(db); + + auto* entry = new Entry(); + db->rootGroup()->addEntry(entry); + entry->setTags("tag1, tag2"); + + QString error; + QVERIFY(!db->renameTag("tag3", "tag3_ren", &error)); + QCOMPARE(error, QObject::tr("The tag \"%1\" was not found.").arg("tag3")); +} + +void TestTags::testRenameTagWithDelimiter() +{ + QScopedPointer db(new Database()); + QVERIFY(db); + + auto* entry = new Entry(); + db->rootGroup()->addEntry(entry); + entry->setTags("tag1"); + + QString error; + QVERIFY(db->renameTag("tag,;1", "tag,;1_ren", &error)); + QVERIFY(error.isEmpty()); + + QCOMPARE(entry->tagList(), QStringList({"tag1_ren"})); +} diff --git a/tests/TestTags.h b/tests/TestTags.h new file mode 100644 index 0000000000..dad357240b --- /dev/null +++ b/tests/TestTags.h @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 Brais Couce + * + * 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 2 or (at your option) + * version 3 of the License. + * + * 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 . + */ + +#ifndef KEEPASSXC_TESTTAGS_H +#define KEEPASSXC_TESTTAGS_H + +#include + +class TestTags : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void testRenameTag(); + void renameEmptyTag(); + void renameExistingTag(); + void testRenameNotExistingTag(); + void testRenameTagWithDelimiter(); +}; + +#endif // KEEPASSXC_TESTTAGS_H