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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs the node:test workflow suites. These create real guides, archives,
|
||||||
|
# and exports in temp directories and assert on the actual output produced.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
node --test tests/unit/
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
/** Create a unique temp directory, auto-registered for cleanup by caller. */
|
||||||
|
function makeTmpDir(label) {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), `stepforge-${label}-`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rmrf(dir) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal valid 1x1 red PNG, used where pixel content doesn't matter.
|
||||||
|
const TINY_PNG = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||||
|
'base64'
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = { makeTmpDir, rmrf, TINY_PNG };
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const {
|
||||||
|
systemPlaceholders, resolveScopes, expandPlaceholders,
|
||||||
|
listPlaceholders, collectGuidePlaceholders,
|
||||||
|
} = require('../../core/placeholders');
|
||||||
|
const { createGuide, createStep } = require('../../core/schema');
|
||||||
|
const { Settings, DEFAULT_SETTINGS } = require('../../core/settings');
|
||||||
|
const { makeTmpDir, rmrf } = require('./helpers');
|
||||||
|
|
||||||
|
test('placeholder expansion respects guide > global > system precedence', () => {
|
||||||
|
const guide = createGuide({ title: 'Install VPN', placeholders: { Author: 'Alice' } });
|
||||||
|
const system = systemPlaceholders(guide, { now: new Date(2026, 5, 10, 9, 5), stepCount: 4 });
|
||||||
|
const values = resolveScopes({ guide, globals: { Author: 'Bob', Company: 'Acme' }, system });
|
||||||
|
|
||||||
|
const out = expandPlaceholders(
|
||||||
|
'Guide [[Guide_Title]] by [[Author]] at [[Company]] on [[Date]] ([[Step_Count]] steps)',
|
||||||
|
values
|
||||||
|
);
|
||||||
|
assert.equal(out, 'Guide Install VPN by Alice at Acme on 2026-06-10 (4 steps)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown placeholders stay visible instead of disappearing', () => {
|
||||||
|
const out = expandPlaceholders('Hello [[Nobody]] and [[Author]]', { Author: 'A' });
|
||||||
|
assert.equal(out, 'Hello [[Nobody]] and A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('placeholders used across a guide are collected from every surface', () => {
|
||||||
|
const guide = createGuide({ title: '[[Product]] setup', descriptionHtml: '<p>[[Company]]</p>' });
|
||||||
|
const steps = [
|
||||||
|
createStep({ title: 'Login as [[Author]]' }),
|
||||||
|
createStep({
|
||||||
|
textBlocks: [{ title: 'Warning [[Severity]]', descriptionHtml: '<p>[[Company]]</p>' }],
|
||||||
|
annotations: [{ type: 'text', x: 0, y: 0, w: 0.1, h: 0.1, text: 'See [[Doc_Ref]]' }],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
collectGuidePlaceholders(guide, steps),
|
||||||
|
['Author', 'Company', 'Doc_Ref', 'Product', 'Severity']
|
||||||
|
);
|
||||||
|
assert.deepEqual(listPlaceholders('no tokens here'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings persist, deep-merge with defaults, and store global placeholders', (t) => {
|
||||||
|
const dir = makeTmpDir('settings');
|
||||||
|
t.after(() => rmrf(dir));
|
||||||
|
|
||||||
|
const s1 = new Settings(dir);
|
||||||
|
assert.equal(s1.get('appearance'), DEFAULT_SETTINGS.appearance);
|
||||||
|
s1.set('appearance', 'dark');
|
||||||
|
s1.set('capture.delayMs', 1500);
|
||||||
|
s1.setGlobalPlaceholders({ Company: 'Acme', Author: 'Tyler' });
|
||||||
|
|
||||||
|
// A fresh instance reads back the changed values merged over defaults.
|
||||||
|
const s2 = new Settings(dir);
|
||||||
|
assert.equal(s2.get('appearance'), 'dark');
|
||||||
|
assert.equal(s2.get('capture.delayMs'), 1500);
|
||||||
|
assert.equal(s2.get('capture.clickMarker'), DEFAULT_SETTINGS.capture.clickMarker);
|
||||||
|
assert.deepEqual(s2.getGlobalPlaceholders(), { Company: 'Acme', Author: 'Tyler' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const { GuideStore } = require('../../core/store');
|
||||||
|
const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers');
|
||||||
|
|
||||||
|
test('create a guide, add image steps, and reload everything from disk', (t) => {
|
||||||
|
const root = makeTmpDir('store');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({
|
||||||
|
title: 'Reset a password',
|
||||||
|
descriptionHtml: '<p>How to reset a <strong>user</strong> password.</p>',
|
||||||
|
placeholders: { Department: 'IT' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const s1 = store.addStep(guide.guideId, { title: 'Open the admin portal' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const s2 = store.addStep(guide.guideId, {
|
||||||
|
title: 'Click Users',
|
||||||
|
descriptionHtml: '<p>In the left nav.</p>',
|
||||||
|
annotations: [{ type: 'arrow', x: 0.1, y: 0.2, w: 0.3, h: 0.1 }],
|
||||||
|
}, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const sub = store.addStep(guide.guideId, { kind: 'empty', title: 'Note', parentStepId: s2.stepId });
|
||||||
|
|
||||||
|
// A brand-new store instance must see exactly what was written.
|
||||||
|
const fresh = new GuideStore(root);
|
||||||
|
const loaded = fresh.getGuide(guide.guideId);
|
||||||
|
assert.equal(loaded.title, 'Reset a password');
|
||||||
|
assert.equal(loaded.placeholders.Department, 'IT');
|
||||||
|
assert.deepEqual(loaded.stepsOrder, [s1.stepId, s2.stepId, sub.stepId]);
|
||||||
|
|
||||||
|
const steps = fresh.listSteps(guide.guideId);
|
||||||
|
assert.equal(steps.size, 3);
|
||||||
|
assert.equal(steps.get(s2.stepId).annotations.length, 1);
|
||||||
|
assert.equal(steps.get(s2.stepId).annotations[0].type, 'arrow');
|
||||||
|
assert.equal(steps.get(sub.stepId).parentStepId, s2.stepId);
|
||||||
|
assert.equal(steps.get(sub.stepId).kind, 'empty');
|
||||||
|
|
||||||
|
// Image files exist and original equals what was captured.
|
||||||
|
const imgPath = fresh.stepImagePath(guide.guideId, s1.stepId, 'original');
|
||||||
|
assert.deepEqual(fs.readFileSync(imgPath), TINY_PNG);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('step reorder, delete with substep reparenting, and order integrity', (t) => {
|
||||||
|
const root = makeTmpDir('reorder');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'Order test' });
|
||||||
|
const a = store.addStep(guide.guideId, { kind: 'empty', title: 'A' });
|
||||||
|
const b = store.addStep(guide.guideId, { kind: 'empty', title: 'B' });
|
||||||
|
const c = store.addStep(guide.guideId, { kind: 'empty', title: 'C', parentStepId: b.stepId });
|
||||||
|
|
||||||
|
store.reorderSteps(guide.guideId, [b.stepId, c.stepId, a.stepId]);
|
||||||
|
assert.deepEqual(store.getGuide(guide.guideId).stepsOrder, [b.stepId, c.stepId, a.stepId]);
|
||||||
|
|
||||||
|
// Reorder must reject losing or inventing steps.
|
||||||
|
assert.throws(() => store.reorderSteps(guide.guideId, [a.stepId]));
|
||||||
|
assert.throws(() => store.reorderSteps(guide.guideId, [a.stepId, b.stepId, 'step-bogus']));
|
||||||
|
|
||||||
|
// Deleting B reparents its substep C to top level and drops B from order.
|
||||||
|
store.deleteStep(guide.guideId, b.stepId);
|
||||||
|
const after = store.getGuide(guide.guideId);
|
||||||
|
assert.deepEqual(after.stepsOrder, [c.stepId, a.stepId]);
|
||||||
|
assert.equal(store.getStep(guide.guideId, c.stepId).parentStepId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate guide produces independent deep copy with fresh ids', (t) => {
|
||||||
|
const root = makeTmpDir('dup');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'Original' });
|
||||||
|
const s1 = store.addStep(guide.guideId, { title: 'Step one' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const s2 = store.addStep(guide.guideId, { kind: 'empty', title: 'Child', parentStepId: s1.stepId });
|
||||||
|
|
||||||
|
const copy = store.duplicateGuide(guide.guideId);
|
||||||
|
assert.notEqual(copy.guideId, guide.guideId);
|
||||||
|
assert.equal(copy.title, 'Original (copy)');
|
||||||
|
assert.equal(copy.stepsOrder.length, 2);
|
||||||
|
assert.ok(!copy.stepsOrder.includes(s1.stepId), 'copied steps must have new ids');
|
||||||
|
|
||||||
|
// Parent/child relationship is remapped to the new ids.
|
||||||
|
const copySteps = store.listSteps(copy.guideId);
|
||||||
|
const copiedChild = [...copySteps.values()].find((s) => s.title === 'Child');
|
||||||
|
const copiedParent = [...copySteps.values()].find((s) => s.title === 'Step one');
|
||||||
|
assert.equal(copiedChild.parentStepId, copiedParent.stepId);
|
||||||
|
|
||||||
|
// Image bytes were copied, and editing the copy does not touch the original.
|
||||||
|
assert.deepEqual(fs.readFileSync(store.stepImagePath(copy.guideId, copiedParent.stepId, 'original')), TINY_PNG);
|
||||||
|
copiedParent.title = 'Edited in copy';
|
||||||
|
store.saveStep(copy.guideId, copiedParent);
|
||||||
|
assert.equal(store.getStep(guide.guideId, s1.stepId).title, 'Step one');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete moves guide to trash and restore brings it back intact', (t) => {
|
||||||
|
const root = makeTmpDir('trash');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'Disposable' });
|
||||||
|
store.addStep(guide.guideId, { kind: 'empty', title: 'only step' });
|
||||||
|
|
||||||
|
store.deleteGuide(guide.guideId);
|
||||||
|
assert.equal(store.guideExists(guide.guideId), false);
|
||||||
|
const trash = store.listTrash();
|
||||||
|
assert.equal(trash.length, 1);
|
||||||
|
|
||||||
|
const restoredId = store.restoreFromTrash(trash[0]);
|
||||||
|
assert.equal(restoredId, guide.guideId);
|
||||||
|
assert.equal(store.getGuide(guide.guideId).title, 'Disposable');
|
||||||
|
assert.equal(store.listSteps(guide.guideId).size, 1);
|
||||||
|
|
||||||
|
store.deleteGuide(guide.guideId);
|
||||||
|
store.purgeTrash();
|
||||||
|
assert.equal(store.listTrash().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('folders and favorites round-trip', (t) => {
|
||||||
|
const root = makeTmpDir('folders');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const g1 = store.createGuide({ title: 'In folder' });
|
||||||
|
const g2 = store.createGuide({ title: 'Loose' });
|
||||||
|
|
||||||
|
const folder = store.createFolder('IT Support');
|
||||||
|
store.moveGuideToFolder(g1.guideId, folder.id);
|
||||||
|
store.setFavorite(g2.guideId, true);
|
||||||
|
|
||||||
|
const fresh = new GuideStore(root);
|
||||||
|
assert.equal(fresh.loadFolders().guideFolders[g1.guideId], folder.id);
|
||||||
|
assert.equal(fresh.getGuide(g2.guideId).favorite, true);
|
||||||
|
|
||||||
|
// Deleting the folder unassigns guides but keeps them in the library.
|
||||||
|
fresh.deleteFolder(folder.id);
|
||||||
|
assert.equal(fresh.loadFolders().guideFolders[g1.guideId], undefined);
|
||||||
|
assert.equal(fresh.guideExists(g1.guideId), true);
|
||||||
|
|
||||||
|
assert.throws(() => fresh.moveGuideToFolder(g1.guideId, 'folder-missing'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('working image can be replaced (crop) and reset without touching original', (t) => {
|
||||||
|
const root = makeTmpDir('crop');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'Crop test' });
|
||||||
|
const step = store.addStep(guide.guideId, { title: 'shot' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
|
||||||
|
const cropped = Buffer.from('not-really-a-png-but-different-bytes');
|
||||||
|
store.setWorkingImage(guide.guideId, step.stepId, cropped, { width: 10, height: 5 });
|
||||||
|
assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, step.stepId, 'working')), cropped);
|
||||||
|
assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, step.stepId, 'original')), TINY_PNG);
|
||||||
|
assert.deepEqual(store.getStep(guide.guideId, step.stepId).image.size, { width: 10, height: 5 });
|
||||||
|
|
||||||
|
store.resetWorkingImage(guide.guideId, step.stepId, { width: 1, height: 1 });
|
||||||
|
assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, step.stepId, 'working')), TINY_PNG);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stored HTML is sanitized: scripts and handlers cannot round-trip', (t) => {
|
||||||
|
const root = makeTmpDir('sanitize');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({
|
||||||
|
title: 'XSS attempt',
|
||||||
|
descriptionHtml: '<p onclick="evil()">hi<script>alert(1)</script> <a href="javascript:evil()">x</a> <a href="https://example.com">ok</a></p>',
|
||||||
|
});
|
||||||
|
const loaded = store.getGuide(guide.guideId);
|
||||||
|
// The dangerous parts are gone; the safe parts survive exactly.
|
||||||
|
assert.equal(
|
||||||
|
loaded.descriptionHtml,
|
||||||
|
'<p>hi <a>x</a> <a href="https://example.com">ok</a></p>'
|
||||||
|
);
|
||||||
|
|
||||||
|
const step = store.addStep(guide.guideId, {
|
||||||
|
kind: 'content',
|
||||||
|
descriptionHtml: '<div><iframe src="https://evil"></iframe><b>bold</b></div>',
|
||||||
|
});
|
||||||
|
assert.equal(store.getStep(guide.guideId, step.stepId).descriptionHtml, '<div><b>bold</b></div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guide ids are validated against path traversal', (t) => {
|
||||||
|
const root = makeTmpDir('traverse');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
assert.throws(() => store.getGuide('../../etc'));
|
||||||
|
assert.throws(() => store.guideDir('a/b'));
|
||||||
|
assert.throws(() => store.stepDir('ok', '..'));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user