mirror of
https://github.com/keepassxreboot/keepassxc.git
synced 2024-10-01 01:26:01 -04:00
Enhance Tags / Saved Searches
* Rename "Database Tags" to "Searches and Tags" * Separate searching for all entries and resetting the search * Support selecting multiple tags to search against * Fix using escaped quotes in search terms * Make tag searching more precise * Support `is:expired-#` to search for entries expiring within # days. Exclude recycled entries from expired search. * Don't list tags from entries that are recycled * Force hide tag auto-completion menu when tag editing widget is hidden. On rare occasions the focus out signal is not called when the tag view is hidden (entry edit is closed), this resolves that problem. * Remove spaces from before and after tags to prevent seemingly duplicate tags from being created. * Also fix some awkward signal/slot dances that were setup over time with the entry view and preview widget. Allow changing tags for multiple entries through context menu * Closes #8277 - show context menu with currently available tags in database and checks those that are set on one or more selected entries. When a tag is selected it is either set or unset on all entries depending on its checked state. * Add ability to save searches and recall them from the "Searches and Tags" view * Add ability to remove a tag from all entries from the "Searches and Tags" view * Cleanup tag handling and widgets
This commit is contained in:
parent
61f922179b
commit
dfee59742f
@ -2249,10 +2249,6 @@ This is definitely a bug, please report it to the developers.</source>
|
||||
</context>
|
||||
<context>
|
||||
<name>DatabaseWidget</name>
|
||||
<message>
|
||||
<source>Database Tags</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Searching…</source>
|
||||
<translation type="unfinished"></translation>
|
||||
@ -2417,6 +2413,22 @@ Disable safe saves and try again?</source>
|
||||
<numerusform></numerusform>
|
||||
</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Searches and Tags</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Enter a unique name or overwrite an existing search from the list:</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Save</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Save Search</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>EditEntryWidget</name>
|
||||
@ -5403,6 +5415,21 @@ We recommend you use the AppImage available on our downloads page.</source>
|
||||
<source>You must restart the application to apply this setting. Would you like to restart now?</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Tags</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>No Tags</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message numerus="yes">
|
||||
<source>%1 Entry(s)</source>
|
||||
<translation type="unfinished">
|
||||
<numerusform></numerusform>
|
||||
<numerusform></numerusform>
|
||||
</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>ManageDatabase</name>
|
||||
@ -8372,6 +8399,10 @@ Kernel: %3 %4</source>
|
||||
<source>Limit search to selected group</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Save Search</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>SettingsClientModel</name>
|
||||
@ -8584,10 +8615,6 @@ Kernel: %3 %4</source>
|
||||
</context>
|
||||
<context>
|
||||
<name>TagModel</name>
|
||||
<message>
|
||||
<source>All</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Expired</source>
|
||||
<translation type="unfinished"></translation>
|
||||
@ -8596,6 +8623,33 @@ Kernel: %3 %4</source>
|
||||
<source>Weak Passwords</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>All Entries</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Clear Search</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>TagView</name>
|
||||
<message>
|
||||
<source>Remove Search</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Remove Tag</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Confirm Remove Tag</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Remove tag "%1" from all entries in this database?</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>TotpDialog</name>
|
||||
|
@ -150,6 +150,7 @@ set(keepassx_SOURCES
|
||||
gui/group/GroupModel.cpp
|
||||
gui/group/GroupView.cpp
|
||||
gui/tag/TagModel.cpp
|
||||
gui/tag/TagView.cpp
|
||||
gui/tag/TagsEdit.cpp
|
||||
gui/databasekey/KeyComponentWidget.cpp
|
||||
gui/databasekey/PasswordEditWidget.cpp
|
||||
|
@ -701,8 +701,8 @@ void Database::updateTagList()
|
||||
// Search groups recursively looking for tags
|
||||
// Use a set to prevent adding duplicates
|
||||
QSet<QString> tagSet;
|
||||
for (const auto group : m_rootGroup->groupsRecursive(true)) {
|
||||
for (const auto entry : group->entries()) {
|
||||
for (auto entry : m_rootGroup->entriesRecursive()) {
|
||||
if (!entry->isRecycled()) {
|
||||
for (auto tag : entry->tagList()) {
|
||||
tagSet.insert(tag);
|
||||
}
|
||||
@ -714,6 +714,17 @@ void Database::updateTagList()
|
||||
emit tagListUpdated();
|
||||
}
|
||||
|
||||
void Database::removeTag(const QString& tag)
|
||||
{
|
||||
if (!m_rootGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (auto entry : m_rootGroup->entriesRecursive()) {
|
||||
entry->removeTag(tag);
|
||||
}
|
||||
}
|
||||
|
||||
const QUuid& Database::cipher() const
|
||||
{
|
||||
return m_data.cipher;
|
||||
|
@ -129,6 +129,7 @@ public:
|
||||
|
||||
const QStringList& commonUsernames() const;
|
||||
const QStringList& tagList() const;
|
||||
void removeTag(const QString& tag);
|
||||
|
||||
QSharedPointer<const CompositeKey> key() const;
|
||||
bool setKey(const QSharedPointer<const CompositeKey>& key,
|
||||
|
@ -187,15 +187,12 @@ QString Entry::overrideUrl() const
|
||||
|
||||
QString Entry::tags() const
|
||||
{
|
||||
return m_data.tags;
|
||||
return m_data.tags.join(",");
|
||||
}
|
||||
|
||||
QStringList Entry::tagList() const
|
||||
{
|
||||
static QRegExp rx("(\\,|\\t|\\;)");
|
||||
auto taglist = tags().split(rx, QString::SkipEmptyParts);
|
||||
std::sort(taglist.begin(), taglist.end());
|
||||
return taglist;
|
||||
return m_data.tags;
|
||||
}
|
||||
|
||||
const TimeInfo& Entry::timeInfo() const
|
||||
@ -654,7 +651,42 @@ void Entry::setOverrideUrl(const QString& url)
|
||||
|
||||
void Entry::setTags(const QString& tags)
|
||||
{
|
||||
set(m_data.tags, tags);
|
||||
static QRegExp rx("(\\,|\\t|\\;)");
|
||||
auto taglist = tags.split(rx, QString::SkipEmptyParts);
|
||||
// Trim whitespace before/after tag text
|
||||
for (auto itr = taglist.begin(); itr != taglist.end(); ++itr) {
|
||||
*itr = itr->trimmed();
|
||||
}
|
||||
// Remove duplicates
|
||||
auto tagSet = QSet<QString>::fromList(taglist);
|
||||
taglist = tagSet.toList();
|
||||
// Sort alphabetically
|
||||
taglist.sort();
|
||||
set(m_data.tags, taglist);
|
||||
}
|
||||
|
||||
void Entry::addTag(const QString& tag)
|
||||
{
|
||||
auto cleanTag = tag.trimmed();
|
||||
cleanTag.remove(QRegExp("(\\,|\\t|\\;)"));
|
||||
|
||||
auto taglist = m_data.tags;
|
||||
if (!taglist.contains(cleanTag)) {
|
||||
taglist.append(cleanTag);
|
||||
taglist.sort();
|
||||
set(m_data.tags, taglist);
|
||||
}
|
||||
}
|
||||
|
||||
void Entry::removeTag(const QString& tag)
|
||||
{
|
||||
auto cleanTag = tag.trimmed();
|
||||
cleanTag.remove(QRegExp("(\\,|\\t|\\;)"));
|
||||
|
||||
auto taglist = m_data.tags;
|
||||
if (taglist.removeAll(tag) > 0) {
|
||||
set(m_data.tags, taglist);
|
||||
}
|
||||
}
|
||||
|
||||
void Entry::setTimeInfo(const TimeInfo& timeInfo)
|
||||
|
@ -58,7 +58,7 @@ struct EntryData
|
||||
QString foregroundColor;
|
||||
QString backgroundColor;
|
||||
QString overrideUrl;
|
||||
QString tags;
|
||||
QStringList tags;
|
||||
bool autoTypeEnabled;
|
||||
int autoTypeObfuscation;
|
||||
QString defaultAutoTypeSequence;
|
||||
@ -158,6 +158,9 @@ public:
|
||||
void setPreviousParentGroup(const Group* group);
|
||||
void setPreviousParentGroupUuid(const QUuid& uuid);
|
||||
|
||||
void addTag(const QString& tag);
|
||||
void removeTag(const QString& tag);
|
||||
|
||||
QList<Entry*> historyItems();
|
||||
const QList<Entry*>& historyItems() const;
|
||||
void addHistoryItem(Entry* entry);
|
||||
|
@ -25,8 +25,6 @@
|
||||
EntrySearcher::EntrySearcher(bool caseSensitive, bool skipProtected)
|
||||
: m_caseSensitive(caseSensitive)
|
||||
, m_skipProtected(skipProtected)
|
||||
, m_termParser(R"re(([-!*+]+)?(?:(\w*):)?(?:(?=")"((?:[^"\\]|\\.)*)"|([^ ]*))( |$))re")
|
||||
// Group 1 = modifiers, Group 2 = field, Group 3 = quoted string, Group 4 = unquoted string
|
||||
{
|
||||
}
|
||||
|
||||
@ -197,11 +195,16 @@ bool EntrySearcher::searchEntryImpl(const Entry* entry)
|
||||
}
|
||||
break;
|
||||
case Field::Tag:
|
||||
found = term.regex.match(entry->tags()).hasMatch();
|
||||
found = entry->tagList().indexOf(term.regex) != -1;
|
||||
break;
|
||||
case Field::Is:
|
||||
if (term.word.compare("expired", Qt::CaseInsensitive) == 0) {
|
||||
found = entry->isExpired();
|
||||
if (term.word.startsWith("expired", Qt::CaseInsensitive)) {
|
||||
auto days = 0;
|
||||
auto parts = term.word.split("-", QString::SkipEmptyParts);
|
||||
if (parts.length() >= 2) {
|
||||
days = parts[1].toInt();
|
||||
}
|
||||
found = entry->willExpireInDays(days) && !entry->isRecycled();
|
||||
break;
|
||||
} else if (term.word.compare("weak", Qt::CaseInsensitive) == 0) {
|
||||
if (!entry->excludeFromReports() && !entry->password().isEmpty() && !entry->isExpired()) {
|
||||
@ -220,8 +223,7 @@ bool EntrySearcher::searchEntryImpl(const Entry* entry)
|
||||
found = term.regex.match(entry->resolvePlaceholder(entry->title())).hasMatch()
|
||||
|| term.regex.match(entry->resolvePlaceholder(entry->username())).hasMatch()
|
||||
|| term.regex.match(entry->resolvePlaceholder(entry->url())).hasMatch()
|
||||
|| term.regex.match(entry->resolvePlaceholder(entry->tags())).hasMatch()
|
||||
|| term.regex.match(entry->notes()).hasMatch();
|
||||
|| entry->tagList().indexOf(term.regex) != -1 || term.regex.match(entry->notes()).hasMatch();
|
||||
}
|
||||
|
||||
// negate the result if exclude:
|
||||
@ -246,23 +248,26 @@ void EntrySearcher::parseSearchTerms(const QString& searchString)
|
||||
{QStringLiteral("notes"), Field::Notes},
|
||||
{QStringLiteral("pw"), Field::Password},
|
||||
{QStringLiteral("password"), Field::Password},
|
||||
{QStringLiteral("title"), Field::Title},
|
||||
{QStringLiteral("t"), Field::Title},
|
||||
{QStringLiteral("u"), Field::Username}, // u: stands for username rather than url
|
||||
{QStringLiteral("title"), Field::Title}, // title before tag to capture t:<word>
|
||||
{QStringLiteral("username"), Field::Username}, // username before url to capture u:<word>
|
||||
{QStringLiteral("url"), Field::Url},
|
||||
{QStringLiteral("username"), Field::Username},
|
||||
{QStringLiteral("group"), Field::Group},
|
||||
{QStringLiteral("tag"), Field::Tag},
|
||||
{QStringLiteral("is"), Field::Is}};
|
||||
|
||||
// Group 1 = modifiers, Group 2 = field, Group 3 = quoted string, Group 4 = unquoted string
|
||||
static QRegularExpression termParser(R"re(([-!*+]+)?(?:(\w*):)?(?:(?=")"((?:[^"\\]|\\.)*)"|([^ ]*))( |$))re");
|
||||
|
||||
m_searchTerms.clear();
|
||||
auto results = m_termParser.globalMatch(searchString);
|
||||
auto results = termParser.globalMatch(searchString);
|
||||
while (results.hasNext()) {
|
||||
auto result = results.next();
|
||||
SearchTerm term{};
|
||||
|
||||
// Quoted string group
|
||||
term.word = result.captured(3);
|
||||
// Unescape quotes
|
||||
term.word.replace("\\\"", "\"");
|
||||
|
||||
// If empty, use the unquoted string group
|
||||
if (term.word.isEmpty()) {
|
||||
|
@ -71,7 +71,6 @@ private:
|
||||
|
||||
bool m_caseSensitive;
|
||||
bool m_skipProtected;
|
||||
QRegularExpression m_termParser;
|
||||
QList<SearchTerm> m_searchTerms;
|
||||
|
||||
friend class TestEntrySearcher;
|
||||
|
@ -24,6 +24,7 @@
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCryptographicHash>
|
||||
#include <QJsonDocument>
|
||||
|
||||
const int Metadata::DefaultHistoryMaxItems = 10;
|
||||
const int Metadata::DefaultHistoryMaxSize = 6 * 1024 * 1024;
|
||||
@ -487,3 +488,26 @@ void Metadata::setSettingsChanged(const QDateTime& value)
|
||||
Q_ASSERT(value.timeSpec() == Qt::UTC);
|
||||
m_settingsChanged = value;
|
||||
}
|
||||
|
||||
void Metadata::addSavedSearch(const QString& name, const QString& searchtext)
|
||||
{
|
||||
auto searches = savedSearches();
|
||||
searches.insert(name, searchtext);
|
||||
auto json = QJsonDocument::fromVariant(searches);
|
||||
m_customData->set("KPXC_SavedSearch", json.toJson());
|
||||
}
|
||||
|
||||
void Metadata::deleteSavedSearch(const QString& name)
|
||||
{
|
||||
auto searches = savedSearches();
|
||||
searches.remove(name);
|
||||
auto json = QJsonDocument::fromVariant(searches);
|
||||
m_customData->set("KPXC_SavedSearch", json.toJson());
|
||||
}
|
||||
|
||||
QVariantMap Metadata::savedSearches()
|
||||
{
|
||||
auto searches = m_customData->value("KPXC_SavedSearch");
|
||||
auto json = QJsonDocument::fromJson(searches.toUtf8());
|
||||
return json.toVariant().toMap();
|
||||
}
|
||||
|
@ -23,6 +23,7 @@
|
||||
#include <QHash>
|
||||
#include <QPointer>
|
||||
#include <QUuid>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include "core/CustomData.h"
|
||||
#include "core/Global.h"
|
||||
@ -150,6 +151,9 @@ public:
|
||||
void setHistoryMaxItems(int value);
|
||||
void setHistoryMaxSize(int value);
|
||||
void setUpdateDatetime(bool value);
|
||||
void addSavedSearch(const QString& name, const QString& searchtext);
|
||||
void deleteSavedSearch(const QString& name);
|
||||
QVariantMap savedSearches();
|
||||
/*
|
||||
* Copy all attributes from other except:
|
||||
* - Group pointers/uuids
|
||||
|
@ -23,6 +23,7 @@
|
||||
#include <QCheckBox>
|
||||
#include <QDesktopServices>
|
||||
#include <QHostInfo>
|
||||
#include <QInputDialog>
|
||||
#include <QKeyEvent>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QProcess>
|
||||
@ -50,7 +51,7 @@
|
||||
#include "gui/group/EditGroupWidget.h"
|
||||
#include "gui/group/GroupView.h"
|
||||
#include "gui/reports/ReportsDialog.h"
|
||||
#include "gui/tag/TagModel.h"
|
||||
#include "gui/tag/TagView.h"
|
||||
#include "keeshare/KeeShare.h"
|
||||
|
||||
#ifdef WITH_XC_NETWORKING
|
||||
@ -82,7 +83,7 @@ DatabaseWidget::DatabaseWidget(QSharedPointer<Database> db, QWidget* parent)
|
||||
, m_keepass1OpenWidget(new KeePass1OpenWidget(this))
|
||||
, m_opVaultOpenWidget(new OpVaultOpenWidget(this))
|
||||
, m_groupView(new GroupView(m_db.data(), this))
|
||||
, m_tagView(new QListView(this))
|
||||
, m_tagView(new TagView(this))
|
||||
, m_saveAttempts(0)
|
||||
, m_entrySearcher(new EntrySearcher(false))
|
||||
{
|
||||
@ -97,20 +98,15 @@ DatabaseWidget::DatabaseWidget(QSharedPointer<Database> db, QWidget* parent)
|
||||
hbox->addWidget(m_mainSplitter);
|
||||
m_mainWidget->setLayout(mainLayout);
|
||||
|
||||
// Setup tags view and place under groups
|
||||
auto tagModel = new TagModel(m_db);
|
||||
// Setup searches and tags view and place under groups
|
||||
m_tagView->setObjectName("tagView");
|
||||
m_tagView->setModel(tagModel);
|
||||
m_tagView->setFrameStyle(QFrame::NoFrame);
|
||||
m_tagView->setSelectionMode(QListView::SingleSelection);
|
||||
m_tagView->setSelectionBehavior(QListView::SelectRows);
|
||||
m_tagView->setCurrentIndex(tagModel->index(0));
|
||||
connect(m_tagView, SIGNAL(activated(QModelIndex)), this, SLOT(filterByTag(QModelIndex)));
|
||||
connect(m_tagView, SIGNAL(clicked(QModelIndex)), this, SLOT(filterByTag(QModelIndex)));
|
||||
m_tagView->setDatabase(m_db);
|
||||
connect(m_tagView, SIGNAL(activated(QModelIndex)), this, SLOT(filterByTag()));
|
||||
connect(m_tagView, SIGNAL(clicked(QModelIndex)), this, SLOT(filterByTag()));
|
||||
|
||||
auto tagsWidget = new QWidget();
|
||||
auto tagsLayout = new QVBoxLayout();
|
||||
auto tagsTitle = new QLabel(tr("Database Tags"));
|
||||
auto tagsTitle = new QLabel(tr("Searches and Tags"));
|
||||
tagsTitle->setProperty("title", true);
|
||||
tagsWidget->setObjectName("tagWidget");
|
||||
tagsWidget->setLayout(tagsLayout);
|
||||
@ -206,13 +202,6 @@ DatabaseWidget::DatabaseWidget(QSharedPointer<Database> db, QWidget* parent)
|
||||
connect(m_groupView, SIGNAL(groupSelectionChanged()), SLOT(onGroupChanged()));
|
||||
connect(m_groupView, SIGNAL(groupSelectionChanged()), SIGNAL(groupChanged()));
|
||||
connect(m_groupView, &GroupView::groupFocused, this, [this] { m_previewView->setGroup(currentGroup()); });
|
||||
connect(m_entryView, &EntryView::entrySelectionChanged, this, [this](Entry * currentEntry) {
|
||||
if (currentEntry) {
|
||||
m_previewView->setEntry(currentEntry);
|
||||
} else {
|
||||
m_previewView->setGroup(groupView()->currentGroup());
|
||||
}
|
||||
});
|
||||
connect(m_entryView, SIGNAL(entryActivated(Entry*,EntryModel::ModelColumn)),
|
||||
SLOT(entryActivationSignalReceived(Entry*,EntryModel::ModelColumn)));
|
||||
connect(m_entryView, SIGNAL(entrySelectionChanged(Entry*)), SLOT(onEntryChanged(Entry*)));
|
||||
@ -431,8 +420,7 @@ void DatabaseWidget::replaceDatabase(QSharedPointer<Database> db)
|
||||
m_db = std::move(db);
|
||||
connectDatabaseSignals();
|
||||
m_groupView->changeDatabase(m_db);
|
||||
auto tagModel = new TagModel(m_db);
|
||||
m_tagView->setModel(tagModel);
|
||||
m_tagView->setDatabase(m_db);
|
||||
|
||||
// Restore the new parent group pointer, if not found default to the root group
|
||||
// this prevents data loss when merging a database while creating a new entry
|
||||
@ -690,11 +678,23 @@ void DatabaseWidget::copyAttribute(QAction* action)
|
||||
}
|
||||
}
|
||||
|
||||
void DatabaseWidget::filterByTag(const QModelIndex& index)
|
||||
void DatabaseWidget::filterByTag()
|
||||
{
|
||||
m_tagView->selectionModel()->setCurrentIndex(index, QItemSelectionModel::Select);
|
||||
const auto model = static_cast<TagModel*>(m_tagView->model());
|
||||
emit requestSearch(model->data(index, Qt::UserRole).toString());
|
||||
QStringList searchTerms;
|
||||
const auto selections = m_tagView->selectionModel()->selectedIndexes();
|
||||
for (const auto& index : selections) {
|
||||
searchTerms << index.data(Qt::UserRole).toString();
|
||||
}
|
||||
emit requestSearch(searchTerms.join(" "));
|
||||
}
|
||||
|
||||
void DatabaseWidget::setTag(QAction* action)
|
||||
{
|
||||
auto tag = action->text();
|
||||
auto state = action->isChecked();
|
||||
for (auto entry : m_entryView->selectedEntries()) {
|
||||
state ? entry->addTag(tag) : entry->removeTag(tag);
|
||||
}
|
||||
}
|
||||
|
||||
void DatabaseWidget::showTotpKeyQrCode()
|
||||
@ -1128,22 +1128,13 @@ void DatabaseWidget::loadDatabase(bool accepted)
|
||||
// Only show expired entries if first unlock and option is enabled
|
||||
if (m_groupBeforeLock.isNull() && config()->get(Config::GUI_ShowExpiredEntriesOnDatabaseUnlock).toBool()) {
|
||||
int expirationOffset = config()->get(Config::GUI_ShowExpiredEntriesOnDatabaseUnlockOffsetDays).toInt();
|
||||
QList<Entry*> expiredEntries;
|
||||
for (auto entry : m_db->rootGroup()->entriesRecursive()) {
|
||||
if (entry->willExpireInDays(expirationOffset) && !entry->excludeFromReports() && !entry->isRecycled()) {
|
||||
expiredEntries << entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (!expiredEntries.isEmpty()) {
|
||||
m_entryView->displaySearch(expiredEntries);
|
||||
m_entryView->setFirstEntryActive();
|
||||
requestSearch(QString("is:expired-%1").arg(expirationOffset));
|
||||
QTimer::singleShot(150, this, [=] {
|
||||
m_searchingLabel->setText(
|
||||
expirationOffset == 0
|
||||
? tr("Expired entries")
|
||||
: tr("Entries expiring within %1 day(s)", "", expirationOffset).arg(expirationOffset));
|
||||
m_searchingLabel->setVisible(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
m_groupBeforeLock = QUuid();
|
||||
@ -1449,6 +1440,40 @@ void DatabaseWidget::search(const QString& searchtext)
|
||||
emit searchModeActivated();
|
||||
}
|
||||
|
||||
void DatabaseWidget::saveSearch(const QString& searchtext)
|
||||
{
|
||||
if (!m_db->isInitialized()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pull the existing searches and prepend an empty string to allow
|
||||
// the user to input a new search name without seeing the first one
|
||||
QStringList searches(m_db->metadata()->savedSearches().keys());
|
||||
searches.prepend("");
|
||||
|
||||
QInputDialog dialog(this);
|
||||
connect(this, &DatabaseWidget::databaseLockRequested, &dialog, &QInputDialog::reject);
|
||||
|
||||
dialog.setComboBoxEditable(true);
|
||||
dialog.setComboBoxItems(searches);
|
||||
dialog.setOkButtonText(tr("Save"));
|
||||
dialog.setLabelText(tr("Enter a unique name or overwrite an existing search from the list:"));
|
||||
dialog.setWindowTitle(tr("Save Search"));
|
||||
dialog.exec();
|
||||
|
||||
auto name = dialog.textValue();
|
||||
if (!name.isEmpty()) {
|
||||
m_db->metadata()->addSavedSearch(name, searchtext);
|
||||
}
|
||||
}
|
||||
|
||||
void DatabaseWidget::deleteSearch(const QString& name)
|
||||
{
|
||||
if (m_db->isInitialized()) {
|
||||
m_db->metadata()->deleteSavedSearch(name);
|
||||
}
|
||||
}
|
||||
|
||||
void DatabaseWidget::setSearchCaseSensitive(bool state)
|
||||
{
|
||||
m_entrySearcher->setCaseSensitive(state);
|
||||
@ -1539,6 +1564,8 @@ void DatabaseWidget::onEntryChanged(Entry* entry)
|
||||
{
|
||||
if (entry) {
|
||||
m_previewView->setEntry(entry);
|
||||
} else {
|
||||
m_previewView->setGroup(groupView()->currentGroup());
|
||||
}
|
||||
|
||||
emit entrySelectionChanged();
|
||||
|
@ -49,6 +49,7 @@ class QSplitter;
|
||||
class QLabel;
|
||||
class MessageWidget;
|
||||
class EntryPreviewWidget;
|
||||
class TagView;
|
||||
|
||||
namespace Ui
|
||||
{
|
||||
@ -175,7 +176,8 @@ public slots:
|
||||
void copyURL();
|
||||
void copyNotes();
|
||||
void copyAttribute(QAction* action);
|
||||
void filterByTag(const QModelIndex& index);
|
||||
void filterByTag();
|
||||
void setTag(QAction* action);
|
||||
void showTotp();
|
||||
void showTotpKeyQrCode();
|
||||
void copyTotp();
|
||||
@ -218,6 +220,8 @@ public slots:
|
||||
|
||||
// Search related slots
|
||||
void search(const QString& searchtext);
|
||||
void saveSearch(const QString& searchtext);
|
||||
void deleteSearch(const QString& name);
|
||||
void setSearchCaseSensitive(bool state);
|
||||
void setSearchLimitGroup(bool state);
|
||||
void endSearch();
|
||||
@ -283,7 +287,7 @@ private:
|
||||
QPointer<KeePass1OpenWidget> m_keepass1OpenWidget;
|
||||
QPointer<OpVaultOpenWidget> m_opVaultOpenWidget;
|
||||
QPointer<GroupView> m_groupView;
|
||||
QPointer<QListView> m_tagView;
|
||||
QPointer<TagView> m_tagView;
|
||||
QPointer<EntryView> m_entryView;
|
||||
|
||||
QScopedPointer<Group> m_newGroup;
|
||||
|
@ -115,13 +115,41 @@ void EntryPreviewWidget::clear()
|
||||
|
||||
void EntryPreviewWidget::setEntry(Entry* selectedEntry)
|
||||
{
|
||||
disconnect(m_currentEntry);
|
||||
disconnect(m_currentGroup);
|
||||
|
||||
m_currentEntry = selectedEntry;
|
||||
m_currentGroup = nullptr;
|
||||
|
||||
if (!selectedEntry) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
m_currentEntry = selectedEntry;
|
||||
connect(selectedEntry, &Entry::modified, this, &EntryPreviewWidget::refresh);
|
||||
refresh();
|
||||
}
|
||||
|
||||
void EntryPreviewWidget::setGroup(Group* selectedGroup)
|
||||
{
|
||||
disconnect(m_currentEntry);
|
||||
disconnect(m_currentGroup);
|
||||
|
||||
m_currentEntry = nullptr;
|
||||
m_currentGroup = selectedGroup;
|
||||
|
||||
if (!selectedGroup) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
connect(m_currentGroup, &Group::modified, this, &EntryPreviewWidget::refresh);
|
||||
refresh();
|
||||
}
|
||||
|
||||
void EntryPreviewWidget::refresh()
|
||||
{
|
||||
if (m_currentEntry) {
|
||||
updateEntryHeaderLine();
|
||||
updateEntryTotp();
|
||||
updateEntryGeneralTab();
|
||||
@ -131,19 +159,11 @@ void EntryPreviewWidget::setEntry(Entry* selectedEntry)
|
||||
setVisible(!config()->get(Config::GUI_HidePreviewPanel).toBool());
|
||||
|
||||
m_ui->stackedWidget->setCurrentWidget(m_ui->pageEntry);
|
||||
const int tabIndex = m_ui->entryTabWidget->isTabEnabled(m_selectedTabEntry) ? m_selectedTabEntry : GeneralTabIndex;
|
||||
const int tabIndex =
|
||||
m_ui->entryTabWidget->isTabEnabled(m_selectedTabEntry) ? m_selectedTabEntry : GeneralTabIndex;
|
||||
Q_ASSERT(m_ui->entryTabWidget->isTabEnabled(GeneralTabIndex));
|
||||
m_ui->entryTabWidget->setCurrentIndex(tabIndex);
|
||||
}
|
||||
|
||||
void EntryPreviewWidget::setGroup(Group* selectedGroup)
|
||||
{
|
||||
if (!selectedGroup) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
m_currentGroup = selectedGroup;
|
||||
} else if (m_currentGroup) {
|
||||
updateGroupHeaderLine();
|
||||
updateGroupGeneralTab();
|
||||
|
||||
@ -154,9 +174,13 @@ void EntryPreviewWidget::setGroup(Group* selectedGroup)
|
||||
setVisible(!config()->get(Config::GUI_HidePreviewPanel).toBool());
|
||||
|
||||
m_ui->stackedWidget->setCurrentWidget(m_ui->pageGroup);
|
||||
const int tabIndex = m_ui->groupTabWidget->isTabEnabled(m_selectedTabGroup) ? m_selectedTabGroup : GeneralTabIndex;
|
||||
const int tabIndex =
|
||||
m_ui->groupTabWidget->isTabEnabled(m_selectedTabGroup) ? m_selectedTabGroup : GeneralTabIndex;
|
||||
Q_ASSERT(m_ui->groupTabWidget->isTabEnabled(GeneralTabIndex));
|
||||
m_ui->groupTabWidget->setCurrentIndex(tabIndex);
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
void EntryPreviewWidget::setDatabaseMode(DatabaseWidget::Mode mode)
|
||||
@ -240,6 +264,8 @@ void EntryPreviewWidget::setNotesVisible(QTextEdit* notesWidget, const QString&
|
||||
} else {
|
||||
if (!notes.isEmpty()) {
|
||||
notesWidget->setPlainText(QString("\u25cf").repeated(6));
|
||||
} else {
|
||||
notesWidget->setPlainText("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ public slots:
|
||||
void setEntry(Entry* selectedEntry);
|
||||
void setGroup(Group* selectedGroup);
|
||||
void setDatabaseMode(DatabaseWidget::Mode mode);
|
||||
void refresh();
|
||||
void clear();
|
||||
|
||||
signals:
|
||||
|
@ -129,6 +129,7 @@ MainWindow::MainWindow()
|
||||
m_entryContextMenu->addAction(m_ui->actionEntryCopyPassword);
|
||||
m_entryContextMenu->addAction(m_ui->menuEntryCopyAttribute->menuAction());
|
||||
m_entryContextMenu->addAction(m_ui->menuEntryTotp->menuAction());
|
||||
m_entryContextMenu->addAction(m_ui->menuTags->menuAction());
|
||||
m_entryContextMenu->addSeparator();
|
||||
m_entryContextMenu->addAction(m_ui->actionEntryAutoType);
|
||||
m_entryContextMenu->addSeparator();
|
||||
@ -240,6 +241,11 @@ MainWindow::MainWindow()
|
||||
m_copyAdditionalAttributeActions, SIGNAL(triggered(QAction*)), SLOT(copyAttribute(QAction*)));
|
||||
connect(m_ui->menuEntryCopyAttribute, SIGNAL(aboutToShow()), this, SLOT(updateCopyAttributesMenu()));
|
||||
|
||||
m_setTagsMenuActions = new QActionGroup(m_ui->menuTags);
|
||||
m_setTagsMenuActions->setExclusive(false);
|
||||
m_actionMultiplexer.connect(m_setTagsMenuActions, SIGNAL(triggered(QAction*)), SLOT(setTag(QAction*)));
|
||||
connect(m_ui->menuTags, &QMenu::aboutToShow, this, &MainWindow::updateSetTagsMenu);
|
||||
|
||||
Qt::Key globalAutoTypeKey = static_cast<Qt::Key>(config()->get(Config::GlobalAutoTypeKey).toInt());
|
||||
Qt::KeyboardModifiers globalAutoTypeModifiers =
|
||||
static_cast<Qt::KeyboardModifiers>(config()->get(Config::GlobalAutoTypeModifiers).toInt());
|
||||
@ -791,6 +797,38 @@ void MainWindow::updateCopyAttributesMenu()
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::updateSetTagsMenu()
|
||||
{
|
||||
// Remove all existing actions
|
||||
m_ui->menuTags->clear();
|
||||
|
||||
auto dbWidget = m_ui->tabWidget->currentDatabaseWidget();
|
||||
if (dbWidget) {
|
||||
// Enumerate tags applied to the selected entries
|
||||
QSet<QString> selectedTags;
|
||||
for (auto entry : dbWidget->entryView()->selectedEntries()) {
|
||||
for (auto tag : entry->tagList()) {
|
||||
selectedTags.insert(tag);
|
||||
}
|
||||
}
|
||||
|
||||
// Add known database tags as actions and set checked if
|
||||
// a selected entry has that tag
|
||||
for (auto tag : dbWidget->database()->tagList()) {
|
||||
auto action = m_ui->menuTags->addAction(icons()->icon("tag"), tag);
|
||||
action->setCheckable(true);
|
||||
action->setChecked(selectedTags.contains(tag));
|
||||
m_setTagsMenuActions->addAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
// If no tags exist in the database then show a tip to the user
|
||||
if (m_ui->menuTags->isEmpty()) {
|
||||
auto action = m_ui->menuTags->addAction(tr("No Tags"));
|
||||
action->setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::openRecentDatabase(QAction* action)
|
||||
{
|
||||
openDatabase(action->data().toString());
|
||||
@ -870,6 +908,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode)
|
||||
m_ui->actionEntryCopyNotes->setEnabled(singleEntrySelected && dbWidget->currentEntryHasNotes());
|
||||
m_ui->menuEntryCopyAttribute->setEnabled(singleEntrySelected);
|
||||
m_ui->menuEntryTotp->setEnabled(singleEntrySelected);
|
||||
m_ui->menuTags->setEnabled(entriesSelected);
|
||||
m_ui->actionEntryAutoType->setEnabled(singleEntrySelected);
|
||||
m_ui->actionEntryAutoType->menu()->setEnabled(singleEntrySelected);
|
||||
m_ui->actionEntryAutoTypeSequence->setText(
|
||||
|
@ -130,6 +130,7 @@ private slots:
|
||||
void clearLastDatabases();
|
||||
void updateLastDatabasesMenu();
|
||||
void updateCopyAttributesMenu();
|
||||
void updateSetTagsMenu();
|
||||
void showEntryContextMenu(const QPoint& globalPos);
|
||||
void showGroupContextMenu(const QPoint& globalPos);
|
||||
void applySettingsChanges();
|
||||
@ -172,6 +173,7 @@ private:
|
||||
QPointer<QMenu> m_entryNewContextMenu;
|
||||
QPointer<QActionGroup> m_lastDatabasesActions;
|
||||
QPointer<QActionGroup> m_copyAdditionalAttributeActions;
|
||||
QPointer<QActionGroup> m_setTagsMenuActions;
|
||||
QPointer<InactivityTimer> m_inactivityTimer;
|
||||
QPointer<InactivityTimer> m_touchIDinactivityTimer;
|
||||
int m_countDefaultAttributes;
|
||||
|
@ -316,6 +316,11 @@
|
||||
<addaction name="actionEntryTotpQRCode"/>
|
||||
<addaction name="actionEntrySetupTotp"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuTags">
|
||||
<property name="title">
|
||||
<string>Tags</string>
|
||||
</property>
|
||||
</widget>
|
||||
<addaction name="actionEntryNew"/>
|
||||
<addaction name="actionEntryEdit"/>
|
||||
<addaction name="actionEntryClone"/>
|
||||
@ -328,6 +333,7 @@
|
||||
<addaction name="actionEntryCopyPassword"/>
|
||||
<addaction name="menuEntryCopyAttribute"/>
|
||||
<addaction name="menuEntryTotp"/>
|
||||
<addaction name="menuTags"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionEntryAutoType"/>
|
||||
<addaction name="separator"/>
|
||||
|
@ -46,6 +46,7 @@ SearchWidget::SearchWidget(QWidget* parent)
|
||||
connect(m_ui->searchEdit, SIGNAL(textChanged(QString)), SLOT(startSearchTimer()));
|
||||
connect(m_ui->helpIcon, SIGNAL(triggered()), SLOT(toggleHelp()));
|
||||
connect(m_ui->searchIcon, SIGNAL(triggered()), SLOT(showSearchMenu()));
|
||||
connect(m_ui->saveIcon, &QAction::triggered, this, [this] { emit saveSearch(m_ui->searchEdit->text()); });
|
||||
connect(m_searchTimer, SIGNAL(timeout()), SLOT(startSearch()));
|
||||
connect(m_clearSearchTimer, SIGNAL(timeout()), SLOT(clearSearch()));
|
||||
connect(this, SIGNAL(escapePressed()), SLOT(clearSearch()));
|
||||
@ -70,6 +71,10 @@ SearchWidget::SearchWidget(QWidget* parent)
|
||||
m_ui->helpIcon->setIcon(icons()->icon("system-help"));
|
||||
m_ui->searchEdit->addAction(m_ui->helpIcon, QLineEdit::TrailingPosition);
|
||||
|
||||
m_ui->saveIcon->setIcon(icons()->icon("document-save"));
|
||||
m_ui->searchEdit->addAction(m_ui->saveIcon, QLineEdit::TrailingPosition);
|
||||
m_ui->saveIcon->setVisible(false);
|
||||
|
||||
// Fix initial visibility of actions (bug in Qt)
|
||||
for (QToolButton* toolButton : m_ui->searchEdit->findChildren<QToolButton*>()) {
|
||||
toolButton->setVisible(toolButton->defaultAction()->isVisible());
|
||||
@ -126,6 +131,7 @@ void SearchWidget::connectSignals(SignalMultiplexer& mx)
|
||||
{
|
||||
// Connects basically only to the current DatabaseWidget, but allows to switch between instances!
|
||||
mx.connect(this, SIGNAL(search(QString)), SLOT(search(QString)));
|
||||
mx.connect(this, SIGNAL(saveSearch(QString)), SLOT(saveSearch(QString)));
|
||||
mx.connect(this, SIGNAL(caseSensitiveChanged(bool)), SLOT(setSearchCaseSensitive(bool)));
|
||||
mx.connect(this, SIGNAL(limitGroupChanged(bool)), SLOT(setSearchLimitGroup(bool)));
|
||||
mx.connect(this, SIGNAL(copyPressed()), SLOT(copyPassword()));
|
||||
@ -165,6 +171,7 @@ void SearchWidget::startSearch()
|
||||
m_searchTimer->stop();
|
||||
}
|
||||
|
||||
m_ui->saveIcon->setVisible(true);
|
||||
search(m_ui->searchEdit->text());
|
||||
}
|
||||
|
||||
@ -208,6 +215,7 @@ void SearchWidget::focusSearch()
|
||||
void SearchWidget::clearSearch()
|
||||
{
|
||||
m_ui->searchEdit->clear();
|
||||
m_ui->saveIcon->setVisible(false);
|
||||
emit searchCanceled();
|
||||
}
|
||||
|
||||
|
@ -61,6 +61,7 @@ signals:
|
||||
void downPressed();
|
||||
void enterPressed();
|
||||
void lostFocus();
|
||||
void saveSearch(const QString& text);
|
||||
|
||||
public slots:
|
||||
void databaseChanged(DatabaseWidget* dbWidget = nullptr);
|
||||
|
@ -56,6 +56,11 @@
|
||||
<string>Search Help</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="saveIcon">
|
||||
<property name="text">
|
||||
<string>Save Search</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<tabstops>
|
||||
<tabstop>searchEdit</tabstop>
|
||||
|
@ -320,8 +320,8 @@
|
||||
<tabstop>usernameComboBox</tabstop>
|
||||
<tabstop>passwordEdit</tabstop>
|
||||
<tabstop>urlEdit</tabstop>
|
||||
<tabstop>tagsList</tabstop>
|
||||
<tabstop>fetchFaviconButton</tabstop>
|
||||
<tabstop>tagsList</tabstop>
|
||||
<tabstop>expireCheck</tabstop>
|
||||
<tabstop>expireDatePicker</tabstop>
|
||||
<tabstop>expirePresets</tabstop>
|
||||
|
@ -263,6 +263,15 @@ Entry* EntryView::currentEntry()
|
||||
}
|
||||
}
|
||||
|
||||
QList<Entry*> EntryView::selectedEntries()
|
||||
{
|
||||
QList<Entry*> list;
|
||||
for (auto row : selectionModel()->selectedRows()) {
|
||||
list.append(m_model->entryFromIndex(m_sortModel->mapToSource(row)));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
int EntryView::numberOfSelectedEntries()
|
||||
{
|
||||
return selectionModel()->selectedRows().size();
|
||||
|
@ -38,6 +38,7 @@ public:
|
||||
void setModel(QAbstractItemModel* model) override;
|
||||
Entry* currentEntry();
|
||||
void setCurrentEntry(Entry* entry);
|
||||
QList<Entry*> selectedEntries();
|
||||
Entry* entryFromIndex(const QModelIndex& index);
|
||||
QModelIndex indexFromEntry(Entry* entry);
|
||||
int currentEntryIndex();
|
||||
|
@ -18,12 +18,19 @@
|
||||
#include "TagModel.h"
|
||||
|
||||
#include "core/Database.h"
|
||||
#include "core/Metadata.h"
|
||||
#include "gui/Icons.h"
|
||||
#include "gui/MessageBox.h"
|
||||
|
||||
TagModel::TagModel(QSharedPointer<Database> db, QObject* parent)
|
||||
#include <QApplication>
|
||||
#include <QMenu>
|
||||
|
||||
TagModel::TagModel(QObject* parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
setDatabase(db);
|
||||
m_defaultSearches << qMakePair(tr("Clear Search"), QString("")) << qMakePair(tr("All Entries"), QString("*"))
|
||||
<< qMakePair(tr("Expired"), QString("is:expired"))
|
||||
<< qMakePair(tr("Weak Passwords"), QString("is:weak"));
|
||||
}
|
||||
|
||||
TagModel::~TagModel()
|
||||
@ -32,12 +39,19 @@ TagModel::~TagModel()
|
||||
|
||||
void TagModel::setDatabase(QSharedPointer<Database> db)
|
||||
{
|
||||
if (m_db) {
|
||||
disconnect(m_db.data());
|
||||
}
|
||||
|
||||
m_db = db;
|
||||
if (!m_db) {
|
||||
m_tagList.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
connect(m_db.data(), SIGNAL(tagListUpdated()), SLOT(updateTagList()));
|
||||
connect(m_db->metadata()->customData(), SIGNAL(modified()), SLOT(updateTagList()));
|
||||
|
||||
updateTagList();
|
||||
}
|
||||
|
||||
@ -45,10 +59,35 @@ void TagModel::updateTagList()
|
||||
{
|
||||
beginResetModel();
|
||||
m_tagList.clear();
|
||||
m_tagList << tr("All") << tr("Expired") << tr("Weak Passwords") << m_db->tagList();
|
||||
|
||||
m_tagList << m_defaultSearches;
|
||||
|
||||
auto savedSearches = m_db->metadata()->savedSearches();
|
||||
for (auto search : savedSearches.keys()) {
|
||||
m_tagList << qMakePair(search, savedSearches[search].toString());
|
||||
}
|
||||
|
||||
m_tagListStart = m_tagList.size();
|
||||
for (auto tag : m_db->tagList()) {
|
||||
auto escapedTag = tag;
|
||||
escapedTag.replace("\"", "\\\"");
|
||||
m_tagList << qMakePair(tag, QString("tag:\"%1\"").arg(escapedTag));
|
||||
}
|
||||
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
TagModel::TagType TagModel::itemType(const QModelIndex& index)
|
||||
{
|
||||
int row = index.row();
|
||||
if (row < m_defaultSearches.size()) {
|
||||
return TagType::DEFAULT_SEARCH;
|
||||
} else if (row < m_tagListStart) {
|
||||
return TagType::SAVED_SEARCH;
|
||||
}
|
||||
return TagType::TAG;
|
||||
}
|
||||
|
||||
int TagModel::rowCount(const QModelIndex& parent) const
|
||||
{
|
||||
Q_UNUSED(parent);
|
||||
@ -61,29 +100,23 @@ QVariant TagModel::data(const QModelIndex& index, int role) const
|
||||
return {};
|
||||
}
|
||||
|
||||
const auto row = index.row();
|
||||
switch (role) {
|
||||
case Qt::DecorationRole:
|
||||
if (index.row() <= 2) {
|
||||
return icons()->icon("tag-search");
|
||||
if (row < m_tagListStart) {
|
||||
return icons()->icon("database-search");
|
||||
}
|
||||
return icons()->icon("tag");
|
||||
case Qt::DisplayRole:
|
||||
return m_tagList.at(index.row());
|
||||
return m_tagList.at(row).first;
|
||||
case Qt::UserRole:
|
||||
if (index.row() == 0) {
|
||||
return "";
|
||||
} else if (index.row() == 1) {
|
||||
return "is:expired";
|
||||
} else if (index.row() == 2) {
|
||||
return "is:weak";
|
||||
return m_tagList.at(row).second;
|
||||
case Qt::UserRole + 1:
|
||||
if (row == (m_defaultSearches.size() - 1)) {
|
||||
return true;
|
||||
}
|
||||
return QString("tag:%1").arg(m_tagList.at(index.row()));
|
||||
return false;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
const QStringList& TagModel::tags() const
|
||||
{
|
||||
return m_tagList;
|
||||
}
|
||||
|
@ -28,21 +28,30 @@ class TagModel : public QAbstractListModel
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit TagModel(QSharedPointer<Database> db, QObject* parent = nullptr);
|
||||
explicit TagModel(QObject* parent = nullptr);
|
||||
~TagModel() override;
|
||||
|
||||
void setDatabase(QSharedPointer<Database> db);
|
||||
const QStringList& tags() const;
|
||||
|
||||
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
|
||||
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
|
||||
|
||||
enum TagType
|
||||
{
|
||||
DEFAULT_SEARCH,
|
||||
SAVED_SEARCH,
|
||||
TAG
|
||||
};
|
||||
TagType itemType(const QModelIndex& index);
|
||||
|
||||
private slots:
|
||||
void updateTagList();
|
||||
|
||||
private:
|
||||
QSharedPointer<Database> m_db;
|
||||
QStringList m_tagList;
|
||||
QList<QPair<QString, QString>> m_defaultSearches;
|
||||
QList<QPair<QString, QString>> m_tagList;
|
||||
int m_tagListStart = 0;
|
||||
};
|
||||
|
||||
#endif // KEEPASSX_TAGMODEL_H
|
||||
|
98
src/gui/tag/TagView.cpp
Normal file
98
src/gui/tag/TagView.cpp
Normal file
@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (C) 2022 KeePassXC Team <team@keepassxc.org>
|
||||
*
|
||||
* 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 "TagView.h"
|
||||
|
||||
#include "TagModel.h"
|
||||
#include "core/Database.h"
|
||||
#include "core/Metadata.h"
|
||||
#include "gui/Icons.h"
|
||||
#include "gui/MessageBox.h"
|
||||
|
||||
#include <QMenu>
|
||||
#include <QPainter>
|
||||
#include <QStyledItemDelegate>
|
||||
|
||||
class TagItemDelegate : public QStyledItemDelegate
|
||||
{
|
||||
public:
|
||||
explicit TagItemDelegate(QObject* parent)
|
||||
: QStyledItemDelegate(parent){};
|
||||
|
||||
void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override
|
||||
{
|
||||
QStyledItemDelegate::paint(painter, option, index);
|
||||
if (index.data(Qt::UserRole + 1).toBool()) {
|
||||
QRect bounds = option.rect;
|
||||
bounds.setY(bounds.bottom());
|
||||
painter->fillRect(bounds, option.palette.mid());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
TagView::TagView(QWidget* parent)
|
||||
: QListView(parent)
|
||||
, m_model(new TagModel(this))
|
||||
{
|
||||
setModel(m_model);
|
||||
setFrameStyle(QFrame::NoFrame);
|
||||
setSelectionMode(QListView::ExtendedSelection);
|
||||
setSelectionBehavior(QListView::SelectRows);
|
||||
setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
setItemDelegate(new TagItemDelegate(this));
|
||||
|
||||
connect(this, &QListView::customContextMenuRequested, this, &TagView::contextMenuRequested);
|
||||
}
|
||||
|
||||
void TagView::setDatabase(QSharedPointer<Database> db)
|
||||
{
|
||||
m_db = db;
|
||||
m_model->setDatabase(db);
|
||||
setCurrentIndex(m_model->index(0));
|
||||
}
|
||||
|
||||
void TagView::contextMenuRequested(const QPoint& pos)
|
||||
{
|
||||
auto index = indexAt(pos);
|
||||
if (!index.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto type = m_model->itemType(index);
|
||||
if (type == TagModel::SAVED_SEARCH) {
|
||||
// Allow deleting saved searches
|
||||
QMenu menu;
|
||||
auto action = menu.exec({new QAction(icons()->icon("trash"), tr("Remove Search"))}, mapToGlobal(pos));
|
||||
if (action) {
|
||||
m_db->metadata()->deleteSavedSearch(index.data(Qt::DisplayRole).toString());
|
||||
}
|
||||
} else if (type == TagModel::TAG) {
|
||||
// Allow removing tags from all entries in a database
|
||||
QMenu menu;
|
||||
auto action = menu.exec({new QAction(icons()->icon("trash"), tr("Remove Tag"))}, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
47
src/gui/tag/TagView.h
Normal file
47
src/gui/tag/TagView.h
Normal file
@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (C) 2022 KeePassXC Team <team@keepassxc.org>
|
||||
*
|
||||
* 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_TAGVIEW_H
|
||||
#define KEEPASSXC_TAGVIEW_H
|
||||
|
||||
#include <QListView>
|
||||
#include <QPointer>
|
||||
#include <QSharedPointer>
|
||||
|
||||
class Database;
|
||||
class QAbstractListModel;
|
||||
class TagModel;
|
||||
|
||||
class TagView : public QListView
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit TagView(QWidget* parent = nullptr);
|
||||
void setDatabase(QSharedPointer<Database> db);
|
||||
|
||||
signals:
|
||||
|
||||
private slots:
|
||||
void contextMenuRequested(const QPoint& pos);
|
||||
|
||||
private:
|
||||
QSharedPointer<Database> m_db;
|
||||
QPointer<TagModel> m_model;
|
||||
};
|
||||
|
||||
#endif // KEEPASSX_ENTRYVIEW_H
|
@ -401,6 +401,7 @@ struct TagsEdit::Impl
|
||||
// and ensures Invariant-1.
|
||||
void editNewTag(int i)
|
||||
{
|
||||
currentText() = currentText().trimmed();
|
||||
tags.insert(std::next(std::begin(tags), static_cast<std::ptrdiff_t>(i)), Tag());
|
||||
if (editing_index >= i) {
|
||||
++editing_index;
|
||||
@ -646,6 +647,12 @@ void TagsEdit::focusOutEvent(QFocusEvent*)
|
||||
viewport()->update();
|
||||
}
|
||||
|
||||
void TagsEdit::hideEvent(QHideEvent* event)
|
||||
{
|
||||
Q_UNUSED(event)
|
||||
impl->completer->popup()->hide();
|
||||
}
|
||||
|
||||
void TagsEdit::paintEvent(QPaintEvent*)
|
||||
{
|
||||
QPainter p(viewport());
|
||||
|
@ -68,6 +68,7 @@ protected:
|
||||
void focusOutEvent(QFocusEvent* event) override;
|
||||
void keyPressEvent(QKeyEvent* event) override;
|
||||
void mouseMoveEvent(QMouseEvent* event) override;
|
||||
void hideEvent(QHideEvent* event) override;
|
||||
|
||||
private:
|
||||
bool isAcceptableInput(QKeyEvent const* event) const;
|
||||
|
@ -205,7 +205,7 @@ void TestEntrySearcher::testSearchTermParser()
|
||||
QCOMPARE(terms[0].exclude, true);
|
||||
|
||||
QCOMPARE(terms[1].field, EntrySearcher::Field::Undefined);
|
||||
QCOMPARE(terms[1].word, QString("quoted \\\"string\\\""));
|
||||
QCOMPARE(terms[1].word, QString("quoted \"string\""));
|
||||
QCOMPARE(terms[1].exclude, false);
|
||||
|
||||
QCOMPARE(terms[2].field, EntrySearcher::Field::Username);
|
||||
|
Loading…
Reference in New Issue
Block a user