diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-12 10:12:45 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-12 10:12:45 +0200 |
| commit | 92cf294993c49fa95547ad14ac7d21c7c7a2b837 (patch) | |
| tree | bd6465e9df4db75306cd87a0db4ecd3af78e4c02 /mail-overview | |
| parent | caca07a9daa948f1bc62b807360c364d77c3a322 (diff) | |
| download | quickshell-92cf294993c49fa95547ad14ac7d21c7c7a2b837.tar.gz quickshell-92cf294993c49fa95547ad14ac7d21c7c7a2b837.zip | |
feat(mail-overview): account model singleton
Accounts.qml is the data layer for the mail drawer: it parses accounts
from qtmaildir.conf rather than hardcoding them, so adding an account
there needs no edit here. The key runs to the closing bracket, not the
first dot, because at least one configured key contains a dot of its
own; matching only up to the first dot would silently truncate it and
drop that account's mail from every count.
Counting uses the account-<key> notmuch tag, not a path glob. notmuch
deduplicates by message id, so a message delivered to two of the
configured addresses is one message with two file paths: a path glob
would count it under both accounts and the per-account rows would sum
higher than the global total the waybar icon already shows (measured
elsewhere as 102 vs 101). The tag is a property of the message, so it
is singular and the rows always sum to the header exactly.
An unknown count is -1, never 0. notmuch has one failure mode with no
distinct exit status: a rejected query fragment can still return 0
exit and empty output, and an empty string must not become a zero,
which would render as "no unread mail" instead of "count unavailable".
Output is validated as a non-negative integer before being trusted.
Includes a temporary console-log probe in shell.qml, added
deliberately for runtime verification of this model outside the
drawer UI (Task 4). It stays until the user has run and confirmed it
against real notmuch output, then gets removed in a follow-up commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WWL8JYHu7yhAdtx5pU9PMU
Diffstat (limited to 'mail-overview')
| -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); + } + } } |
