summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/mailsync.cpp129
-rw-r--r--src/mailsync.h42
-rw-r--r--src/mainwindow.cpp52
-rw-r--r--src/mainwindow.h26
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;