Files
autodoc/core/schema.js
T
Iisyourdad 2a602d7477 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>
2026-06-10 16:34:15 -05:00

181 lines
6.3 KiB
JavaScript

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