// 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 desktop-assistant's config.toml, tested by // test_config.js under node. // // The file is edited line by line so its comments survive: the example it // is copied from documents every key in place. Only the shapes this page // writes are understood: top-level `key = value` before the first table, // and `KEY = "value"` lines in [env]. [[cloud]] tables are read when they // hold only what setClouds writes, and rewritten whole. A top-level key that is unset sits // commented out (`# key = value`), which is how the example ships, so // setting one activates its line and unsetting comments it back out. // ponytail: no multi-line strings or arrays; the page never writes one, // and Save validates the whole file with tomllib before writing. const KEY = /^(\s*)(#\s*)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/; const HEADER = /^\s*\[/; function quote(s) { return '"' + String(s).replace(/[\\"]/g, c => "\\" + c) .replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r") + '"'; } // The value at the start of rest and where it ends, so what follows (an // inline comment) can be kept. 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) { const n = rest[++i]; out += n === "n" ? "\n" : n === "t" ? "\t" : n === "r" ? "\r" : n; } else out += c; } return { v: out, end: rest.length }; } const m = /^[^\s#]*/.exec(rest)[0]; const v = m === "true" ? true : m === "false" ? false : m !== "" && !isNaN(Number(m)) ? Number(m) : m; return { v: v, end: m.length }; } function firstHeader(lines) { const i = lines.findIndex(l => HEADER.test(l) && !/^\s*#/.test(l)); return i < 0 ? lines.length : i; } // {key: value} for the active top-level keys among keys. function scalars(text, keys) { const lines = text.split("\n"); const out = {}; for (let i = 0; i < firstHeader(lines); i++) { const m = KEY.exec(lines[i]); if (m && !m[2] && keys.includes(m[3])) out[m[3]] = value(m[4]).v; } return out; } // toml is the value already written as TOML (quote() for a string), or null // to unset. function setScalar(text, key, toml) { const lines = text.split("\n"); const end = firstHeader(lines); let commented = -1; for (let i = 0; i < end; i++) { const m = KEY.exec(lines[i]); if (!m || m[3] !== key) continue; if (!m[2]) { const tail = m[4].slice(value(m[4]).end); lines[i] = toml === null ? m[1] + "# " + key + " = " + m[4] : m[1] + key + " = " + toml + tail; return lines.join("\n"); } if (commented < 0) commented = i; } if (toml === null) return text; if (commented >= 0) { const m = KEY.exec(lines[commented]); lines[commented] = m[1] + key + " = " + toml + m[4].slice(value(m[4]).end); } else { // Before the blank line that separates the first table, if any. const at = end > 0 && lines[end - 1].trim() === "" ? end - 1 : end; lines.splice(at, 0, key + " = " + toml); } return lines.join("\n"); } function envRange(lines) { const h = lines.findIndex(l => /^\s*\[env\]\s*(#.*)?$/.test(l)); if (h < 0) return null; let e = lines.findIndex((l, i) => i > h && HEADER.test(l)); return { h: h, end: e < 0 ? lines.length : e }; } // [{key, value}] for the active lines of [env], in file order. function env(text) { const lines = text.split("\n"); const r = envRange(lines); 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], value: String(value(m[4]).v) }); } return out; } // value is a plain string, quoted here; null removes the key. function setEnv(text, key, val) { const lines = text.split("\n"); const r = envRange(lines); if (!r) { if (val === null) return text; return text.replace(/\n*$/, "") + "\n\n[env]\n" + key + " = " + quote(val) + "\n"; } let last = r.h; for (let i = r.h + 1; i < r.end; i++) { const m = KEY.exec(lines[i]); if (!m || m[2]) continue; if (m[3] === key) { if (val === null) lines.splice(i, 1); else lines[i] = m[1] + key + " = " + quote(val) + m[4].slice(value(m[4]).end); return lines.join("\n"); } last = i; } if (val === null) return text; lines.splice(last + 1, 0, key + " = " + quote(val)); return lines.join("\n"); } // [[cloud]] tables, the daemon's providers: name, url and key_cmd strings // and a one-line models array, as setClouds writes them. const CLOUD = /^\s*\[\[cloud\]\]\s*(#.*)?$/; // A one-line array of strings, ["a", "b"]; null for anything else. function strings(rest) { let r = rest.trim(); if (!r.startsWith("[")) return null; const out = []; r = r.slice(1).trim(); while (!r.startsWith("]")) { if (!r.startsWith('"')) return null; const v = value(r); out.push(v.v); r = r.slice(v.end).trim(); if (r.startsWith(",")) r = r.slice(1).trim(); else if (!r.startsWith("]")) return null; } const tail = r.slice(1).trim(); return tail === "" || tail.startsWith("#") ? out : null; } // [{start, end}] of each [[cloud]] table, its header to the next header. function cloudBlocks(lines) { const out = []; lines.forEach((l, i) => { if (!CLOUD.test(l)) return; const e = lines.findIndex((x, j) => j > i && HEADER.test(x)); out.push({ start: i, end: e < 0 ? lines.length : e }); }); return out; } // [{name, url, key_cmd, models}] in file order, or null when a table holds // a line this cannot read (a multi-line array, another key): the page then // leaves them to be edited by hand rather than rewrite what it did not read. function clouds(text) { const lines = text.split("\n"); const out = []; for (const b of cloudBlocks(lines)) { const c = { name: "", url: "", key_cmd: "", models: [] }; for (let i = b.start + 1; i < b.end; i++) { if (/^\s*(#.*)?$/.test(lines[i])) continue; const m = KEY.exec(lines[i]); if (!m) return null; if (m[3] === "models") { const a = strings(m[4]); if (a === null) return null; c.models = a; } else if (["name", "url", "key_cmd"].includes(m[3]) && m[4].startsWith('"')) { c[m[3]] = value(m[4]).v; } else return null; } out.push(c); } return out; } // Replaces every [[cloud]] table with list's, at the end of the file. The // lines inside the old tables go with them; comments above them stay. function setClouds(text, list) { const lines = text.split("\n"); for (const b of cloudBlocks(lines).reverse()) lines.splice(b.start, b.end - b.start); while (lines.length && lines[lines.length - 1].trim() === "") lines.pop(); for (const c of list) lines.push("", "[[cloud]]", "name = " + quote(c.name), "url = " + quote(c.url), "key_cmd = " + quote(c.key_cmd), "models = [" + c.models.map(quote).join(", ") + "]"); return lines.join("\n") + "\n"; } // Why list cannot be saved, "" when it can: the daemon refuses to start on // any of these (assistant.py, check_cloud). function cloudError(list) { for (const c of list) { if (!c.name.trim() || !c.url.trim() || !c.key_cmd.trim()) return "A cloud provider needs a name, a URL and a key command."; if (c.name.includes(":")) return `Cloud name ${c.name} has a colon.`; } const names = list.map(c => c.name); const dup = names.find((n, i) => names.indexOf(n) !== i); return dup !== undefined ? `Cloud name ${dup} is used twice.` : ""; }