Commit dc60839a authored by Ingo Klöcker's avatar Ingo Klöcker
Browse files

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
parent a37cf79c
Pipeline #89012 passed with stage
in 30 minutes and 35 seconds
......@@ -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;
......
......@@ -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
......
......@@ -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 <Akonadi/EntityTreeModel>
#include <Akonadi/KMime/SpecialMailCollections>
#include <QRegularExpression>
namespace MailCommon
{
class Q_DECL_HIDDEN EntityCollectionOrderProxyModel::EntityCollectionOrderProxyModelPrivate
......@@ -71,6 +74,7 @@ public:
QMap<Akonadi::Collection::Id, int> 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());
}
}
......@@ -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<EntityCollectionOrderProxyModelPrivate> const d;
......
......@@ -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);
}
}
......
/*
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;
}
}
/*
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;
};
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment