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