'use strict'; const fs = require('node:fs'); const path = require('node:path'); const { newId, nowIso, writeJsonSync, readJsonSync, readJsonIfExists, atomicWriteFileSync, deepClone, } = require('./util'); const { createGuide, createStep, validateGuide, validateStep, normalizeGuide, normalizeStep, } = require('./schema'); const { sanitizeHtml } = require('./sanitize'); /** * Thrown by revision-aware saves when the on-disk revision no longer matches * the caller's expectation — i.e. someone else wrote in between. Callers that * pass expectedRevision (background/AI/capture writes) use this to avoid * clobbering a newer user edit. */ class RevisionConflictError extends Error { constructor(kind, id, expected, actual) { super(`${kind} ${id} changed since it was read (expected revision ${expected}, found ${actual})`); this.name = 'RevisionConflictError'; this.code = 'STEPFORGE_REVISION_CONFLICT'; this.expected = expected; this.actual = actual; } } /** * Folder-based guide store. One directory per guide, one directory per step, * all JSON written atomically. This is the only module that knows the * on-disk layout of the library. */ class GuideStore { constructor(rootDir) { if (!rootDir) throw new Error('GuideStore requires a root directory'); this.root = rootDir; this.settingsDir = path.join(rootDir, 'settings'); this.templatesDir = path.join(this.settingsDir, 'templates'); this.libraryDir = path.join(rootDir, 'library'); this.guidesDir = path.join(this.libraryDir, 'guides'); this.indexDir = path.join(this.libraryDir, 'index'); this.trashDir = path.join(this.libraryDir, 'trash'); this.quarantineDir = path.join(this.libraryDir, 'quarantine'); this.tempDir = path.join(rootDir, 'temp'); this.sharedLinksDir = path.join(rootDir, 'shared-links'); this.foldersFile = path.join(this.libraryDir, 'folders.json'); // In-memory log of files quarantined this session (corrupt/unreadable), // surfaced to the UI instead of silently vanishing. this.recoveryReport = []; this.ensureLayout(); } ensureLayout() { for (const dir of [ this.settingsDir, this.templatesDir, this.guidesDir, this.indexDir, this.trashDir, this.quarantineDir, this.tempDir, this.sharedLinksDir, ]) { fs.mkdirSync(dir, { recursive: true }); } } /** * Move a corrupt/unreadable file or directory into quarantine (preserving * the original bytes) and record it, rather than silently dropping it. A * guide/step never just disappears without an explanation. */ quarantine(sourcePath, kind, reason) { const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const dest = path.join(this.quarantineDir, `${kind}-${path.basename(sourcePath)}-${stamp}`); try { fs.mkdirSync(this.quarantineDir, { recursive: true }); fs.renameSync(sourcePath, dest); } catch { // If we cannot move it (e.g. cross-device or vanished), still record it. } const entry = { kind, source: sourcePath, quarantined: dest, reason: String(reason || 'unreadable'), at: nowIso() }; this.recoveryReport.push(entry); return entry; } /** Corrupt files quarantined this session (for a recovery UI). */ getRecoveryReport() { return [...this.recoveryReport]; } guideDir(guideId) { if (!/^[a-zA-Z0-9_-]+$/.test(guideId)) throw new Error(`bad guide id: ${guideId}`); return path.join(this.guidesDir, guideId); } stepDir(guideId, stepId) { if (!/^[a-zA-Z0-9_-]+$/.test(stepId)) throw new Error(`bad step id: ${stepId}`); return path.join(this.guideDir(guideId), 'steps', stepId); } // ---- guides ------------------------------------------------------------- createGuide(fields = {}) { const guide = createGuide(fields); validateGuide(guide); writeJsonSync(path.join(this.guideDir(guide.guideId), 'guide.json'), guide); return guide; } guideExists(guideId) { return fs.existsSync(path.join(this.guideDir(guideId), 'guide.json')); } getGuide(guideId) { const raw = readJsonSync(path.join(this.guideDir(guideId), 'guide.json')); return normalizeGuide(raw); } saveGuide(guide, { touch = true, expectedRevision = null } = {}) { validateGuide(guide); // Optimistic concurrency: a caller that read the guide can pass the // revision it saw; if disk moved on since, refuse rather than clobber. if (expectedRevision !== null) { const current = this.guideExists(guide.guideId) ? this.getGuide(guide.guideId).revision : 0; if (current !== expectedRevision) { throw new RevisionConflictError('guide', guide.guideId, expectedRevision, current); } } const stored = deepClone(guide); stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml); stored.revision = (Number.isInteger(stored.revision) ? stored.revision : 0) + 1; if (touch) stored.updatedAt = nowIso(); writeJsonSync(path.join(this.guideDir(guide.guideId), 'guide.json'), stored); return stored; } listGuides() { const out = []; for (const entry of fs.readdirSync(this.guidesDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const dir = path.join(this.guidesDir, entry.name); const file = path.join(dir, 'guide.json'); if (!fs.existsSync(file)) continue; // in-progress/empty dir, not corruption try { out.push(normalizeGuide(readJsonSync(file))); } catch (err) { // A corrupt guide.json used to make the guide silently vanish from the // library. Quarantine the directory (preserving it) and record it so // the user can be told, instead of losing it without explanation. this.quarantine(dir, 'guide', err && err.message); } } out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); return out; } setFavorite(guideId, favorite) { const guide = this.getGuide(guideId); guide.favorite = Boolean(favorite); return this.saveGuide(guide, { touch: false }); } /** Move a guide directory into trash (recoverable until purged). */ deleteGuide(guideId) { const dir = this.guideDir(guideId); if (!fs.existsSync(dir)) throw new Error(`guide not found: ${guideId}`); const dest = path.join(this.trashDir, `${guideId}-${Date.now()}`); fs.renameSync(dir, dest); const folders = this.loadFolders(); delete folders.guideFolders[guideId]; this.saveFolders(folders); return dest; } restoreFromTrash(trashName) { const src = path.join(this.trashDir, path.basename(trashName)); const guide = readJsonSync(path.join(src, 'guide.json')); const dest = this.guideDir(guide.guideId); if (fs.existsSync(dest)) throw new Error(`guide already exists: ${guide.guideId}`); fs.renameSync(src, dest); return guide.guideId; } listTrash() { if (!fs.existsSync(this.trashDir)) return []; return fs.readdirSync(this.trashDir).filter((n) => { return fs.existsSync(path.join(this.trashDir, n, 'guide.json')); }); } purgeTrash() { for (const name of fs.readdirSync(this.trashDir)) { fs.rmSync(path.join(this.trashDir, name), { recursive: true, force: true }); } } purgeTrashItems(names) { for (const name of names) { fs.rmSync(path.join(this.trashDir, path.basename(name)), { recursive: true, force: true }); } } duplicateGuide(guideId, { title } = {}) { const src = this.getGuide(guideId); const steps = this.listSteps(guideId); const copy = createGuide({ ...deepClone(src), guideId: undefined, title: title || `${src.title} (copy)`, linkedSource: null, }); const idMap = new Map(); for (const oldId of src.stepsOrder) idMap.set(oldId, newId('step')); this.createGuide({ ...copy }); for (const oldId of src.stepsOrder) { const oldStep = steps.get(oldId); if (!oldStep) continue; const newStep = deepClone(oldStep); newStep.stepId = idMap.get(oldId); newStep.parentStepId = oldStep.parentStepId ? idMap.get(oldStep.parentStepId) || null : null; writeJsonSync(path.join(this.stepDir(copy.guideId, newStep.stepId), 'step.json'), newStep); const oldDir = this.stepDir(guideId, oldId); for (const file of fs.readdirSync(oldDir)) { if (file === 'step.json') continue; fs.copyFileSync(path.join(oldDir, file), path.join(this.stepDir(copy.guideId, newStep.stepId), file)); } } copy.stepsOrder = src.stepsOrder.map((id) => idMap.get(id)).filter(Boolean); return this.saveGuide(copy); } // ---- steps -------------------------------------------------------------- /** * Create a step and append it to the guide's order. * `imageBuffer` (PNG bytes) is optional; when given it is stored as both * original.png (immutable) and working.png (crop target). */ addStep(guideId, fields = {}, imageBuffer = null, imageSize = null, { position } = {}) { const guide = this.getGuide(guideId); const step = createStep(fields); if (imageBuffer) { const dir = this.stepDir(guideId, step.stepId); fs.mkdirSync(dir, { recursive: true }); atomicWriteFileSync(path.join(dir, 'original.png'), imageBuffer); atomicWriteFileSync(path.join(dir, 'working.png'), imageBuffer); step.kind = 'image'; step.image = { originalPath: 'original.png', workingPath: 'working.png', size: imageSize || { width: 0, height: 0 }, }; } validateStep(step); writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), step); const at = Number.isInteger(position) ? position : guide.stepsOrder.length; guide.stepsOrder.splice(at, 0, step.stepId); this.saveGuide(guide); return step; } getStep(guideId, stepId) { return normalizeStep(readJsonSync(path.join(this.stepDir(guideId, stepId), 'step.json'))); } /** Map of stepId -> step for every step directory of the guide. */ listSteps(guideId) { const stepsRoot = path.join(this.guideDir(guideId), 'steps'); const map = new Map(); if (!fs.existsSync(stepsRoot)) return map; for (const entry of fs.readdirSync(stepsRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const dir = path.join(stepsRoot, entry.name); const file = path.join(dir, 'step.json'); if (!fs.existsSync(file)) continue; try { map.set(entry.name, normalizeStep(readJsonSync(file))); } catch (err) { // Quarantine a corrupt step (preserving it) and record it rather than // silently dropping it from the guide. this.quarantine(dir, 'step', err && err.message); } } return map; } saveStep(guideId, step, { expectedRevision = null } = {}) { // Optimistic concurrency for background/AI/capture writes: refuse to // overwrite a step that changed since it was read. Direct user edits pass // no expectedRevision (last-write-wins — the user is the authority). if (expectedRevision !== null) { let current = 0; try { current = this.getStep(guideId, step.stepId).revision; } catch { current = 0; // step vanished; treat as revision 0 } if (current !== expectedRevision) { throw new RevisionConflictError('step', step.stepId, expectedRevision, current); } } const stored = normalizeStep(deepClone(step)); stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml); stored.revision = (Number.isInteger(stored.revision) ? stored.revision : 0) + 1; validateStep(stored); writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), stored); const guide = this.getGuide(guideId); this.saveGuide(guide); // bump updatedAt return stored; } deleteStep(guideId, stepId) { const guide = this.getGuide(guideId); // Re-parent substeps of the deleted step to the top level. for (const [, step] of this.listSteps(guideId)) { if (step.parentStepId === stepId) { step.parentStepId = null; writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), step); } } fs.rmSync(this.stepDir(guideId, stepId), { recursive: true, force: true }); guide.stepsOrder = guide.stepsOrder.filter((id) => id !== stepId); this.saveGuide(guide); } /** * Recreate a previously-deleted step, preserving its stepId, and splice it * back into the guide's order. Used to undo step deletion. */ restoreStep(guideId, step, images, position) { const guide = this.getGuide(guideId); const restored = normalizeStep(deepClone(step)); validateStep(restored); const dir = this.stepDir(guideId, restored.stepId); fs.mkdirSync(dir, { recursive: true }); if (restored.image && images) { if (images.original) atomicWriteFileSync(path.join(dir, restored.image.originalPath), images.original); if (images.working) atomicWriteFileSync(path.join(dir, restored.image.workingPath), images.working); } writeJsonSync(path.join(dir, 'step.json'), restored); const at = Number.isInteger(position) ? Math.min(position, guide.stepsOrder.length) : guide.stepsOrder.length; guide.stepsOrder.splice(at, 0, restored.stepId); this.saveGuide(guide); return restored; } reorderSteps(guideId, newOrder) { const guide = this.getGuide(guideId); const current = new Set(guide.stepsOrder); if (newOrder.length !== guide.stepsOrder.length || !newOrder.every((id) => current.has(id))) { throw new Error('reorderSteps: new order must contain exactly the existing steps'); } guide.stepsOrder = [...newOrder]; return this.saveGuide(guide); } stepImagePath(guideId, stepId, which = 'working') { const step = this.getStep(guideId, stepId); if (!step.image) return null; const rel = which === 'original' ? step.image.originalPath : step.image.workingPath; return path.join(this.stepDir(guideId, stepId), rel); } /** Replace the working image (crop result). The original is never touched. */ setWorkingImage(guideId, stepId, pngBuffer, size, stepPatch = null) { const step = stepPatch ? deepClone(stepPatch) : this.getStep(guideId, stepId); if (!step.image) throw new Error('step has no image'); atomicWriteFileSync(path.join(this.stepDir(guideId, stepId), step.image.workingPath), pngBuffer); step.image.size = size; return this.saveStep(guideId, step); } /** Restore working.png from original.png (un-crop). */ resetWorkingImage(guideId, stepId, size) { const step = this.getStep(guideId, stepId); if (!step.image) throw new Error('step has no image'); const dir = this.stepDir(guideId, stepId); fs.copyFileSync(path.join(dir, step.image.originalPath), path.join(dir, step.image.workingPath)); if (size) step.image.size = size; return this.saveStep(guideId, step); } // ---- folders & favorites ------------------------------------------------ loadFolders() { return readJsonIfExists(this.foldersFile, { folders: [], guideFolders: {} }); } saveFolders(data) { writeJsonSync(this.foldersFile, data); return data; } createFolder(name, parentId = null) { const data = this.loadFolders(); const folder = { id: newId('folder'), name, parentId }; data.folders.push(folder); this.saveFolders(data); return folder; } renameFolder(folderId, name) { const data = this.loadFolders(); const folder = data.folders.find((f) => f.id === folderId); if (!folder) throw new Error(`folder not found: ${folderId}`); folder.name = name; this.saveFolders(data); return folder; } deleteFolder(folderId) { const data = this.loadFolders(); data.folders = data.folders.filter((f) => f.id !== folderId); for (const [gid, fid] of Object.entries(data.guideFolders)) { if (fid === folderId) delete data.guideFolders[gid]; } for (const f of data.folders) { if (f.parentId === folderId) f.parentId = null; } this.saveFolders(data); } moveGuideToFolder(guideId, folderId) { const data = this.loadFolders(); if (folderId === null) delete data.guideFolders[guideId]; else { if (!data.folders.some((f) => f.id === folderId)) throw new Error(`folder not found: ${folderId}`); data.guideFolders[guideId] = folderId; } this.saveFolders(data); } } module.exports = { GuideStore, RevisionConflictError };