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:
@@ -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,
|
||||
};
|
||||
@@ -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, '&')}"`;
|
||||
}
|
||||
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
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeXml(text) {
|
||||
return escapeHtml(text).replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user