From dc60839a851c8db9b3b24eab759405e18f9b2ae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ingo=20Kl=C3=B6cker?= Date: Fri, 15 Oct 2021 16:24:52 +0200 Subject: [PATCH] Add hierarchical filtering of folders This allows filtering a hierarchy of folders with path patterns like "parent/sub" which matches all folders matching "sub" with parent folders matching "parent". Funded by: Intevation GmbH BUG: 443791 FIXED-IN: 5.19.0 --- autotests/foldertreewidgettest.cpp | 89 +++++++++++++++ src/CMakeLists.txt | 1 + .../entitycollectionorderproxymodel.cpp | 19 ++++ src/folder/entitycollectionorderproxymodel.h | 7 ++ src/folder/foldertreewidget.cpp | 20 ++-- src/folder/hierarchicalfoldermatcher.cpp | 103 ++++++++++++++++++ src/folder/hierarchicalfoldermatcher_p.h | 37 +++++++ 7 files changed, 267 insertions(+), 9 deletions(-) create mode 100644 src/folder/hierarchicalfoldermatcher.cpp create mode 100644 src/folder/hierarchicalfoldermatcher_p.h diff --git a/autotests/foldertreewidgettest.cpp b/autotests/foldertreewidgettest.cpp index ee07937..2f7a441 100644 --- a/autotests/foldertreewidgettest.cpp +++ b/autotests/foldertreewidgettest.cpp @@ -202,6 +202,83 @@ private Q_SLOTS: #endif } + void testFiltering() + { + const auto model = mFolderTreeWidget->entityOrderProxy(); + QCOMPARE(collectNamesRecursive(model), (QStringList{"res3", "res1", "sub1", "res2", "sub2"})); + + mFolderTreeWidget->applyFilter(QStringLiteral("sub")); + // matches all folders matching "sub" + QCOMPARE(collectNamesRecursive(model), (QStringList{"res1", "sub1", "res2", "sub2"})); + QCOMPARE(mFolderTreeWidget->currentIndex().data().toString(), "sub1"); + + mFolderTreeWidget->applyFilter(QStringLiteral("res")); + // matches all folders matching "res" + QCOMPARE(collectNamesRecursive(model), (QStringList{"res3", "res1", "res2"})); + // "res1" is current because it became current when previous current "sub1" was filtered out + QCOMPARE(mFolderTreeWidget->currentIndex().data().toString(), "res1"); + + mFolderTreeWidget->applyFilter(QStringLiteral("foo")); + // matches nothing + QCOMPARE(collectNamesRecursive(model), (QStringList{})); + QVERIFY(!mFolderTreeWidget->currentIndex().isValid()); + + mFolderTreeWidget->applyFilter(QStringLiteral("res/sub")); + // matches folders matching "sub" with parents matching "res" + QCOMPARE(collectNamesRecursive(model), (QStringList{"res1", "sub1", "res2", "sub2"})); + QCOMPARE(mFolderTreeWidget->currentIndex().data().toString(), "sub1"); + + mFolderTreeWidget->applyFilter(QStringLiteral("res/1")); + // matches folders matching "1" with parents matching "res" + QCOMPARE(collectNamesRecursive(model), (QStringList{"res1", "sub1"})); + QCOMPARE(mFolderTreeWidget->currentIndex().data().toString(), "sub1"); + + mFolderTreeWidget->applyFilter(QStringLiteral("res/")); + // matches folders matching anything ("" always matches) with parents matching "res" + QCOMPARE(collectNamesRecursive(model), (QStringList{"res1", "sub1", "res2", "sub2"})); + QCOMPARE(mFolderTreeWidget->currentIndex().data().toString(), "sub1"); + + mFolderTreeWidget->applyFilter(QStringLiteral("sub/")); + // matches nothing (there are no folders matching "sub" that have subfolders) + QCOMPARE(collectNamesRecursive(model), (QStringList{})); + QVERIFY(!mFolderTreeWidget->currentIndex().isValid()); + + mFolderTreeWidget->applyFilter(QStringLiteral("/sub")); + // matches folders matching "sub" with parents matching anything + QCOMPARE(collectNamesRecursive(model), (QStringList{"res1", "sub1", "res2", "sub2"})); + QCOMPARE(mFolderTreeWidget->currentIndex().data().toString(), "sub1"); + + mFolderTreeWidget->applyFilter(QStringLiteral("/res")); + // matches nothing (there are no subfolders matching "res") + QCOMPARE(collectNamesRecursive(model), (QStringList{})); + QVERIFY(!mFolderTreeWidget->currentIndex().isValid()); + + mFolderTreeWidget->applyFilter(QStringLiteral("//sub")); + // matches nothing (there are no subsubfolders matching "sub") + QCOMPARE(collectNamesRecursive(model), (QStringList{})); + QVERIFY(!mFolderTreeWidget->currentIndex().isValid()); + + mFolderTreeWidget->applyFilter(QStringLiteral("res//")); + // matches nothing (there are no folders matching "res" that have subsubfolders) + QCOMPARE(collectNamesRecursive(model), (QStringList{})); + QVERIFY(!mFolderTreeWidget->currentIndex().isValid()); + + mFolderTreeWidget->applyFilter(QStringLiteral("1/1")); + // matches folders matching "1" with parents matching "1" + QCOMPARE(collectNamesRecursive(model), (QStringList{"res1", "sub1"})); + QCOMPARE(mFolderTreeWidget->currentIndex().data().toString(), "sub1"); + + mFolderTreeWidget->applyFilter(QStringLiteral("2/")); + // matches folders matching anything with parents matching "2" + QCOMPARE(collectNamesRecursive(model), (QStringList{"res2", "sub2"})); + QCOMPARE(mFolderTreeWidget->currentIndex().data().toString(), "sub2"); + + mFolderTreeWidget->applyFilter(QStringLiteral("1/2")); + // matches nothing (there are no folders matching "2" with parents matching "1") + QCOMPARE(collectNamesRecursive(model), (QStringList{})); + QVERIFY(!mFolderTreeWidget->currentIndex().isValid()); + } + private: static Collection topLevelCollectionForResource(const QString &identifier) { @@ -235,6 +312,18 @@ private: } } + static QStringList collectNamesRecursive(const QAbstractItemModel *model, const QModelIndex &parent = QModelIndex{}) + { + QStringList ret; + ret.reserve(model->rowCount(parent)); + for (int row = 0; row < model->rowCount(parent); ++row) { + QModelIndex idx = model->index(row, 0, parent); + ret.append(idx.data().toString()); + ret.append(collectNamesRecursive(model, idx)); + } + return ret; + } + static QStringList collectNames(QAbstractItemModel *model); EntityMimeTypeFilterModel *mCollectionModel = nullptr; QAbstractItemModel *mTopModel = nullptr; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3ed399f..dde35cc 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -140,6 +140,7 @@ target_sources(KF5MailCommon PRIVATE folder/entitycollectionorderproxymodel.cpp folder/accountconfigorderdialog.cpp folder/favoritecollectionorderproxymodel.cpp + folder/hierarchicalfoldermatcher.cpp job/jobscheduler.cpp job/folderjob.cpp job/expirejob.cpp diff --git a/src/folder/entitycollectionorderproxymodel.cpp b/src/folder/entitycollectionorderproxymodel.cpp index 8fe5b36..e730c96 100644 --- a/src/folder/entitycollectionorderproxymodel.cpp +++ b/src/folder/entitycollectionorderproxymodel.cpp @@ -6,6 +6,7 @@ */ #include "entitycollectionorderproxymodel.h" +#include "hierarchicalfoldermatcher_p.h" #include "kernel/mailkernel.h" #include "mailcommon_debug.h" #include "util/mailutil.h" @@ -14,6 +15,8 @@ #include #include +#include + namespace MailCommon { class Q_DECL_HIDDEN EntityCollectionOrderProxyModel::EntityCollectionOrderProxyModelPrivate @@ -71,6 +74,7 @@ public: QMap collectionRanks; QStringList topLevelOrder; + HierarchicalFolderMatcher matcher; bool manualSortingActive = false; }; @@ -155,4 +159,19 @@ bool EntityCollectionOrderProxyModel::isManualSortingActive() const { return d->manualSortingActive; } + +void EntityCollectionOrderProxyModel::setFolderMatcher(const HierarchicalFolderMatcher &matcher) +{ + d->matcher = matcher; + invalidateFilter(); +} + +bool EntityCollectionOrderProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const +{ + if (d->matcher.isNull()) { + return EntityOrderProxyModel::filterAcceptsRow(sourceRow, sourceParent); + } + QModelIndex sourceIndex = sourceModel()->index(sourceRow, filterKeyColumn(), sourceParent); + return d->matcher.matches(sourceModel(), sourceIndex, filterRole()); +} } diff --git a/src/folder/entitycollectionorderproxymodel.h b/src/folder/entitycollectionorderproxymodel.h index 1ad824e..8845cd6 100644 --- a/src/folder/entitycollectionorderproxymodel.h +++ b/src/folder/entitycollectionorderproxymodel.h @@ -12,6 +12,8 @@ namespace MailCommon { +class HierarchicalFolderMatcher; + /** * @brief The EntityCollectionOrderProxyModel class implements ordering of mail collections. * It supports two modes: manual sorting and automatic sorting. @@ -41,9 +43,14 @@ public: void clearRanks(); void setTopLevelOrder(const QStringList &list); + void setFolderMatcher(const HierarchicalFolderMatcher &matcher); + public Q_SLOTS: void slotSpecialCollectionsChanged(); +protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; + private: class EntityCollectionOrderProxyModelPrivate; std::unique_ptr const d; diff --git a/src/folder/foldertreewidget.cpp b/src/folder/foldertreewidget.cpp index c25dac3..1e856f4 100644 --- a/src/folder/foldertreewidget.cpp +++ b/src/folder/foldertreewidget.cpp @@ -8,6 +8,7 @@ #include "foldertreewidget.h" #include "entitycollectionorderproxymodel.h" #include "foldertreeview.h" +#include "hierarchicalfoldermatcher_p.h" #include "kernel/mailkernel.h" #include "util/mailutil.h" @@ -311,16 +312,17 @@ void FolderTreeWidget::applyFilter(const QString &filter) { d->label->setText(filter.isEmpty() ? i18n("You can start typing to filter the list of folders.") : i18n("Path: (%1)", filter)); - d->entityOrderProxy->setFilterWildcard(filter); + HierarchicalFolderMatcher matcher; + matcher.setFilter(filter, d->entityOrderProxy->filterCaseSensitivity()); + d->entityOrderProxy->setFolderMatcher(matcher); d->folderTreeView->expandAll(); - QAbstractItemModel *model = d->folderTreeView->model(); - QModelIndex current = d->folderTreeView->currentIndex(); - QModelIndex start = current.isValid() ? current : model->index(0, 0); - QModelIndexList list = model->match(start, Qt::DisplayRole, d->filter, 1 /* stop at first hit */, Qt::MatchContains | Qt::MatchWrap | Qt::MatchRecursive); - if (!list.isEmpty()) { - current = list.first(); - d->folderTreeView->setCurrentIndex(current); - d->folderTreeView->scrollTo(current); + const QAbstractItemModel *const model = d->folderTreeView->model(); + const QModelIndex current = d->folderTreeView->currentIndex(); + const QModelIndex start = current.isValid() ? current : model->index(0, 0); + const QModelIndex firstMatch = matcher.findFirstMatch(model, start); + if (firstMatch.isValid()) { + d->folderTreeView->setCurrentIndex(firstMatch); + d->folderTreeView->scrollTo(firstMatch); } } diff --git a/src/folder/hierarchicalfoldermatcher.cpp b/src/folder/hierarchicalfoldermatcher.cpp new file mode 100644 index 0000000..a7388eb --- /dev/null +++ b/src/folder/hierarchicalfoldermatcher.cpp @@ -0,0 +1,103 @@ +/* + SPDX-FileCopyrightText: 2021 Intevation GmbH + SPDX-FileContributor: Ingo Klöcker + + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#include "hierarchicalfoldermatcher_p.h" + +#include +#include +#include + +namespace MailCommon +{ + +HierarchicalFolderMatcher::HierarchicalFolderMatcher() +{ +} + +bool HierarchicalFolderMatcher::isNull() +{ + return filterRegExps.empty(); +} + +void HierarchicalFolderMatcher::setFilter(const QString &filter, Qt::CaseSensitivity caseSensitivity) +{ + filterRegExps.clear(); + if (filter.isEmpty()) { + return; + } + const auto patternOptions = caseSensitivity == Qt::CaseInsensitive ? + QRegularExpression::CaseInsensitiveOption : + QRegularExpression::NoPatternOption; + const auto parts = filter.split(QLatin1Char('/')); + std::transform(std::begin(parts), std::end(parts), + std::back_inserter(filterRegExps), + [patternOptions](const auto &part) { + // QRegularExpression::wildcardToRegularExpression() returns a fully anchored + // regular expression, but we want to check for substring matches; wrap + // the user's filter part into '*' to fix this + return QRegularExpression{QRegularExpression::wildcardToRegularExpression( + QLatin1Char('*') + part + QLatin1Char('*')), patternOptions}; + }); +} + +bool HierarchicalFolderMatcher::matches(const QAbstractItemModel *model, const QModelIndex &start, int role) +{ + if (!start.isValid()) { + return false; + } + + const auto filterKeyColumn = start.column(); + QModelIndex idx = start; + for (auto it = filterRegExps.crbegin(); it != filterRegExps.crend(); ++it) { + if (!idx.isValid()) { + // we have exceeded the model root or the column does not exist + return false; + } + const QString key = model->data(idx, role).toString(); + if (!it->match(key).hasMatch()) { + return false; + } + idx = idx.parent().siblingAtColumn(filterKeyColumn); + } + return true; +} + +QModelIndex HierarchicalFolderMatcher::findFirstMatch(const QAbstractItemModel *model, const QModelIndex &start, int role) +{ + // inspired by QAbstractItemModel::match(), but using our own matching + QModelIndex result; + + const int filterKeyColumn = start.column(); + const QModelIndex p = model->parent(start); + int from = start.row(); + int to = model->rowCount(p); + + // iterate twice (first from start row to last row; then from first row to before start row) + for (int i = 0; (i < 2) && !result.isValid(); ++i) { + for (int row = from; (row < to) && !result.isValid(); ++row) { + QModelIndex idx = model->index(row, filterKeyColumn, p); + if (!idx.isValid()) { + continue; + } + if (matches(model, idx, role)) { + result = idx; + break; + } + const auto idxAsParent = filterKeyColumn != 0 ? idx.siblingAtColumn(0) : idx; + if (model->hasChildren(idxAsParent)) { + result = findFirstMatch(model, model->index(0, filterKeyColumn, idxAsParent), role); + } + } + // prepare for the next iteration + from = 0; + to = start.row(); + } + + return result; +} + +} diff --git a/src/folder/hierarchicalfoldermatcher_p.h b/src/folder/hierarchicalfoldermatcher_p.h new file mode 100644 index 0000000..3af8901 --- /dev/null +++ b/src/folder/hierarchicalfoldermatcher_p.h @@ -0,0 +1,37 @@ +/* + SPDX-FileCopyrightText: 2021 Intevation GmbH + SPDX-FileContributor: Ingo Klöcker + + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#pragma once + +#include + +#include + +class QAbstractItemModel; +class QModelIndex; +class QRegularExpression; +class QString; + +namespace MailCommon +{ +class HierarchicalFolderMatcher +{ +public: + HierarchicalFolderMatcher(); + + bool isNull(); + + void setFilter(const QString &filter, Qt::CaseSensitivity caseSensitivity); + + bool matches(const QAbstractItemModel *model, const QModelIndex &start, int role = Qt::DisplayRole); + + QModelIndex findFirstMatch(const QAbstractItemModel *model, const QModelIndex &start, int role = Qt::DisplayRole); + +private: + std::vector filterRegExps; +}; +}