diff --git a/core/placeholders.js b/core/placeholders.js new file mode 100644 index 0000000..9208c99 --- /dev/null +++ b/core/placeholders.js @@ -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, +}; diff --git a/core/sanitize.js b/core/sanitize.js new file mode 100644 index 0000000..8c5bb5d --- /dev/null +++ b/core/sanitize.js @@ -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(//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) ? '' : ``; + if (VOID_TAGS.has(tag)) return `<${tag}>`; + return `<${tag}${sanitizeAttrs(tag, rawAttrs)}>`; + } + ); +} + +module.exports = { sanitizeHtml, ALLOWED_TAGS }; diff --git a/core/schema.js b/core/schema.js new file mode 100644 index 0000000..907d5fd --- /dev/null +++ b/core/schema.js @@ -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, +}; diff --git a/core/settings.js b/core/settings.js new file mode 100644 index 0000000..e43043b --- /dev/null +++ b/core/settings.js @@ -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 }; diff --git a/core/store.js b/core/store.js new file mode 100644 index 0000000..8117b15 --- /dev/null +++ b/core/store.js @@ -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 }; diff --git a/core/util.js b/core/util.js new file mode 100644 index 0000000..c2e9e00 --- /dev/null +++ b/core/util.js @@ -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(/]/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, '"'); +} + +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-. */ +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, +}; diff --git a/tests/checks/test_unit_workflows.sh b/tests/checks/test_unit_workflows.sh new file mode 100644 index 0000000..1020734 --- /dev/null +++ b/tests/checks/test_unit_workflows.sh @@ -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/ diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js new file mode 100644 index 0000000..02e809d --- /dev/null +++ b/tests/unit/helpers.js @@ -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 }; diff --git a/tests/unit/placeholders.test.js b/tests/unit/placeholders.test.js new file mode 100644 index 0000000..3e372d9 --- /dev/null +++ b/tests/unit/placeholders.test.js @@ -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' }); +}); diff --git a/tests/unit/store.test.js b/tests/unit/store.test.js new file mode 100644 index 0000000..5dccd01 --- /dev/null +++ b/tests/unit/store.test.js @@ -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', '..')); +});