diff options
| -rw-r--r-- | mail-overview/Accounts.qml | 204 | ||||
| -rw-r--r-- | mail-overview/shell.qml | 13 |
2 files changed, 217 insertions, 0 deletions
diff --git a/mail-overview/Accounts.qml b/mail-overview/Accounts.qml new file mode 100644 index 0000000..1bb451f --- /dev/null +++ b/mail-overview/Accounts.qml @@ -0,0 +1,204 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +// Which accounts exist, how much unread mail each has, and what the newest of +// it is. +// +// The account list is not here. qtmaildir.conf already carries one +// [account.<key>] section per account, where <key> is exactly the suffix of +// the notmuch tag account-<key>, plus a short label and a colour. Parsing that +// means adding an account to qtmaildir makes it appear here with no edit. +Singleton { + id: root + + readonly property string config: `${Quickshell.env("HOME")}/.config/qtmaildir/qtmaildir.conf` + + // Counting by the account-* tag rather than by a path glob is deliberate. + // notmuch deduplicates by message id, so a message that arrived at two of + // the configured addresses is one message with two paths: a path glob + // counts it under both accounts and the rows then sum to more than the + // total the waybar icon shows. The tag is a property of the message, so + // it is singular and the rows always sum to the header. + readonly property string scope: "tag:unread and tag:inbox" + + // [{ key, label, color, count, threads: [{ authors, date, subject }] }] + // count is -1 until known, and stays -1 on failure: a zero there would + // read as an empty inbox. + property var accounts: [] + + property string error: "" + property bool loading: false + + readonly property int total: + accounts.reduce((sum, a) => sum + Math.max(a.count, 0), 0) + + readonly property bool anyUnknown: accounts.some(a => a.count < 0) + + // The config is watched, so adding an account in qtmaildir updates this + // list without restarting the shell. + FileView { + path: root.config + watchChanges: true + onFileChanged: reload() + onLoadFailed: { + root.accounts = []; + root.error = "cannot read qtmaildir.conf"; + } + onLoaded: { + root.accounts = root.parseAccounts(text()); + root.error = root.accounts.length ? "" : "no accounts in qtmaildir.conf"; + root.refresh(); + } + } + + // Sections in file order, which is the display order. + function parseAccounts(conf) { + const out = []; + // QML's JS engine has no String.matchAll, so this is an exec loop. + // At least one key contains a dot of its own, so the key runs to the + // closing bracket rather than to the first dot. + const re = /^\[account\.([^\]]+)\]([^\[]*)/gm; + let m; + while ((m = re.exec(conf)) !== null) { + const key = m[1]; + const body = m[2]; + const field = name => { + const f = body.match(new RegExp(`^\\s*${name}\\s*=\\s*(.+)$`, "m")); + return f ? f[1].trim() : ""; + }; + out.push({ + key: key, + label: field("label") || key, + color: field("color"), + count: -1, + threads: [], + }); + } + return out; + } + + function refresh() { + if (!accounts.length) return; + loading = true; + proc.next = 0; + proc.loadNext(); + } + + // One process per account, in turn, fetching the count and the newest + // three threads together and splitting on a marker: two calls are needed + // because a search limited to three rows cannot report the total, and + // doing them as one command keeps the pair consistent. + Process { + id: proc + property int next: 0 + property int current: 0 + + function loadNext() { + if (next >= root.accounts.length) { + root.loading = false; + return; + } + current = next; + next++; + const q = `${root.scope} and tag:account-${root.accounts[current].key}`; + command = ["sh", "-c", + `notmuch count ${JSON.stringify(q)}; ` + + `echo '===SPLIT==='; ` + + `notmuch search --format=json --limit=3 --sort=newest-first ${JSON.stringify(q)}`]; + // Assigning true to an already-true `running` does nothing, and + // this Process is reused for every account in turn. + running = false; + running = true; + } + + stdout: StdioCollector { + onStreamFinished: { + root.applyResult(proc.current, text); + proc.loadNext(); + } + } + + onExited: code => { + // A non-zero exit leaves this account's count at whatever it was, + // which for a first load is -1 and renders as a dash. Carrying on + // to the next account matters: one failure must not blank the + // whole panel. + if (code !== 0) proc.loadNext(); + } + } + + function applyResult(index, out) { + const parts = out.split("===SPLIT==="); + if (parts.length < 2) return; + + const next = accounts.slice(); + const acct = Object.assign({}, next[index]); + + // The output is the test, not the exit status: a query notmuch rejects + // prints nothing, and an empty string must not become a zero, which + // would read as "no new mail". + const n = parts[0].trim(); + acct.count = /^\d+$/.test(n) ? parseInt(n, 10) : -1; + + acct.threads = []; + try { + const rows = JSON.parse(parts[1]); + if (Array.isArray(rows)) { + acct.threads = rows.map(r => ({ + authors: String(r.authors ?? ""), + date: String(r.date_relative ?? ""), + subject: String(r.subject ?? "(no subject)"), + })); + } + } catch (e) { + // Keep the count, drop the thread list: a count with no preview is + // still useful, and an empty search is a legitimate "[]". + } + + next[index] = acct; + accounts = next; + } + + // Launching qtmaildir, and syncing. qtmaildir takes no arguments, so + // there is nothing to tell it about the account or thread clicked. + Process { id: openProc; command: [`${Quickshell.env("HOME")}/bin/qtmaildir`] } + + function openClient() { + openProc.running = false; + openProc.running = true; + } + + property bool syncing: false + + // mailsync.sh is already lock-protected against a concurrent cron run, so + // this does not need its own guard beyond not stacking clicks. + Process { + id: syncProc + command: [`${Quickshell.env("HOME")}/bin/mailsync.sh`] + onExited: { + root.syncing = false; + root.refresh(); + } + } + + function sync() { + if (syncing) return; + syncing = true; + syncProc.running = false; + syncProc.running = true; + } +} diff --git a/mail-overview/shell.qml b/mail-overview/shell.qml index cd2e907..1925f44 100644 --- a/mail-overview/shell.qml +++ b/mail-overview/shell.qml @@ -21,4 +21,17 @@ ShellRoot { function show() { panel.show(); } function close() { panel.close(); } } + + // TEMPORARY probe for Task 3 verification. Removed in Step 5. + Component.onCompleted: Qt.callLater(() => { + console.log("ACCOUNTS", JSON.stringify(Accounts.accounts.map(a => a.key + ":" + a.label))); + }) + Connections { + target: Accounts + function onLoadingChanged() { + if (!Accounts.loading) + console.log("COUNTS", JSON.stringify(Accounts.accounts.map(a => a.label + "=" + a.count)), + "total", Accounts.total); + } + } } |
