diff options
| -rw-r--r-- | src/notmuchworker.cpp | 200 | ||||
| -rw-r--r-- | src/notmuchworker.h | 34 | ||||
| -rw-r--r-- | tests/test_notmuchworker.cpp | 136 |
3 files changed, 370 insertions, 0 deletions
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp index 75a4483..821c575 100644 --- a/src/notmuchworker.cpp +++ b/src/notmuchworker.cpp @@ -349,11 +349,21 @@ static const int kSortOrderMetaType = static const int kSenderCountsMetaType = qRegisterMetaType<QHash<QString, int>>("QHash<QString,int>"); +/// And the same for the dashboard's digest, which crosses the queued +/// threadDigestLoaded connection. Here beside the signal that carries it +/// rather than in MainWindow, for the reason SortOrder above records: the +/// registration belongs to the type, and a caller that never builds a window +/// still needs it. threaddigest.h is header-only, so this file is where a +/// static initialiser for it can live. +static const int kThreadDigestMetaType = + qRegisterMetaType<ThreadDigest>("ThreadDigest"); + NotmuchWorker::NotmuchWorker(const QString ¬muchConfigPath, QObject *parent) : QObject(parent), m_configPath(notmuchConfigPath) { Q_UNUSED(kSortOrderMetaType); Q_UNUSED(kSenderCountsMetaType); + Q_UNUSED(kThreadDigestMetaType); } NotmuchWorker::~NotmuchWorker() @@ -681,6 +691,196 @@ void NotmuchWorker::loadThreadTree(const QString &threadId, emit threadTreeLoaded(nodes, generation); } +QString NotmuchWorker::threadIdForTesting(const QString &query) +{ + if (!openReadOnly()) + return QString(); + + NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData())); + if (!nmQuery) + return QString(); + + notmuch_threads_t *rawThreads = nullptr; + if (notmuch_query_search_threads(nmQuery.get(), &rawThreads) + != NOTMUCH_STATUS_SUCCESS) { + return QString(); + } + NmThreads threads(rawThreads); + if (!notmuch_threads_valid(threads.get())) + return QString(); + + NmThread thread(notmuch_threads_get(threads.get())); + if (!thread) + return QString(); + return QString::fromUtf8(notmuch_thread_get_thread_id(thread.get())); +} + +void NotmuchWorker::loadThreadDigest(const QString &threadId, + quint64 generation) +{ + ThreadDigest digest; + digest.threadId = threadId; + // Always kBuckets entries, on every path out of here including the failure + // ones. The sparkline's geometry is written against a fixed count, so an + // empty vector from an unknown thread would be a second shape for the + // widget to handle rather than a thread with nothing in it. + digest.buckets.assign(ThreadDigest::kBuckets, 0); + + if (!openReadOnly()) { + emit threadDigestLoaded(digest, generation); + return; + } + + const QString query = QStringLiteral("thread:%1").arg(threadId); + NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData())); + if (!nmQuery) { + emit threadDigestLoaded(digest, generation); + return; + } + + notmuch_threads_t *rawThreads = nullptr; + if (notmuch_query_search_threads(nmQuery.get(), &rawThreads) + != NOTMUCH_STATUS_SUCCESS) { + emit threadDigestLoaded(digest, generation); + return; + } + NmThreads threads(rawThreads); + if (!notmuch_threads_valid(threads.get())) { + // An unknown thread id. Emitted rather than dropped, for the reason + // loadMessage() documents: a caller that arms state on the request + // and disarms it on the reply otherwise waits for ever. + emit threadDigestLoaded(digest, generation); + return; + } + + // Held for the whole walk. Every message pointer below belongs to this + // thread and is freed with it (notmuch.h:1637), which is why none of them + // is wrapped in NmMessage: that would destroy memory the thread frees + // again. The same rule walkReplies follows. + NmThread thread(notmuch_threads_get(threads.get())); + if (!thread) { + emit threadDigestLoaded(digest, generation); + return; + } + + // Grouped by the bare ADDRESS, labelled with the raw From header. + // + // The address is the identity: a sender whose display name varies between + // messages is still one participant, and the address is what the avatar + // hashes, so the dashboard's squircle matches the card's. The label keeps + // the display name, which is what a human reads. + QHash<QString, int> countByAddress; + QHash<QString, QString> labelByAddress; + QStringList addressOrder; + + QVector<MessageNode> unread; + QVector<qint64> timestamps; + + notmuch_messages_t *messages = notmuch_thread_get_messages(thread.get()); + for (; notmuch_messages_valid(messages); + notmuch_messages_move_to_next(messages)) { + + notmuch_message_t *message = notmuch_messages_get(messages); + if (!message) + continue; + + ++digest.totalCount; + + const qint64 when = notmuch_message_get_date(message); + timestamps.append(when); + + const QString rawFrom = + QString::fromUtf8(notmuch_message_get_header(message, "from")); + QString address = senderAddressOf(message); + if (address.isEmpty()) + address = rawFrom; + const QString key = address.toLower(); + if (!countByAddress.contains(key)) { + addressOrder.append(key); + labelByAddress.insert(key, rawFrom.isEmpty() ? address : rawFrom); + } + countByAddress[key] += 1; + + const QStringList tags = tagsOf(message); + if (!tags.contains(QStringLiteral("unread"))) + continue; + + ++digest.unreadTotal; + + // MessageNode rather than MessageRef, because the dashboard draws + // these rows without opening the message. Every field here is served + // from the INDEX, so the struct's no-file-opening contract holds. + MessageNode node; + node.messageId = + QString::fromUtf8(notmuch_message_get_message_id(message)); + node.threadId = threadId; + node.from = rawFrom; + node.senderAddress = address; + node.subject = + QString::fromUtf8(notmuch_message_get_header(message, "subject")); + node.date = QDateTime::fromSecsSinceEpoch(when); + node.tags = tags; + node.filePath = + QString::fromUtf8(notmuch_message_get_filename(message)); + unread.append(node); + } + + if (digest.totalCount == 0) { + emit threadDigestLoaded(digest, generation); + return; + } + + std::sort(timestamps.begin(), timestamps.end()); + digest.firstTimestamp = timestamps.first(); + digest.lastTimestamp = timestamps.last(); + + // A zero-width span is the degenerate case: one message, or several sharing + // a timestamp. Dividing it into buckets is either a division by zero or a + // bucket width of zero that throws everything into the last slot, so the + // whole thread lands in bucket 0 instead, which is what "all of it happened + // at once" honestly looks like. + const qint64 span = digest.lastTimestamp - digest.firstTimestamp; + for (qint64 when : timestamps) { + int bucket = 0; + if (span > 0) { + bucket = static_cast<int>(((when - digest.firstTimestamp) + * ThreadDigest::kBuckets) / span); + bucket = qBound(0, bucket, ThreadDigest::kBuckets - 1); + } + digest.buckets[bucket] += 1; + } + + digest.busiestBucket = 0; + for (int i = 1; i < ThreadDigest::kBuckets; ++i) { + if (digest.buckets.at(i) > digest.buckets.at(digest.busiestBucket)) + digest.busiestBucket = i; + } + + // Most prolific first, ties broken by the order they were met, so the + // list does not reshuffle between two openings of the same thread. + std::stable_sort(addressOrder.begin(), addressOrder.end(), + [&countByAddress](const QString &a, const QString &b) { + return countByAddress.value(a) + > countByAddress.value(b); + }); + for (const QString &key : addressOrder) { + digest.senders.append( + qMakePair(labelByAddress.value(key), countByAddress.value(key))); + } + + // Newest first, then capped. unreadTotal already carries the real number, + // so the cap costs nothing but the rows nobody would have read. + std::stable_sort(unread.begin(), unread.end(), + [](const MessageNode &a, const MessageNode &b) { + return a.date > b.date; + }); + if (unread.size() > ThreadDigest::kUnreadShown) + unread.resize(ThreadDigest::kUnreadShown); + digest.unread = unread; + + emit threadDigestLoaded(digest, generation); +} + void NotmuchWorker::loadMessage(const QString &messageId, quint64 generation) { // Every failure below emits an EMPTY result as well as its error, and that diff --git a/src/notmuchworker.h b/src/notmuchworker.h index 8171f3c..a0c8feb 100644 --- a/src/notmuchworker.h +++ b/src/notmuchworker.h @@ -24,6 +24,7 @@ #include <QStringList> #include <QVector> +#include "threaddigest.h" #include "types.h" struct _notmuch_database; @@ -110,6 +111,35 @@ public slots: void loadThreadTree(const QString &threadId, const QString &matchQuery, quint64 generation); + /// Everything the thread dashboard shows, from ONE walk of the index. + /// + /// Sender counts, the newest unread messages, and an activity histogram, + /// all served from the index: no message file is opened, which is the + /// contract ThreadDigest states and the reason this can run on every + /// selection. See threaddigest.h. + /// + /// \p generation is the DASHBOARD's own counter, never m_generation's + /// query generation. Bumping that one would discard a thread load in + /// flight and blank the message pane because the user selected a row, + /// which is the rule requestMessageCounts and countSenders already follow. + /// + /// A thread id the index does not hold yields an EMPTY digest rather than + /// silence, for the reason loadMessage() documents: a caller that arms + /// state on the request and disarms it on the reply otherwise waits for + /// ever. + void loadThreadDigest(const QString &threadId, quint64 generation); + +public: + /// The first thread id matching \p query, for tests. + /// + /// A test knows a message id and needs the thread id the digest call + /// takes; nothing in the UI ever needs this, since every caller there + /// already holds a thread id from a query result. Not a slot, so it cannot + /// be reached across the thread boundary by accident. + QString threadIdForTesting(const QString &query); + +public slots: + /// Loads ONE message, for a message row selected in the list. /// /// Emits messageLoaded with an empty vector when the id is unknown, which @@ -315,6 +345,10 @@ signals: void threadTreeLoaded(const QVector<MessageNode> &nodes, quint64 generation); void messageLoaded(const QVector<MessageRef> &messages, quint64 generation); + + /// The dashboard's digest. `generation` is the dashboard's own counter, + /// echoed back so a stale answer can be discarded. + void threadDigestLoaded(const ThreadDigest &digest, quint64 generation); void tagsApplied(const TagChange &change); /// Carries the ids that ACTUALLY moved, which may be fewer than requested. diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index f70a8f5..1766589 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -21,6 +21,7 @@ #include "notmuchfixture.h" #include "notmuchworker.h" +#include "threaddigest.h" #include "types.h" /// NotmuchWorker against a throwaway database. This is the only code in the @@ -120,6 +121,10 @@ private slots: void aQuerySeesMailIndexedAfterTheWorkerOpened(); + void aDigestCountsSendersAndUnread(); + void aDigestCapsItsUnreadListButNotItsCount(); + void aOneMessageThreadGivesASaneSpan(); + private: /// Adds one read message in `folder` and reindexes, for the move tests. /// Each of those takes its own message, because a move is destructive and @@ -2178,5 +2183,136 @@ void TestNotmuchWorker::aSplitIndexListsTheMaildirsFolders() "the index's own directory was listed as a mail folder"); } +void TestNotmuchWorker::aDigestCountsSendersAndUnread() +{ + NotmuchFixture fixture; + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("d0@example.org"), + QStringLiteral("Digest root"), + QStringLiteral("alice@example.org"), + QStringLiteral("Mon, 24 Aug 2026 10:00:00 +0200"), + QStringLiteral("Root."), false)); + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("d1@example.org"), + QStringLiteral("Re: Digest root"), + QStringLiteral("alice@example.org"), + QStringLiteral("Tue, 25 Aug 2026 10:00:00 +0200"), + QStringLiteral("Again."), false, + QStringLiteral("d0@example.org"))); + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("d2@example.org"), + QStringLiteral("Re: Digest root"), + QStringLiteral("bob@example.org"), + QStringLiteral("Wed, 26 Aug 2026 10:00:00 +0200"), + QStringLiteral("Unread one."), true, + QStringLiteral("d0@example.org"))); + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy spy(&worker, &NotmuchWorker::threadDigestLoaded); + + const QString threadId = worker.threadIdForTesting( + QStringLiteral("id:d0@example.org")); + QVERIFY(!threadId.isEmpty()); + worker.loadThreadDigest(threadId, 1); + + QCOMPARE(spy.count(), 1); + const ThreadDigest digest = spy.at(0).at(0).value<ThreadDigest>(); + + QCOMPARE(digest.totalCount, 3); + QCOMPARE(digest.unreadTotal, 1); + QCOMPARE(digest.unread.size(), 1); + QCOMPARE(digest.unread.at(0).messageId, QStringLiteral("d2@example.org")); + + // Alice twice, Bob once, most prolific first. + QCOMPARE(digest.senders.size(), 2); + QCOMPARE(digest.senders.at(0).second, 2); + QCOMPARE(digest.senders.at(1).second, 1); + + QCOMPARE(digest.buckets.size(), ThreadDigest::kBuckets); + int summed = 0; + for (int n : digest.buckets) + summed += n; + QCOMPARE(summed, 3); + + // The dashboard reconstructs the busiest bucket's date from the span, so + // an index outside the histogram would name a date the thread never saw. + QVERIFY(digest.busiestBucket >= 0); + QVERIFY(digest.busiestBucket < ThreadDigest::kBuckets); + QVERIFY(digest.firstTimestamp <= digest.lastTimestamp); +} + +void TestNotmuchWorker::aDigestCapsItsUnreadListButNotItsCount() +{ + NotmuchFixture fixture; + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("c0@example.org"), + QStringLiteral("Cap root"), + QStringLiteral("alice@example.org"), + QStringLiteral("Mon, 24 Aug 2026 10:00:00 +0200"), + QStringLiteral("Root."), false)); + for (int i = 1; i <= 8; ++i) { + QVERIFY(fixture.addMessage( + QStringLiteral("inbox"), + QStringLiteral("c%1@example.org").arg(i), + QStringLiteral("Re: Cap root"), + QStringLiteral("bob@example.org"), + QStringLiteral("Tue, 25 Aug 2026 %1:00:00 +0200") + .arg(i, 2, 10, QLatin1Char('0')), + QStringLiteral("Reply."), true, QStringLiteral("c0@example.org"))); + } + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy spy(&worker, &NotmuchWorker::threadDigestLoaded); + worker.loadThreadDigest( + worker.threadIdForTesting(QStringLiteral("id:c0@example.org")), 1); + + QCOMPARE(spy.count(), 1); + const ThreadDigest digest = spy.at(0).at(0).value<ThreadDigest>(); + QCOMPARE(digest.unreadTotal, 8); + QCOMPARE(digest.unread.size(), ThreadDigest::kUnreadShown); + // Newest first: c8 is the latest. + QCOMPARE(digest.unread.at(0).messageId, QStringLiteral("c8@example.org")); +} + +void TestNotmuchWorker::aOneMessageThreadGivesASaneSpan() +{ + // The degenerate case the dashboard would otherwise divide by: one message + // is a zero-width span, and a bucket width of zero either divides by zero + // or throws every message into the last bucket. + NotmuchFixture fixture; + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("s0@example.org"), + QStringLiteral("Alone"), + QStringLiteral("alice@example.org"), + QStringLiteral("Mon, 24 Aug 2026 10:00:00 +0200"), + QStringLiteral("Only."), false)); + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy spy(&worker, &NotmuchWorker::threadDigestLoaded); + worker.loadThreadDigest( + worker.threadIdForTesting(QStringLiteral("id:s0@example.org")), 1); + + QCOMPARE(spy.count(), 1); + const ThreadDigest digest = spy.at(0).at(0).value<ThreadDigest>(); + QCOMPARE(digest.totalCount, 1); + QCOMPARE(digest.buckets.size(), ThreadDigest::kBuckets); + QCOMPARE(digest.buckets.at(0), 1); + QCOMPARE(digest.busiestBucket, 0); + QCOMPARE(digest.firstTimestamp, digest.lastTimestamp); + + // An unknown thread yields an EMPTY digest rather than nothing at all, so a + // caller that arms state on the request always gets its reply. + QSignalSpy missing(&worker, &NotmuchWorker::threadDigestLoaded); + worker.loadThreadDigest(QStringLiteral("0000000000000000"), 2); + QCOMPARE(missing.count(), 1); + const ThreadDigest empty = missing.at(0).at(0).value<ThreadDigest>(); + QCOMPARE(empty.totalCount, 0); + QCOMPARE(empty.buckets.size(), ThreadDigest::kBuckets); + QCOMPARE(empty.busiestBucket, -1); +} + QTEST_MAIN(TestNotmuchWorker) #include "test_notmuchworker.moc" |
