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:
Iisyourdad
2026-06-10 16:34:15 -05:00
parent 70d812007f
commit 2a602d7477
10 changed files with 1160 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
'use strict';
const { htmlToText } = require('./util');
/**
* Placeholders are [[Name]] tokens usable in titles, descriptions, text
* blocks, and export cover pages. Resolution precedence (highest wins):
* guide placeholders > global placeholders > system placeholders.
* Unknown tokens are left untouched so typos are visible in output.
*/
const TOKEN_RE = /\[\[([A-Za-z0-9_ .-]+)\]\]/g;
function systemPlaceholders(guide, { now = new Date(), stepCount = null } = {}) {
const pad = (n) => String(n).padStart(2, '0');
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
return {
Guide_Title: guide ? guide.title : '',
Guide_Description: guide ? htmlToText(guide.descriptionHtml) : '',
Date: date,
Time: time,
DateTime: `${date} ${time}`,
Year: String(now.getFullYear()),
Step_Count: stepCount == null ? '' : String(stepCount),
App_Name: 'StepForge',
};
}
/** Build the effective name->value map for a guide. */
function resolveScopes({ guide = null, globals = {}, system = {} } = {}) {
return { ...system, ...globals, ...(guide && guide.placeholders ? guide.placeholders : {}) };
}
function expandPlaceholders(text, values) {
if (!text) return text == null ? '' : text;
return String(text).replace(TOKEN_RE, (whole, name) => {
const key = name.trim();
return Object.prototype.hasOwnProperty.call(values, key) && values[key] != null
? String(values[key])
: whole;
});
}
/** List distinct placeholder names used in a string. */
function listPlaceholders(text) {
const names = new Set();
if (text) {
for (const m of String(text).matchAll(TOKEN_RE)) names.add(m[1].trim());
}
return [...names];
}
/** Collect every placeholder name used anywhere in a guide + its steps. */
function collectGuidePlaceholders(guide, steps) {
const names = new Set();
const add = (text) => listPlaceholders(text).forEach((n) => names.add(n));
add(guide.title);
add(guide.descriptionHtml);
for (const step of steps) {
add(step.title);
add(step.descriptionHtml);
for (const tb of step.textBlocks || []) {
add(tb.title);
add(tb.descriptionHtml);
}
for (const ann of step.annotations || []) add(ann.text);
}
return [...names].sort();
}
module.exports = {
TOKEN_RE,
systemPlaceholders,
resolveScopes,
expandPlaceholders,
listPlaceholders,
collectGuidePlaceholders,
};
+71
View File
@@ -0,0 +1,71 @@
'use strict';
/**
* Allowlist HTML sanitizer for guide/step description fragments.
*
* Descriptions are stored as sanitized HTML and re-sanitized before display
* or export, so this is the single place that defines what rich text may
* contain. No scripts, no event handlers, no styles, no embedded resources.
*/
const ALLOWED_TAGS = new Set([
'p', 'br', 'hr', 'b', 'strong', 'i', 'em', 'u', 's', 'sub', 'sup',
'ul', 'ol', 'li', 'a', 'code', 'pre', 'blockquote',
'h1', 'h2', 'h3', 'h4',
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'span', 'div',
]);
const VOID_TAGS = new Set(['br', 'hr']);
// href schemes a link may use. step: is the internal step-link scheme.
const SAFE_HREF = /^(https?:|mailto:|step:|#)/i;
const ALLOWED_ATTRS = {
a: ['href', 'data-step-id'],
td: ['colspan', 'rowspan'],
th: ['colspan', 'rowspan'],
};
function sanitizeAttrs(tag, rawAttrs) {
const allowed = ALLOWED_ATTRS[tag];
if (!allowed || !rawAttrs) return '';
let out = '';
const attrRe = /([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
let m;
while ((m = attrRe.exec(rawAttrs)) !== null) {
const name = m[1].toLowerCase();
if (!allowed.includes(name)) continue;
const value = m[3] !== undefined ? m[3] : m[4] !== undefined ? m[4] : m[5];
if (name === 'href' && !SAFE_HREF.test(value.trim())) continue;
if (/[<>"]/.test(value)) continue;
out += ` ${name}="${value.replace(/&(?!(amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g, '&amp;')}"`;
}
return out;
}
/**
* Sanitize an HTML fragment. Unknown/dangerous tags are dropped entirely
* (their text content is kept); script/style/iframe content is removed
* including the text inside.
*/
function sanitizeHtml(html) {
if (html == null) return '';
let text = String(html);
// Remove comments and the content of actively dangerous containers.
text = text.replace(/<!--[\s\S]*?-->/g, '');
text = text.replace(/<(script|style|iframe|object|embed|template)\b[\s\S]*?<\/\1\s*>/gi, '');
text = text.replace(/<(script|style|iframe|object|embed|template)\b[^>]*>/gi, '');
return text.replace(
/<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g,
(whole, slash, rawTag, rawAttrs) => {
const tag = rawTag.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) return '';
if (slash) return VOID_TAGS.has(tag) ? '' : `</${tag}>`;
if (VOID_TAGS.has(tag)) return `<${tag}>`;
return `<${tag}${sanitizeAttrs(tag, rawAttrs)}>`;
}
);
}
module.exports = { sanitizeHtml, ALLOWED_TAGS };
+180
View File
@@ -0,0 +1,180 @@
'use strict';
const { newId, nowIso, deepClone } = require('./util');
const { sanitizeHtml } = require('./sanitize');
const SCHEMA_VERSION = 1;
const STEP_KINDS = ['image', 'empty', 'content'];
const STEP_STATUSES = ['todo', 'in-progress', 'done'];
const ANNOTATION_TYPES = [
'rect', 'oval', 'line', 'arrow', 'text', 'tooltip', 'number',
'blur', 'highlight', 'magnify', 'cursor',
];
const TEXTBLOCK_LEVELS = ['info', 'warn', 'error', 'success'];
const TEXTBLOCK_POSITIONS = [
'before-title', 'after-title', 'before-image', 'after-image',
'before-description', 'after-description',
];
const DEFAULT_ANNOTATION_STYLE = {
stroke: '#E5484D',
fill: 'transparent',
textColor: '#FFFFFF',
strokeWidth: 3,
fontSize: 0.022, // fraction of image height
};
function createGuide(fields = {}) {
const now = nowIso();
return {
schemaVersion: SCHEMA_VERSION,
guideId: fields.guideId || newId('guide'),
title: fields.title || 'Untitled guide',
descriptionHtml: sanitizeHtml(fields.descriptionHtml || ''),
placeholders: { ...(fields.placeholders || {}) },
flags: {
focusedViewDefault: false,
hideSkippedStepsInExports: true,
...(fields.flags || {}),
},
themeOverride: fields.themeOverride || 'system',
createdAt: now,
updatedAt: now,
stepsOrder: [],
favorite: Boolean(fields.favorite),
linkedSource: fields.linkedSource || null,
exportProfiles: { ...(fields.exportProfiles || {}) },
};
}
function createStep(fields = {}) {
return {
stepId: fields.stepId || newId('step'),
parentStepId: fields.parentStepId || null,
kind: STEP_KINDS.includes(fields.kind) ? fields.kind : 'image',
status: STEP_STATUSES.includes(fields.status) ? fields.status : 'todo',
title: fields.title || '',
descriptionHtml: sanitizeHtml(fields.descriptionHtml || ''),
hidden: Boolean(fields.hidden),
skipped: Boolean(fields.skipped),
forceNewPage: Boolean(fields.forceNewPage),
focusedView: {
enabled: false,
zoom: 1,
panX: 0.5,
panY: 0.5,
...(fields.focusedView || {}),
},
image: fields.image || null, // { originalPath, workingPath, size:{width,height} }
extraImages: fields.extraImages || [], // multi-image steps
annotations: (fields.annotations || []).map(normalizeAnnotation),
textBlocks: (fields.textBlocks || []).map(normalizeTextBlock),
codeBlocks: fields.codeBlocks || [], // { id, language, code }
tableBlocks: fields.tableBlocks || [], // { id, rows: [[cellText,..],..], headerRow }
links: fields.links || [], // { id, label, targetStepId }
};
}
function normalizeAnnotation(a) {
const ann = {
id: a.id || newId('ann'),
type: ANNOTATION_TYPES.includes(a.type) ? a.type : 'rect',
x: num(a.x, 0.25),
y: num(a.y, 0.25),
w: num(a.w, 0.2),
h: num(a.h, 0.1),
text: typeof a.text === 'string' ? a.text : '',
style: { ...DEFAULT_ANNOTATION_STYLE, ...(a.style || {}) },
};
if (ann.type === 'number') ann.value = Number.isFinite(a.value) ? a.value : null;
if (ann.type === 'magnify') ann.zoom = num(a.zoom, 2);
if (ann.type === 'blur') ann.radius = num(a.radius, 8);
if (ann.type === 'tooltip') ann.style.tail = a.style && a.style.tail ? a.style.tail : 'bottom';
return ann;
}
function normalizeTextBlock(tb) {
return {
id: tb.id || newId('tb'),
position: TEXTBLOCK_POSITIONS.includes(tb.position) ? tb.position : 'after-description',
level: TEXTBLOCK_LEVELS.includes(tb.level) ? tb.level : 'info',
title: tb.title || '',
descriptionHtml: sanitizeHtml(tb.descriptionHtml || ''),
};
}
function num(v, fallback) {
return Number.isFinite(v) ? v : fallback;
}
function isNonEmptyString(v) {
return typeof v === 'string' && v.length > 0;
}
/** Throws with a descriptive message when the guide object is invalid. */
function validateGuide(guide) {
const errors = [];
if (!guide || typeof guide !== 'object') throw new Error('guide must be an object');
if (guide.schemaVersion !== SCHEMA_VERSION) errors.push(`unsupported schemaVersion ${guide.schemaVersion}`);
if (!isNonEmptyString(guide.guideId)) errors.push('guideId missing');
if (typeof guide.title !== 'string') errors.push('title must be a string');
if (!Array.isArray(guide.stepsOrder)) errors.push('stepsOrder must be an array');
else if (new Set(guide.stepsOrder).size !== guide.stepsOrder.length) errors.push('stepsOrder has duplicates');
if (guide.placeholders && typeof guide.placeholders !== 'object') errors.push('placeholders must be an object');
if (errors.length) throw new Error(`invalid guide: ${errors.join('; ')}`);
return guide;
}
function validateStep(step) {
const errors = [];
if (!step || typeof step !== 'object') throw new Error('step must be an object');
if (!isNonEmptyString(step.stepId)) errors.push('stepId missing');
if (!STEP_KINDS.includes(step.kind)) errors.push(`bad kind ${step.kind}`);
if (!STEP_STATUSES.includes(step.status)) errors.push(`bad status ${step.status}`);
if (step.kind === 'image' && step.image) {
if (!isNonEmptyString(step.image.originalPath)) errors.push('image.originalPath missing');
if (!step.image.size || !Number.isFinite(step.image.size.width) || !Number.isFinite(step.image.size.height)) {
errors.push('image.size invalid');
}
}
for (const a of step.annotations || []) {
if (!ANNOTATION_TYPES.includes(a.type)) errors.push(`bad annotation type ${a.type}`);
for (const k of ['x', 'y', 'w', 'h']) {
if (!Number.isFinite(a[k])) errors.push(`annotation ${a.id} ${k} not a number`);
}
}
if (errors.length) throw new Error(`invalid step: ${errors.join('; ')}`);
return step;
}
/** Fill defaults on objects loaded from disk (forward-compatible load). */
function normalizeGuide(raw) {
const guide = { ...createGuide(raw), guideId: raw.guideId };
guide.createdAt = raw.createdAt || guide.createdAt;
guide.updatedAt = raw.updatedAt || guide.updatedAt;
guide.stepsOrder = Array.isArray(raw.stepsOrder) ? [...raw.stepsOrder] : [];
return guide;
}
function normalizeStep(raw) {
return { ...createStep(raw), stepId: raw.stepId };
}
module.exports = {
SCHEMA_VERSION,
STEP_KINDS,
STEP_STATUSES,
ANNOTATION_TYPES,
TEXTBLOCK_LEVELS,
TEXTBLOCK_POSITIONS,
DEFAULT_ANNOTATION_STYLE,
createGuide,
createStep,
normalizeAnnotation,
normalizeTextBlock,
validateGuide,
validateStep,
normalizeGuide,
normalizeStep,
};
+94
View File
@@ -0,0 +1,94 @@
'use strict';
const path = require('node:path');
const { writeJsonSync, readJsonIfExists, deepClone } = require('./util');
const DEFAULT_SETTINGS = {
schemaVersion: 1,
appearance: 'system', // system | light | dark
language: 'en',
spellcheck: true,
capture: {
delayMs: 0,
mode: 'fullscreen', // fullscreen | window | region
includeCursor: true,
clickMarker: true,
clickMarkerColor: '#E5484D',
hotkeyCapture: 'CommandOrControl+Shift+1',
hotkeyPauseResume: 'CommandOrControl+Shift+2',
captureOutsideClicks: true,
confirmSimpleCapture: false,
},
editor: {
focusedViewDefaultForNewSteps: false,
autoTitleTemplate: '[[Mode]] capture [[Time]]',
},
exports: {
previewStepCount: 3,
openFolderAfterExport: true,
lastOutputDirs: {}, // format -> dir
},
library: {
sortBy: 'updatedAt',
},
backups: {
automatic: true,
keepLast: 10,
everyNSaves: 25,
},
};
class Settings {
constructor(settingsDir) {
this.file = path.join(settingsDir, 'app-settings.json');
this.globalPlaceholdersFile = path.join(settingsDir, 'placeholders.json');
this.data = this.load();
}
load() {
const stored = readJsonIfExists(this.file, {});
return mergeDeep(deepClone(DEFAULT_SETTINGS), stored);
}
save() {
writeJsonSync(this.file, this.data);
return this.data;
}
get(keyPath) {
return keyPath.split('.').reduce((o, k) => (o == null ? undefined : o[k]), this.data);
}
set(keyPath, value) {
const keys = keyPath.split('.');
let obj = this.data;
for (const k of keys.slice(0, -1)) {
if (typeof obj[k] !== 'object' || obj[k] === null) obj[k] = {};
obj = obj[k];
}
obj[keys[keys.length - 1]] = value;
return this.save();
}
getGlobalPlaceholders() {
return readJsonIfExists(this.globalPlaceholdersFile, {});
}
setGlobalPlaceholders(values) {
writeJsonSync(this.globalPlaceholdersFile, values);
return values;
}
}
function mergeDeep(base, extra) {
for (const [k, v] of Object.entries(extra || {})) {
if (v && typeof v === 'object' && !Array.isArray(v) && base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) {
mergeDeep(base[k], v);
} else {
base[k] = v;
}
}
return base;
}
module.exports = { Settings, DEFAULT_SETTINGS };
+329
View File
@@ -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 };
+122
View File
@@ -0,0 +1,122 @@
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
function newId(prefix) {
const uuid = crypto.randomUUID();
return prefix ? `${prefix}-${uuid}` : uuid;
}
function nowIso() {
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
}
/**
* Crash-safe write: write to a temp file in the same directory, then rename
* over the target so readers never observe a half-written file.
*/
function atomicWriteFileSync(file, data) {
const dir = path.dirname(file);
fs.mkdirSync(dir, { recursive: true });
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
const fd = fs.openSync(tmp, 'w');
try {
fs.writeFileSync(fd, data);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, file);
}
function writeJsonSync(file, obj) {
atomicWriteFileSync(file, JSON.stringify(obj, null, 2) + '\n');
}
function readJsonSync(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function readJsonIfExists(file, fallback) {
try {
return readJsonSync(file);
} catch (err) {
if (err.code === 'ENOENT') return fallback;
throw err;
}
}
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" };
function decodeEntities(text) {
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+|#39);/g, (m, name) => {
if (name[0] === '#') {
const code = name[1] === 'x' || name[1] === 'X'
? parseInt(name.slice(2), 16)
: parseInt(name.slice(1), 10);
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m;
}
return Object.prototype.hasOwnProperty.call(ENTITIES, name) ? ENTITIES[name] : m;
});
}
/** Convert an HTML fragment to plain text (for search indexing and exports). */
function htmlToText(html) {
if (!html) return '';
let text = String(html)
.replace(/<(br|\/p|\/div|\/li|\/h[1-6]|\/tr)\s*\/?>/gi, '\n')
.replace(/<li[\s>]/gi, '• <')
.replace(/<[^>]*>/g, '');
text = decodeEntities(text);
return text.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim();
}
function escapeHtml(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeXml(text) {
return escapeHtml(text).replace(/'/g, '&apos;');
}
function deepClone(obj) {
return obj === undefined ? undefined : JSON.parse(JSON.stringify(obj));
}
function clamp(v, min, max) {
return Math.min(max, Math.max(min, v));
}
/** Filesystem-safe slug for export folder names like steps-<title>. */
function slugify(text, fallback = 'untitled') {
const slug = String(text || '')
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-zA-Z0-9._ -]+/g, '')
.trim()
.replace(/\s+/g, '-')
.slice(0, 80);
return slug || fallback;
}
module.exports = {
newId,
nowIso,
atomicWriteFileSync,
writeJsonSync,
readJsonSync,
readJsonIfExists,
htmlToText,
decodeEntities,
escapeHtml,
escapeXml,
deepClone,
clamp,
slugify,
};