'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, };