Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions share/translations/keepassxc_en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,22 @@ Backup database located at %2</source>
<source>Key not transformed. This is a bug, please report it to the developers.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>The tag name cannot be empty.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Both tag names are the same.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>The tag &quot;%1&quot; already exists.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>The tag &quot;%1&quot; was not found.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Recycle Bin</source>
<translation type="unfinished"></translation>
Expand Down Expand Up @@ -10453,10 +10469,22 @@ This option is deprecated, use --set-key-file instead.</source>
<source>Remove Search</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Rename Tag</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Remove Tag</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>New tag name for &quot;%1&quot;:</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Error</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Confirm Remove Tag</source>
<translation type="unfinished"></translation>
Expand Down
54 changes: 54 additions & 0 deletions src/core/Database.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,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;
Expand Down
2 changes: 2 additions & 0 deletions src/core/Database.h
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ class Database : public ModifiableObject
const QStringList& commonUsernames() 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<const CompositeKey> key() const;
bool setKey(const QSharedPointer<const CompositeKey>& key,
Expand Down
40 changes: 40 additions & 0 deletions src/core/Entry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,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;
Expand Down
2 changes: 2 additions & 0 deletions src/core/Entry.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<Entry*> historyItems();
const QList<Entry*>& historyItems() const;
Expand Down
37 changes: 29 additions & 8 deletions src/gui/tag/TagView.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
#include "gui/Icons.h"
#include "gui/MessageBox.h"

#include <QInputDialog>
#include <QMenu>
#include <QPainter>
#include <QStyledItemDelegate>
Expand Down Expand Up @@ -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);
}
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,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})

Expand Down
118 changes: 118 additions & 0 deletions tests/TestTags.cpp
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

#include "TestTags.h"

#include <QTest>

#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<Database> 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<Database> 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<Database> 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<Database> 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<Database> 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"}));
}
36 changes: 36 additions & 0 deletions tests/TestTags.h
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

#ifndef KEEPASSXC_TESTTAGS_H
#define KEEPASSXC_TESTTAGS_H

#include <QObject>

class TestTags : public QObject
{
Q_OBJECT

private slots:
void initTestCase();
void testRenameTag();
void renameEmptyTag();
void renameExistingTag();
void testRenameNotExistingTag();
void testRenameTagWithDelimiter();
};

#endif // KEEPASSXC_TESTTAGS_H