diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.cpp | 37 | ||||
| -rw-r--r-- | src/config.h | 31 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 50 | ||||
| -rw-r--r-- | src/mainwindow.h | 28 | ||||
| -rw-r--r-- | src/mimeparser.cpp | 59 | ||||
| -rw-r--r-- | src/mimeparser.h | 17 | ||||
| -rw-r--r-- | src/notmuchworker.cpp | 57 | ||||
| -rw-r--r-- | src/notmuchworker.h | 15 | ||||
| -rw-r--r-- | src/threadlistmodel.cpp | 39 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 19 | ||||
| -rw-r--r-- | src/types.h | 12 |
11 files changed, 355 insertions, 9 deletions
diff --git a/src/config.cpp b/src/config.cpp index 9e223f8..47fadec 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -47,6 +47,37 @@ QString Account::scopedQuery(const QString &query) const return QStringLiteral("%1 and (%2)").arg(prefix, query); } +QString Account::sentQuery() const +{ + if (sent.isEmpty()) + return QString(); + + // The QUOTES are load-bearing, not decoration. A real provider nests its + // sent folder under a bracketed parent, "[Provider]/Posta inviata", and + // "[" and "]" are Xapian syntax: unquoted, the term is parsed rather than + // matched and the query silently returns nothing while looking correct. + // + // The path is user config and is interpolated into a query, so this is the + // only place that composition happens; a caller building it by hand would + // be a second chance to forget the quotes. + return QStringLiteral("path:\"%1/%2/**\"").arg(maildir, sent); +} + +QString Config::allSentQuery() const +{ + // Collect first, join after. Appending "or" per account and trimming the + // result is the version that produced the defect this guards: an account + // with no sent key contributes an empty term, notmuch accepts the bare + // "or" without complaint, and the query quietly means something else. + QStringList parts; + for (const Account &account : m_accounts) { + const QString query = account.sentQuery(); + if (!query.isEmpty()) + parts.append(query); + } + return parts.join(QStringLiteral(" or ")); +} + QString Config::defaultPath() { const QString base = @@ -281,6 +312,12 @@ void Config::load(const QString &path) account.maildir = settings.value(QStringLiteral("maildir")).toString(); account.drafts = settings.value(QStringLiteral("drafts")).toString(); + // Optional, and absent for an account that keeps no sent mail locally. + // Trimmed because a trailing space would land inside the quoted path + // and match nothing, which is invisible in a config file. + account.sent = + settings.value(QStringLiteral("sent")).toString().trimmed(); + // Both optional, and both describe this account's chip in the thread // list. An account tag is a different taxonomy from a functional one, // saying which mailbox a thread arrived in rather than what state it diff --git a/src/config.h b/src/config.h index bcbfb34..e3c5b6e 100644 --- a/src/config.h +++ b/src/config.h @@ -36,6 +36,18 @@ struct Account QString maildir; ///< Relative to notmuch's database.path. QString drafts; ///< Unused in v1; send is v2. + /// The account's sent folder, relative to maildir. Optional and empty for + /// an account that has none, which is a real case rather than a + /// misconfiguration: an account may keep no sent mail locally at all. + /// + /// A key rather than a <maildir>/Sent convention because the folder is not + /// uniform across providers. Measured across one real setup: two accounts + /// use `Sent`, two nest a localised name under a bracketed parent + /// (`[Provider]/Posta inviata`), and one has no sent folder whatsoever. A + /// convention would produce an empty view for the nested ones and a wrong + /// one for the account that has none. + QString sent; + /// Chip colour in the thread list. Invalid when unset, in which case one /// is generated from the account tag's name. QColor color; @@ -62,6 +74,13 @@ struct Account /// Restricts a notmuch query to this account's subtree. QString scopedQuery(const QString &query) const; + + /// Matches this account's sent mail, or empty when `sent` is unset. + /// + /// Composes with scopedQuery() rather than replacing it: the account + /// selector wraps whatever query runs, so a Sent view under one account + /// intersects to that account's sent mail and cannot leak another's. + QString sentQuery() const; }; struct SavedQuery @@ -114,6 +133,18 @@ public: /// Optional alternate notmuch config file. Empty means "let notmuch decide". QString notmuchConfig() const { return m_notmuchConfig; } + /// Matches every configured account's sent mail, or empty when no account + /// configures one. + /// + /// Joins only the NON-EMPTY sentQuery() results. An account without a + /// `sent` key contributes nothing, and joining it anyway would leave a bare + /// `or` in the query. notmuch does not reject that: `A or or B` returns + /// 190 messages where the correct pair returns 211, measured directly. A + /// malformed query that still returns plausible mail is the failure that + /// ships, which is why the join lives here and is tested rather than being + /// open-coded at the call site. + QString allSentQuery() const; + /// A QDateTime::toString() pattern for the date on a card, or empty for the /// system locale's short format. /// diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 374f228..be6cfdb 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -457,6 +457,7 @@ void MainWindow::buildUi() this, &MainWindow::runCurrentQuery); m_queryEdit = new QLineEdit(central); + m_queryEdit->setObjectName(QStringLiteral("queryEdit")); m_queryEdit->setPlaceholderText(tr("notmuch query, e.g. tag:inbox")); // Qt draws the clear button inside the field and shows it only when there // is text, themed by the desktop. A hand-rolled button beside the bar would @@ -553,6 +554,28 @@ void MainWindow::buildUi() }); queryRow->addWidget(button); } + + // Sent sits with the saved queries and is not one: its query is COMPOSED + // from the accounts' `sent` keys at click time, so adding an account or + // correcting a folder name is a config edit and nothing else. A [queries] + // entry holding the same string would go stale silently, and could not + // narrow to the selected account the way this does through + // runCurrentQuery()'s existing scope wrap. + // + // Hidden entirely when no account configures a sent folder, rather than + // offering a button that always finds nothing. + if (!m_config.allSentQuery().isEmpty()) { + auto *sentButton = new QPushButton(tr("Sent"), central); + sentButton->setObjectName(QStringLiteral("sentButton")); + connect(sentButton, &QPushButton::clicked, this, [this]() { + m_queryEdit->setText(m_config.allSentQuery()); + // Flat for this query only. runCurrentQuery() clears it again for + // anything else, including the same query typed by hand, so the + // flag cannot outlive the button that set it. + runQuery(FlatResult::Yes); + }); + queryRow->addWidget(sentButton); + } layout->addLayout(queryRow); // Thread list and message pane. @@ -1471,8 +1494,14 @@ void MainWindow::showWarnings() problems.join(QLatin1Char('\n'))); } -void MainWindow::runCurrentQuery() +void MainWindow::runQuery(FlatResult flat) { + // Set on EVERY run, not only when Yes. This is the line that stops flat + // mode leaking: any query that is not the Sent button restores the tree, + // so the flag cannot survive into the next view. + m_sentView = flat == FlatResult::Yes; + m_model->setFlatMode(m_sentView); + QString query = m_queryEdit->text().trimmed(); const QString accountKey = m_accountBox->currentData().toString(); @@ -1516,10 +1545,13 @@ void MainWindow::runCurrentQuery() const auto sort = m_sortOrder->currentIndex() == 1 ? NotmuchWorker::OldestFirst : NotmuchWorker::NewestFirst; + // Recipients only for the Sent view: the fold reads message FILES, which + // is tens of seconds over an inbox. See ThreadSummary::recipients. QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection, Q_ARG(QString, query), Q_ARG(quint64, m_generation), - Q_ARG(NotmuchWorker::SortOrder, sort)); + Q_ARG(NotmuchWorker::SortOrder, sort), + Q_ARG(bool, m_sentView)); } void MainWindow::onThreadsReady(const QVector<ThreadSummary> &threads, @@ -1863,10 +1895,16 @@ void MainWindow::onThreadSelected(const QModelIndex ¤t, m_currentMessageId.clear(); m_currentMessageThreadId.clear(); + // In the Sent view the pane shows only what matched, which is what the + // user sent. Without this the flat list is right and the pane still opens + // the whole conversation, replies included, under a heading that says + // Sent: the row was never expanded, so the model never learned the + // thread's first message and this is the only path a Sent row can take. QMetaObject::invokeMethod(m_worker, "loadThread", Qt::QueuedConnection, Q_ARG(QString, m_currentThreadId), Q_ARG(QString, m_lastQuery), - Q_ARG(quint64, m_generation)); + Q_ARG(quint64, m_generation), + Q_ARG(bool, m_sentView)); } void MainWindow::onMessageLoaded(const QVector<MessageRef> &messages, @@ -2250,10 +2288,14 @@ void MainWindow::refreshCurrentQuery() const auto sort = m_sortOrder->currentIndex() == 1 ? NotmuchWorker::OldestFirst : NotmuchWorker::NewestFirst; + // The SAME recipients flag the visible view was built with. A refresh that + // dropped it would quietly replace a Sent view's recipients with empty + // strings on the first background sync, while the user was reading it. QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection, Q_ARG(QString, m_lastQuery), Q_ARG(quint64, m_refreshGeneration), - Q_ARG(NotmuchWorker::SortOrder, sort)); + Q_ARG(NotmuchWorker::SortOrder, sort), + Q_ARG(bool, m_sentView)); } void MainWindow::updateStaleThreadNotice() diff --git a/src/mainwindow.h b/src/mainwindow.h index a7ea5c3..6e90ba1 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -173,8 +173,25 @@ protected: /// this the action fires from inside the bar and the query never runs. bool eventFilter(QObject *watched, QEvent *event) override; +public: + /// Whether the result of a query is shown as a flat list of threads rather + /// than as an expandable tree. + /// + /// Only the Sent button asks for Yes. Every other route runs the no-arg + /// slot, which passes No, so flat mode cannot outlive the view that asked + /// for it: the same query typed by hand comes back as a tree. + enum class FlatResult { No, Yes }; + Q_ENUM(FlatResult) + +private: + /// The real query runner. Kept off the slot list deliberately: a slot with + /// a defaulted argument does not satisfy QObject::connect, which matches + /// signal and slot arity at compile time, so the zero-argument slot below + /// is what widgets connect to. + void runQuery(FlatResult flat); + private slots: - void runCurrentQuery(); + void runCurrentQuery() { runQuery(FlatResult::No); } /// Brings back a thread that stopped matching, and restores the reader's /// place inside it. @@ -642,6 +659,15 @@ private: /// the whole result set rather than the batches that have arrived so far. /// Gates mark_all_read, which cannot honestly say "all" before then. bool m_queryComplete = false; + + /// Whether the CURRENT view is the Sent one, and therefore whether it is + /// flat and carries recipients. + /// + /// Held rather than recomputed because a background refresh re-runs the + /// same query without going through the Sent button: without this, the + /// first cron sync would silently turn a Sent view back into a tree of + /// senders while the user was reading it. + bool m_sentView = false; QString m_lastQuery; QString m_currentThreadId; diff --git a/src/mimeparser.cpp b/src/mimeparser.cpp index 4ad617e..ecf56f1 100644 --- a/src/mimeparser.cpp +++ b/src/mimeparser.cpp @@ -213,6 +213,65 @@ QString Attachment::saveWithoutOverwriting(const QString &directory, return target; } +QString recipientSummary(const QString &rawTo, int maxNames) +{ + const QByteArray utf8 = rawTo.trimmed().toUtf8(); + if (utf8.isEmpty()) + return {}; + + // Returns NULL rather than an empty list for input it cannot make anything + // of, including the empty string. Guarded above and again here: the header + // is untrusted and this is the crash if it is not. + InternetAddressList *list = internet_address_list_parse(nullptr, + utf8.constData()); + if (!list) + return {}; + + QStringList names; + int total = 0; + const int count = internet_address_list_length(list); + for (int i = 0; i < count; ++i) { + InternetAddress *address = internet_address_list_get_address(list, i); + if (!address) + continue; + + // A group (`undisclosed-recipients:;`) has a name but no address, and + // its members, if any, are a list of their own. Only mailboxes are + // counted: naming the group would print "undisclosed-recipients" as + // though it were a person. + if (!INTERNET_ADDRESS_IS_MAILBOX(address)) + continue; + + ++total; + if (names.size() >= maxNames) + continue; + + const char *name = internet_address_get_name(address); + const QString display = name ? QString::fromUtf8(name).trimmed() + : QString(); + if (!display.isEmpty()) { + names.append(display); + continue; + } + const char *addr = internet_address_mailbox_get_addr( + INTERNET_ADDRESS_MAILBOX(address)); + if (addr) + names.append(QString::fromUtf8(addr)); + else + --total; // Neither a name nor an address: nothing to show. + } + g_object_unref(list); + + if (names.isEmpty()) + return {}; + + QString summary = names.join(QStringLiteral(", ")); + const int hidden = total - names.size(); + if (hidden > 0) + summary += QStringLiteral(" +%1").arg(hidden); + return summary; +} + QString attachmentFolderName(const QString &rfc822Date, const QString &subject) { // The date prefix sorts chronologically in a file manager. A Date: header diff --git a/src/mimeparser.h b/src/mimeparser.h index af7619f..9fceb2f 100644 --- a/src/mimeparser.h +++ b/src/mimeparser.h @@ -94,6 +94,23 @@ struct Attachment /// subject can be far longer than that. QString attachmentFolderName(const QString &rfc822Date, const QString &subject); +/// A one-line summary of a raw To: header, for the sender's place on a card in +/// a Sent view. +/// +/// Parsed with GMime rather than split on commas: a display name may CONTAIN a +/// comma, so `"Rossi, Mario" <m@example.org>, info@example.net` is two +/// addresses and naive splitting reports three. +/// +/// Each address renders as its display name, falling back to the address when +/// it has none, so a list does not mix "Mario Rossi" with a bare address. With +/// more than `maxNames` recipients the rest collapse into "+N", mirroring the +/// tag strip's overflow chip rather than being elided mid-name. +/// +/// The header is untrusted and may be empty, malformed, or a group such as +/// `undisclosed-recipients:;`. Returns an empty string when nothing usable can +/// be read, never a partial parse. +QString recipientSummary(const QString &rawTo, int maxNames = 2); + struct ParsedMessage { bool ok = false; diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp index d34c032..24f3fd9 100644 --- a/src/notmuchworker.cpp +++ b/src/notmuchworker.cpp @@ -24,6 +24,7 @@ #include <cstdlib> +#include "mimeparser.h" #include "nmraii.h" namespace { @@ -46,6 +47,45 @@ QStringList tagsOf(notmuch_thread_t *thread) return result; } +/// Who a thread's messages were addressed to, summarised for one line. +/// +/// EXPENSIVE, and only called when a query asks. "To" is not served from +/// notmuch's index, so every call here reads message FILES: 8.7 ms per thread +/// measured against a real database, which is 38 seconds over a 4411-thread +/// inbox. See ThreadSummary::recipients. +/// +/// The messages come from the THREAD and are owned by it, freed when it is +/// freed (notmuch.h:1637). They are therefore held raw and never wrapped in +/// NmMessage, which would call notmuch_message_destroy on memory the thread +/// frees again, and the walk finishes before the caller drops the thread. This +/// is the same rule walkReplies follows, and getting it wrong is a double-free +/// rather than a leak. +QString recipientsOf(notmuch_thread_t *thread) +{ + // The first message with a usable To wins. A thread is one conversation, + // and the alternative, folding every message's recipients together, is the + // participants-list problem item 2 rejected: it produces a union that + // misdescribes itself the moment a thread has replies going both ways. + notmuch_messages_t *messages = notmuch_thread_get_messages(thread); + for (; notmuch_messages_valid(messages); + notmuch_messages_move_to_next(messages)) { + notmuch_message_t *message = notmuch_messages_get(messages); + if (!message) + continue; + + // Returns "" for a missing header and NULL on error, and the two mean + // different things only to notmuch: both are "nothing to show" here. + const char *to = notmuch_message_get_header(message, "To"); + if (!to || !*to) + continue; + + const QString summary = recipientSummary(QString::fromUtf8(to)); + if (!summary.isEmpty()) + return summary; + } + return QString(); +} + /// 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, @@ -187,7 +227,7 @@ void NotmuchWorker::close() } void NotmuchWorker::runQuery(const QString &query, quint64 generation, - SortOrder sort) + SortOrder sort, bool withRecipients) { if (!openReadOnly()) return; @@ -231,6 +271,8 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation, summary.totalCount = notmuch_thread_get_total_messages(thread.get()); summary.matchedCount = notmuch_thread_get_matched_messages(thread.get()); summary.tags = tagsOf(thread.get()); + if (withRecipients) + summary.recipients = recipientsOf(thread.get()); batch.append(summary); ++total; @@ -250,7 +292,7 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation, void NotmuchWorker::loadThread(const QString &threadId, const QString &matchQuery, - quint64 generation) + quint64 generation, bool matchedOnly) { if (!openReadOnly()) return; @@ -303,6 +345,17 @@ void NotmuchWorker::loadThread(const QString &threadId, ref.filePath = QString::fromUtf8(notmuch_message_get_filename(message.get())); ref.tags = tagsOf(message.get()); ref.matched = !haveMatchSet || matchedIds.contains(ref.messageId); + + // Dropped rather than rendered as a stub. + // + // haveMatchSet is redundant here and kept deliberately: ref.matched is + // already true for every message when no query was given, so the two + // conditions cannot disagree today. It states the invariant this + // depends on at the point that depends on it, so a later change to how + // ref.matched is computed cannot silently empty the pane. + if (matchedOnly && haveMatchSet && !ref.matched) + continue; + result.append(ref); } diff --git a/src/notmuchworker.h b/src/notmuchworker.h index 1d8c8c0..2dd766f 100644 --- a/src/notmuchworker.h +++ b/src/notmuchworker.h @@ -60,14 +60,25 @@ public: public slots: /// Runs a query. generation lets the UI discard results from a superseded /// query without the worker needing to know about cancellation. + /// `withRecipients` fills ThreadSummary::recipients by reading each + /// thread's To headers. OFF by default and deliberately opt-in: To is not + /// in notmuch's index, so this reads message FILES, at roughly 8.7 ms per + /// thread. Only a Sent query asks for it; turning it on for an inbox query + /// costs tens of seconds and changes nothing a user can see. void runQuery(const QString &query, quint64 generation, - SortOrder sort = NewestFirst); + SortOrder sort = NewestFirst, bool withRecipients = false); /// Loads the messages of one thread, oldest first. matchQuery is the /// user's current query; messages matching it render expanded, the rest /// as stubs. + /// `matchedOnly` drops the messages that did not match `matchQuery` rather + /// than rendering them as stubs. For the Sent view, where the thread is not + /// the unit the user is reading: a sent message pulls in the replies it + /// received, and a pane claiming to show what they sent then shows a + /// conversation. Ignored when `matchQuery` is empty, since nothing was + /// filtered and every message counts as matched. void loadThread(const QString &threadId, const QString &matchQuery, - quint64 generation); + quint64 generation, bool matchedOnly = false); /// Loads a thread as a reply TREE, for the message rows in the list. /// diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp index 33356f0..5874648 100644 --- a/src/threadlistmodel.cpp +++ b/src/threadlistmodel.cpp @@ -176,6 +176,21 @@ QModelIndex ThreadListModel::parent(const QModelIndex &child) const return createIndex(static_cast<int>(id), 0, static_cast<quintptr>(-1)); } +void ThreadListModel::setFlatMode(bool flat) +{ + if (m_flatMode == flat) + return; + + // A full reset, not dataChanged. Flat mode changes what hasChildren() and + // rowCount() answer for every thread row, and a view that has already + // expanded one is holding indexes below it: dataChanged says "these rows + // are different", not "the shape under them is gone", and leaves the view + // drawing children the model no longer offers. + beginResetModel(); + m_flatMode = flat; + endResetModel(); +} + int ThreadListModel::rowCount(const QModelIndex &parent) const { if (!parent.isValid()) @@ -190,6 +205,11 @@ int ThreadListModel::rowCount(const QModelIndex &parent) const if (parent.row() < 0 || parent.row() >= m_threads.size()) return 0; + // Hidden, not discarded: leaving flat mode restores the tree with no + // reload, and an expansion loaded before the switch is still there. + if (m_flatMode) + return 0; + return m_threads.at(parent.row()).children.size(); } @@ -209,6 +229,10 @@ bool ThreadListModel::hasChildren(const QModelIndex &parent) const if (parent.row() < 0 || parent.row() >= m_threads.size()) return false; + // No expander in a flat list, whatever the thread turns out to contain. + if (m_flatMode) + return false; + const ThreadNode &node = m_threads.at(parent.row()); // Once loaded the children are the truth, including "there are none", which @@ -469,6 +493,15 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const // twice on the same card. return thread.subject; case SendersRole: + // Recipients in their place when the query supplied them, which only a + // Sent query does. The sender there is the user on every row and says + // nothing; who it went TO is the question the view exists to answer. + // + // Falls back to authors when the fold found no usable To, so a message + // with a malformed or absent recipient header shows the sender rather + // than a blank line where a name belongs. + if (!thread.recipients.isEmpty()) + return thread.recipients; return thread.authors; case DateRole: // The QDateTime itself. Formatting belongs to the delegate now: the @@ -480,6 +513,12 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const case IsFlaggedRole: return thread.isFlagged(); case ReplyCountRole: + // Zero in a flat list, so the card draws no expander pill. The count + // and hasChildren() must agree: a card advertising "3 replies" that + // cannot be opened is the inert-glyph defect this project has already + // shipped once. + if (m_flatMode) + return 0; // totalCount includes the root message, which is the card itself. return qMax(0, thread.totalCount - 1); case DateFormatRole: diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h index 777841e..8b8f414 100644 --- a/src/threadlistmodel.h +++ b/src/threadlistmodel.h @@ -170,6 +170,24 @@ public: /// The pattern DateFormatRole answers with. Empty means the system format. void setDateFormat(const QString &format) { m_dateFormat = format; } + /// One row per thread, with no expander and no reply count. + /// + /// For the Sent view, where a thread is the wrong unit: the user's model of + /// "what I sent" is a list, and a matching sent message otherwise drags in + /// the replies they RECEIVED, under a view that claims to be their outbox. + /// + /// A flag on this model rather than a second model or a filtered query, and + /// that is what keeps it from leaking: it is off by default, only the Sent + /// button turns it on, and every other query turns it off again. The + /// expander already comes from hasChildren() and the card's count from + /// ReplyCountRole, so flat mode is those two answering differently and + /// nothing else changes. + /// + /// The children are not discarded, only hidden. Leaving flat mode restores + /// the tree without reloading anything. + void setFlatMode(bool flat); + bool flatMode() const { return m_flatMode; } + QModelIndex index(int row, int column, const QModelIndex &parent = {}) const override; QModelIndex parent(const QModelIndex &child) const override; @@ -284,4 +302,5 @@ private: QVector<ThreadNode> m_threads; const TagColors *m_tagColors = nullptr; QString m_dateFormat; + bool m_flatMode = false; }; diff --git a/src/types.h b/src/types.h index d04670e..2211619 100644 --- a/src/types.h +++ b/src/types.h @@ -33,6 +33,18 @@ struct ThreadSummary int matchedCount = 0; QStringList tags; + /// Who the thread's messages were sent TO, summarised for one line. + /// + /// Empty unless the query asked for it, and that is a performance + /// contract rather than a default: To is NOT served from notmuch's index, + /// so filling this reads every message file. Measured at 8.7 ms per thread, + /// which is 38 seconds over a 4411-thread inbox and 663 ms over a + /// 601-thread sent view. Only a Sent query asks. + /// + /// Shown in the sender's place there, where `authors` is the user on every + /// row and carries nothing. + QString recipients; + bool isUnread() const { return tags.contains(QStringLiteral("unread")); } bool isFlagged() const { return tags.contains(QStringLiteral("flagged")); } bool isDeleted() const { return tags.contains(QStringLiteral("deleted")); } |
