// Word editor — the add-on half. All the document work happens in the // editor tab; this side owns the sidebar panel, the scratch folder and the // ring of recently-opened documents. // // Flow: // panel picks a file (, or a drop) → invokes "stash" with // the bytes → we write them to the scratch dir and push them onto the // recent ring → panel invokes "openEditor" → we park a pointer under // storage.__pending and openTab("editor.html") → the editor drains // __pending on its first paint and pulls the bytes with "getBytes". // // Only a pointer goes through storage, never the document: add-on storage is // a single JSON file rewritten in full on every set, and a few megabytes of // base64 in there would make every unrelated write expensive. const fs = require("node:fs"); const path = require("node:path"); const MAX_RECENT = 8; // documents kept in the ring const SCRATCH_DIR = "docx-scratch"; const MAX_BYTES = 64 * 1024 * 1024; // refuse absurd inputs early module.exports = { activate(api) { api.registerSidebarPanel({ id: "main", title: "Word editor", page: "panel.html", }); // Per-add-on scratch dir under /addons-data/, same arrangement // as the screenshot add-on: never write inside the add-on folder itself, // where it would confuse anyone reading the shipped source. const dataParent = path.dirname(path.join(api.folder, "..")); const scratchDir = path.join(dataParent, "addons-data", SCRATCH_DIR); try { fs.mkdirSync(scratchDir, { recursive: true }); } catch (e) { api.log("scratch mkdir failed:", e?.message); } function stamp() { const d = new Date(); const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; } // A scratch filename that can't escape the folder however the original // was named. The display name is kept separately in the ring. function scratchName(displayName) { const base = String(displayName || "document") .replace(/\.docx$/i, "") .replace(/[^\w.\- ]+/g, "_") .replace(/\s+/g, " ") .trim() .slice(0, 60) || "document"; return `${stamp()}-${base}.docx`; } function pruneRecent(recent) { const alive = recent.filter((r) => { try { fs.accessSync(r.path); return true; } catch { return false; } }); const keep = alive.slice(0, MAX_RECENT); for (const gone of alive.slice(MAX_RECENT)) { try { fs.unlinkSync(gone.path); } catch {} } return keep; } function readRecent() { let recent = api.storage.get("recent", []); return Array.isArray(recent) ? recent : []; } function findRecent(id) { return readRecent().find((r) => r.id === id) || null; } function pathOf(id) { // Belt and braces: the id came from a renderer, so re-derive the path // from the ring rather than joining whatever string arrived. const hit = findRecent(id); if (!hit) throw new Error(`no document "${id}" in the recent list`); const abs = path.resolve(hit.path); if (path.dirname(abs) !== path.resolve(scratchDir)) { throw new Error("document path escapes the scratch folder"); } return abs; } // Write bytes to the scratch folder and put them at the head of the ring. // `kind` distinguishes what the user opened from what the editor saved // back, so the panel can say which is which. function stash({ name, base64, kind, replaces }) { const buf = Buffer.from(String(base64 || ""), "base64"); if (!buf.length) throw new Error("no document bytes"); if (buf.length > MAX_BYTES) throw new Error(`document is too large (${(buf.length / 1048576).toFixed(0)} MB)`); // Every .docx is a zip; catching this here beats a confusing parse // error three layers deeper in the editor. if (!(buf[0] === 0x50 && buf[1] === 0x4b)) { throw new Error("that doesn't look like a .docx file"); } const id = scratchName(name); const file = path.join(scratchDir, id); fs.writeFileSync(file, buf); let recent = readRecent(); if (replaces) recent = recent.filter((r) => r.id !== replaces); recent.unshift({ id, name: String(name || "document.docx"), path: file, bytes: buf.length, at: Date.now(), kind: kind === "saved" ? "saved" : "opened", }); recent = pruneRecent(recent); api.storage.set("recent", recent); api.log(`stashed ${id} (${buf.length} bytes, ${kind || "opened"})`); return { id, name: String(name || "document.docx"), bytes: buf.length }; } api.onMessage("stash", (payload) => stash(payload || {})); // Hand a document to the editor tab. The pointer under __pending is what // the editor drains on its first paint; the query string carries the same // id so a reload of the tab still finds its document. api.onMessage("openEditor", (payload) => { const id = payload && payload.id ? String(payload.id) : ""; if (id) { const hit = findRecent(id); if (!hit) throw new Error(`no document "${id}" in the recent list`); api.storage.set("__pending", { id, name: hit.name, at: Date.now() }); } else { api.storage.set("__pending", null); } api.openTab("editor.html", id ? { query: { doc: id } } : undefined); api.log(id ? `opening editor for ${id}` : "opening editor with a blank document"); return { ok: true, id }; }); api.onMessage("listRecent", () => { const recent = pruneRecent(readRecent()); api.storage.set("recent", recent); return recent.map((r) => ({ id: r.id, name: r.name, bytes: r.bytes, at: r.at, kind: r.kind })); }); api.onMessage("getBytes", (payload) => { const id = String(payload && payload.id || ""); const hit = findRecent(id); if (!hit) throw new Error(`no document "${id}" in the recent list`); const buf = fs.readFileSync(pathOf(id)); return { id, name: hit.name, base64: buf.toString("base64"), bytes: buf.length, at: hit.at, kind: hit.kind }; }); // Autosave. The editor calls this as the user works; each document keeps // one autosave entry rather than filling the ring with its own history. api.onMessage("autosave", (payload) => { const p = payload || {}; const res = stash({ name: String(p.name || "document.docx"), base64: p.base64, kind: "saved", replaces: p.replaces ? String(p.replaces) : "", }); return res; }); api.onMessage("clearRecent", (payload) => { const id = payload && payload.id ? String(payload.id) : ""; let recent = readRecent(); if (id) { const hit = recent.find((r) => r.id === id); if (hit) { try { fs.unlinkSync(hit.path); } catch {} } recent = recent.filter((r) => r.id !== id); } else { for (const r of recent) { try { fs.unlinkSync(r.path); } catch {} } recent = []; } api.storage.set("recent", recent); return { ok: true }; }); api.onMessage("openFolder", (payload) => { const { shell } = api.require("electron"); const id = payload && payload.id ? String(payload.id) : ""; if (id) { const hit = findRecent(id); if (hit) { shell.showItemInFolder(hit.path); return { ok: true, path: hit.path }; } } shell.openPath(scratchDir); return { ok: true, path: scratchDir }; }); api.log("registered docx-editor sidebar panel"); }, };