Files
markdown-converter/src/main/VersionHistory.js
T
amitwh c4dcbd8caf feat: v4.6.0 — AI assistant, collaboration, knowledge base, and 15 more features
- AI Assistant plugin: multi-provider chat (OpenAI/Anthropic/Ollama/LM Studio),
  summarize/improve/translate commands, proofread via ai:analyze; calls
  proxied through main so API keys stay out of the renderer
- Collaboration plugin: anchor-based comments in .comments/ sidecars with
  drift detection and F8 navigation
- Local knowledge base: [[wiki-links]] with click-to-create + Backlinks panel
- Crash recovery: debounced session snapshots with restore prompt on launch
- Version history: pre-save snapshots, History panel with restore/diff/delete
- Real PDF encryption: swap pdf-lib for @cantoo/pdf-lib (probe-driven UI)
- XLSX export (native workbooks via JSZip), ODT headers/footers + page size
- Offline KaTeX (bundled CSS+fonts), local-first PlantUML rendering
- Editor: vim mode toggle, snippet Tab-expansion, zen word-goal setter,
  writing heatmap, writing-studio panels wired with rail icons
- Quick Note global scratchpad (Ctrl+Alt+Q), markdownconverter:// deep links,
  REPL first-run confirmation
- Fix: Ctrl+Shift+P collision, pandoc converter availability check, CLI
  dangling --css/--reference-doc flags, dead converter button

8 new test suites; 613 tests green; lint clean
2026-09-05 20:48:39 +05:30

161 lines
5.4 KiB
JavaScript

/**
* Local document version history ("timemachine lite").
*
* Every time a file is saved, the previous on-disk content is snapshotted to
* <userData>/versions/<hash-of-path>/<timestamp>.md plus a meta.json index.
* Users browse/restore/diff versions from the History sidebar panel —
* recovery from a bad edit no longer depends on Git.
*
* Design notes:
* - Versions store the content that is about to be REPLACED (pre-save), so
* the newest version is always the last state before the current one.
* - `maxKeep` prunes oldest entries per document (default 20).
* - Version ids are `<ms-timestamp>-<suffix>`; strict format validation on
* read prevents path traversal via crafted ids.
* - All fs/path access is injectable for unit tests.
*
* @module VersionHistory
*/
const DEFAULT_MAX_KEEP = 20;
/** Inject a crypto-like object (Node's crypto by default). */
function defaultCrypto() {
return require('crypto');
}
/**
* Stable per-document storage folder name: first 16 hex chars of the sha1 of
* the absolute path. Avoids OS path-length and illegal-character issues.
*/
function folderFor(docPath, pathUtil, crypto) {
const hash = crypto.createHash('sha1').update(String(docPath)).digest('hex').slice(0, 16);
return pathUtil.join('by-path', hash);
}
/** Build a fresh version id for "now" (sortable + filesystem-safe). */
function newVersionId(now = Date.now()) {
return `${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/** Version ids must be strictly alphanumeric-dash to stay inside the folder. */
function isValidVersionId(id) {
return typeof id === 'string' && /^[a-z0-9-]{6,64}$/.test(id);
}
/**
* Snapshot one version of a document.
*
* @param {object} args
* @param {string} args.docPath Absolute path of the document
* @param {string} args.content The content to snapshot
* @param {string} [args.label='auto-save'] How it was captured
* @param {number} [args.maxKeep] Prune to this many versions per document
* @param {object} args.io { rootDir, fs, pathUtil, crypto } — rootDir is the
* versions root (main passes <userData>/versions)
* @returns {{id, createdAt, label, wordCount}} the stored version meta
*/
function saveVersion({ docPath, content, label = 'auto-save', maxKeep = DEFAULT_MAX_KEEP, io }) {
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
const dir = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto));
fs.mkdirSync(dir, { recursive: true });
const id = newVersionId();
const createdAt = Date.now();
const wordCount = String(content || '')
.split(/\s+/)
.filter(Boolean).length;
// meta.json is the index; the .md blob is the content
const metaPath = pathUtil.join(dir, 'meta.json');
let meta = { doc: docPath, versions: [] };
try {
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
} catch {
/* first version for this document */
}
if (!Array.isArray(meta.versions)) meta.versions = [];
const entry = { id, createdAt, label, wordCount };
meta.versions.unshift(entry);
// Prune oldest beyond maxKeep (entries are newest-first)
if (meta.versions.length > maxKeep) {
for (const removed of meta.versions.slice(maxKeep)) {
try {
fs.unlinkSync(pathUtil.join(dir, `${removed.id}.md`));
} catch {
/* already gone */
}
}
meta.versions = meta.versions.slice(0, maxKeep);
}
fs.writeFileSync(pathUtil.join(dir, `${id}.md`), String(content ?? ''), 'utf-8');
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf-8');
return entry;
}
/**
* List versions for a document, newest first.
* @returns {Array<{id, createdAt, label, wordCount}>}
*/
function listVersions({ docPath, io }) {
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
const metaPath = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto), 'meta.json');
try {
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
return Array.isArray(meta.versions) ? meta.versions : [];
} catch {
return [];
}
}
/**
* Read one version's content.
* @throws when the id is malformed or the version is missing
*/
function readVersion({ docPath, id, io }) {
if (!isValidVersionId(id)) throw new Error('Invalid version id');
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
const file = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto), `${id}.md`);
return fs.readFileSync(file, 'utf-8');
}
/**
* Delete one version (also repairs the index if a blob is already gone).
* @returns {boolean} whether the version was found and removed
*/
function deleteVersion({ docPath, id, io }) {
if (!isValidVersionId(id)) return false;
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
const dir = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto));
const metaPath = pathUtil.join(dir, 'meta.json');
let meta;
try {
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
} catch {
return false;
}
const before = meta.versions?.length || 0;
meta.versions = (meta.versions || []).filter((v) => v.id !== id);
if (meta.versions.length === before) return false;
try {
fs.unlinkSync(pathUtil.join(dir, `${id}.md`));
} catch {
/* blob already missing — index repair is the important part */
}
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf-8');
return true;
}
module.exports = {
DEFAULT_MAX_KEEP,
saveVersion,
listVersions,
readVersion,
deleteVersion,
newVersionId,
isValidVersionId,
folderFor,
};