2a602d7477
- Folder-based GuideStore with atomic writes, trash/restore, duplicate, substep reparenting, folders/favorites, working-image crop/reset - Allowlist HTML sanitizer applied on every store write - Placeholder scopes (guide > global > system) and collection - Persisted app settings with deep default merge - 16 workflow tests exercising real on-disk round-trips Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
330 lines
11 KiB
JavaScript
330 lines
11 KiB
JavaScript
'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');
|
|
|
|
/**
|
|
* 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.tempDir = path.join(rootDir, 'temp');
|
|
this.sharedLinksDir = path.join(rootDir, 'shared-links');
|
|
this.foldersFile = path.join(this.libraryDir, 'folders.json');
|
|
this.ensureLayout();
|
|
}
|
|
|
|
ensureLayout() {
|
|
for (const dir of [
|
|
this.settingsDir, this.templatesDir, this.guidesDir, this.indexDir,
|
|
this.trashDir, this.tempDir, this.sharedLinksDir,
|
|
]) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
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 } = {}) {
|
|
validateGuide(guide);
|
|
const stored = deepClone(guide);
|
|
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
|
|
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 file = path.join(this.guidesDir, entry.name, 'guide.json');
|
|
try {
|
|
out.push(normalizeGuide(readJsonSync(file)));
|
|
} catch {
|
|
// skip unreadable entries rather than failing the whole library
|
|
}
|
|
}
|
|
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 });
|
|
}
|
|
}
|
|
|
|
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;
|
|
try {
|
|
map.set(entry.name, normalizeStep(readJsonSync(path.join(stepsRoot, entry.name, 'step.json'))));
|
|
} catch {
|
|
// skip unreadable step
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
saveStep(guideId, step) {
|
|
const stored = deepClone(step);
|
|
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
|
|
for (const tb of stored.textBlocks || []) tb.descriptionHtml = sanitizeHtml(tb.descriptionHtml);
|
|
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);
|
|
}
|
|
|
|
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) {
|
|
const step = 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 };
|