diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 19:26:33 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 19:26:33 -0300 |
| commit | d80640d01eeb84907e36ff4af33add9081eda17c (patch) | |
| tree | 27698b34e06e28d111c2196700015d9c82f78274 /src/wamail/store.js | |
| download | wa-cli-main.tar.gz wa-cli-main.zip | |
Diffstat (limited to 'src/wamail/store.js')
| -rw-r--r-- | src/wamail/store.js | 172 |
1 files changed, 172 insertions, 0 deletions
diff --git a/src/wamail/store.js b/src/wamail/store.js new file mode 100644 index 0000000..fc8a6e0 --- /dev/null +++ b/src/wamail/store.js @@ -0,0 +1,172 @@ +// Per-chat Maildir + persistent state + lock, for wamail. +// +// <maildir>/<chat-dir>/{new,cur,tmp}/<ts>.<stanzaId>.wa[:2,<flags>] +// ~/.local/state/wamail/<account>.json last synced state (base for the diff) +// ~/.local/state/wamail/<account>.lock one sync at a time (timer, hook, neomutt macro) + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const HOME = os.homedir(); +export const STATE_DIR = path.join(process.env.XDG_STATE_HOME || path.join(HOME, '.local/state'), 'wamail'); +export const CACHE_DIR = path.join(process.env.XDG_CACHE_HOME || path.join(HOME, '.cache'), 'wamail'); + +const FILE_RE = /^(\d+)\.([A-Za-z0-9_-]+)\.wa(?::2,([A-Za-z]*))?$/; + +export const expandHome = (p) => p.replace(/^~(?=$|\/)/, HOME); + +// ---------------------------------------------------------------- nombres + +export function slug(name) { + return String(name || '') + .normalize('NFD').replace(/\p{M}/gu, '') + .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') + .slice(0, 32).replace(/-+$/, ''); +} + +// Stable directory for a chat: chosen once and kept in the state even if the chat +// name changes later. +export function chatDirName(chatId, name, taken) { + const user = chatId.split('@')[0].replace(/\D/g, ''); + const base = slug(name) || (chatId.endsWith('@g.us') ? 'grupo' : 'chat'); + for (let n = 6; n <= user.length + 1; n++) { + const candidate = `${base}-${user.slice(-n) || n}`; + if (!taken.has(candidate)) return candidate; + } + let i = 2; + while (taken.has(`${base}-${user}-${i}`)) i++; + return `${base}-${user}-${i}`; +} + +export const stanzaFileId = (stanzaId) => String(stanzaId).replace(/[^A-Za-z0-9_-]/g, '_'); + +// ---------------------------------------------------------------- Maildir + +export function ensureMaildir(dir) { + for (const sub of ['new', 'cur', 'tmp']) fs.mkdirSync(path.join(dir, sub), { recursive: true }); +} + +// { [fileId]: { path, sub, flags: Set, ts } } — reads new/ before cur/ so a message that +// neomutt moves from new to cur during the scan is not lost. +export function scanDir(dir) { + const out = {}; + for (const sub of ['new', 'cur']) { + let names; + try { + names = fs.readdirSync(path.join(dir, sub)); + } catch { + continue; + } + for (const name of names) { + const m = FILE_RE.exec(name); + if (!m) continue; + const entry = { path: path.join(dir, sub, name), sub, flags: new Set(m[3] || ''), ts: Number(m[1]) }; + const prev = out[m[2]]; + if (prev) { + // duplicate (hook/timer race): keep the one in cur/ + const drop = prev.sub === 'cur' ? entry : prev; + fs.rmSync(drop.path, { force: true }); + if (drop === prev) out[m[2]] = entry; + continue; + } + out[m[2]] = entry; + } + } + return out; +} + +export function writeMessage(dir, { fileId, ts, raw, seen }) { + ensureMaildir(dir); + const base = `${ts}.${fileId}.wa`; + const tmp = path.join(dir, 'tmp', `${base}.${process.pid}`); + const dest = seen ? path.join(dir, 'cur', `${base}:2,S`) : path.join(dir, 'new', base); + fs.writeFileSync(tmp, raw); + fs.utimesSync(tmp, ts, ts); + fs.renameSync(tmp, dest); + return { path: dest, sub: seen ? 'cur' : 'new', flags: new Set(seen ? 'S' : ''), ts }; +} + +// Changes the flags of a file (moving it to cur/). Returns the updated entry. +export function setFlags(dir, fileId, entry, flags) { + const want = [...flags].sort().join(''); + if (entry.sub === 'cur' && [...entry.flags].sort().join('') === want) return entry; + const dest = path.join(dir, 'cur', `${entry.ts}.${fileId}.wa:2,${want}`); + try { + fs.renameSync(entry.path, dest); + } catch (e) { + if (e.code === 'ENOENT') return entry; // neomutt moved/deleted it meanwhile + throw e; + } + return { ...entry, path: dest, sub: 'cur', flags: new Set(want) }; +} + +// ---------------------------------------------------------------- estado + +export function statePath(account) { + return path.join(STATE_DIR, `${account}.json`); +} + +export function loadState(account) { + try { + const s = JSON.parse(fs.readFileSync(statePath(account), 'utf8')); + if (s.version === 1) return { chats: {}, msgs: {}, tombstones: {}, ...s }; + } catch (e) { + if (e.code !== 'ENOENT') throw new Error(`corrupt state in ${statePath(account)}: ${e.message}`); + } + return { version: 1, chats: {}, msgs: {}, tombstones: {} }; +} + +export function saveState(account, state) { + fs.mkdirSync(STATE_DIR, { recursive: true }); + const file = statePath(account); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(state)); + fs.renameSync(tmp, file); +} + +// ---------------------------------------------------------------- lock + +function alive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return e.code === 'EPERM'; + } +} + +export async function withLock(account, { waitMs = 120000, onWait } = {}, fn) { + fs.mkdirSync(STATE_DIR, { recursive: true }); + const file = path.join(STATE_DIR, `${account}.lock`); + const deadline = Date.now() + waitMs; + let warned = false; + for (;;) { + try { + fs.writeFileSync(file, `${process.pid}\n`, { flag: 'wx' }); + break; + } catch (e) { + if (e.code !== 'EEXIST') throw e; + const pid = Number.parseInt(fs.readFileSync(file, 'utf8'), 10); + if (!pid || !alive(pid)) { + fs.rmSync(file, { force: true }); + continue; + } + if (Date.now() > deadline) throw new Error(`another wamail (pid ${pid}) has held the lock for too long`); + if (!warned) onWait?.(pid); + warned = true; + await new Promise((r) => setTimeout(r, 300)); + } + } + const release = () => { + try { + if (Number.parseInt(fs.readFileSync(file, 'utf8'), 10) === process.pid) fs.rmSync(file, { force: true }); + } catch { /* already gone */ } + }; + process.once('exit', release); + try { + return await fn(); + } finally { + release(); + } +} |