From c6fc5bbef45bcfa4f8d5e50ca7401aa1f5b6bc7d Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 17 Oct 2026 11:08:49 +0200 Subject: 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 --- desktop/modules/qtmaildir/Ini.js | 138 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 desktop/modules/qtmaildir/Ini.js (limited to 'desktop/modules/qtmaildir/Ini.js') 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. +// +// 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"); +} -- cgit v1.2.3