- 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.
191 lines
7.6 KiB
JavaScript
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");
|
|
},
|
|
};
|