Add core domain layer: schema, store, sanitizer, placeholders, settings
- 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>
This commit is contained in:
+329
@@ -0,0 +1,329 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user