diff options
Diffstat (limited to 'src/notmuchworker.cpp')
| -rw-r--r-- | src/notmuchworker.cpp | 316 |
1 files changed, 316 insertions, 0 deletions
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp new file mode 100644 index 0000000..ae4cafe --- /dev/null +++ b/src/notmuchworker.cpp @@ -0,0 +1,316 @@ +#include "notmuchworker.h" + +#include <notmuch.h> + +#include <QSet> + +#include <cstdlib> + +#include "nmraii.h" + +namespace { + +QStringList tagsOf(notmuch_message_t *message) +{ + QStringList result; + NmTags tags(notmuch_message_get_tags(message)); + for (; notmuch_tags_valid(tags.get()); notmuch_tags_move_to_next(tags.get())) + result.append(QString::fromUtf8(notmuch_tags_get(tags.get()))); + return result; +} + +QStringList tagsOf(notmuch_thread_t *thread) +{ + QStringList result; + NmTags tags(notmuch_thread_get_tags(thread)); + for (; notmuch_tags_valid(tags.get()); notmuch_tags_move_to_next(tags.get())) + result.append(QString::fromUtf8(notmuch_tags_get(tags.get()))); + return result; +} + +/// Collects the message ids a query matches. Returns false if the query could +/// not be run at all, which is different from a query that matched nothing. +bool collectMessageIds(notmuch_database_t *db, const QString &query, + QStringList *ids) +{ + NmQuery nmQuery(notmuch_query_create(db, query.toUtf8().constData())); + if (!nmQuery) + return false; + + notmuch_messages_t *raw = nullptr; + if (notmuch_query_search_messages(nmQuery.get(), &raw) != NOTMUCH_STATUS_SUCCESS) + return false; + + 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) + ids->append(QString::fromUtf8(notmuch_message_get_message_id(message.get()))); + } + return true; +} + +} // namespace + +NotmuchWorker::NotmuchWorker(const QString ¬muchConfigPath, QObject *parent) + : QObject(parent), m_configPath(notmuchConfigPath) +{ +} + +NotmuchWorker::~NotmuchWorker() +{ + close(); +} + +/// An empty config path means "let notmuch resolve its own config", which +/// libnotmuch spells as NULL. The QByteArray is returned by value so callers +/// keep it alive for as long as they use constData(). +QByteArray NotmuchWorker::configPathArg() const +{ + return m_configPath.isEmpty() ? QByteArray() : m_configPath.toLocal8Bit(); +} + +bool NotmuchWorker::openReadOnly() +{ + if (m_db) + return true; + + const QByteArray configPath = configPathArg(); + char *error = nullptr; + const notmuch_status_t status = notmuch_database_open_with_config( + nullptr, // let config decide path + NOTMUCH_DATABASE_MODE_READ_ONLY, + configPath.isEmpty() ? nullptr : configPath.constData(), + nullptr, + &m_db, + &error); + + if (status != NOTMUCH_STATUS_SUCCESS) { + emit errorOccurred(QStringLiteral("Cannot open notmuch database: %1") + .arg(QString::fromUtf8(error ? error : notmuch_status_to_string(status)))); + free(error); + m_db = nullptr; + return false; + } + return true; +} + +void NotmuchWorker::close() +{ + if (m_db) { + notmuch_database_destroy(m_db); + m_db = nullptr; + } +} + +void NotmuchWorker::runQuery(const QString &query, quint64 generation) +{ + if (!openReadOnly()) + return; + + NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData())); + if (!nmQuery) { + emit errorOccurred(QStringLiteral("Invalid query: %1").arg(query)); + return; + } + notmuch_query_set_sort(nmQuery.get(), NOTMUCH_SORT_NEWEST_FIRST); + + notmuch_threads_t *rawThreads = nullptr; + const notmuch_status_t status = + notmuch_query_search_threads(nmQuery.get(), &rawThreads); + if (status != NOTMUCH_STATUS_SUCCESS) { + emit errorOccurred(QStringLiteral("Query failed: %1") + .arg(QString::fromUtf8(notmuch_status_to_string(status)))); + return; + } + NmThreads threads(rawThreads); + + QVector<ThreadSummary> batch; + batch.reserve(kBatchSize); + int total = 0; + + for (; notmuch_threads_valid(threads.get()); + notmuch_threads_move_to_next(threads.get())) { + + NmThread thread(notmuch_threads_get(threads.get())); + if (!thread) + continue; + + ThreadSummary summary; + summary.threadId = QString::fromUtf8(notmuch_thread_get_thread_id(thread.get())); + summary.subject = QString::fromUtf8(notmuch_thread_get_subject(thread.get())); + summary.authors = QString::fromUtf8(notmuch_thread_get_authors(thread.get())); + summary.date = QDateTime::fromSecsSinceEpoch( + notmuch_thread_get_newest_date(thread.get())); + summary.totalCount = notmuch_thread_get_total_messages(thread.get()); + summary.matchedCount = notmuch_thread_get_matched_messages(thread.get()); + summary.tags = tagsOf(thread.get()); + + batch.append(summary); + ++total; + + if (batch.size() >= kBatchSize) { + emit threadsReady(batch, generation); + batch.clear(); + batch.reserve(kBatchSize); + } + } + + if (!batch.isEmpty()) + emit threadsReady(batch, generation); + + emit queryFinished(total, generation); +} + +void NotmuchWorker::loadThread(const QString &threadId, + const QString &matchQuery, + quint64 generation) +{ + if (!openReadOnly()) + return; + + // Which messages of the thread matched the user's query. Running the query + // intersected with the thread is cheaper than testing each message. + // + // haveMatchSet distinguishes "no query was given, so everything counts as + // matched" from "a query was given and matched nothing in this thread". + // Collapsing those would render a whole thread expanded precisely when the + // user filtered it down to nothing. + QSet<QString> matchedIds; + bool haveMatchSet = false; + if (!matchQuery.trimmed().isEmpty()) { + const QString intersect = + QStringLiteral("thread:%1 and (%2)").arg(threadId, matchQuery); + QStringList ids; + if (collectMessageIds(m_db, intersect, &ids)) { + matchedIds = QSet<QString>(ids.begin(), ids.end()); + haveMatchSet = true; + } + } + + const QString query = QStringLiteral("thread:%1").arg(threadId); + NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData())); + if (!nmQuery) { + emit errorOccurred(QStringLiteral("Cannot load thread %1").arg(threadId)); + return; + } + notmuch_query_set_sort(nmQuery.get(), NOTMUCH_SORT_OLDEST_FIRST); + + notmuch_messages_t *rawMessages = nullptr; + if (notmuch_query_search_messages(nmQuery.get(), &rawMessages) + != NOTMUCH_STATUS_SUCCESS) { + emit errorOccurred(QStringLiteral("Cannot search thread %1").arg(threadId)); + return; + } + NmMessages messages(rawMessages); + + QVector<MessageRef> result; + for (; notmuch_messages_valid(messages.get()); + notmuch_messages_move_to_next(messages.get())) { + + NmMessage message(notmuch_messages_get(messages.get())); + if (!message) + continue; + + MessageRef ref; + ref.messageId = QString::fromUtf8(notmuch_message_get_message_id(message.get())); + ref.filePath = QString::fromUtf8(notmuch_message_get_filename(message.get())); + ref.tags = tagsOf(message.get()); + ref.matched = !haveMatchSet || matchedIds.contains(ref.messageId); + result.append(ref); + } + + emit threadLoaded(result, generation); +} + +void NotmuchWorker::applyTagsToThreads(const QStringList &threadIds, + const QStringList &add, + const QStringList &remove, + const QString &description) +{ + if (threadIds.isEmpty()) + return; + + if (!openReadOnly()) + return; + + // Resolve every thread to its message ids in ONE query. Issuing a query per + // thread would reopen the same Xapian cursor hundreds of times on a large + // selection. + QStringList terms; + terms.reserve(threadIds.size()); + for (const QString &id : threadIds) + terms.append(QStringLiteral("thread:%1").arg(id)); + + QStringList messageIds; + if (!collectMessageIds(m_db, terms.join(QStringLiteral(" or ")), &messageIds)) { + emit errorOccurred(QStringLiteral("Cannot resolve selected threads")); + return; + } + + if (messageIds.isEmpty()) { + emit errorOccurred(QStringLiteral("Selected threads contain no messages")); + return; + } + + applyTags(TagChange{ messageIds, add, remove, description }); +} + +void NotmuchWorker::applyTags(const TagChange &change) +{ + if (change.messageIds.isEmpty()) + return; + + // The read-only handle must be closed first: notmuch allows only one open + // handle per process. + close(); + + const QByteArray configPath = configPathArg(); + notmuch_database_t *db = nullptr; + char *error = nullptr; + const notmuch_status_t status = notmuch_database_open_with_config( + nullptr, + NOTMUCH_DATABASE_MODE_READ_WRITE, + configPath.isEmpty() ? nullptr : configPath.constData(), + nullptr, + &db, + &error); + + if (status != NOTMUCH_STATUS_SUCCESS) { + emit errorOccurred( + QStringLiteral("Cannot open database for writing (is a sync running?): %1") + .arg(QString::fromUtf8(error ? error + : notmuch_status_to_string(status)))); + free(error); + return; + } + + for (const QString &id : change.messageIds) { + notmuch_message_t *raw = nullptr; + // find_message reports SUCCESS with a null message when the id is not + // in the database, so both have to be checked. A stale id must not + // abort the batch: the live ids alongside it still need tagging. + if (notmuch_database_find_message(db, id.toUtf8().constData(), &raw) + != NOTMUCH_STATUS_SUCCESS || !raw) { + continue; + } + NmMessage message(raw); + + notmuch_message_freeze(message.get()); + for (const QString &tag : change.removed) + notmuch_message_remove_tag(message.get(), tag.toUtf8().constData()); + for (const QString &tag : change.added) + notmuch_message_add_tag(message.get(), tag.toUtf8().constData()); + notmuch_message_thaw(message.get()); + + // Renames the file on disk when the seen/flagged tags changed, keeping + // the Maildir and the index in agreement for the next `notmuch new`. + notmuch_message_tags_to_maildir_flags(message.get()); + } + + notmuch_database_close(db); + notmuch_database_destroy(db); + + emit tagsApplied(change); +} |
