mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
VersionHistory snapshots the previous content on every explicit save — an unsaved buffer is still lost on crash. AutosaveBuffer fills that gap. - src/main/AutosaveBuffer.js — pure module mirroring VersionHistory's injectable-IO pattern; one blob per doc path under <userData>/autosave/by-path/<sha1>/recovery.md + meta.json. No history (VersionHistory owns that) — just the latest dirty buffer. - src/main.js — IPC channels autosave:write/read/clear/list; real paths go through validatePath, synthetic 'untitled-tab-<id>' keys skip it. - src/preload.js — added the four channels to ALLOWED_SEND_CHANNELS. - src/renderer/autosave-client.js — debounced (2s) flush per tab + periodic safety net (10s max age) + dirty-write retry on failure. - src/renderer.js — register on tab create, unregister on close, notifyChange piggybacks on performAutoSave's existing dirty-check, clearForDocPath after a successful save, showAutosaveRecoveryBanner on startup listing pending recoveries with Restore/Dismiss. Tests (39 new): - tests/main/AutosaveBuffer.test.js (19): round-trip, overwrite, isolation, unicode/emoji, empty content, null coercion, ENOENT vs corrupt meta, list ordering, corrupt-sibling skip, input validation, sha1 storage. - tests/autosave-client.test.js (11): debounce, flushNow bypass, no-path skip, clearForDocPath, list proxy, IPC error fallback, unregister tear-down, failure-retry, periodic flush, idempotency. Full suite: 692 tests pass, 63 suites, lint+format clean. Amit Haridas
179 lines
6.0 KiB
JavaScript
179 lines
6.0 KiB
JavaScript
/**
|
|
* Autosave + crash-recovery buffer.
|
|
*
|
|
* Mirrors VersionHistory's pattern (per-doc isolated folder, injectable IO for
|
|
* tests) but covers a different gap: VersionHistory snapshots the *previous*
|
|
* content on every explicit save, so an unsaved buffer is lost when the app
|
|
* crashes. AutosaveBuffer fills that gap by periodically (renderer-driven)
|
|
* writing the current in-memory buffer to a recovery store.
|
|
*
|
|
* Recovery model:
|
|
* - <userData>/autosave/by-path/<sha1-of-path>/recovery.md — latest buffer
|
|
* - <userData>/autosave/by-path/<sha1-of-path>/meta.json — {docPath, savedAt, byteSize, appVersion}
|
|
*
|
|
* One recovery entry per document is sufficient — newer writes overwrite older
|
|
* ones. There's no history to keep (VersionHistory handles that).
|
|
*
|
|
* Safety:
|
|
* - Writes are atomic via writeFileSync (small files; loss of partial write
|
|
* is preferable to corrupted recovery)
|
|
* - meta.json is read defensively; missing/corrupt = empty list
|
|
* - All fs/path access is injectable for unit tests
|
|
*
|
|
* @module AutosaveBuffer
|
|
*/
|
|
|
|
function defaultCrypto() {
|
|
return require('crypto');
|
|
}
|
|
|
|
function folderFor(docPath, pathUtil, crypto) {
|
|
const hash = crypto.createHash('sha1').update(String(docPath)).digest('hex').slice(0, 16);
|
|
return pathUtil.join('by-path', hash);
|
|
}
|
|
|
|
/**
|
|
* Write the current buffer for a document. Overwrites any prior recovery
|
|
* entry — the latest buffer is the only one that matters for crash recovery.
|
|
*
|
|
* @param {object} args
|
|
* @param {string} args.docPath Absolute path of the document (use a synthetic
|
|
* key like 'untitled-tab-<tabId>' for tabs that have never been saved)
|
|
* @param {string} args.content Buffer content to persist
|
|
* @param {string} [args.appVersion] Stored so a future schema change can ignore stale entries
|
|
* @param {object} args.io { rootDir, fs, pathUtil, crypto }
|
|
* @returns {{savedAt:number, byteSize:number}} meta written alongside the blob
|
|
*/
|
|
function writeRecovery({ docPath, content, appVersion = 'unknown', io }) {
|
|
if (!docPath || typeof docPath !== 'string') {
|
|
throw new Error('AutosaveBuffer: docPath is required');
|
|
}
|
|
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
|
|
const dir = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto));
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
|
|
const body = String(content ?? '');
|
|
const savedAt = Date.now();
|
|
const byteSize = Buffer.byteLength(body, 'utf-8');
|
|
|
|
fs.writeFileSync(pathUtil.join(dir, 'recovery.md'), body, 'utf-8');
|
|
fs.writeFileSync(
|
|
pathUtil.join(dir, 'meta.json'),
|
|
JSON.stringify({ docPath, savedAt, byteSize, appVersion }, null, 2),
|
|
'utf-8'
|
|
);
|
|
return { savedAt, byteSize };
|
|
}
|
|
|
|
/**
|
|
* Read the recovery blob + meta for a document. Returns null when no entry
|
|
* exists; throws when meta.json is present but corrupt (so the renderer can
|
|
* surface a "couldn't read recovery" warning instead of silently swallowing it).
|
|
*
|
|
* @returns {{docPath:string, content:string, savedAt:number, byteSize:number, appVersion:string}|null}
|
|
*/
|
|
function readRecovery({ docPath, io }) {
|
|
if (!docPath || typeof docPath !== 'string') {
|
|
throw new Error('AutosaveBuffer: docPath is required');
|
|
}
|
|
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
|
|
const dir = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto));
|
|
const metaPath = pathUtil.join(dir, 'meta.json');
|
|
const blobPath = pathUtil.join(dir, 'recovery.md');
|
|
|
|
let meta;
|
|
try {
|
|
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') return null;
|
|
throw new Error(`AutosaveBuffer: corrupt meta.json for ${docPath}: ${err.message}`);
|
|
}
|
|
|
|
let content;
|
|
try {
|
|
content = fs.readFileSync(blobPath, 'utf-8');
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') return null;
|
|
throw err;
|
|
}
|
|
|
|
return {
|
|
docPath: meta.docPath || docPath,
|
|
content,
|
|
savedAt: meta.savedAt || 0,
|
|
byteSize: meta.byteSize ?? Buffer.byteLength(content, 'utf-8'),
|
|
appVersion: meta.appVersion || 'unknown',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Delete the recovery entry for a document. Called after a successful explicit
|
|
* save (the buffer is now on disk at its real path) or after the user
|
|
* dismisses the recovery banner.
|
|
*
|
|
* @returns {boolean} whether an entry was removed
|
|
*/
|
|
function clearRecovery({ docPath, io }) {
|
|
if (!docPath || typeof docPath !== 'string') {
|
|
throw new Error('AutosaveBuffer: docPath is required');
|
|
}
|
|
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
|
|
const dir = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto));
|
|
let removedAny = false;
|
|
for (const file of ['recovery.md', 'meta.json']) {
|
|
try {
|
|
fs.unlinkSync(pathUtil.join(dir, file));
|
|
removedAny = true;
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') throw err;
|
|
}
|
|
}
|
|
return removedAny;
|
|
}
|
|
|
|
/**
|
|
* List every recovery entry across all documents. Used at startup to decide
|
|
* whether to show a "recover unsaved work?" prompt.
|
|
*
|
|
* @returns {Array<{docPath:string, savedAt:number, byteSize:number, appVersion:string}>}
|
|
*/
|
|
function listRecoveries({ io }) {
|
|
const { rootDir, fs, pathUtil } = io;
|
|
const baseDir = pathUtil.join(rootDir, 'by-path');
|
|
let dirs;
|
|
try {
|
|
dirs = fs.readdirSync(baseDir);
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') return [];
|
|
throw err;
|
|
}
|
|
const out = [];
|
|
for (const hash of dirs) {
|
|
const metaPath = pathUtil.join(baseDir, hash, 'meta.json');
|
|
try {
|
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
if (meta && typeof meta.docPath === 'string') {
|
|
out.push({
|
|
docPath: meta.docPath,
|
|
savedAt: meta.savedAt || 0,
|
|
byteSize: meta.byteSize || 0,
|
|
appVersion: meta.appVersion || 'unknown',
|
|
});
|
|
}
|
|
} catch {
|
|
// Corrupt or missing meta — skip; the next write will rewrite it
|
|
}
|
|
}
|
|
// Newest first — most likely to be relevant
|
|
out.sort((a, b) => b.savedAt - a.savedAt);
|
|
return out;
|
|
}
|
|
|
|
module.exports = {
|
|
writeRecovery,
|
|
readRecovery,
|
|
clearRecovery,
|
|
listRecoveries,
|
|
folderFor,
|
|
};
|