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:
+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,
|
||||
};
|
||||
Reference in New Issue
Block a user