[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;
 };
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.