diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.cpp | 5 | ||||
| -rw-r--r-- | src/config.h | 13 | ||||
| -rw-r--r-- | src/mailsync.cpp | 16 | ||||
| -rw-r--r-- | src/mailsync.h | 11 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 62 | ||||
| -rw-r--r-- | src/mainwindow.h | 20 | ||||
| -rw-r--r-- | src/threadlistmodel.cpp | 18 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 10 |
8 files changed, 149 insertions, 6 deletions
diff --git a/src/config.cpp b/src/config.cpp index 8267835..cac4c51 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -199,6 +199,11 @@ void Config::load(const QString &path) // is in, so these live here rather than in [tagcolors]. account.label = settings.value(QStringLiteral("label")).toString(); + // Optional, and absent for most accounts: syncChannel() falls back to + // the key. Needed only where the section key and the mbsync channel + // name diverge. + account.channel = settings.value(QStringLiteral("channel")).toString(); + const QString colour = settings.value(QStringLiteral("color")).toString(); if (!colour.isEmpty()) { account.color = QColor(colour); diff --git a/src/config.h b/src/config.h index 84f02df..d3ee767 100644 --- a/src/config.h +++ b/src/config.h @@ -45,6 +45,19 @@ struct Account /// bit of information. This renames nothing in notmuch, only the display. QString label; + /// mbsync channel name, when it differs from the key. Optional, and empty + /// for most accounts: see syncChannel(). + QString channel; + + /// The mbsync channel to sync this account, for item 49's per-account sync. + /// + /// Defaults to the key, which is right for most accounts, but the two are + /// genuinely separate names and cannot be collapsed. A QSettings section + /// key may carry dots that the channel does not ([account.mail-first.last] + /// against the channel `mail-firstlast`), and mbsync exits nonzero on a + /// channel it does not know, which qtmaildir would report as a failed sync. + QString syncChannel() const { return channel.isEmpty() ? key : channel; } + bool isValid() const { return !key.isEmpty() && !maildir.isEmpty(); } /// Restricts a notmuch query to this account's subtree. diff --git a/src/mailsync.cpp b/src/mailsync.cpp index 005580c..1d2a99f 100644 --- a/src/mailsync.cpp +++ b/src/mailsync.cpp @@ -167,7 +167,7 @@ bool MailSync::isRunning() const return m_process.state() != QProcess::NotRunning; } -bool MailSync::start() +bool MailSync::start(const QStringList &channels) { if (!isAvailable() || isRunning()) return false; @@ -180,8 +180,20 @@ bool MailSync::start() m_log.clear(); + QStringList arguments = parts.mid(1); + + // Appended as separate list entries, never spliced into the command string: + // these names come from config, the same trust boundary as the command + // itself, and QProcess passes an argument list without a shell. + for (const QString &channel : channels) { + // An empty name would reach mbsync as a channel called "", failing the + // whole run, so a stray blank costs the user nothing here. + if (!channel.trimmed().isEmpty()) + arguments.append(channel); + } + m_process.setProgram(parts.first()); - m_process.setArguments(parts.mid(1)); + m_process.setArguments(arguments); // Deliberately no waitForStarted(): the spec requires the UI stay usable // during sync, and a failed launch arrives via errorOccurred() instead. diff --git a/src/mailsync.h b/src/mailsync.h index 3c0d616..f83b526 100644 --- a/src/mailsync.h +++ b/src/mailsync.h @@ -21,6 +21,7 @@ #include <QObject> #include <QProcess> #include <QString> +#include <QStringList> /// Which half of the sync script is running. /// @@ -84,7 +85,15 @@ public: /// Returns false if unavailable or already running. A true return means the /// process was handed to the event loop, not that it launched successfully: /// a missing binary surfaces asynchronously through finished(false, ...). - bool start(); + /// + /// \p channels names the mbsync channels to sync, appended to the + /// configured command as separate arguments. Empty, the default, appends + /// nothing and leaves the script to sync everything: a sync with nothing + /// pending is a fetch, and fetching only the account that happened to hold + /// the last edit would silently stop collecting mail for the others. + /// Blank entries are dropped rather than passed, since mbsync reads an + /// empty argument as a channel name and fails the whole run on it. + bool start(const QStringList &channels = {}); QString log() const { return m_log; } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 0ce94a8..f830683 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -217,7 +217,7 @@ void MainWindow::closeEvent(QCloseEvent *event) box.exec(); if (box.clickedButton() == sync) { - if (m_sync->start()) { + if (m_sync->start(pendingSyncChannels())) { m_syncingForExit = true; m_syncLog->clear(); setSyncBusy(true); @@ -238,7 +238,7 @@ void MainWindow::closeEvent(QCloseEvent *event) return; } } else if (m_config.syncOnExit() == Config::SyncOnExit::Always) { - if (m_sync->start()) { + if (m_sync->start(pendingSyncChannels())) { m_syncingForExit = true; m_syncLog->clear(); setSyncBusy(true); @@ -1700,6 +1700,16 @@ void MainWindow::onSyncFinished(bool success, int exitCode) // so they go to the mail store on the NEXT run. That is the same one-run // delay any edit made mid-sync gets, bounded by the cron interval. const bool sentHeldEdits = !m_heldEdits.isEmpty(); + + // Snapshotted BEFORE the flush, and this ordering is load-bearing. + // flushHeldEdits() calls sendThreadTagChange(), which inserts into + // m_editedAccounts SYNCHRONOUSLY, unlike the pending-edit map below which + // is written on the worker's queued reply and so is safely counted rather + // than wiped. Clearing the whole set after the flush would therefore + // discard accounts whose edits this run did not carry, and those edits + // would sync only when some later edit happened to name the same account. + const QSet<QString> accountsThisRunCarried = m_editedAccounts; + flushHeldEdits(); if (success) { @@ -1708,6 +1718,12 @@ void MainWindow::onSyncFinished(bool success, int exitCode) // what failed to put them there. m_pendingTagEdits.clear(); m_unnettablePendingEdits = 0; + + // Only what this run actually carried, per the snapshot above. An + // account added by flushHeldEdits() stays, because its edit reaches the + // index after the sync that would have taken it and goes out on the + // next run. + m_editedAccounts.subtract(accountsThisRunCarried); m_lastSyncFailed = false; updatePendingIndicator(); @@ -1997,7 +2013,7 @@ void MainWindow::startSync() m_syncPhase.reset(); m_syncLineBuffer.clear(); - if (!m_sync->start()) { + if (!m_sync->start(pendingSyncChannels())) { showTransientStatus(tr("Sync already running")); return; } @@ -2022,6 +2038,34 @@ void MainWindow::recordPendingEdit(const QString &messageId, const QString &tag, m_pendingTagEdits.insert(key, added); } +QStringList MainWindow::pendingSyncChannels() const +{ + // Nothing pending means this run is a FETCH, and a fetch must cover every + // account: narrowing it to wherever the last edit happened to be would + // quietly stop collecting mail everywhere else. Empty is the signal for + // that, and MailSync::start() appends nothing. + if (m_editedAccounts.isEmpty()) + return {}; + + QStringList channels; + for (const Account &account : m_config.accounts()) { + if (m_editedAccounts.contains(account.key)) + channels.append(account.syncChannel()); + } + + // An account tag with no matching [account.<key>] section yields no + // channel, and syncing a subset that omits it would leave its edits behind + // with nothing to say so. Fall back to a full sync, which is correct if + // wasteful; the alternative is silently stranding an edit. + if (channels.size() != m_editedAccounts.size()) + return {}; + + // Stable order so a run is reproducible and the log reads the same way + // twice. QSet has no order of its own. + channels.sort(); + return channels; +} + int MainWindow::pendingEditCount() const { // A held edit has NOT reached the index, so onTagsApplied() never counted @@ -2188,6 +2232,18 @@ void MainWindow::sendThreadTagChange(const QStringList &threadIds, for (const QString &threadId : threadIds) m_model->applyTagChange(threadId, add, remove); + // Which accounts this touches, recorded HERE and not in onTagsApplied(): + // TagChange carries message ids, while the account is a property of the + // thread, and by the time the worker confirms, the rows may be gone. A + // write that is later rejected leaves an account listed here that needed no + // sync, which costs one redundant channel on the next run; missing one + // would strand the user's edits, which is the failure worth avoiding. + for (const QString &threadId : threadIds) { + const QStringList keys = m_model->accountKeysForThread(threadId); + for (const QString &key : keys) + m_editedAccounts.insert(key); + } + // The strip shows the open thread's tags, so it has to follow a change to // that thread rather than waiting for the next selection. if (threadIds.contains(m_currentThreadId)) { diff --git a/src/mainwindow.h b/src/mainwindow.h index 579597c..2d41b5e 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -19,6 +19,7 @@ #pragma once #include <QHash> +#include <QSet> #include <QMainWindow> #include <QPointer> #include <QThread> @@ -524,6 +525,25 @@ private: /// the UI thread one user action at a time. TagChange m_pendingChange; QStringList m_pendingThreadIds; + + /// Account keys whose mail store has edits a sync has not yet carried, + /// for item 49's per-account sync. + /// + /// Deliberately NOT netted the way m_pendingTagEdits is. That map tracks + /// the INDEX, where removing a tag and re-adding it leaves nothing + /// outstanding; this tracks the MAIL STORE, where both writes have already + /// renamed files that mbsync still has to propagate. Netting this to empty + /// would skip the very account whose files changed. + /// + /// Populated where the threads are known, since TagChange carries message + /// ids and the account is a property of the thread. Cleared only by a + /// SUCCESSFUL sync, alongside the pending-edit map. + QSet<QString> m_editedAccounts; + + /// The channel names for m_editedAccounts, resolved through the config. + /// Empty means sync everything, which is what a fetch with nothing pending + /// has to do. + QStringList pendingSyncChannels() const; }; /// Undo entry for a tag change over a set of threads. diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp index 4794d8c..c675488 100644 --- a/src/threadlistmodel.cpp +++ b/src/threadlistmodel.cpp @@ -325,6 +325,24 @@ ThreadSummary ThreadListModel::threadAt(int row) const return m_threads.at(row); } +QStringList ThreadListModel::accountKeysForThread(const QString &threadId) const +{ + QStringList keys; + for (const ThreadSummary &thread : m_threads) { + if (thread.threadId != threadId) + continue; + for (const QString &tag : thread.tags) { + if (!TagColors::isAccountTag(tag)) + continue; + const QString key = TagColors::accountKeyForTag(tag); + if (!key.isEmpty() && !keys.contains(key)) + keys.append(key); + } + break; + } + return keys; +} + void ThreadListModel::applyTagChange(const QString &threadId, const QStringList &added, const QStringList &removed) diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h index 461e467..2eaa88e 100644 --- a/src/threadlistmodel.h +++ b/src/threadlistmodel.h @@ -120,6 +120,16 @@ public: ThreadSummary threadAt(int row) const; + /// The account keys behind a thread's account tags, for item 49's + /// per-account sync. + /// + /// Returns every one of them, not the first: the thread list shows only one + /// chip per row, but a thread whose messages landed in two mailboxes really + /// does span two accounts, and tagging it touches files under both. Syncing + /// only the one that happens to be shown would strand the other's edits. + /// Empty when the thread is unknown or carries no account tag. + QStringList accountKeysForThread(const QString &threadId) const; + /// Applies a tag change locally so the UI updates before the worker /// confirms. To revert a failed write, call again with added and removed /// swapped. |
