aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/qtmaildir/Ini.js
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-17 11:08:49 +0200
committerDanilo M. <danix@danix.xyz>2026-10-17 11:08:49 +0200
commitc6fc5bbef45bcfa4f8d5e50ca7401aa1f5b6bc7d (patch)
tree775fa89b284c68d30205c58d83fae55336676f60 /desktop/modules/qtmaildir/Ini.js
parent8e9d8b9ac1b593032dea9eca312fa1495c0c8858 (diff)
downloadquickshell-c6fc5bbef45bcfa4f8d5e50ca7401aa1f5b6bc7d.tar.gz
quickshell-c6fc5bbef45bcfa4f8d5e50ca7401aa1f5b6bc7d.zip
settings: qtmaildir's config, in four Mail pages
qtmaildir is configured through qtmaildir.conf alone, which it reads at startup and never writes. Four settings pages under a new Mail group edit it: Mail ([general], [sync], [completion]), Compose, Accounts (a card per account, add and remove) and Tags & shortcuts ([tagcolors] and [keys]). Fields save on Enter and unset when emptied, with qtmaildir's defaults as placeholders and numbers range-checked; the pages say when qtmaildir is running and a change waits for its restart. Ini.js edits the file line by line so its comments survive. Headers are only whole [name] lines: a section is never cut at the next "[", since values such as [Gmail]/Bozze hold brackets, the trap AGENTS.md records. The shortcut actions are copied from qtmaildir's KeyMap::knownActions() rather than exported by it, since a CLI flag there would have meant translations and a changelog entry; the field also takes free text, and the copy is marked as a ponytail ceiling. The settings sidebar now clips, which a list long enough to scroll showed it did not: entries drew over the search box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules/qtmaildir/Ini.js')
-rw-r--r--desktop/modules/qtmaildir/Ini.js138
1 files changed, 138 insertions, 0 deletions
diff --git a/desktop/modules/qtmaildir/Ini.js b/desktop/modules/qtmaildir/Ini.js
new file mode 100644
index 0000000..0b23e51
--- /dev/null
+++ b/desktop/modules/qtmaildir/Ini.js
@@ -0,0 +1,138 @@
+// 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 library
+
+// Pure helpers for qtmaildir.conf, tested by test_ini.js under node.
+//
+// The file is walked a line at a time, never cut at the next "[": values
+// hold brackets ([Gmail]/Bozze), and a section body taken as "everything up
+// to the next [" once lost three accounts their labels (AGENTS.md). A header
+// is a line that is only "[name]". Comments and order are kept: a key unset
+// is commented out in place, a commented key that is set again is activated,
+// and anything this file does not touch passes through.
+//
+// Values follow QSettings' INI reading: an unquoted ; starts a comment, and
+// a "quoted" value may hold one.
+
+const HEADER = /^\s*\[([^\]]+)\]\s*(;.*)?$/;
+const KEY = /^(\s*)(;\s*)?([^=;\s][^=]*?)\s*=\s*(.*)$/;
+
+function value(rest) {
+ if (rest.startsWith('"')) {
+ let out = "";
+ for (let i = 1; i < rest.length; i++) {
+ const c = rest[i];
+ if (c === '"') return { v: out, end: i + 1 };
+ if (c === "\\" && i + 1 < rest.length) out += rest[++i];
+ else out += c;
+ }
+ return { v: out, end: rest.length };
+ }
+ const semi = rest.search(/\s*;/);
+ const raw = semi < 0 ? rest : rest.slice(0, semi);
+ return { v: raw.trim(), end: raw.replace(/\s+$/, "").length };
+}
+
+function quote(v) {
+ return /[;"]|^\s|\s$/.test(v) ? '"' + v.replace(/[\\"]/g, c => "\\" + c) + '"' : v;
+}
+
+// [{name, h, end}]: the header's line and the line after the section.
+function ranges(lines) {
+ const out = [];
+ lines.forEach((l, i) => {
+ const m = HEADER.exec(l);
+ if (!m) return;
+ if (out.length) out[out.length - 1].end = i;
+ out.push({ name: m[1].trim(), h: i, end: lines.length });
+ });
+ return out;
+}
+
+function sections(text) { return ranges(text.split("\n")).map(r => r.name); }
+
+// [{key, value}] for the active keys of a section, in file order.
+function entries(text, section) {
+ const lines = text.split("\n");
+ const r = ranges(lines).find(x => x.name === section);
+ if (!r) return [];
+ const out = [];
+ for (let i = r.h + 1; i < r.end; i++) {
+ const m = KEY.exec(lines[i]);
+ if (m && !m[2]) out.push({ key: m[3].trim(), value: value(m[4]).v });
+ }
+ return out;
+}
+
+function get(text, section, key) {
+ const e = entries(text, section).find(x => x.key === key);
+ return e ? e.value : undefined;
+}
+
+// value null unsets: the key is commented out where it stands.
+function set(text, section, key, val) {
+ const lines = text.split("\n");
+ const r = ranges(lines).find(x => x.name === section);
+ if (!r) {
+ if (val === null) return text;
+ return text.replace(/\n*$/, "") + "\n\n[" + section + "]\n" + key + " = " + quote(val) + "\n";
+ }
+ let commented = -1;
+ let last = r.h;
+ for (let i = r.h + 1; i < r.end; i++) {
+ const m = KEY.exec(lines[i]);
+ if (lines[i].trim() !== "") last = i;
+ if (!m || m[3].trim() !== key) continue;
+ const tail = m[4].slice(value(m[4]).end);
+ if (!m[2]) {
+ lines[i] = val === null ? m[1] + "; " + key + " = " + m[4]
+ : m[1] + key + " = " + quote(val) + tail;
+ return lines.join("\n");
+ }
+ if (commented < 0) commented = i;
+ }
+ if (val === null) return text;
+ if (commented >= 0) {
+ const m = KEY.exec(lines[commented]);
+ lines[commented] = m[1] + key + " = " + quote(val) + m[4].slice(value(m[4]).end);
+ } else lines.splice(last + 1, 0, key + " = " + quote(val));
+ return lines.join("\n");
+}
+
+// Deletes an active key's line outright, for the free-form sections
+// ([tagcolors], [keys]) where a commented-out row would only pile up.
+function remove(text, section, key) {
+ const lines = text.split("\n");
+ const r = ranges(lines).find(x => x.name === section);
+ if (!r) return text;
+ for (let i = r.h + 1; i < r.end; i++) {
+ const m = KEY.exec(lines[i]);
+ if (m && !m[2] && m[3].trim() === key) { lines.splice(i, 1); return lines.join("\n"); }
+ }
+ return text;
+}
+
+// pairs: [[key, value], ...]
+function addSection(text, section, pairs) {
+ return text.replace(/\n*$/, "") + "\n\n[" + section + "]\n"
+ + pairs.map(p => p[0] + " = " + quote(p[1])).join("\n") + "\n";
+}
+
+// The header through the line before the next header, which takes the blank
+// line that separated it from the next section with it.
+function removeSection(text, section) {
+ const lines = text.split("\n");
+ const r = ranges(lines).find(x => x.name === section);
+ if (!r) return text;
+ lines.splice(r.h, r.end - r.h);
+ return lines.join("\n");
+}