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
wilder-portage
Ingo Klöcker 4 years ago
parent a37cf79cc0
commit dc60839a85
  1. 89
      autotests/foldertreewidgettest.cpp
  2. 1
      src/CMakeLists.txt
  3. 19
      src/folder/entitycollectionorderproxymodel.cpp
  4. 7
      src/folder/entitycollectionorderproxymodel.h
  5. 20
      src/folder/foldertreewidget.cpp
  6. 103
      src/folder/hierarchicalfoldermatcher.cpp
  7. 37
      src/folder/hierarchicalfoldermatcher_p.h

@ -202,6 +202,83 @@ private Q_SLOTS:
#endif #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: private:
static Collection topLevelCollectionForResource(const QString &identifier) 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); static QStringList collectNames(QAbstractItemModel *model);
EntityMimeTypeFilterModel *mCollectionModel = nullptr; EntityMimeTypeFilterModel *mCollectionModel = nullptr;
QAbstractItemModel *mTopModel = nullptr; QAbstractItemModel *mTopModel = nullptr;

@ -140,6 +140,7 @@ target_sources(KF5MailCommon PRIVATE
folder/entitycollectionorderproxymodel.cpp folder/entitycollectionorderproxymodel.cpp
folder/accountconfigorderdialog.cpp folder/accountconfigorderdialog.cpp
folder/favoritecollectionorderproxymodel.cpp folder/favoritecollectionorderproxymodel.cpp
folder/hierarchicalfoldermatcher.cpp
job/jobscheduler.cpp job/jobscheduler.cpp
job/folderjob.cpp job/folderjob.cpp
job/expirejob.cpp job/expirejob.cpp

@ -6,6 +6,7 @@
*/ */
#include "entitycollectionorderproxymodel.h" #include "entitycollectionorderproxymodel.h"
#include "hierarchicalfoldermatcher_p.h"
#include "kernel/mailkernel.h" #include "kernel/mailkernel.h"
#include "mailcommon_debug.h" #include "mailcommon_debug.h"
#include "util/mailutil.h" #include "util/mailutil.h"
@ -14,6 +15,8 @@
#include <Akonadi/EntityTreeModel> #include <Akonadi/EntityTreeModel>
#include <Akonadi/KMime/SpecialMailCollections> #include <Akonadi/KMime/SpecialMailCollections>
#include <QRegularExpression>
namespace MailCommon namespace MailCommon
{ {
class Q_DECL_HIDDEN EntityCollectionOrderProxyModel::EntityCollectionOrderProxyModelPrivate class Q_DECL_HIDDEN EntityCollectionOrderProxyModel::EntityCollectionOrderProxyModelPrivate
@ -71,6 +74,7 @@ public:
QMap<Akonadi::Collection::Id, int> collectionRanks; QMap<Akonadi::Collection::Id, int> collectionRanks;
QStringList topLevelOrder; QStringList topLevelOrder;
HierarchicalFolderMatcher matcher;
bool manualSortingActive = false; bool manualSortingActive = false;
}; };
@ -155,4 +159,19 @@ bool EntityCollectionOrderProxyModel::isManualSortingActive() const
{ {
return d->manualSortingActive; 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());
}
} }

@ -12,6 +12,8 @@
namespace MailCommon namespace MailCommon
{ {
class HierarchicalFolderMatcher;
/** /**
* @brief The EntityCollectionOrderProxyModel class implements ordering of mail collections. * @brief The EntityCollectionOrderProxyModel class implements ordering of mail collections.
* It supports two modes: manual sorting and automatic sorting. * It supports two modes: manual sorting and automatic sorting.
@ -41,9 +43,14 @@ public:
void clearRanks(); void clearRanks();
void setTopLevelOrder(const QStringList &list); void setTopLevelOrder(const QStringList &list);
void setFolderMatcher(const HierarchicalFolderMatcher &matcher);
public Q_SLOTS: public Q_SLOTS:
void slotSpecialCollectionsChanged(); void slotSpecialCollectionsChanged();
protected:
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
private: private:
class EntityCollectionOrderProxyModelPrivate; class EntityCollectionOrderProxyModelPrivate;
std::unique_ptr<EntityCollectionOrderProxyModelPrivate> const d; std::unique_ptr<EntityCollectionOrderProxyModelPrivate> const d;

@ -8,6 +8,7 @@
#include "foldertreewidget.h" #include "foldertreewidget.h"
#include "entitycollectionorderproxymodel.h" #include "entitycollectionorderproxymodel.h"
#include "foldertreeview.h" #include "foldertreeview.h"
#include "hierarchicalfoldermatcher_p.h"
#include "kernel/mailkernel.h" #include "kernel/mailkernel.h"
#include "util/mailutil.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->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(); d->folderTreeView->expandAll();
QAbstractItemModel *model = d->folderTreeView->model(); const QAbstractItemModel *const model = d->folderTreeView->model();
QModelIndex current = d->folderTreeView->currentIndex(); const QModelIndex current = d->folderTreeView->currentIndex();
QModelIndex start = current.isValid() ? current : model->index(0, 0); const 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); const QModelIndex firstMatch = matcher.findFirstMatch(model, start);
if (!list.isEmpty()) { if (firstMatch.isValid()) {
current = list.first(); d->folderTreeView->setCurrentIndex(firstMatch);
d->folderTreeView->setCurrentIndex(current); d->folderTreeView->scrollTo(firstMatch);
d->folderTreeView->scrollTo(current);
} }
} }

@ -0,0 +1,103 @@
/*
SPDX-FileCopyrightText: 2021 Intevation GmbH
SPDX-FileContributor: Ingo Klöcker <dev@ingo-kloecker.de>
SPDX-License-Identifier: GPL-2.0-or-later
*/
#include "hierarchicalfoldermatcher_p.h"
#include <QAbstractItemModel>
#include <QModelIndex>
#include <QRegularExpression>
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;
}
}

@ -0,0 +1,37 @@
/*
SPDX-FileCopyrightText: 2021 Intevation GmbH
SPDX-FileContributor: Ingo Klöcker <dev@ingo-kloecker.de>
SPDX-License-Identifier: GPL-2.0-or-later
*/
#pragma once
#include <Qt>
#include <vector>
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<QRegularExpression> filterRegExps;
};
}
Loading…
Cancel
Save