From ec8c2d7492b479846270f702541a26be5643a376 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 18 Aug 2026 12:12:49 +0200 Subject: feat(worker): resolve whole threads to their messages, ids and paths Delete thread MOVES every message now, and a move needs message ids and file paths that the UI does not hold: a thread the user never expanded has no nodes in the model for its replies, so those live only in the database. resolveThreadMessages() answers with both, in one combined query, for the same reason applyTagsToThreads() resolves threads here rather than in the UI: a query per thread reopens the same Xapian cursor once per selected row. Paths come back RELATIVE to the database root, matching ThreadSummary::firstMessagePath, because the UI knows accounts only by their maildir, itself a database-relative prefix. Without them the caller would know which messages to move and not where any of them belongs. Tags come back too, because Restore reads a message's `deleted-from:` tag to decide where to send it and an unexpanded thread's messages have no node to read tags from. They are joined by a TAB, not a space. A notmuch tag may absolutely contain a space: the folder "Inbox/SlackBuilds users" produces `deleted-from:Inbox/SlackBuilds users`, and splitting that on spaces truncated the folder to "Inbox/SlackBuilds". Restore then moved the messages into a folder of that name, CREATING it, so real messages ended up in a directory mbsync does not sync and read as missing. Under `Create Both` that folder can propagate to the mail server. A tab cannot appear in a tag, since notmuch's own dump format is line-based and whitespace-delimited. Co-Authored-By: Claude Opus 5 --- src/notmuchworker.cpp | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/notmuchworker.h | 27 +++++++++++++++++++++ 2 files changed, 93 insertions(+) (limited to 'src') diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp index 6a0694f..62174c9 100644 --- a/src/notmuchworker.cpp +++ b/src/notmuchworker.cpp @@ -776,6 +776,72 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds, emit messagesMovedFrom(origins, destFolder); } +void NotmuchWorker::resolveThreadMessages(const QStringList &threadIds, + const QString &requestTag) +{ + if (threadIds.isEmpty()) + return; + + if (!openReadOnly()) + return; + + // One combined query, for the reason applyTagsToThreads() gives: a query + // per thread reopens the same Xapian cursor once per selected row. + QStringList terms; + terms.reserve(threadIds.size()); + for (const QString &id : threadIds) + terms.append(QStringLiteral("thread:%1").arg(id)); + + const QString query = terms.join(QStringLiteral(" or ")); + NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData())); + if (!nmQuery) { + emit errorOccurred(QStringLiteral("Cannot resolve selected threads")); + return; + } + + notmuch_messages_t *raw = nullptr; + if (notmuch_query_search_messages(nmQuery.get(), &raw) + != NOTMUCH_STATUS_SUCCESS) { + emit errorOccurred(QStringLiteral("Cannot resolve selected threads")); + return; + } + + // Paths are reported RELATIVE to the database root, matching + // ThreadSummary::firstMessagePath: the UI knows accounts only by their + // maildir, itself a database-relative prefix. + const QString dbRoot = + QDir(QString::fromUtf8(notmuch_database_get_path(m_db))).absolutePath(); + + QStringList messageIds; + QStringList paths; + QStringList tags; + NmMessages messages(raw); + for (; notmuch_messages_valid(messages.get()); + notmuch_messages_move_to_next(messages.get())) { + NmMessage message(notmuch_messages_get(messages.get())); + if (!message) + continue; + const char *rawName = notmuch_message_get_filename(message.get()); + if (!rawName) + continue; + messageIds.append( + QString::fromUtf8(notmuch_message_get_message_id(message.get()))); + paths.append( + QDir(dbRoot).relativeFilePath(QString::fromUtf8(rawName))); + // Joined by a TAB, not a space. A notmuch tag may absolutely contain + // a space: a Maildir folder named "Inbox/SlackBuilds users" produces + // `deleted-from:Inbox/SlackBuilds users`, and splitting that on spaces + // truncated the folder to "Inbox/SlackBuilds". Restore then moved the + // messages into a folder of that name, CREATING it, so four real + // messages ended up in a directory mbsync does not sync and the user + // could not find them. A tab cannot appear in a tag, because notmuch's + // own dump/restore format is whitespace-delimited by line. + tags.append(tagsOf(message.get()).join(QLatin1Char('\t'))); + } + + emit threadMessagesResolved(messageIds, paths, tags, requestTag); +} + void NotmuchWorker::requestAllTags(quint64 generation) { if (!openReadOnly()) diff --git a/src/notmuchworker.h b/src/notmuchworker.h index d1bec59..dfd4303 100644 --- a/src/notmuchworker.h +++ b/src/notmuchworker.h @@ -143,6 +143,21 @@ public slots: const QStringList &remove, const QString &description); + /// Resolves whole threads to the message ids and file paths they contain. + /// + /// Delete thread MOVES every message, and a move needs message ids, which + /// the UI does not hold for a thread it never expanded. The resolution + /// happens here for the same reason applyTagsToThreads() does it here: + /// the database handle lives on this thread, and one combined query beats + /// reopening the cursor per thread. + /// + /// Paths come back beside the ids because the destination is per ACCOUNT + /// and the UI resolves an account from a message's path. Without them the + /// caller would know which messages to move and not where any of them + /// belongs. + void resolveThreadMessages(const QStringList &threadIds, + const QString &requestTag); + /// Every tag in the database, sorted. Feeds query bar completion, which /// cannot offer tag names it has no way to enumerate. Called at startup, /// after a sync, and after a tag mutation introduces an unknown tag. @@ -222,6 +237,18 @@ signals: /// from here can be passed straight back to move a message home. void messagesMovedFrom(const QMap &originByMessageId, const QString &destFolder); + /// The answer to resolveThreadMessages(), as parallel lists: `messageIds` + /// and the database-relative `paths` of the same messages, in the same + /// order. `requestTag` is echoed back so a caller can tell which request + /// this answers. + /// `tags` carries each message's tags joined by a space, in the same + /// order. Needed because Restore reads a message's `deleted-from:` tag to + /// decide where to send it, and an unexpanded thread's messages have no + /// node in the model to read tags from. + void threadMessagesResolved(const QStringList &messageIds, + const QStringList &paths, + const QStringList &tags, + const QString &requestTag); void allTagsReady(const QStringList &tags, quint64 generation); /// One entry per requested query, in the order they were asked for. A query -- cgit v1.2.3