diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.h | 2 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 32 | ||||
| -rw-r--r-- | src/mainwindow.h | 12 | ||||
| -rw-r--r-- | src/notmuchworker.cpp | 26 | ||||
| -rw-r--r-- | src/notmuchworker.h | 2 | ||||
| -rw-r--r-- | src/tagdialog.cpp | 2 | ||||
| -rw-r--r-- | src/types.h | 5 |
7 files changed, 55 insertions, 26 deletions
diff --git a/src/config.h b/src/config.h index aa3c332..5a43d8c 100644 --- a/src/config.h +++ b/src/config.h @@ -109,7 +109,7 @@ struct Account /// The account's inbox folder, relative to maildir. Optional. /// /// Only Restore reads it, as the destination for a message that carries no - /// `deleted-from:` origin, which is what mail trashed by another client + /// `moved-from:` origin, which is what mail trashed by another client /// looks like. Defaults to "Inbox", the Maildir convention and mbsync's /// own default. /// diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 2cba6b1..6c66953 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -5969,7 +5969,7 @@ MainWindow::TagPresence MainWindow::selectionTagPresence( // three-message thread left the replies undeleted, so the union // carried no `deleted`, so a second press read the row as // not-deleted and deleted it AGAIN, trash-to-trash, ending with - // `deleted-from:inbox` and `deleted-from:Trash` at once and no way + // `moved-from:inbox` and `moved-from:Trash` at once and no way // back. Item 177 removes the case rather than the symptom: a // three-message row is a conversation now and is asked about its // conversation, above. @@ -5979,7 +5979,7 @@ MainWindow::TagPresence MainWindow::selectionTagPresence( // messageById() and NOT summary.firstMessageTags, which is the // value the QUERY delivered and is not refreshed by an optimistic // update: applyMessageTagChange() writes the row's node, so after - // a delete the node reads `deleted, deleted-from:inbox` while the + // a delete the node reads `deleted, moved-from:inbox` while the // summary still reads `inbox, unread`. Measured, and preferring // the summary left this defect exactly as it was. tags = own.messageId.isEmpty() ? summary.firstMessageTags @@ -6349,7 +6349,7 @@ void MainWindow::trashMessages(const QStringList &messageIds, // view after being thrown away: measured 2026-08-26 on the user's own // mail, where it was the only message ever deleted from an inbox and // therefore the only one that could show it. Restore does not depend - // on it surviving, since `deleted-from:` carries the origin. + // on it surviving, since `moved-from:` carries the origin. sendMove(it.value(), it.key(), { QStringLiteral("deleted"), kOriginTagPlaceholder() }, { QStringLiteral("unread"), QStringLiteral("inbox") }, @@ -6362,7 +6362,7 @@ void MainWindow::trashMessages(const QStringList &messageIds, QString MainWindow::originTagFor(const QString &dbRelativeFolder) const { - // `acct/inbox` becomes `deleted-from:inbox`. The tag stores the folder + // `acct/inbox` becomes `moved-from:inbox`. The tag stores the folder // relative to the ACCOUNT, never to the database: the account prefix is // recomposed from the message's own path when it is read back, so storing // it would duplicate it and would go stale the day the user renames a @@ -6371,7 +6371,7 @@ QString MainWindow::originTagFor(const QString &dbRelativeFolder) const // Shared by the two sites that need the tag, rather than derived twice. // They disagreed once already: onMessagesMoved() resolved a placeholder // from the folder the worker reported, which on a RESTORE is the trash - // rather than the origin, so the restore stripped `deleted-from:Trash` + // rather than the origin, so the restore stripped `moved-from:Trash` // and left the real tag in place. const Account account = accountForMessagePath(dbRelativeFolder + QLatin1Char('/')); @@ -6382,7 +6382,7 @@ QString MainWindow::originTagFor(const QString &dbRelativeFolder) const } if (accountRelative.isEmpty()) return QString(); - return QStringLiteral("deleted-from:%1").arg(accountRelative); + return QString(kOriginTagPrefix) + accountRelative; } void MainWindow::trashThreads(const QStringList &threadIds) @@ -6476,9 +6476,9 @@ void MainWindow::onThreadMessagesResolved(const QStringList &messageIds, return; // Restore, resolved per message: each one goes back to the folder its own - // `deleted-from:` tag names, so a thread whose messages were deleted from + // `moved-from:` tag names, so a thread whose messages were deleted from // different folders reassembles correctly rather than collapsing into one. - const QString prefix = QStringLiteral("deleted-from:"); + const QString prefix = QString::fromLatin1(kOriginTagPrefix); QHash<QString, QStringList> byOrigin; QStringList unknown; for (int i = 0; i < messageIds.size(); ++i) { @@ -6592,7 +6592,7 @@ void MainWindow::restoreResolvedMessages(const QStringList &messageIds, if (messageIds.size() != paths.size() || messageIds.size() != tags.size()) return; - const QString prefix = QStringLiteral("deleted-from:"); + const QString prefix = QString::fromLatin1(kOriginTagPrefix); QHash<QString, QStringList> byOrigin; QHash<QString, QStringList> byInbox; QStringList stranded; @@ -6652,7 +6652,7 @@ void MainWindow::restoreResolvedMessages(const QStringList &messageIds, // and is out of scope here. // // The destination FOLDER, taken from the key rather than from - // `origin` above: that is the finished TAG, `deleted-from:Inbox`, + // `origin` above: that is the finished TAG, `moved-from:Inbox`, // which never equals `Inbox` however the account spells it. The // comparison was therefore always false and the `inbox` tag never came // back, so a restored message sat in the inbox folder invisible to the @@ -6900,7 +6900,7 @@ void MainWindow::restoreSelected(bool fallbackToInbox) // Where each message came from, read back off its own tag. This is what // the tag exists for: the file has moved, so nothing on disk and nothing // in notmuch still records the original folder. - const QString prefix = QStringLiteral("deleted-from:"); + const QString prefix = QString::fromLatin1(kOriginTagPrefix); QHash<QString, QStringList> byOrigin; QStringList unknown; for (const QString &messageId : scope.messageIds) { @@ -6986,8 +6986,8 @@ void MainWindow::restoreSelected(bool fallbackToInbox) // onMessagesMoved() resolves the placeholder from the origin the // WORKER reports, which is where the message is coming FROM. On a // delete that is the inbox and correct; on a restore it is the trash, - // so the placeholder resolved to `deleted-from:Trash` and asked to - // remove a tag that never existed, while the real `deleted-from:inbox` + // so the placeholder resolved to `moved-from:Trash` and asked to + // remove a tag that never existed, while the real `moved-from:inbox` // was never named. The message came home still claiming to have been // deleted from somewhere, which then made Restore offer to move a // message that was already back. @@ -7102,7 +7102,7 @@ void MainWindow::sendMove(const QStringList &messageIds, // account before the first confirmation arrives both name `acct/Trash`, // so the second insert overwrote the first and the second confirmation // took an empty PendingMove. That file landed in the trash carrying - // neither `deleted` nor `deleted-from:`, which makes it unrestorable and + // neither `deleted` nor `moved-from:`, which makes it unrestorable and // invisible to a `tag:deleted` query. The worker handles one move at a // time on its own thread and emits in the order it was asked, so a plain // FIFO matches confirmations to requests without needing a key at all. @@ -7279,7 +7279,7 @@ void MainWindow::onMessagesMoved(const QMap<QString, QString> &originByMessageId return; // The origin differs per message, so the tags do too: two messages deleted - // from different folders get different `deleted-from:` tags out of one + // from different folders get different `moved-from:` tags out of one // gesture. Grouped by the resolved tag list so identical ones still travel // as a single write. QHash<QString, QStringList> byOrigin; @@ -7325,7 +7325,7 @@ void MainWindow::onMessagesMoved(const QMap<QString, QString> &originByMessageId // It used to be handed pending.add straight, which still holds the // unresolved placeholder: undo then asked to remove a tag by that // literal name, which no message carries, so the removal was a silent - // no-op and `deleted-from:inbox` survived the undo. The file came home + // no-op and `moved-from:inbox` survived the undo. The file came home // still claiming to have been deleted from somewhere. Same defect as // the one the second-Delete path had, reached through Ctrl+Z instead. // diff --git a/src/mainwindow.h b/src/mainwindow.h index 55d67ae..97d90e5 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -1187,7 +1187,7 @@ private: /// failed, which is the half-done state item 103 exists to remove. /// /// `add` may contain the placeholder kOriginTagPlaceholder, which - /// onMessagesMoved() replaces with `deleted-from:<origin>` per message. + /// onMessagesMoved() replaces with `moved-from:<origin>` per message. /// The origin is not known until the worker reports it, and it differs per /// message in a multi-row selection. /// `fromUndo` marks a move the undo stack itself started, which must NOT @@ -1241,7 +1241,7 @@ private: const QString &requestTag); /// The inverse: moves each selected row's message back to the folder its - /// `deleted-from:` tag names, stripping both tags. + /// `moved-from:` tag names, stripping both tags. /// /// `fallbackToInbox` decides what happens to a message with NO origin tag, /// and the two callers want opposite things. From the trash view the @@ -1320,7 +1320,7 @@ private: /// in it, and such a message carries no tag of ours. bool isShowingTrash() const; - /// The `deleted-from:` tag naming `dbRelativeFolder`, or empty when no + /// The `moved-from:` tag naming `dbRelativeFolder`, or empty when no /// account owns it. /// /// One rule for both sites that need the tag: the delete that writes it @@ -1381,7 +1381,7 @@ private: /// account before the first confirmation arrives name the same folder, so /// a keyed map dropped the first entry and left the second confirmation /// with nothing to apply. That file reached the trash carrying neither - /// `deleted` nor `deleted-from:`, unrestorable and invisible to a + /// `deleted` nor `moved-from:`, unrestorable and invisible to a /// `tag:deleted` query. The worker moves one batch at a time and emits in /// request order, so position alone matches a confirmation to its request. struct PendingMove { @@ -1477,7 +1477,7 @@ private: friend class MessageTagCommand; friend class MoveCommand; - /// Stands in for `deleted-from:<origin>` between asking for a move and + /// Stands in for `moved-from:<origin>` between asking for a move and /// learning where each message actually came from. Not a tag anyone ever /// sees: onMessagesMoved() substitutes the real one per message before /// anything is written. @@ -2061,7 +2061,7 @@ private: /// Undo entry for a message MOVE, which is a file rename plus a tag change. /// /// The destination is CARRIED rather than derived, and that is the whole -/// reason `deleted-from:` exists at all. A Maildir filename does not record +/// reason `moved-from:` exists at all. A Maildir filename does not record /// where a message came from, and once the file has moved notmuch cannot /// answer either, so an undo that recomputed the origin would have nothing to /// recompute it from. Each message carries its own, since one selection can diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp index bc3ae65..2672f6a 100644 --- a/src/notmuchworker.cpp +++ b/src/notmuchworker.cpp @@ -36,6 +36,7 @@ #include <QSet> #include <cstdlib> +#include <utility> #include "maildirname.h" #include "mimeparser.h" @@ -1122,17 +1123,40 @@ void NotmuchWorker::applyTags(const TagChange &change) // write, so the only way to know is to look first. const QStringList before = tagsOf(message.get()); + // One origin tag ever: writing a `moved-from:` tag strips any OTHER + // tag with that prefix the message still carries, so a message that + // travelled inbox -> spam -> trash ends with exactly one origin and + // Restore has one answer. Without this the reader's first-match + // break() picks silently. + QStringList strippedOrigins; + const bool writingOrigin = + std::any_of(change.added.cbegin(), change.added.cend(), + [](const QString &t) { + return t.startsWith(QLatin1String(kOriginTagPrefix)); + }); + if (writingOrigin) { + for (const QString &tag : std::as_const(before)) { + if (tag.startsWith(QLatin1String(kOriginTagPrefix)) + && !change.added.contains(tag)) { + strippedOrigins.append(tag); + } + } + } + bool moves = false; for (const QString &tag : change.removed) moves = moves || before.contains(tag); for (const QString &tag : change.added) moves = moves || !before.contains(tag); + moves = moves || !strippedOrigins.isEmpty(); if (moves) changedIds.append(id); notmuch_message_freeze(message.get()); for (const QString &tag : change.removed) notmuch_message_remove_tag(message.get(), tag.toUtf8().constData()); + for (const QString &tag : strippedOrigins) + 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()); @@ -1689,7 +1713,7 @@ void NotmuchWorker::resolveQuery(const QString &query, 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 + // `moved-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 diff --git a/src/notmuchworker.h b/src/notmuchworker.h index 32eab58..b4e9707 100644 --- a/src/notmuchworker.h +++ b/src/notmuchworker.h @@ -398,7 +398,7 @@ signals: /// 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 + /// order. Needed because Restore reads a message's `moved-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, diff --git a/src/tagdialog.cpp b/src/tagdialog.cpp index fb2c629..1810e63 100644 --- a/src/tagdialog.cpp +++ b/src/tagdialog.cpp @@ -276,7 +276,7 @@ void TagDialog::accept() // troublesome tag being CREATED; a tag that already exists is a fact, and // refusing to remove it because it breaks a rule leaves the user with a // tag they can see, cannot type, and cannot get rid of. That happened with - // `deleted-from:Inbox/SlackBuilds users`: an origin tag naming a Maildir + // `moved-from:Inbox/SlackBuilds users`: an origin tag naming a Maildir // folder whose name contains a space, rejected by the space rule, so the // one dialog that could have cleared it refused the only text that names // it. Whether such a tag SHOULD exist is a separate question from whether diff --git a/src/types.h b/src/types.h index fb7901c..6fd54a2 100644 --- a/src/types.h +++ b/src/types.h @@ -23,6 +23,11 @@ #include <QString> #include <QStringList> +/// Prefix of the origin tag a move writes. One origin tag per message, +/// overwritten on each move, so a reader cannot be handed two and forced to +/// pick one silently. Not translated, not user-facing. +inline constexpr auto kOriginTagPrefix = "moved-from:"; + struct ThreadSummary { QString threadId; |
