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:
Iisyourdad
2026-06-10 16:34:15 -05:00
parent 70d812007f
commit 2a602d7477
10 changed files with 1160 additions and 0 deletions
+79
View File
@@ -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,
};