[pim/akonadi] /: EntityTreeModel: add stable collection-key helpers
Allen Winter <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit 4d25fd4a5f203c017d49171e3237bea88a4f916f by Allen Winter, on behalf of David Faure.
Committed on 10/08/2026 at 11:59.
Pushed by dfaure into branch 'master'.
EntityTreeModel: add stable collection-key helpers
Move the stable remote-path key logic (a collection's resource id plus the chain
of remoteIds up to the resource root) out of ETMViewStateSaver into reusable
static helpers: stableKeyForCollectionIndex(), modelIndexForStableKey(), and a
model-less stableKeyForCollection() that walks parentCollection().
Such a key survives a resource re-listing its collections with new ids, so it can
persist a collection reference in a config file. This lets korganizer's default
calendar and konsolekalendar reuse the same format the calendar selection already
uses.
M +55 -0 autotests/libs/etmviewstatesavertest.cpp
M +81 -0 src/core/models/entitytreemodel.cpp
M +40 -0 src/core/models/entitytreemodel.h
M +2 -57 src/widgets/etmviewstatesaver.cpp
M +0 -6 src/widgets/etmviewstatesaver.h
https://invent.kde.org/pim/akonadi/-/commit/4d25fd4a5f203c017d49171e3237bea88a4f916f
diff --git a/autotests/libs/etmviewstatesavertest.cpp b/autotests/libs/etmviewstatesavertest.cpp
index fb0f059e2..d0fe2fd83 100644
--- a/autotests/libs/etmviewstatesavertest.cpp
+++ b/autotests/libs/etmviewstatesavertest.cpp
@@ -58,6 +58,61 @@ private Q_SLOTS:
QCOMPARE(saver.indexToConfigString(calendar->index()), QStringLiteral("rres1/https:%2F%2Fs%2Fcal%2Fwork%2F"));
}
+ void shouldBuildStableKeyFromCollectionChain()
+ {
+ // The Collection-based helper (no model) must produce the same key as the index-based one,
+ // walking parentCollection() instead of model indexes.
+ Collection root(1);
+ root.setResource(QStringLiteral("res1"));
+ root.setRemoteId(QStringLiteral("res1root"));
+ root.setParentCollection(Collection::root());
+
+ Collection calendar(2);
+ calendar.setResource(QStringLiteral("res1"));
+ calendar.setRemoteId(QStringLiteral("https://s/cal/work/"));
+ calendar.setParentCollection(root);
+
+ QCOMPARE(EntityTreeModel::stableKeyForCollection(calendar), QStringLiteral("rres1/https:%2F%2Fs%2Fcal%2Fwork%2F"));
+
+ // A single-collection resource (the root itself) is keyed by the resource anchor alone.
+ QCOMPARE(EntityTreeModel::stableKeyForCollection(root), QStringLiteral("rres1/"));
+
+ // A collection with no resource has no stable key.
+ Collection noResource(3);
+ noResource.setRemoteId(QStringLiteral("x"));
+ QVERIFY(EntityTreeModel::stableKeyForCollection(noResource).isEmpty());
+ }
+
+ void shouldMatchIndexAndCollectionKeys()
+ {
+ // The index-based and Collection-based helpers must agree for the same collection.
+ Collection root(1);
+ root.setResource(QStringLiteral("res1"));
+ root.setRemoteId(QStringLiteral("res1root"));
+ root.setParentCollection(Collection::root());
+
+ Collection mid(2);
+ mid.setResource(QStringLiteral("res1"));
+ mid.setRemoteId(QStringLiteral("home"));
+ mid.setParentCollection(root);
+
+ Collection leaf(3);
+ leaf.setResource(QStringLiteral("res1"));
+ leaf.setRemoteId(QStringLiteral("2026"));
+ leaf.setParentCollection(mid);
+
+ QStandardItemModel model;
+ auto *rootItem = collectionItem(1, QStringLiteral("res1"), QStringLiteral("res1root"));
+ auto *midItem = collectionItem(2, QStringLiteral("res1"), QStringLiteral("home"));
+ auto *leafItem = collectionItem(3, QStringLiteral("res1"), QStringLiteral("2026"));
+ midItem->appendRow(leafItem);
+ rootItem->appendRow(midItem);
+ model.appendRow(rootItem);
+
+ QCOMPARE(EntityTreeModel::stableKeyForCollection(leaf), EntityTreeModel::stableKeyForCollectionIndex(leafItem->index()));
+ QCOMPARE(EntityTreeModel::stableKeyForCollection(leaf), QStringLiteral("rres1/home/2026"));
+ }
+
void shouldRoundTripStableRemotePathKey()
{
QStandardItemModel model;
diff --git a/src/core/models/entitytreemodel.cpp b/src/core/models/entitytreemodel.cpp
index c5b6c72be..b138edf7e 100644
--- a/src/core/models/entitytreemodel.cpp
+++ b/src/core/models/entitytreemodel.cpp
@@ -1085,6 +1085,87 @@ QModelIndex EntityTreeModel::modelIndexForCollection(const QAbstractItemModel *m
return proxiedIndex(idx, proxy);
}
+// The remoteId chain is joined with '/', so percent-encode any literal '/' inside a remoteId
+// (and '%' itself, to keep the encoding reversible). Backslash escaping would be more in line
+// with Akonadi::CollectionPathResolver, but KConfig escapes backslashes twice over, which turns
+// a DAV url into an unreadable wall of '\\\\/'.
+static QString escapeStableKeySegment(const QString &segment)
+{
+ QString escaped = segment;
+ escaped.replace(u'%', QStringLiteral("%25"));
+ escaped.replace(u'/', QStringLiteral("%2F"));
+ return escaped;
+}
+
+// Escapes and assembles the "r<resource>/<rid>/..." key from a root-to-leaf chain of remoteIds, or
+// an empty string if any remoteId in the chain is empty (a pathless node). An empty chain denotes
+// the resource-root collection itself. `resource` must be non-empty.
+static QString stableKeyFromRemoteIds(const QString &resource, const QStringList &remoteIdsRootToLeaf)
+{
+ QStringList segments;
+ segments.reserve(remoteIdsRootToLeaf.size());
+ for (const QString &remoteId : remoteIdsRootToLeaf) {
+ if (remoteId.isEmpty()) {
+ return QString();
+ }
+ segments.append(escapeStableKeySegment(remoteId));
+ }
+ return QStringLiteral("r%1/%2").arg(escapeStableKeySegment(resource), segments.join(u'/'));
+}
+
+QString EntityTreeModel::stableKeyForCollectionIndex(const QModelIndex &collectionIndex)
+{
+ const auto collection = collectionIndex.data(EntityTreeModel::CollectionRole).value<Collection>();
+ if (!collection.isValid() || collection.resource().isEmpty()) {
+ return QString(); // an item, or a collection not anchored to a resource
+ }
+
+ // Collect remoteIds from the collection up to, but not including, the resource-root collection
+ // (the top-level row, whose parent index is invalid). resource() already identifies that root,
+ // so its own remoteId is redundant. Each ancestor's remoteId is read from its own model node.
+ QStringList remoteIds;
+ for (QModelIndex idx = collectionIndex; idx.parent().isValid(); idx = idx.parent()) {
+ remoteIds.prepend(idx.data(EntityTreeModel::CollectionRole).value<Collection>().remoteId());
+ }
+ return stableKeyFromRemoteIds(collection.resource(), remoteIds);
+}
+
+QString EntityTreeModel::stableKeyForCollection(const Collection &collection)
+{
+ if (!collection.isValid() || collection.resource().isEmpty()) {
+ return QString();
+ }
+
+ // Same as the index-based version, but walking parentCollection(). The resource-root collection
+ // is the one whose parent is Collection::root() (id 0), and it is excluded from the path.
+ QStringList remoteIds;
+ for (Collection col = collection; col.parentCollection().isValid() && col.parentCollection().id() > 0; col = col.parentCollection()) {
+ remoteIds.prepend(col.remoteId());
+ }
+ return stableKeyFromRemoteIds(collection.resource(), remoteIds);
+}
+
+static QModelIndex stableKeyIndexSearch(const QAbstractItemModel *model, const QString &key, const QModelIndex &parent)
+{
+ const int rows = model->rowCount(parent);
+ for (int row = 0; row < rows; ++row) {
+ const QModelIndex idx = model->index(row, 0, parent);
+ if (EntityTreeModel::stableKeyForCollectionIndex(idx) == key) {
+ return idx;
+ }
+ const QModelIndex child = stableKeyIndexSearch(model, key, idx);
+ if (child.isValid()) {
+ return child;
+ }
+ }
+ return QModelIndex();
+}
+
+QModelIndex EntityTreeModel::modelIndexForStableKey(const QAbstractItemModel *model, const QString &key)
+{
+ return stableKeyIndexSearch(model, key, QModelIndex());
+}
+
QModelIndexList EntityTreeModel::modelIndexesForItem(const QAbstractItemModel *model, const Item &item)
{
const auto &[proxy, etm] = proxiesAndModel(model);
diff --git a/src/core/models/entitytreemodel.h b/src/core/models/entitytreemodel.h
index 388a8991a..f9b2d3d99 100644
--- a/src/core/models/entitytreemodel.h
+++ b/src/core/models/entitytreemodel.h
@@ -616,6 +616,46 @@ public:
static Collection updatedCollection(const QAbstractItemModel *model, qint64 collectionId);
static Collection updatedCollection(const QAbstractItemModel *model, const Collection &col);
+ /*!
+ * Returns a stable string identifying the collection at \a collectionIndex by its resource
+ * identifier and the chain of remoteIds up to the resource root, or an empty string if it has
+ * no usable remote path (an item, or a collection with an empty remoteId anywhere in the chain,
+ * e.g. a search/virtual collection).
+ *
+ * Unlike a numeric Collection::Id, this key survives a resource re-listing its collections with
+ * new ids, so it is suited for persisting a collection reference (a selection, a default) in a
+ * config file. Resolve it back with modelIndexForStableKey().
+ *
+ * \a collectionIndex an index whose CollectionRole holds the collection
+ * \sa modelIndexForStableKey
+ * \since 6.9
+ */
+ static QString stableKeyForCollectionIndex(const QModelIndex &collectionIndex);
+
+ /*!
+ * Same as stableKeyForCollectionIndex(), but computed from \a collection and its
+ * parentCollection() chain rather than from a model. Use this when no model is available, for
+ * example resolving against the result of a CollectionFetchJob with ancestor retrieval. The
+ * ancestors must be populated (their remoteId in particular), otherwise an empty string is
+ * returned.
+ *
+ * \a collection a collection with its ancestor chain populated
+ * \sa stableKeyForCollectionIndex
+ * \since 6.9
+ */
+ static QString stableKeyForCollection(const Collection &collection);
+
+ /*!
+ * Returns the index in \a model whose stableKeyForCollectionIndex() equals \a key, or an
+ * invalid index if none matches. This can be used through proxy models if \a model is a proxy.
+ *
+ * \a model the model to search
+ * \a key a key produced by stableKeyForCollectionIndex()
+ * \sa stableKeyForCollectionIndex
+ * \since 6.9
+ */
+ static QModelIndex modelIndexForStableKey(const QAbstractItemModel *model, const QString &key);
+
Q_SIGNALS:
/*!
* Signal emitted when the collection tree has been fetched for the first time.
diff --git a/src/widgets/etmviewstatesaver.cpp b/src/widgets/etmviewstatesaver.cpp
index 66deb9c55..4a50b7bd0 100644
--- a/src/widgets/etmviewstatesaver.cpp
+++ b/src/widgets/etmviewstatesaver.cpp
@@ -32,65 +32,10 @@ ETMViewStateSaver::KeyFormat ETMViewStateSaver::keyFormat() const
return mKeyFormat;
}
-// The remoteId chain is joined with '/', so percent-encode any literal '/' inside a remoteId
-// (and '%' itself, to keep the encoding reversible). Backslash escaping would be more in line
-// with Akonadi::CollectionPathResolver, but KConfig escapes backslashes twice over, which turns
-// a DAV url into an unreadable wall of '\\\\/'.
-static QString escapeSegment(const QString &segment)
-{
- QString escaped = segment;
- escaped.replace(u'%', QStringLiteral("%25"));
- escaped.replace(u'/', QStringLiteral("%2F"));
- return escaped;
-}
-
-QString ETMViewStateSaver::stableKeyForIndex(const QModelIndex &index) const
-{
- const auto collection = index.data(EntityTreeModel::CollectionRole).value<Collection>();
- if (!collection.isValid()) {
- return QString(); // an item, not a collection
- }
- const QString resource = collection.resource();
- if (resource.isEmpty()) {
- return QString(); // can't anchor to a resource
- }
-
- // Collect remoteIds from the collection up to, but not including, the
- // resource-root collection (the top-level row, whose parent index is invalid).
- // resource() already identifies that root, so its own remoteId is redundant.
- QStringList segments;
- for (QModelIndex idx = index; idx.parent().isValid(); idx = idx.parent()) {
- const auto col = idx.data(EntityTreeModel::CollectionRole).value<Collection>();
- if (col.remoteId().isEmpty()) {
- return QString(); // pathless node -> caller falls back to the id key
- }
- segments.prepend(escapeSegment(col.remoteId()));
- }
- // A resource with a single collection (the root itself) yields an empty path,
- // which still uniquely denotes that root via the resource anchor.
- return QStringLiteral("r%1/%2").arg(escapeSegment(resource), segments.join(u'/'));
-}
-
-QModelIndex ETMViewStateSaver::indexForStableKey(const QAbstractItemModel *model, const QString &key, const QModelIndex &parent) const
-{
- const int rows = model->rowCount(parent);
- for (int row = 0; row < rows; ++row) {
- const QModelIndex idx = model->index(row, 0, parent);
- if (stableKeyForIndex(idx) == key) {
- return idx;
- }
- const QModelIndex child = indexForStableKey(model, key, idx);
- if (child.isValid()) {
- return child;
- }
- }
- return QModelIndex();
-}
-
QModelIndex ETMViewStateSaver::indexFromConfigString(const QAbstractItemModel *model, const QString &key) const
{
if (key.startsWith(u'r')) {
- return indexForStableKey(model, key, QModelIndex());
+ return EntityTreeModel::modelIndexForStableKey(model, key);
}
if (key.startsWith(u'x')) {
@@ -126,7 +71,7 @@ QString ETMViewStateSaver::indexToConfigString(const QModelIndex &index) const
const auto c = index.data(EntityTreeModel::CollectionRole).value<Collection>();
if (c.isValid()) {
if (mKeyFormat == RemotePathKeys) {
- const QString key = stableKeyForIndex(index);
+ const QString key = EntityTreeModel::stableKeyForCollectionIndex(index);
if (!key.isEmpty()) {
return key;
}
diff --git a/src/widgets/etmviewstatesaver.h b/src/widgets/etmviewstatesaver.h
index 32e3a3263..a8d278e00 100644
--- a/src/widgets/etmviewstatesaver.h
+++ b/src/widgets/etmviewstatesaver.h
@@ -104,12 +104,6 @@ protected:
QString indexToConfigString(const QModelIndex &index) const override;
private:
- /// Builds the stable "r<resource>/<rid>/..." key for a collection index, or an empty string
- /// if it has no usable remote path (item, or a collection with an empty remoteId anywhere in the chain).
- [[nodiscard]] QString stableKeyForIndex(const QModelIndex &index) const;
- /// Depth-first search for the collection index whose stableKeyForIndex() equals \a key.
- [[nodiscard]] QModelIndex indexForStableKey(const QAbstractItemModel *model, const QString &key, const QModelIndex &parent) const;
-
KeyFormat mKeyFormat = IdKeys;
};