theseus/extensions/docx-editor/index.js
Local Dev 862e92543c extensions: real PDF/DOC icons + fix translate default URL
- pdf-editor 0.1.1 → 0.1.2: manifest.icon is a data:image/svg+xml red PDF
  document badge (dock renders it as <img>). Same on toolbar-menu.icon.
  Item-level icons dropped — those go through the native OS menu that
  doesn't render data URIs.
- translate 0.1.0 → 0.1.1: default LibreTranslate mirror was
  translate.argosopentech.com, which is now a dead domain — panel just
  said "Failed" on every request. Switched default to
  translate.disroot.org (currently up), added a datalist of known
  mirrors, and a one-shot migration off the dead default so existing
  installs recover on next load.
- docx-editor 0.1.0 → 0.1.1: manifest.icon is a data:image/svg+xml blue
  DOC document badge, index.js no longer overrides it with 📝, and the
  panel header uses the same inline SVG. Same rationale as pdf-editor —
  every extension was rendering as either 📄 or 📝, so PDF and Word
  were visually identical to the Notepad.
2026-09-21 02:41:16 +02:00

191 lines
7.6 KiB
JavaScript

// 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 (<input type=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 <userData>/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");
},
};