mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
feat(recovery): autosave buffer + crash-recovery banner
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
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -152,9 +152,7 @@ function buildLatexThemeHeader(themeId) {
|
||||
// exotic minimal TeX install degrades by omission (pandoc still succeeds)
|
||||
lines.push('\\usepackage{titlesec}');
|
||||
if (p.sansHeadings) lines.push('\\renewcommand{\\familydefault}{\\sfdefault}');
|
||||
const rule = p.sectionRule && p.ruleColor
|
||||
? '{\\color{mcthemerule}\\titlerule[0.6pt]}'
|
||||
: '';
|
||||
const rule = p.sectionRule && p.ruleColor ? '{\\color{mcthemerule}\\titlerule[0.6pt]}' : '';
|
||||
lines.push(
|
||||
`\\titleformat{\\section}{\\LARGE\\bfseries\\color{mcthemeheading}}{\\thesection}{0.8em}{}${rule}`
|
||||
);
|
||||
@@ -167,7 +165,9 @@ function buildLatexThemeHeader(themeId) {
|
||||
|
||||
// Pandoc loads hyperref itself; color links via \hypersetup in the header
|
||||
lines.push('\\usepackage{etoolbox}');
|
||||
lines.push('\\AfterEndPreamble{\\hypersetup{colorlinks=true,linkcolor=mcthemelink,urlcolor=mcthemelink}}');
|
||||
lines.push(
|
||||
'\\AfterEndPreamble{\\hypersetup{colorlinks=true,linkcolor=mcthemelink,urlcolor=mcthemelink}}'
|
||||
);
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
@@ -193,7 +193,16 @@ function applyDocxTheme(docxPath, themeId, io = {}) {
|
||||
if (!stylesFile) return false;
|
||||
let xml = stylesFile.asText();
|
||||
|
||||
const headingIds = ['Title', 'Subtitle', 'Heading1', 'Heading2', 'Heading3', 'Heading4', 'Heading5', 'Heading6'];
|
||||
const headingIds = [
|
||||
'Title',
|
||||
'Subtitle',
|
||||
'Heading1',
|
||||
'Heading2',
|
||||
'Heading3',
|
||||
'Heading4',
|
||||
'Heading5',
|
||||
'Heading6',
|
||||
];
|
||||
for (const styleId of headingIds) {
|
||||
// Match this style's <w:style …w:styleId="X">…</w:style> block (they
|
||||
// never nest), then rewrite its rPr color/rFonts
|
||||
@@ -234,10 +243,7 @@ function applyDocxTheme(docxPath, themeId, io = {}) {
|
||||
// Body font: patch the docDefaults rPrDefault (affects Normal everywhere)
|
||||
if (d.bodyFont) {
|
||||
const patch = `<w:rFonts w:ascii="${d.bodyFont}" w:hAnsi="${d.bodyFont}" w:cs="${d.bodyFont}"/>`;
|
||||
xml = xml.replace(
|
||||
/(<w:docDefaults>\s*<w:rPrDefault>\s*<w:rPr>)/,
|
||||
`$1${patch}`
|
||||
);
|
||||
xml = xml.replace(/(<w:docDefaults>\s*<w:rPrDefault>\s*<w:rPr>)/, `$1${patch}`);
|
||||
}
|
||||
|
||||
zip.file('word/styles.xml', xml);
|
||||
|
||||
@@ -160,7 +160,9 @@ async function convertToMarkdown(inputPath, options = {}) {
|
||||
// Prefer the LAST informative stderr line: markitdown ends its
|
||||
// output with the actionable install hint ("pip install
|
||||
// 'markitdown[pdf]'"), while the first lines are a traceback.
|
||||
const stderrLines = safe(stderr).split('\n').filter((l) => l.trim().length > 0);
|
||||
const stderrLines = safe(stderr)
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().length > 0);
|
||||
const detail =
|
||||
stderrLines[stderrLines.length - 1] ||
|
||||
safe(error.message).split('\n')[0] ||
|
||||
|
||||
@@ -445,10 +445,7 @@ async function loadPdfjs() {
|
||||
// Windows path (C:\...\standard_fonts\) fails that check and breaks
|
||||
// extractText/extractImages on Windows, so always hand pdfjs a file:// URL.
|
||||
function getStandardFontDataUrl() {
|
||||
const dir = path.join(
|
||||
path.dirname(require.resolve('pdfjs-dist/package.json')),
|
||||
'standard_fonts'
|
||||
);
|
||||
const dir = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts');
|
||||
const url = require('url').pathToFileURL(dir);
|
||||
return url.href.endsWith('/') ? url.href : url.href + '/';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user