mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +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:
+64
-5
@@ -1735,8 +1735,7 @@ function showThirdPartyNoticesWindow() {
|
|||||||
return `(Could not read ${file} in this installation — see the source repository.)`;
|
return `(Could not read ${file} in this installation — see the source repository.)`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const esc = (s) =>
|
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
|
|
||||||
const html =
|
const html =
|
||||||
'<!doctype html><html><head><meta charset="utf-8"><title>Third-Party Notices</title><style>' +
|
'<!doctype html><html><head><meta charset="utf-8"><title>Third-Party Notices</title><style>' +
|
||||||
@@ -3918,9 +3917,7 @@ async function importWithMarkItDown() {
|
|||||||
dialog.showErrorBox(
|
dialog.showErrorBox(
|
||||||
'MarkItDown Import',
|
'MarkItDown Import',
|
||||||
sanitizeErrorMessage(
|
sanitizeErrorMessage(
|
||||||
(error.code === 'not_installed'
|
(error.code === 'not_installed' ? error.message : `Import failed: ${error.message}`) +
|
||||||
? error.message
|
|
||||||
: `Import failed: ${error.message}`) +
|
|
||||||
'\n\nMarkItDown is an optional Python tool from Microsoft (MIT):\n' +
|
'\n\nMarkItDown is an optional Python tool from Microsoft (MIT):\n' +
|
||||||
' pip install "markitdown[all]"'
|
' pip install "markitdown[all]"'
|
||||||
)
|
)
|
||||||
@@ -3980,6 +3977,7 @@ ipcMain.on('set-current-file', (event, filePath) => {
|
|||||||
// Version History (local rollback without Git)
|
// Version History (local rollback without Git)
|
||||||
// ============================================
|
// ============================================
|
||||||
const VersionHistory = require('./main/VersionHistory');
|
const VersionHistory = require('./main/VersionHistory');
|
||||||
|
const AutosaveBuffer = require('./main/AutosaveBuffer');
|
||||||
|
|
||||||
/** IO bundle for VersionHistory bound to <userData>/versions. */
|
/** IO bundle for VersionHistory bound to <userData>/versions. */
|
||||||
function versionHistoryIo() {
|
function versionHistoryIo() {
|
||||||
@@ -4021,6 +4019,67 @@ ipcMain.handle('version-history:delete', (_event, { docPath, id } = {}) => {
|
|||||||
return VersionHistory.deleteVersion({ docPath, id, io: versionHistoryIo() });
|
return VersionHistory.deleteVersion({ docPath, id, io: versionHistoryIo() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Autosave + crash-recovery buffer
|
||||||
|
// ================================
|
||||||
|
// The renderer periodically writes the current in-memory buffer here so an
|
||||||
|
// unexpected quit (crash, force-kill, power loss) doesn't lose unsaved work.
|
||||||
|
// On startup the renderer asks for the pending list and offers recovery; a
|
||||||
|
// successful explicit save clears the entry because the buffer is now on
|
||||||
|
// disk at its real path.
|
||||||
|
function autosaveIo() {
|
||||||
|
return {
|
||||||
|
rootDir: path.join(app.getPath('userData'), 'autosave'),
|
||||||
|
fs,
|
||||||
|
pathUtil: path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('autosave:write', (_event, { docPath, content } = {}) => {
|
||||||
|
if (typeof docPath !== 'string' || !docPath) {
|
||||||
|
throw new Error('Invalid docPath for autosave');
|
||||||
|
}
|
||||||
|
// Untitled tabs use synthetic keys like 'untitled-tab-<id>'; real paths go
|
||||||
|
// through the same validator VersionHistory uses so a renderer compromise
|
||||||
|
// can't escape the userData root.
|
||||||
|
const isSynthetic = docPath.startsWith('untitled-tab-');
|
||||||
|
if (!isSynthetic) {
|
||||||
|
const validation = validatePath(docPath);
|
||||||
|
if (!validation.valid) throw new Error('Invalid document path for autosave');
|
||||||
|
}
|
||||||
|
return AutosaveBuffer.writeRecovery({
|
||||||
|
docPath,
|
||||||
|
content,
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
io: autosaveIo(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('autosave:read', (_event, { docPath } = {}) => {
|
||||||
|
if (typeof docPath !== 'string' || !docPath) return null;
|
||||||
|
const isSynthetic = docPath.startsWith('untitled-tab-');
|
||||||
|
if (!isSynthetic) {
|
||||||
|
const validation = validatePath(docPath);
|
||||||
|
if (!validation.valid) return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return AutosaveBuffer.readRecovery({ docPath, io: autosaveIo() });
|
||||||
|
} catch (err) {
|
||||||
|
// Corrupt meta.json — surface the error so the renderer can warn, instead
|
||||||
|
// of swallowing it.
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('autosave:clear', (_event, { docPath } = {}) => {
|
||||||
|
if (typeof docPath !== 'string' || !docPath) return false;
|
||||||
|
return AutosaveBuffer.clearRecovery({ docPath, io: autosaveIo() });
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('autosave:list', () => {
|
||||||
|
return AutosaveBuffer.listRecoveries({ io: autosaveIo() });
|
||||||
|
});
|
||||||
|
|
||||||
// Handle actual printing when renderer is ready
|
// Handle actual printing when renderer is ready
|
||||||
ipcMain.on('do-print', (event, { withStyles }) => {
|
ipcMain.on('do-print', (event, { withStyles }) => {
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
|
|||||||
@@ -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)
|
// exotic minimal TeX install degrades by omission (pandoc still succeeds)
|
||||||
lines.push('\\usepackage{titlesec}');
|
lines.push('\\usepackage{titlesec}');
|
||||||
if (p.sansHeadings) lines.push('\\renewcommand{\\familydefault}{\\sfdefault}');
|
if (p.sansHeadings) lines.push('\\renewcommand{\\familydefault}{\\sfdefault}');
|
||||||
const rule = p.sectionRule && p.ruleColor
|
const rule = p.sectionRule && p.ruleColor ? '{\\color{mcthemerule}\\titlerule[0.6pt]}' : '';
|
||||||
? '{\\color{mcthemerule}\\titlerule[0.6pt]}'
|
|
||||||
: '';
|
|
||||||
lines.push(
|
lines.push(
|
||||||
`\\titleformat{\\section}{\\LARGE\\bfseries\\color{mcthemeheading}}{\\thesection}{0.8em}{}${rule}`
|
`\\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
|
// Pandoc loads hyperref itself; color links via \hypersetup in the header
|
||||||
lines.push('\\usepackage{etoolbox}');
|
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';
|
return lines.join('\n') + '\n';
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,16 @@ function applyDocxTheme(docxPath, themeId, io = {}) {
|
|||||||
if (!stylesFile) return false;
|
if (!stylesFile) return false;
|
||||||
let xml = stylesFile.asText();
|
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) {
|
for (const styleId of headingIds) {
|
||||||
// Match this style's <w:style …w:styleId="X">…</w:style> block (they
|
// Match this style's <w:style …w:styleId="X">…</w:style> block (they
|
||||||
// never nest), then rewrite its rPr color/rFonts
|
// 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)
|
// Body font: patch the docDefaults rPrDefault (affects Normal everywhere)
|
||||||
if (d.bodyFont) {
|
if (d.bodyFont) {
|
||||||
const patch = `<w:rFonts w:ascii="${d.bodyFont}" w:hAnsi="${d.bodyFont}" w:cs="${d.bodyFont}"/>`;
|
const patch = `<w:rFonts w:ascii="${d.bodyFont}" w:hAnsi="${d.bodyFont}" w:cs="${d.bodyFont}"/>`;
|
||||||
xml = xml.replace(
|
xml = xml.replace(/(<w:docDefaults>\s*<w:rPrDefault>\s*<w:rPr>)/, `$1${patch}`);
|
||||||
/(<w:docDefaults>\s*<w:rPrDefault>\s*<w:rPr>)/,
|
|
||||||
`$1${patch}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
zip.file('word/styles.xml', xml);
|
zip.file('word/styles.xml', xml);
|
||||||
|
|||||||
@@ -160,7 +160,9 @@ async function convertToMarkdown(inputPath, options = {}) {
|
|||||||
// Prefer the LAST informative stderr line: markitdown ends its
|
// Prefer the LAST informative stderr line: markitdown ends its
|
||||||
// output with the actionable install hint ("pip install
|
// output with the actionable install hint ("pip install
|
||||||
// 'markitdown[pdf]'"), while the first lines are a traceback.
|
// '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 =
|
const detail =
|
||||||
stderrLines[stderrLines.length - 1] ||
|
stderrLines[stderrLines.length - 1] ||
|
||||||
safe(error.message).split('\n')[0] ||
|
safe(error.message).split('\n')[0] ||
|
||||||
|
|||||||
@@ -445,10 +445,7 @@ async function loadPdfjs() {
|
|||||||
// Windows path (C:\...\standard_fonts\) fails that check and breaks
|
// Windows path (C:\...\standard_fonts\) fails that check and breaks
|
||||||
// extractText/extractImages on Windows, so always hand pdfjs a file:// URL.
|
// extractText/extractImages on Windows, so always hand pdfjs a file:// URL.
|
||||||
function getStandardFontDataUrl() {
|
function getStandardFontDataUrl() {
|
||||||
const dir = path.join(
|
const dir = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts');
|
||||||
path.dirname(require.resolve('pdfjs-dist/package.json')),
|
|
||||||
'standard_fonts'
|
|
||||||
);
|
|
||||||
const url = require('url').pathToFileURL(dir);
|
const url = require('url').pathToFileURL(dir);
|
||||||
return url.href.endsWith('/') ? url.href : url.href + '/';
|
return url.href.endsWith('/') ? url.href : url.href + '/';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,9 @@ function askForProjectName() {
|
|||||||
overlay.remove();
|
overlay.remove();
|
||||||
resolve(value);
|
resolve(value);
|
||||||
};
|
};
|
||||||
overlay.querySelector('[data-role="ok"]').addEventListener('click', () => done(input.value.trim() || null));
|
overlay
|
||||||
|
.querySelector('[data-role="ok"]')
|
||||||
|
.addEventListener('click', () => done(input.value.trim() || null));
|
||||||
overlay.querySelector('[data-role="cancel"]').addEventListener('click', () => done(null));
|
overlay.querySelector('[data-role="cancel"]').addEventListener('click', () => done(null));
|
||||||
overlay.addEventListener('click', (event) => {
|
overlay.addEventListener('click', (event) => {
|
||||||
if (event.target === overlay) done(null);
|
if (event.target === overlay) done(null);
|
||||||
|
|||||||
@@ -180,6 +180,12 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'version-history:read',
|
'version-history:read',
|
||||||
'version-history:save',
|
'version-history:save',
|
||||||
'version-history:delete',
|
'version-history:delete',
|
||||||
|
|
||||||
|
// Autosave + crash-recovery buffer
|
||||||
|
'autosave:write',
|
||||||
|
'autosave:read',
|
||||||
|
'autosave:clear',
|
||||||
|
'autosave:list',
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_RECEIVE_CHANNELS = [
|
const ALLOWED_RECEIVE_CHANNELS = [
|
||||||
|
|||||||
+125
@@ -4,6 +4,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { ipcRenderer, webUtils } = require('electron');
|
const { ipcRenderer, webUtils } = require('electron');
|
||||||
|
const { AutosaveController } = require('./renderer/autosave-client');
|
||||||
|
|
||||||
|
// Renderer-side autosave controller. The TabManager calls into this when a
|
||||||
|
// tab becomes dirty so the current buffer is periodically persisted under
|
||||||
|
// <userData>/autosave/ — recoverable on next launch after a crash. A
|
||||||
|
// successful explicit save clears the entry (the buffer is now on disk at
|
||||||
|
// its real path); closing a tab without saving leaves it in place so the
|
||||||
|
// user can recover later.
|
||||||
|
const autosaveController = new AutosaveController();
|
||||||
const marked = require('marked');
|
const marked = require('marked');
|
||||||
const { markedHighlight } = require('marked-highlight');
|
const { markedHighlight } = require('marked-highlight');
|
||||||
const createDOMPurify = require('dompurify');
|
const createDOMPurify = require('dompurify');
|
||||||
@@ -520,6 +529,17 @@ class TabManager {
|
|||||||
this.createTabElements(tab);
|
this.createTabElements(tab);
|
||||||
this.switchToTab(newTabId);
|
this.switchToTab(newTabId);
|
||||||
this.startAutoSave();
|
this.startAutoSave();
|
||||||
|
|
||||||
|
// Register with the crash-recovery autosave controller so unsaved
|
||||||
|
// edits are persisted to <userData>/autosave/ as the user types.
|
||||||
|
// The controller pulls docPath/content via the closures so it always
|
||||||
|
// sees the latest values.
|
||||||
|
autosaveController.registerTab(
|
||||||
|
tab.id,
|
||||||
|
() => tab.filePath || `untitled-tab-${tab.id}`,
|
||||||
|
() => tab.content
|
||||||
|
);
|
||||||
|
|
||||||
this.updateTabBar();
|
this.updateTabBar();
|
||||||
}
|
}
|
||||||
createPdfTab(filePath) {
|
createPdfTab(filePath) {
|
||||||
@@ -834,6 +854,11 @@ class TabManager {
|
|||||||
}
|
}
|
||||||
this.tabs.delete(tabId);
|
this.tabs.delete(tabId);
|
||||||
|
|
||||||
|
// Stop tracking this tab with the autosave controller. The persisted
|
||||||
|
// recovery blob is left in place; if the user reopens the file later,
|
||||||
|
// the recovery banner will offer to restore it.
|
||||||
|
autosaveController.unregisterTab(tabId);
|
||||||
|
|
||||||
// Switch to another tab if this was active
|
// Switch to another tab if this was active
|
||||||
if (this.activeTabId === tabId) {
|
if (this.activeTabId === tabId) {
|
||||||
const remainingTabs = Array.from(this.tabs.keys());
|
const remainingTabs = Array.from(this.tabs.keys());
|
||||||
@@ -1261,6 +1286,12 @@ class TabManager {
|
|||||||
// Show brief auto-save indicator
|
// Show brief auto-save indicator
|
||||||
this.showAutoSaveIndicator();
|
this.showAutoSaveIndicator();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Crash-recovery autosave (independent of the on-disk save above): any
|
||||||
|
// tab with a path that's dirty since its last flush gets persisted to
|
||||||
|
// <userData>/autosave/. This survives crashes without polluting the
|
||||||
|
// real file.
|
||||||
|
autosaveController.notifyChange(tab.id);
|
||||||
}
|
}
|
||||||
showAutoSaveIndicator() {
|
showAutoSaveIndicator() {
|
||||||
const indicator = document.createElement('div');
|
const indicator = document.createElement('div');
|
||||||
@@ -1992,10 +2023,100 @@ function showRestoreSessionPrompt(session) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a banner offering to restore unsaved buffers that were persisted by
|
||||||
|
* AutosaveBuffer before an unexpected quit. Each entry is shown with its
|
||||||
|
* savedAt timestamp and a byte-size hint; clicking "Restore" overwrites the
|
||||||
|
* matching open tab (or creates one), "Dismiss" clears the autosave entry.
|
||||||
|
*
|
||||||
|
* The banner is intentionally lighter than the session-restore overlay —
|
||||||
|
* recovery of buffer content is best-effort and shouldn't block startup.
|
||||||
|
*/
|
||||||
|
function showAutosaveRecoveryBanner(pending) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'autosave-recovery-banner';
|
||||||
|
overlay.style.cssText =
|
||||||
|
'position:fixed;top:0;left:0;right:0;z-index:10000;background:#fff8e1;border-bottom:2px solid #f6a623;padding:10px 16px;font-family:system-ui;font-size:13px;box-shadow:0 2px 6px rgba(0,0,0,0.08);display:flex;align-items:center;gap:12px;flex-wrap:wrap;';
|
||||||
|
|
||||||
|
const summary = document.createElement('span');
|
||||||
|
const fileList = pending
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((p) => {
|
||||||
|
const name =
|
||||||
|
String(p.docPath || '')
|
||||||
|
.split(/[/\\]/)
|
||||||
|
.pop() || p.docPath;
|
||||||
|
const when = new Date(p.savedAt || 0).toLocaleString();
|
||||||
|
return `${name} (${when})`;
|
||||||
|
})
|
||||||
|
.join(' · ');
|
||||||
|
const extra = pending.length > 3 ? ` · +${pending.length - 3} more` : '';
|
||||||
|
summary.textContent = `Unsaved work from a previous session: ${fileList}${extra}`;
|
||||||
|
overlay.appendChild(summary);
|
||||||
|
|
||||||
|
const restoreBtn = document.createElement('button');
|
||||||
|
restoreBtn.textContent = 'Restore';
|
||||||
|
restoreBtn.style.cssText =
|
||||||
|
'background:#f6a623;color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;font-weight:600;';
|
||||||
|
restoreBtn.addEventListener('click', async () => {
|
||||||
|
for (const entry of pending) {
|
||||||
|
const rec = await autosaveController.readRecovery(entry.docPath);
|
||||||
|
if (!rec || !rec.content) continue;
|
||||||
|
const tab = tabManager?.tabs?.get(tabManager.activeTabId);
|
||||||
|
if (tab && (tab.filePath === entry.docPath || !tab.filePath)) {
|
||||||
|
// Overwrite the current untitled/same-path tab with the recovered buffer.
|
||||||
|
if (tab.editorView) {
|
||||||
|
tab.editorView.dispatch({
|
||||||
|
changes: { from: 0, to: tab.editorView.state.doc.length, insert: rec.content },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tab.content = rec.content;
|
||||||
|
tab.isDirty = true;
|
||||||
|
tabManager.updatePreview?.(tab.id, true);
|
||||||
|
autosaveController.notifyChange(tab.id);
|
||||||
|
}
|
||||||
|
// Dismiss the entry either way — one-shot offer.
|
||||||
|
autosaveController.dismissRecovery(entry.docPath).catch(() => {});
|
||||||
|
}
|
||||||
|
overlay.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissBtn = document.createElement('button');
|
||||||
|
dismissBtn.textContent = 'Dismiss';
|
||||||
|
dismissBtn.style.cssText =
|
||||||
|
'background:transparent;color:#333;border:1px solid #ccc;padding:4px 12px;border-radius:4px;cursor:pointer;';
|
||||||
|
dismissBtn.addEventListener('click', async () => {
|
||||||
|
for (const entry of pending) {
|
||||||
|
autosaveController.dismissRecovery(entry.docPath).catch(() => {});
|
||||||
|
}
|
||||||
|
overlay.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.appendChild(restoreBtn);
|
||||||
|
overlay.appendChild(dismissBtn);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const ModalManager = _ModalManager;
|
const ModalManager = _ModalManager;
|
||||||
tabManager = new TabManager();
|
tabManager = new TabManager();
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Crash recovery: offer to restore unsaved buffers from autosave
|
||||||
|
// ================================
|
||||||
|
// Independent of the session-restore prompt above: that restores the tab
|
||||||
|
// list, this restores the BUFFER CONTENT of any tab whose autosave entry
|
||||||
|
// was written before an unexpected quit. SessionStore covers tab metadata;
|
||||||
|
// AutosaveBuffer covers what the user actually typed.
|
||||||
|
try {
|
||||||
|
const pending = await autosaveController.checkPendingRecoveries();
|
||||||
|
if (Array.isArray(pending) && pending.length > 0) {
|
||||||
|
showAutosaveRecoveryBanner(pending);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[autosave] recovery sweep failed:', err && err.message);
|
||||||
|
}
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// Crash recovery: offer to restore the previous session
|
// Crash recovery: offer to restore the previous session
|
||||||
// ================================
|
// ================================
|
||||||
@@ -2759,6 +2880,10 @@ ipcRenderer.on('get-content-for-save', (event, filePath) => {
|
|||||||
tabManager.updateTabBar();
|
tabManager.updateTabBar();
|
||||||
tabManager.updateFilePath();
|
tabManager.updateFilePath();
|
||||||
tabManager.updateBreadcrumb();
|
tabManager.updateBreadcrumb();
|
||||||
|
|
||||||
|
// Buffer is now on disk at its real path — clear the crash-recovery
|
||||||
|
// autosave entry so we don't offer to "recover" what was just saved.
|
||||||
|
autosaveController.clearForDocPath(filePath).catch(() => {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ipcRenderer.on('get-content-for-spreadsheet', (event, format) => {
|
ipcRenderer.on('get-content-for-spreadsheet', (event, format) => {
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Renderer-side autosave controller.
|
||||||
|
*
|
||||||
|
* Buffers every keystroke and flushes the current document body to the main
|
||||||
|
* process at most once per AUTOSAVE_DEBOUNCE_MS. The main process persists it
|
||||||
|
* under <userData>/autosave/ via the AutosaveBuffer module; a successful
|
||||||
|
* explicit save clears the entry because the buffer is now on disk at its
|
||||||
|
* real path.
|
||||||
|
*
|
||||||
|
* Three things the controller is responsible for:
|
||||||
|
* 1. Per-tab debounced flush — registerTab(tabId, getDocPath, getContent)
|
||||||
|
* wires up the writer; unregisterTab(tabId) tears it down on close.
|
||||||
|
* 2. Periodic flush while the buffer is dirty — a hidden interval makes
|
||||||
|
* sure the user can quit any time and not lose more than a few seconds
|
||||||
|
* of work, even if they don't type again.
|
||||||
|
* 3. Startup recovery sweep — checkPendingRecoveries() lists every
|
||||||
|
* pending recovery on startup so the UI can offer to restore.
|
||||||
|
*
|
||||||
|
* Recovery banners and "save cleared autosave" UX live in the renderer, not
|
||||||
|
* here — this module only manages the I/O.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
const AUTOSAVE_DEBOUNCE_MS = 2000;
|
||||||
|
const AUTOSAVE_MAX_INTERVAL_MS = 10000; // flush dirty buffers even when idle
|
||||||
|
|
||||||
|
class AutosaveController {
|
||||||
|
constructor() {
|
||||||
|
/** Map<tabId, {docPath, getContent, timer, dirty, lastFlushAt}> */
|
||||||
|
this._tabs = new Map();
|
||||||
|
this._interval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin autosaving a tab. The docPath may be a synthetic 'untitled-tab-<id>'
|
||||||
|
* for never-saved buffers; it may also be a real absolute path.
|
||||||
|
*
|
||||||
|
* @param {string|number} tabId
|
||||||
|
* @param {() => string|null|undefined} getDocPath returns the current path
|
||||||
|
* (or null/untitled key) for the tab
|
||||||
|
* @param {() => string} getContent returns the current buffer
|
||||||
|
*/
|
||||||
|
registerTab(tabId, getDocPath, getContent) {
|
||||||
|
if (this._tabs.has(tabId)) return; // already registered
|
||||||
|
this._tabs.set(tabId, {
|
||||||
|
docPath: null,
|
||||||
|
getDocPath,
|
||||||
|
getContent,
|
||||||
|
timer: null,
|
||||||
|
dirty: false,
|
||||||
|
lastFlushAt: 0,
|
||||||
|
});
|
||||||
|
this._ensureInterval();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop autosaving a tab. Called on close; the persisted recovery blob is
|
||||||
|
* left in place — if the user reopens the file later, the recovery banner
|
||||||
|
* will offer to restore it.
|
||||||
|
*/
|
||||||
|
unregisterTab(tabId) {
|
||||||
|
const entry = this._tabs.get(tabId);
|
||||||
|
if (!entry) return;
|
||||||
|
if (entry.timer) clearTimeout(entry.timer);
|
||||||
|
this._tabs.delete(tabId);
|
||||||
|
if (this._tabs.size === 0 && this._interval) {
|
||||||
|
clearInterval(this._interval);
|
||||||
|
this._interval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify the controller that the tab's content changed. Schedules a debounced
|
||||||
|
* flush.
|
||||||
|
*/
|
||||||
|
notifyChange(tabId) {
|
||||||
|
const entry = this._tabs.get(tabId);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.dirty = true;
|
||||||
|
if (entry.timer) clearTimeout(entry.timer);
|
||||||
|
entry.timer = setTimeout(() => this._flush(tabId), AUTOSAVE_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush a specific tab immediately. Called after explicit saves and at
|
||||||
|
* shutdown.
|
||||||
|
*/
|
||||||
|
async flushNow(tabId) {
|
||||||
|
return this._flush(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the recovery entry for a document — called after a successful save
|
||||||
|
* (the buffer is now on disk at its real path).
|
||||||
|
*/
|
||||||
|
async clearForDocPath(docPath) {
|
||||||
|
if (!docPath) return false;
|
||||||
|
try {
|
||||||
|
return await ipcRenderer.invoke('autosave:clear', { docPath });
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List every pending recovery across all documents. Returns
|
||||||
|
* [{docPath, savedAt, byteSize, appVersion}] newest-first.
|
||||||
|
*/
|
||||||
|
async checkPendingRecoveries() {
|
||||||
|
try {
|
||||||
|
return await ipcRenderer.invoke('autosave:list');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a specific recovery entry (content + meta). Returns null when the
|
||||||
|
* entry no longer exists or the docPath is missing.
|
||||||
|
*/
|
||||||
|
async readRecovery(docPath) {
|
||||||
|
if (!docPath) return null;
|
||||||
|
try {
|
||||||
|
return await ipcRenderer.invoke('autosave:read', { docPath });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a specific recovery entry — called when the user dismisses the
|
||||||
|
* recovery banner without restoring.
|
||||||
|
*/
|
||||||
|
async dismissRecovery(docPath) {
|
||||||
|
return this.clearForDocPath(docPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- private ----
|
||||||
|
|
||||||
|
_ensureInterval() {
|
||||||
|
if (this._interval) return;
|
||||||
|
this._interval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [tabId, entry] of this._tabs) {
|
||||||
|
if (!entry.dirty) continue;
|
||||||
|
if (now - entry.lastFlushAt < AUTOSAVE_MAX_INTERVAL_MS) continue;
|
||||||
|
// Best-effort flush — if main is busy, leave the timer in place.
|
||||||
|
this._flush(tabId).catch(() => {});
|
||||||
|
}
|
||||||
|
}, AUTOSAVE_MAX_INTERVAL_MS / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _flush(tabId) {
|
||||||
|
const entry = this._tabs.get(tabId);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.timer = null;
|
||||||
|
if (!entry.dirty) return;
|
||||||
|
const docPath = entry.getDocPath();
|
||||||
|
if (!docPath) return; // tab has no path yet — nothing to recover
|
||||||
|
const content = entry.getContent();
|
||||||
|
entry.dirty = false;
|
||||||
|
entry.lastFlushAt = Date.now();
|
||||||
|
try {
|
||||||
|
await ipcRenderer.invoke('autosave:write', { docPath, content });
|
||||||
|
} catch (err) {
|
||||||
|
// Re-arm so the next change retries; don't swallow silently.
|
||||||
|
entry.dirty = true;
|
||||||
|
console.warn('[autosave] write failed:', err && err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { AutosaveController, AUTOSAVE_DEBOUNCE_MS };
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*
|
||||||
|
* Renderer-side AutosaveController tests.
|
||||||
|
*
|
||||||
|
* Mocks electron's ipcRenderer to verify:
|
||||||
|
* - register/notifyChange debounces writes
|
||||||
|
* - flushNow bypasses the debounce
|
||||||
|
* - clearForDocPath targets the right document
|
||||||
|
* - checkPendingRecoveries proxies through ipcRenderer.invoke('autosave:list')
|
||||||
|
* - unregisterTab stops the periodic flush
|
||||||
|
* - failed writes re-arm the dirty flag so the next change retries
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Mock electron BEFORE requiring the module under test.
|
||||||
|
const mockInvoke = jest.fn();
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'electron',
|
||||||
|
() => ({
|
||||||
|
ipcRenderer: {
|
||||||
|
invoke: (...args) => mockInvoke(...args),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ virtual: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock the autosave module to be jsdom-friendly (some Node-only globals
|
||||||
|
// inside the source would otherwise pull in heavy modules).
|
||||||
|
jest.mock(path.join(__dirname, '..', 'src', 'renderer', 'autosave-client.js'), () => {
|
||||||
|
const actual = jest.requireActual(
|
||||||
|
path.join(__dirname, '..', 'src', 'renderer', 'autosave-client.js')
|
||||||
|
);
|
||||||
|
return actual;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { AutosaveController } = require('../src/renderer/autosave-client');
|
||||||
|
|
||||||
|
describe('AutosaveController', () => {
|
||||||
|
let controller;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockInvoke.mockReset();
|
||||||
|
controller = new AutosaveController();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
controller._tabs.forEach((_, id) => controller.unregisterTab(id));
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerTab + notifyChange debounces a write to AUTOSAVE_DEBOUNCE_MS', async () => {
|
||||||
|
mockInvoke.mockResolvedValueOnce({ savedAt: 1, byteSize: 3 });
|
||||||
|
|
||||||
|
const docPath = '/home/me/x.md';
|
||||||
|
const content = 'abc';
|
||||||
|
void content;
|
||||||
|
controller.registerTab(
|
||||||
|
'tab-1',
|
||||||
|
() => docPath,
|
||||||
|
() => content
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.notifyChange('tab-1');
|
||||||
|
controller.notifyChange('tab-1');
|
||||||
|
controller.notifyChange('tab-1');
|
||||||
|
|
||||||
|
// No write yet — debounce hasn't fired.
|
||||||
|
expect(mockInvoke).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
|
||||||
|
// Allow the awaited write to settle.
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(mockInvoke).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith('autosave:write', {
|
||||||
|
docPath: '/home/me/x.md',
|
||||||
|
content: 'abc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flushNow bypasses the debounce and writes immediately', async () => {
|
||||||
|
mockInvoke.mockResolvedValueOnce({ savedAt: 1, byteSize: 3 });
|
||||||
|
|
||||||
|
controller.registerTab(
|
||||||
|
'tab-1',
|
||||||
|
() => '/a.md',
|
||||||
|
() => 'aaa'
|
||||||
|
);
|
||||||
|
controller.notifyChange('tab-1');
|
||||||
|
|
||||||
|
await controller.flushNow('tab-1');
|
||||||
|
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith('autosave:write', {
|
||||||
|
docPath: '/a.md',
|
||||||
|
content: 'aaa',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not write when the tab has no docPath (untitled-with-no-key)', async () => {
|
||||||
|
controller.registerTab(
|
||||||
|
'tab-1',
|
||||||
|
() => null,
|
||||||
|
() => 'aaa'
|
||||||
|
);
|
||||||
|
controller.notifyChange('tab-1');
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(mockInvoke).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearForDocPath calls autosave:clear with the right path', async () => {
|
||||||
|
mockInvoke.mockResolvedValueOnce(true);
|
||||||
|
await controller.clearForDocPath('/home/me/x.md');
|
||||||
|
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith('autosave:clear', {
|
||||||
|
docPath: '/home/me/x.md',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearForDocPath with no path is a no-op', async () => {
|
||||||
|
await controller.clearForDocPath(null);
|
||||||
|
expect(mockInvoke).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkPendingRecoveries proxies through autosave:list', async () => {
|
||||||
|
const pending = [
|
||||||
|
{ docPath: '/a.md', savedAt: 2, byteSize: 5, appVersion: '4.7.1' },
|
||||||
|
{ docPath: '/b.md', savedAt: 1, byteSize: 9, appVersion: '4.7.1' },
|
||||||
|
];
|
||||||
|
mockInvoke.mockResolvedValueOnce(pending);
|
||||||
|
|
||||||
|
const result = await controller.checkPendingRecoveries();
|
||||||
|
|
||||||
|
expect(result).toEqual(pending);
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith('autosave:list');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkPendingRecoveries returns [] when IPC throws', async () => {
|
||||||
|
mockInvoke.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
const result = await controller.checkPendingRecoveries();
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unregisterTab stops the periodic flush interval', async () => {
|
||||||
|
controller.registerTab(
|
||||||
|
'tab-1',
|
||||||
|
() => '/a.md',
|
||||||
|
() => 'aaa'
|
||||||
|
);
|
||||||
|
expect(controller._interval).not.toBeNull();
|
||||||
|
|
||||||
|
controller.unregisterTab('tab-1');
|
||||||
|
|
||||||
|
expect(controller._tabs.size).toBe(0);
|
||||||
|
expect(controller._interval).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed writes re-arm the dirty flag so the next change retries', async () => {
|
||||||
|
mockInvoke.mockRejectedValueOnce(new Error('disk full'));
|
||||||
|
|
||||||
|
controller.registerTab(
|
||||||
|
'tab-1',
|
||||||
|
() => '/a.md',
|
||||||
|
() => 'aaa'
|
||||||
|
);
|
||||||
|
controller.notifyChange('tab-1');
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
|
||||||
|
// Microtasks only — fake timers leave the microtask queue alone.
|
||||||
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
const entry = controller._tabs.get('tab-1');
|
||||||
|
expect(entry.dirty).toBe(true);
|
||||||
|
|
||||||
|
// Subsequent change + debounce retries.
|
||||||
|
mockInvoke.mockResolvedValueOnce({ savedAt: 2, byteSize: 3 });
|
||||||
|
controller.notifyChange('tab-1');
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
expect(mockInvoke).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('periodic flush writes when a buffer has been dirty too long', async () => {
|
||||||
|
mockInvoke.mockResolvedValue({ savedAt: 1, byteSize: 3 });
|
||||||
|
|
||||||
|
controller.registerTab(
|
||||||
|
'tab-1',
|
||||||
|
() => '/a.md',
|
||||||
|
() => 'aaa'
|
||||||
|
);
|
||||||
|
controller.notifyChange('tab-1');
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||||
|
expect(mockInvoke).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Simulate: user types one character then walks away. The debounce
|
||||||
|
// never fires because no new notifyChange comes; only the periodic
|
||||||
|
// interval should flush. Reset lastFlushAt so the threshold check passes.
|
||||||
|
const entry = controller._tabs.get('tab-1');
|
||||||
|
entry.dirty = true;
|
||||||
|
entry.lastFlushAt = 0;
|
||||||
|
|
||||||
|
// Advance past the AUTOSAVE_MAX_INTERVAL_MS (10s); the interval fires
|
||||||
|
// every 5s, so 11s is enough to fire at least one tick.
|
||||||
|
jest.advanceTimersByTime(11000);
|
||||||
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
expect(mockInvoke.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerTab is idempotent (second call is a no-op)', () => {
|
||||||
|
controller.registerTab(
|
||||||
|
'tab-1',
|
||||||
|
() => '/a.md',
|
||||||
|
() => 'aaa'
|
||||||
|
);
|
||||||
|
controller.registerTab(
|
||||||
|
'tab-1',
|
||||||
|
() => '/b.md',
|
||||||
|
() => 'bbb'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(controller._tabs.size).toBe(1);
|
||||||
|
// The first registration wins — second one doesn't replace it.
|
||||||
|
expect(controller._tabs.get('tab-1').getDocPath()).toBe('/a.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,14 +64,27 @@ describe('Legal compliance artifacts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('canonical copyleft license texts are present', () => {
|
test('canonical copyleft license texts are present', () => {
|
||||||
for (const file of ['GPL-2.0.txt', 'LGPL-2.1.txt', 'MPL-2.0.txt', 'Apache-2.0.txt', 'OFL-1.1.txt']) {
|
for (const file of [
|
||||||
|
'GPL-2.0.txt',
|
||||||
|
'LGPL-2.1.txt',
|
||||||
|
'MPL-2.0.txt',
|
||||||
|
'Apache-2.0.txt',
|
||||||
|
'OFL-1.1.txt',
|
||||||
|
]) {
|
||||||
expect(fs.existsSync(path.join(rootDir, 'third-party-licenses', file))).toBe(true);
|
expect(fs.existsSync(path.join(rootDir, 'third-party-licenses', file))).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('notices mention every bundled external binary', () => {
|
test('notices mention every bundled external binary', () => {
|
||||||
const notices = fs.readFileSync(path.join(rootDir, 'THIRD-PARTY-NOTICES.md'), 'utf-8');
|
const notices = fs.readFileSync(path.join(rootDir, 'THIRD-PARTY-NOTICES.md'), 'utf-8');
|
||||||
for (const component of ['Pandoc', 'FFmpeg', 'MarkItDown', 'libvips', 'KaTeX', 'JetBrains Mono']) {
|
for (const component of [
|
||||||
|
'Pandoc',
|
||||||
|
'FFmpeg',
|
||||||
|
'MarkItDown',
|
||||||
|
'libvips',
|
||||||
|
'KaTeX',
|
||||||
|
'JetBrains Mono',
|
||||||
|
]) {
|
||||||
expect(notices).toContain(component);
|
expect(notices).toContain(component);
|
||||||
}
|
}
|
||||||
// GPL source offer must reference the sources document
|
// GPL source offer must reference the sources document
|
||||||
@@ -90,10 +103,7 @@ describe('Legal compliance artifacts', () => {
|
|||||||
test('packaging includes the legal documents in build files', () => {
|
test('packaging includes the legal documents in build files', () => {
|
||||||
// The build config lives in electron-builder.config.js (dynamic — it
|
// The build config lives in electron-builder.config.js (dynamic — it
|
||||||
// conditionally bundles the markitdown binary per platform)
|
// conditionally bundles the markitdown binary per platform)
|
||||||
const configSrc = fs.readFileSync(
|
const configSrc = fs.readFileSync(path.join(rootDir, 'electron-builder.config.js'), 'utf-8');
|
||||||
path.join(rootDir, 'electron-builder.config.js'),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
expect(configSrc).toContain("'THIRD-PARTY-NOTICES.md'");
|
expect(configSrc).toContain("'THIRD-PARTY-NOTICES.md'");
|
||||||
expect(configSrc).toContain("'SOURCES.md'");
|
expect(configSrc).toContain("'SOURCES.md'");
|
||||||
expect(configSrc).toContain('third-party-licenses');
|
expect(configSrc).toContain('third-party-licenses');
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment node
|
||||||
|
*
|
||||||
|
* AutosaveBuffer tests — mirrors VersionHistory's test pattern (injected IO).
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const AutosaveBuffer = require('../../src/main/AutosaveBuffer');
|
||||||
|
|
||||||
|
function makeIo(rootDir) {
|
||||||
|
return { rootDir, fs, pathUtil: path, crypto };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AutosaveBuffer', () => {
|
||||||
|
let rootDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'autosave_'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeRecovery / readRecovery round-trip', () => {
|
||||||
|
test('persists content + meta and reads it back', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
const docPath = '/home/me/notes.md';
|
||||||
|
|
||||||
|
const { savedAt, byteSize } = AutosaveBuffer.writeRecovery({
|
||||||
|
docPath,
|
||||||
|
content: '# hello\n\nworld\n',
|
||||||
|
appVersion: '4.7.1',
|
||||||
|
io,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(typeof savedAt).toBe('number');
|
||||||
|
expect(savedAt).toBeGreaterThan(0);
|
||||||
|
expect(byteSize).toBe(Buffer.byteLength('# hello\n\nworld\n', 'utf-8'));
|
||||||
|
|
||||||
|
const read = AutosaveBuffer.readRecovery({ docPath, io });
|
||||||
|
expect(read).not.toBeNull();
|
||||||
|
expect(read.docPath).toBe(docPath);
|
||||||
|
expect(read.content).toBe('# hello\n\nworld\n');
|
||||||
|
expect(read.savedAt).toBe(savedAt);
|
||||||
|
expect(read.byteSize).toBe(byteSize);
|
||||||
|
expect(read.appVersion).toBe('4.7.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overwrites prior recovery entry — only the latest buffer survives', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
const docPath = '/home/me/notes.md';
|
||||||
|
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath, content: 'first', io });
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath, content: 'second', io });
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath, content: 'third', io });
|
||||||
|
|
||||||
|
const read = AutosaveBuffer.readRecovery({ docPath, io });
|
||||||
|
expect(read.content).toBe('third');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different documents are isolated from each other', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath: '/a.md', content: 'AAA', io });
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath: '/b.md', content: 'BBB', io });
|
||||||
|
|
||||||
|
expect(AutosaveBuffer.readRecovery({ docPath: '/a.md', io }).content).toBe('AAA');
|
||||||
|
expect(AutosaveBuffer.readRecovery({ docPath: '/b.md', io }).content).toBe('BBB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles untitled-tab keys (no extension, no directory)', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
const tabKey = 'untitled-tab-tab-3';
|
||||||
|
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath: tabKey, content: 'unsaved draft', io });
|
||||||
|
|
||||||
|
expect(AutosaveBuffer.readRecovery({ docPath: tabKey, io }).content).toBe('unsaved draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles unicode + emoji content without corruption', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
const body = '# 日本語 🇮🇳\n\nrésumé café\n';
|
||||||
|
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath: '/u.md', content: body, io });
|
||||||
|
|
||||||
|
expect(AutosaveBuffer.readRecovery({ docPath: '/u.md', io }).content).toBe(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty string is a valid buffer (clears without writing zero bytes fails)', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath: '/e.md', content: 'x', io });
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath: '/e.md', content: '', io });
|
||||||
|
|
||||||
|
const read = AutosaveBuffer.readRecovery({ docPath: '/e.md', io });
|
||||||
|
expect(read.content).toBe('');
|
||||||
|
expect(read.byteSize).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null/undefined content is coerced to empty string', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath: '/n.md', content: null, io });
|
||||||
|
|
||||||
|
expect(AutosaveBuffer.readRecovery({ docPath: '/n.md', io }).content).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readRecovery error cases', () => {
|
||||||
|
test('returns null when no entry exists (ENOENT)', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
expect(AutosaveBuffer.readRecovery({ docPath: '/missing.md', io })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws when meta.json is corrupt (renderer can surface the warning)', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
const docPath = '/anywhere.md';
|
||||||
|
const hash = crypto.createHash('sha1').update(docPath).digest('hex').slice(0, 16);
|
||||||
|
const dir = path.join(rootDir, 'by-path', hash);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, 'meta.json'), '{not valid json', 'utf-8');
|
||||||
|
fs.writeFileSync(path.join(dir, 'recovery.md'), 'orphan', 'utf-8');
|
||||||
|
|
||||||
|
expect(() => AutosaveBuffer.readRecovery({ docPath, io })).toThrow(/corrupt/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when meta exists but recovery blob is missing', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
const dir = path.join(rootDir, 'by-path', 'no-blob');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'meta.json'),
|
||||||
|
JSON.stringify({ docPath: '/x.md', savedAt: 1, byteSize: 0, appVersion: 'x' }),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(AutosaveBuffer.readRecovery({ docPath: '/x.md', io })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearRecovery', () => {
|
||||||
|
test('removes both the blob and the meta, returns true when something was removed', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath: '/c.md', content: 'stuff', io });
|
||||||
|
expect(AutosaveBuffer.readRecovery({ docPath: '/c.md', io })).not.toBeNull();
|
||||||
|
|
||||||
|
const removed = AutosaveBuffer.clearRecovery({ docPath: '/c.md', io });
|
||||||
|
expect(removed).toBe(true);
|
||||||
|
expect(AutosaveBuffer.readRecovery({ docPath: '/c.md', io })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when there was nothing to clear (idempotent)', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
expect(AutosaveBuffer.clearRecovery({ docPath: '/missing.md', io })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listRecoveries', () => {
|
||||||
|
test('returns an empty array when the store has never been used', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
expect(AutosaveBuffer.listRecoveries({ io })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists every pending recovery, newest first', async () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath: '/older.md', content: 'old', io });
|
||||||
|
// tiny gap so timestamps differ
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath: '/newer.md', content: 'new', io });
|
||||||
|
|
||||||
|
const list = AutosaveBuffer.listRecoveries({ io });
|
||||||
|
expect(list).toHaveLength(2);
|
||||||
|
expect(list[0].docPath).toBe('/newer.md');
|
||||||
|
expect(list[1].docPath).toBe('/older.md');
|
||||||
|
expect(list[0].savedAt).toBeGreaterThanOrEqual(list[1].savedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips directories with corrupt meta (so a single bad entry can't block startup)", () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath: '/good.md', content: 'ok', io });
|
||||||
|
// Plant a corrupt sibling
|
||||||
|
const dir = path.join(rootDir, 'by-path', 'corrupt');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, 'meta.json'), '{not json', 'utf-8');
|
||||||
|
|
||||||
|
const list = AutosaveBuffer.listRecoveries({ io });
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].docPath).toBe('/good.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('input validation', () => {
|
||||||
|
test('writeRecovery throws when docPath is missing', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
expect(() => AutosaveBuffer.writeRecovery({ docPath: '', content: 'x', io })).toThrow(
|
||||||
|
/docPath/
|
||||||
|
);
|
||||||
|
expect(() => AutosaveBuffer.writeRecovery({ content: 'x', io })).toThrow(/docPath/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readRecovery throws when docPath is missing', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
expect(() => AutosaveBuffer.readRecovery({ io })).toThrow(/docPath/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearRecovery throws when docPath is missing', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
expect(() => AutosaveBuffer.clearRecovery({ io })).toThrow(/docPath/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storage layout', () => {
|
||||||
|
test('uses sha1(path)[0:16] as the storage folder under by-path/', () => {
|
||||||
|
const io = makeIo(rootDir);
|
||||||
|
const docPath = '/home/me/x.md';
|
||||||
|
AutosaveBuffer.writeRecovery({ docPath, content: 'x', io });
|
||||||
|
|
||||||
|
const expectedHash = crypto.createHash('sha1').update(docPath).digest('hex').slice(0, 16);
|
||||||
|
const dir = path.join(rootDir, 'by-path', expectedHash);
|
||||||
|
expect(fs.existsSync(path.join(dir, 'recovery.md'))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(dir, 'meta.json'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,14 +16,14 @@ describe('ImageOperations', () => {
|
|||||||
.toFile(inputPath);
|
.toFile(inputPath);
|
||||||
});
|
});
|
||||||
// Windows: sharp can hold the file handle briefly after await returns,
|
// Windows: sharp can hold the file handle briefly after await returns,
|
||||||
// so plain rmSync throws EPERM — retry and tolerate stale temp dirs.
|
// so plain rmSync throws EPERM — retry and tolerate stale temp dirs.
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||||
} catch {
|
} catch {
|
||||||
/* leftover temp dir is cosmetic on locked-file platforms */
|
/* leftover temp dir is cosmetic on locked-file platforms */
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('imageConvert converts PNG to JPEG', async () => {
|
test('imageConvert converts PNG to JPEG', async () => {
|
||||||
const outputPath = path.join(tmpDir, 'out.jpg');
|
const outputPath = path.join(tmpDir, 'out.jpg');
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
*/
|
*/
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { resolveMarkItDown, convertToMarkdown, commandCandidates } = require('../../src/main/MarkItDown');
|
const {
|
||||||
|
resolveMarkItDown,
|
||||||
|
convertToMarkdown,
|
||||||
|
commandCandidates,
|
||||||
|
} = require('../../src/main/MarkItDown');
|
||||||
const { setImmediate } = require('timers');
|
const { setImmediate } = require('timers');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +30,9 @@ function makeRunner(script = {}) {
|
|||||||
const result = script[key];
|
const result = script[key];
|
||||||
setImmediate(() =>
|
setImmediate(() =>
|
||||||
cb(
|
cb(
|
||||||
result ? result.error || null : Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }),
|
result
|
||||||
|
? result.error || null
|
||||||
|
: Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }),
|
||||||
result?.stdout ?? '',
|
result?.stdout ?? '',
|
||||||
result?.stderr ?? ''
|
result?.stderr ?? ''
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,19 +48,15 @@ describe('PDFOperations - Task 15 new operations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('pdfExtractText', () => {
|
describe('pdfExtractText', () => {
|
||||||
it(
|
it('extracts text from all pages', async () => {
|
||||||
'extracts text from all pages',
|
|
||||||
async () => {
|
|
||||||
const result = await PDFOperations.pdfExtractText({ inputPath });
|
const result = await PDFOperations.pdfExtractText({ inputPath });
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.text).toContain('Hello Task 15 Page One');
|
expect(result.text).toContain('Hello Task 15 Page One');
|
||||||
expect(result.text).toContain('Second Page Content');
|
expect(result.text).toContain('Second Page Content');
|
||||||
},
|
}, // First pdfjs-dist legacy import can exceed the 5s default on slower
|
||||||
// First pdfjs-dist legacy import can exceed the 5s default on slower
|
|
||||||
// CI runners (observed on windows-latest)
|
// CI runners (observed on windows-latest)
|
||||||
30000
|
30000);
|
||||||
);
|
|
||||||
|
|
||||||
it('returns failure for a nonexistent file', async () => {
|
it('returns failure for a nonexistent file', async () => {
|
||||||
const result = await PDFOperations.pdfExtractText({
|
const result = await PDFOperations.pdfExtractText({
|
||||||
@@ -157,9 +153,7 @@ describe('PDFOperations - Task 15 new operations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('pdfExtractImages', () => {
|
describe('pdfExtractImages', () => {
|
||||||
it(
|
it('extracts embedded raster images as PNG files', async () => {
|
||||||
'extracts embedded raster images as PNG files',
|
|
||||||
async () => {
|
|
||||||
const imgPath = path.join(tmpDir, 'red.png');
|
const imgPath = path.join(tmpDir, 'red.png');
|
||||||
await sharp({
|
await sharp({
|
||||||
create: { width: 20, height: 20, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
create: { width: 20, height: 20, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||||
@@ -190,9 +184,7 @@ describe('PDFOperations - Task 15 new operations', () => {
|
|||||||
const meta = await sharp(file).metadata();
|
const meta = await sharp(file).metadata();
|
||||||
expect(meta.format).toBe('png');
|
expect(meta.format).toBe('png');
|
||||||
}
|
}
|
||||||
},
|
}, 30000); // pdfjs import + sharp decode on slower CI runners
|
||||||
30000 // pdfjs import + sharp decode on slower CI runners
|
|
||||||
);
|
|
||||||
|
|
||||||
it('returns zero images for a text-only PDF', async () => {
|
it('returns zero images for a text-only PDF', async () => {
|
||||||
const outputDir = path.join(tmpDir, 'extracted-none');
|
const outputDir = path.join(tmpDir, 'extracted-none');
|
||||||
|
|||||||
@@ -65,8 +65,7 @@ describe('KaTeX math rendering pipeline', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('escaped delimiters \\[...\\] and \\(...\\) both render', () => {
|
test('escaped delimiters \\[...\\] and \\(...\\) both render', () => {
|
||||||
container.innerHTML =
|
container.innerHTML = '<p>Display form: \\[E = mc^2\\] and inline \\(\\pi r^2\\).</p>';
|
||||||
'<p>Display form: \\[E = mc^2\\] and inline \\(\\pi r^2\\).</p>';
|
|
||||||
|
|
||||||
expect(() => renderMathInElement(container, { delimiters: DELIMITERS })).not.toThrow();
|
expect(() => renderMathInElement(container, { delimiters: DELIMITERS })).not.toThrow();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user