diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/mailsync.cpp | 129 | ||||
| -rw-r--r-- | src/mailsync.h | 42 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 52 | ||||
| -rw-r--r-- | src/mainwindow.h | 26 |
4 files changed, 242 insertions, 7 deletions
diff --git a/src/mailsync.cpp b/src/mailsync.cpp index b1a5208..005580c 100644 --- a/src/mailsync.cpp +++ b/src/mailsync.cpp @@ -18,6 +18,135 @@ #include "mailsync.h" +#include <QCoreApplication> +#include <QRegularExpression> + +namespace { + +/// Longest status text put into the label. Sync output is unstructured and +/// arrives from a script rather than from this code, so a line is truncated +/// rather than trusted to be a sensible length: an unbounded one would resize +/// the status bar and push the permanent widgets beside it off. +constexpr int kMaxStatusChars = 120; + +/// Strips the script's "HH:MM:SS " prefix and anything that could disturb a +/// single-line label. Returns plain text, never markup. +QString sanitiseLine(const QString &line) +{ + QString text = line.trimmed(); + + static const QRegularExpression timestamp( + QStringLiteral("^\\d{2}:\\d{2}:\\d{2}\\s+")); + text.remove(timestamp); + + // Collapse every control character, not just newlines: a stray \r would + // otherwise leave the label showing the tail of the line only. + static const QRegularExpression controls(QStringLiteral("[\\x00-\\x1f\\x7f]+")); + text.replace(controls, QStringLiteral(" ")); + text = text.simplified(); + + if (text.size() > kMaxStatusChars) + text = text.left(kMaxStatusChars - 1) + QStringLiteral("…"); + + return text; +} + +} // namespace + +void SyncPhaseTracker::reset() +{ + m_phase = SyncPhase::Starting; + m_status.clear(); +} + +bool SyncPhaseTracker::feed(const QString &line) +{ + const QString text = sanitiseLine(line); + if (text.isEmpty()) + return false; + + // The script's own banners. Skipped before anything else: RUN START would + // otherwise read as mbsync output, and RUN END carries a status= field that + // must not be parsed, since the exit code is the authority on the outcome. + if (text.startsWith(QLatin1String("====="))) + return false; + + const QString before = m_status; + + // notmuch new announces itself by what it reports, since it prints no + // banner. Any of these means mbsync is done and the reindex is running. + // Matched loosely and case-insensitively: the wording varies by version. + static const QRegularExpression notmuchLine( + QStringLiteral("^(processed \\d|added \\d|no new mail|found \\d)"), + QRegularExpression::CaseInsensitiveOption); + + if (notmuchLine.match(text).hasMatch()) { + m_phase = SyncPhase::Notmuch; + m_status = QCoreApplication::translate("SyncPhaseTracker", + "Reindexing (notmuch)..."); + return m_status != before; + } + + // The channel mbsync is working on, which is the account name the user + // wants to see. Only printed under -V, which is why the shipped script + // passes it: without -V mbsync is silent until it exits. + // + // Taken from the output rather than from config, so what is shown is what + // is actually happening, and in the order it actually happens. + static const QRegularExpression channel( + QStringLiteral("^Channel\\s+(\\S.*)$"), + QRegularExpression::CaseInsensitiveOption); + + if (const auto match = channel.match(text); match.hasMatch()) { + m_phase = SyncPhase::Mbsync; + // The name comes from a config file this app does not own and lands in + // a label, so it is truncated on its own before being interpolated: + // bounding only the finished string would let a long name push the + // wording out instead of itself. + QString name = match.captured(1).trimmed(); + constexpr int kMaxNameChars = 60; + if (name.size() > kMaxNameChars) + name = name.left(kMaxNameChars - 1) + QStringLiteral("…"); + + m_status = QCoreApplication::translate("SyncPhaseTracker", + "Syncing %1...").arg(name); + return m_status != before; + } + + // mbsync's end-of-run summary, printed with or without -V. It arrives after + // every channel is done, so it reports rather than progresses; the + // "Far:/Near:" tail is dropped as unreadable at a glance. + static const QRegularExpression summary( + QStringLiteral("^Channels:\\s*(\\d+)\\s+Boxes:\\s*(\\d+)"), + QRegularExpression::CaseInsensitiveOption); + + if (const auto match = summary.match(text); match.hasMatch()) { + m_phase = SyncPhase::Mbsync; + m_status = QCoreApplication::translate( + "SyncPhaseTracker", "Syncing mail: %1 channels, %2 boxes") + .arg(match.captured(1), match.captured(2)); + if (m_status.size() > kMaxStatusChars) + m_status = m_status.left(kMaxStatusChars - 1) + QStringLiteral("…"); + return m_status != before; + } + + // Everything else while mbsync runs. The bulk of a real run is one + // "Ignoring non-mail file" line per Maildir, so individual lines are never + // shown: only the fact that mbsync is the phase. + // + // Only ever an upgrade from Starting, never a downgrade. Once the summary + // has given real counts, a later noise line must not overwrite them with + // the generic wording: the label would flicker back to saying less than it + // already said, for every one of thousands of ignored files. + if (m_phase == SyncPhase::Starting) { + m_phase = SyncPhase::Mbsync; + m_status = QCoreApplication::translate("SyncPhaseTracker", + "Syncing mail (mbsync)..."); + } + + return m_status != before; +} + MailSync::MailSync(const QString &command, QObject *parent) : QObject(parent), m_command(command) { diff --git a/src/mailsync.h b/src/mailsync.h index 8123ea9..3c0d616 100644 --- a/src/mailsync.h +++ b/src/mailsync.h @@ -22,6 +22,48 @@ #include <QProcess> #include <QString> +/// Which half of the sync script is running. +/// +/// The script runs mbsync and then `notmuch new`, so the phase is derived from +/// the output rather than announced: there is no side channel, and adding one +/// would mean the script and the app had to agree on a protocol. +enum class SyncPhase { + Starting, ///< Launched, nothing recognised yet. + Mbsync, ///< Fetching mail. + Notmuch, ///< Reindexing. +}; + +/// Derives a short status line from the sync script's output as it streams. +/// +/// Kept separate from MailSync so it can be tested against captured output +/// without running a process, and free of any widget so the matching rules stay +/// one thing rather than being spread through a UI handler. +/// +/// **Matching is deliberately loose.** mbsync's and notmuch's exact wording +/// varies by version, and a status line that goes blank because a string moved +/// is worse than the fixed "Syncing..." this replaces. Nothing here decides +/// whether the run succeeded: the exit status is the only authority on that, and +/// a second opinion derived from text would eventually disagree with it. +class SyncPhaseTracker +{ +public: + /// Feeds one line. Returns true when the status text changed as a result, + /// so the caller can avoid rewriting the label for every line of noise. + bool feed(const QString &line); + + /// Clears back to Starting for a new run. + void reset(); + + SyncPhase phase() const { return m_phase; } + + /// Plain text, already truncated, safe to put straight into a label. + QString statusText() const { return m_status; } + +private: + SyncPhase m_phase = SyncPhase::Starting; + QString m_status; +}; + /// Runs the configured external sync command. /// /// qtmaildir deliberately does not implement sync itself. The existing script diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index a2174a2..a3b5496 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -465,6 +465,7 @@ void MainWindow::buildUi() connect(m_sync, &MailSync::finished, this, &MainWindow::onSyncFinished); connect(m_sync, &MailSync::outputReceived, this, [this](const QString &chunk) { m_syncLog->appendPlainText(chunk.trimmed()); + feedSyncPhase(chunk); }); // Syncs this window did not start. The user's cron runs the same script @@ -1576,12 +1577,46 @@ void MainWindow::showTransientStatus(const QString &text) m_statusTimer->start(); } +void MainWindow::feedSyncPhase(const QString &chunk) +{ + // readAll() returns whatever happened to be buffered, which splits mid-line + // as often as not, so lines are reassembled here rather than in the tracker: + // a half-line fed to it would match nothing and the phase would stall. + m_syncLineBuffer += chunk; + + int newline; + bool changed = false; + while ((newline = m_syncLineBuffer.indexOf(QLatin1Char('\n'))) >= 0) { + const QString line = m_syncLineBuffer.left(newline); + m_syncLineBuffer.remove(0, newline + 1); + if (m_syncPhase.feed(line)) + changed = true; + } + + // The tail without a newline is deliberately left in the buffer: mbsync can + // sit on a line for a while, and feeding a partial one would report a phase + // from half a word. + + if (!changed) + return; + + // Not showTransientStatus(): a phase is state, not an event, and must not + // expire out from under a sync that is still running. Writing the label + // directly also leaves m_transientMessage alone, so the timer will not + // reclaim a phase it did not arm. + m_statusLabel->setText(m_syncPhase.statusText()); +} + void MainWindow::setSyncBusy(bool busy) { m_localSyncBusy = busy; updateSyncControls(); - if (busy) + // The phase tracker is reset in startSync(), before the process launches, + // not here: this runs after start() and a fast run has already produced + // output by then. Setting the label is still right, since the tracker has + // nothing to say until a line it recognises arrives. + if (busy && m_syncPhase.statusText().isEmpty()) m_statusLabel->setText(tr("Syncing...")); } @@ -1623,13 +1658,22 @@ void MainWindow::startSync() tr("No sync command configured ([sync] command in qtmaildir.conf)")); return; } + + // Fresh run, fresh output: leaving the previous run's lines in place + // makes a stale failure look like the current one. + m_syncLog->clear(); + + // BEFORE start(), not after. A short run can deliver its whole output + // before control returns here, and resetting afterwards would wipe the + // phase those lines had already produced, leaving a fast sync showing + // nothing between "Syncing..." and "Sync complete". + m_syncPhase.reset(); + m_syncLineBuffer.clear(); + if (!m_sync->start()) { showTransientStatus(tr("Sync already running")); return; } - // Fresh run, fresh output: leaving the previous run's lines in place - // makes a stale failure look like the current one. - m_syncLog->clear(); setSyncBusy(true); } diff --git a/src/mainwindow.h b/src/mainwindow.h index e673181..3984e78 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -28,6 +28,9 @@ #include "config.h" #include "keymap.h" +// Included rather than forward-declared: SyncPhaseTracker is held by value, so +// its size must be known here. MailSync itself stays a forward declaration. +#include "mailsync.h" #include "syncmonitor.h" #include "tagcolors.h" #include "types.h" @@ -155,6 +158,14 @@ private slots: /// the meta-object without widening the public API. void onExternalSyncStateChanged(SyncMonitor::State state); + /// Starts a sync and shows that it started. Every route in goes through + /// here: the toolbar, the menu, the shortcut and the button. + /// + /// A private slot for the same reason as the two above: a test needs to + /// start a real run through the meta-object to exercise the output + /// handling, without this becoming public API. + void startSync(); + /// A tag mutation the worker has confirmed reached the database. Counts it /// as unsynced, since reaching the index is not reaching the mail store. void onTagsApplied(const TagChange &change); @@ -217,9 +228,9 @@ private: /// says "working, duration unknown", which is the truth. void setSyncBusy(bool busy); - /// Starts a sync and shows that it started. Every route in goes through - /// here: the toolbar, the menu, the shortcut and the button. - void startSync(); + /// Reassembles lines from a sync output chunk and updates the status label + /// when the phase or its detail changes. + void feedSyncPhase(const QString &chunk); /// Applies the sync progress bar and button state from BOTH sync sources. /// @@ -298,6 +309,15 @@ private: MessageView *m_messageView = nullptr; MailSync *m_sync = nullptr; + /// Derives "which half of the sync is running" from the output stream, so + /// the status bar says more than "Syncing...". Reset at the start of each + /// local run. + SyncPhaseTracker m_syncPhase; + + /// Holds the tail of a chunk that did not end on a newline, since + /// QProcess::readAll() splits wherever it happens to. + QString m_syncLineBuffer; + /// Watches the sync lock for runs this window did not start. SyncMonitor *m_syncMonitor = nullptr; |
