621 lines
25 KiB
JavaScript
621 lines
25 KiB
JavaScript
import { hasFrontmatter, normalizeDefinition, parseFrontmatter, parseSkill } from './registry';
|
|
import { SUPPORTED_OWLIVER_CAPABILITIES, sourceSupportsOption } from './surfaces';
|
|
|
|
/**
|
|
* A definition, read into editor fields — and edited fields, written back.
|
|
*
|
|
* Two things used to be missing here, and between them they are the whole of
|
|
* "uploading a `.md` does nothing" and "the form fields do not work":
|
|
*
|
|
* - **Nothing read a file into the fields.** All three upload buttons set the
|
|
* Markdown and stopped, so an author who uploaded a complete definition was
|
|
* looking at a form full of empty boxes beside it. The two editors each had
|
|
* a reader that did exactly this job — `metaFromSource` and
|
|
* `draftFromSource` — and neither was reachable from the upload path. They
|
|
* live here now, so one file is read the same way wherever it arrives.
|
|
* - **Nothing wrote the fields back.** Both editors regenerated the whole
|
|
* definition from a template while the Markdown was untouched, and stopped
|
|
* the moment it was touched — which is every edit of an existing skill,
|
|
* because `touched` starts true there. Typing a new name updated React
|
|
* state that nothing saved. `patchFrontmatter` is the missing half: a field
|
|
* edits the key it owns, in place, and the body is never rewritten.
|
|
*
|
|
* Everything here reads the *registered* form of a definition — what
|
|
* `parseSkill` made of it — rather than the raw text. The form therefore shows
|
|
* what the product will actually do, including the placement an author left out
|
|
* and the vocabulary that filled it in.
|
|
*/
|
|
|
|
/* ── Reading a definition into fields ───────────────────────────────────── */
|
|
|
|
/**
|
|
* A definition, or nothing.
|
|
*
|
|
* `parseSkill` is deliberately forgiving: a file with no frontmatter at all
|
|
* still parses, taking its id from the path it was given. That is right for the
|
|
* registry, and wrong here — a text file dropped on the upload button would
|
|
* otherwise fill the ID field with `custom` and the name with `Untitled skill`,
|
|
* which reads as a definition the product understood. A form is filled from a
|
|
* definition or it is left alone.
|
|
*/
|
|
function readSkill(source) {
|
|
/* `hasFrontmatter`, not a second regex of this module's own. The two used to
|
|
be written out separately and drifted: a file the registry was willing to
|
|
read could be one this rejected, and rejecting it here empties a form the
|
|
preview beside it has just filled in. One detector, one answer. */
|
|
if (!hasFrontmatter(source)) return null;
|
|
return parseSkill(source, { custom: true });
|
|
}
|
|
|
|
/**
|
|
* A definition, cleaned up on the way in.
|
|
*
|
|
* Re-exported under the name the editors use, so an uploaded file is stored in
|
|
* the form every reader already agrees on rather than carrying a byte-order
|
|
* mark into `patchFrontmatter`, which would not recognise the fence and would
|
|
* write a second one above it.
|
|
*/
|
|
export const normalizeUpload = normalizeDefinition;
|
|
|
|
/** Whether a definition can be read into the fields at all. */
|
|
export const isReadableDefinition = (source) => hasFrontmatter(source);
|
|
|
|
/** The Board (UI) editor's fields, before a definition is loaded into them. */
|
|
export const EMPTY_BOARD_FIELDS = {
|
|
id: '', name: '', description: '', pages: [],
|
|
type: 'flow', placement: '', title: '', source: 'candidates.activity', periods: [],
|
|
};
|
|
|
|
/**
|
|
* The Owliver editor's fields, before a definition is loaded into them.
|
|
*
|
|
* `responses` is keyed by capability, because that is how the format is keyed
|
|
* and how the runtime reads it. It used to be one `source` and one `periods`
|
|
* shared by every selected capability, and that single field is the whole of
|
|
* the bug this shape exists to remove:
|
|
*
|
|
* - A capability selected before a source was picked composed **no**
|
|
* `responses:` block at all. `normalizeSkillOwliver` then dropped every
|
|
* capability for want of a source, `owliver.capabilities` normalized to
|
|
* `[]`, and `owliverSkillsForContext` excludes a skill with none — so the
|
|
* definition registered, showed as active on its page, and contributed no
|
|
* suggestion chip.
|
|
* - Two capabilities could not read different sources, and one global source
|
|
* that suited the first often could not be drawn as the second: `list`
|
|
* against `candidates.activity` is refused by `normalizeSection`, so
|
|
* picking it silently cost the author that capability.
|
|
*
|
|
* A form that cannot express what the format can is not a shortcut to it.
|
|
*/
|
|
export const EMPTY_OWLIVER_FIELDS = {
|
|
id: '', name: '', description: '', pages: [],
|
|
triggers: [], suggestions: [], capabilities: [], responses: {},
|
|
};
|
|
|
|
/**
|
|
* A Board skill, read back into the fields that compose one.
|
|
*
|
|
* The section comes from the registry's reading rather than from the Markdown
|
|
* text, so a definition that omitted `placement:` shows the placement it will
|
|
* actually render at.
|
|
*/
|
|
export function boardFieldsFromSource(source) {
|
|
try {
|
|
const skill = readSkill(source);
|
|
if (!skill) throw new Error('no frontmatter');
|
|
/* The first section, in declaration order — what the single-valued fields
|
|
can show. `pages` still carries every page the definition reaches, and
|
|
`uiIsEditableFromFields` is what stops those fields writing over the
|
|
ones this record cannot represent. */
|
|
const section = Object.values(skill.ui || {}).flatMap((p) => p.sections || [])[0];
|
|
return {
|
|
id: skill.id,
|
|
name: skill.name,
|
|
description: skill.description,
|
|
pages: skill.pages,
|
|
type: section?.type || EMPTY_BOARD_FIELDS.type,
|
|
placement: section?.placement || '',
|
|
/* The heading the card draws. Composed from the skill's name when it is
|
|
left out, but it is a field of its own in the format and a definition
|
|
that set it must survive a round trip through the form. */
|
|
title: section?.title || '',
|
|
source: section?.source || EMPTY_BOARD_FIELDS.source,
|
|
periods: section?.periods || [],
|
|
};
|
|
} catch {
|
|
return EMPTY_BOARD_FIELDS;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An Owliver skill, read back into the fields that compose one.
|
|
*
|
|
* Reads the resolved capabilities, which includes the ones whose source was
|
|
* inherited from a `ui:` block rather than written out — so the form shows what
|
|
* Owliver can be asked for, not what the file happened to spell.
|
|
*/
|
|
export function owliverFieldsFromSource(source) {
|
|
try {
|
|
const skill = readSkill(source);
|
|
if (!skill) throw new Error('no frontmatter');
|
|
|
|
/**
|
|
* Every capability the definition *declares*, not only the ones that
|
|
* resolved.
|
|
*
|
|
* `skill.owliver.capabilities` is filtered to those that found a reading,
|
|
* which is right for the runtime and wrong for a form: a stored definition
|
|
* declaring `summary` and `list` where `list` lost its source would open
|
|
* with `list` simply absent, and the next save would delete a capability
|
|
* its author never removed. The one that cannot answer is exactly the one
|
|
* they opened the editor to fix, so it is shown — checked, with no source,
|
|
* and refused by the same validation as before.
|
|
*
|
|
* Read from the frontmatter the definition actually carries, and narrowed
|
|
* to the closed vocabulary, so a typo is still not a capability.
|
|
*/
|
|
const declared = (() => {
|
|
let data;
|
|
try {
|
|
({ data } = parseFrontmatter(String(source)));
|
|
} catch {
|
|
return [];
|
|
}
|
|
const block = data?.owliver;
|
|
if (!block || typeof block !== 'object' || Array.isArray(block)) return [];
|
|
const listed = Array.isArray(block.capabilities) ? block.capabilities : [];
|
|
const keyed = block.responses && typeof block.responses === 'object' && !Array.isArray(block.responses)
|
|
? Object.keys(block.responses)
|
|
: [];
|
|
return [...listed, ...keyed]
|
|
.map((capability) => String(capability).trim())
|
|
.filter((capability) => SUPPORTED_OWLIVER_CAPABILITIES.includes(capability));
|
|
})();
|
|
|
|
/* Declaration order first, then anything that resolved without being
|
|
listed — a definition whose capability came from its `ui:` section. */
|
|
const capabilities = [...new Set([...declared, ...skill.owliver.capabilities])];
|
|
|
|
/**
|
|
* Every capability's own reading, not the first one's.
|
|
*
|
|
* Reading one and showing it against all of them is how a two-capability
|
|
* definition lost the second's source the first time any field was edited:
|
|
* the form wrote back what it had read, and it had read half the block.
|
|
*/
|
|
const responses = Object.fromEntries(
|
|
capabilities.map((capability) => {
|
|
const response = skill.owliver.responses[capability];
|
|
return [capability, {
|
|
source: response?.source || '',
|
|
periods: response?.periods || [],
|
|
limit: response?.limit ?? null,
|
|
}];
|
|
})
|
|
);
|
|
|
|
return {
|
|
id: skill.id,
|
|
name: skill.name,
|
|
description: skill.description,
|
|
pages: skill.pages,
|
|
/**
|
|
* Only the triggers the definition actually claimed.
|
|
*
|
|
* `skill.triggers` falls back to the skill's own name when none are
|
|
* declared, which is right for matching and wrong to put in a form: the
|
|
* field would fill with an inherited phrase, and the next edit would
|
|
* write it into the file as a declared one. That flips
|
|
* `declaredTriggers`, which the matcher weighs differently — and it would
|
|
* pin the *old* name's phrase the moment the skill is renamed.
|
|
*/
|
|
triggers: skill.declaredTriggers ? skill.triggers : [],
|
|
suggestions: skill.owliver.suggestions.map((s) => s.label),
|
|
capabilities,
|
|
responses,
|
|
};
|
|
} catch {
|
|
return EMPTY_OWLIVER_FIELDS;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Which management surfaces this definition belongs to, for the upload handoff.
|
|
*
|
|
* A file dropped into the Board editor that declares only an `owliver:` block
|
|
* is being edited in the wrong half of the product. Knowing that lets the
|
|
* editor offer the other one rather than showing a form none of whose fields
|
|
* apply.
|
|
*/
|
|
export function facetsFromSource(source) {
|
|
try {
|
|
return readSkill(source)?.facets || [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Which of the `ui:` shapes a definition uses.
|
|
*
|
|
* `shorthand` is one section applying to every declared page — the form the
|
|
* editors compose, and the only one whose four fields can represent the whole
|
|
* block. `list` and `per-page` can each hold several sections across several
|
|
* pages, so the fields would have to throw away everything but the first to
|
|
* write them back. Telling them apart is what lets the editor show a multi-page
|
|
* definition without being able to flatten it.
|
|
*/
|
|
export function uiShape(source) {
|
|
let data;
|
|
try {
|
|
({ data } = parseFrontmatter(String(source)));
|
|
} catch {
|
|
return 'none';
|
|
}
|
|
const ui = data?.ui;
|
|
if (!ui || typeof ui !== 'object') return 'none';
|
|
if (Array.isArray(ui)) return ui.length ? 'list' : 'none';
|
|
const SECTION_KEYS = ['type', 'source', 'data', 'placement', 'position', 'periods', 'title'];
|
|
return SECTION_KEYS.some((key) => key in ui) ? 'shorthand' : 'per-page';
|
|
}
|
|
|
|
/** Whether the section fields can write this definition's `ui:` block back. */
|
|
export const uiIsEditableFromFields = (source) => uiShape(source) !== 'list'
|
|
&& uiShape(source) !== 'per-page';
|
|
|
|
/**
|
|
* Whether this definition's pages come from its `ui:` block rather than a
|
|
* `pages:` key of its own.
|
|
*
|
|
* When they do, the Pages field is a read-out and must not be written back.
|
|
* Writing it would add a `pages:` list that agrees with the block today and
|
|
* silently governs it tomorrow: `normalizeSkillUi` refuses an entry naming a
|
|
* page that `pages:` does not list, so the next entry the author adds to the
|
|
* block would be rejected by a key they never wrote. A derived value is shown,
|
|
* not owned.
|
|
*/
|
|
export function pagesAreDerived(source) {
|
|
let data;
|
|
try {
|
|
({ data } = parseFrontmatter(String(source)));
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (Array.isArray(data?.pages) && data.pages.length) return false;
|
|
return uiShape(source) !== 'none';
|
|
}
|
|
|
|
/* ── Writing fields back into the frontmatter ───────────────────────────── */
|
|
|
|
/**
|
|
* A scalar, written so the parser reads back what was meant.
|
|
*
|
|
* Quoted whenever the plain form would be read as something else — a value
|
|
* containing `:` or `#`, one with edge whitespace, or one that looks like a
|
|
* number, a boolean or null but is a string.
|
|
*/
|
|
function writeScalar(value) {
|
|
if (value === null || value === undefined) return 'null';
|
|
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
|
|
|
|
const text = String(value);
|
|
const ambiguous = text === ''
|
|
|| /[:#]/.test(text)
|
|
|| text !== text.trim()
|
|
|| /^(true|false|null|~)$/.test(text)
|
|
|| /^-?\d+$/.test(text)
|
|
|| /^-?\d*\.\d+$/.test(text)
|
|
|| /^['"-]/.test(text);
|
|
|
|
return ambiguous ? `'${text.replace(/'/g, "''")}'` : text;
|
|
}
|
|
|
|
/**
|
|
* A value, as the block lines that follow its key.
|
|
*
|
|
* Returns `null` for a scalar, which is written on the key's own line instead.
|
|
* Only the subset in `yaml.js` is emitted — block maps and block sequences,
|
|
* nested to any depth — because that is the only subset the parser reads back.
|
|
*/
|
|
function writeBlock(value, indent) {
|
|
const pad = ' '.repeat(indent);
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => {
|
|
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
const entries = Object.entries(item).filter(([, v]) => v !== undefined);
|
|
if (!entries.length) return `${pad}- {}`;
|
|
return entries
|
|
.map(([k, v], i) => {
|
|
const prefix = i === 0 ? `${pad}- ` : `${pad} `;
|
|
const nested = writeBlock(v, indent + 4);
|
|
return nested === null ? `${prefix}${k}: ${writeScalar(v)}` : `${prefix}${k}:\n${nested}`;
|
|
})
|
|
.join('\n');
|
|
}
|
|
return `${pad}- ${writeScalar(item)}`;
|
|
}).join('\n');
|
|
}
|
|
|
|
if (value && typeof value === 'object') {
|
|
return Object.entries(value)
|
|
.filter(([, v]) => v !== undefined)
|
|
.map(([k, v]) => {
|
|
const nested = writeBlock(v, indent + 2);
|
|
return nested === null ? `${pad}${k}: ${writeScalar(v)}` : `${pad}${k}:\n${nested}`;
|
|
})
|
|
.join('\n');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** The lines belonging to the key at `start`: everything indented under it. */
|
|
function blockEnd(lines, start, indent) {
|
|
let end = start + 1;
|
|
while (end < lines.length) {
|
|
const line = lines[end];
|
|
if (line.trim() === '' || /^\s*#/.test(line)) { end += 1; continue; }
|
|
if (line.match(/^\s*/)[0].replace(/\t/g, ' ').length <= indent) break;
|
|
end += 1;
|
|
}
|
|
/* Trailing blanks and comments belong to whatever comes next, not to this
|
|
key — a comment written above the following key must not be swallowed by
|
|
the block above it. */
|
|
while (end > start + 1 && lines[end - 1].trim() === '') end -= 1;
|
|
return end;
|
|
}
|
|
|
|
/**
|
|
* How wide a line's indentation is.
|
|
*
|
|
* The same measurement the parser makes — `yaml.js` reads a leading run of
|
|
* whitespace and counts a tab as two — and it has to be, because a key the
|
|
* parser can see and the
|
|
* writer cannot is a key the writer will decide is missing and add a second
|
|
* copy of.
|
|
*
|
|
* That is not hypothetical: a definition stored on this account indents with
|
|
* U+00A0. JavaScript's `\s` matches it, so the parser read the file correctly
|
|
* and every screen showed the right values; the writer compared against literal
|
|
* spaces, found no `title:` inside `ui:`, and appended a whole second `ui:`
|
|
* block on the first edit. The Go port agrees with the parser here too — see
|
|
* `jsIsSpace` in `internal/definition/jsvalue.go`, which lists `0x00A0` — so the
|
|
* writer was the only thing in the chain using a narrower idea of a space.
|
|
*/
|
|
const indentWidth = (line) => (line.match(/^\s*/)?.[0] || '').replace(/\t/g, ' ').length;
|
|
|
|
/** The index of `key` at `indent` within `[from, to)`, or -1. */
|
|
function findKey(lines, key, indent, from, to) {
|
|
const pattern = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
|
|
for (let i = from; i < to; i += 1) {
|
|
const line = lines[i];
|
|
if (line === undefined || indentWidth(line) !== indent) continue;
|
|
/* Measured, then matched on what is left — so the comparison is about how
|
|
deep the key sits, never about which characters were used to put it
|
|
there. Identical for ASCII input, which is every definition this
|
|
repository ships. */
|
|
if (pattern.test(line.replace(/^\s*/, ''))) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* One dotted path, set in a frontmatter line array.
|
|
*
|
|
* Scalars replace the value on their own line; anything else replaces the block
|
|
* beneath it. A key that is not there is appended to the end of its parent, so
|
|
* a definition that never declared `status:` gains one rather than being
|
|
* refused. Every line the path does not touch — including comments and key
|
|
* order — is left exactly as written, which is why this is a patch and not a
|
|
* re-serialisation.
|
|
*/
|
|
function setPath(lines, path, value) {
|
|
const parts = path.split('.');
|
|
let from = 0;
|
|
let to = lines.length;
|
|
let indent = 0;
|
|
|
|
for (let depth = 0; depth < parts.length - 1; depth += 1) {
|
|
const at = findKey(lines, parts[depth], indent, from, to);
|
|
/* A parent that does not exist is created empty, then descended into. */
|
|
if (at === -1) {
|
|
lines.splice(to, 0, `${' '.repeat(indent)}${parts[depth]}:`);
|
|
from = to + 1;
|
|
to = from;
|
|
indent += 2;
|
|
continue;
|
|
}
|
|
const end = blockEnd(lines, at, indent);
|
|
from = at + 1;
|
|
to = end;
|
|
indent += 2;
|
|
}
|
|
|
|
const key = parts[parts.length - 1];
|
|
const at = findKey(lines, key, indent, from, to);
|
|
const block = writeBlock(value, indent + 2);
|
|
const replacement = block === null
|
|
? [`${' '.repeat(indent)}${key}: ${writeScalar(value)}`]
|
|
: [`${' '.repeat(indent)}${key}:`, ...block.split('\n')];
|
|
|
|
if (at === -1) {
|
|
lines.splice(to, 0, ...replacement);
|
|
return replacement.length;
|
|
}
|
|
|
|
const end = blockEnd(lines, at, indent);
|
|
lines.splice(at, end - at, ...replacement);
|
|
return replacement.length - (end - at);
|
|
}
|
|
|
|
/**
|
|
* A definition with some frontmatter keys changed, and nothing else touched.
|
|
*
|
|
* `patch` is keyed by dotted path — `name`, `pages`, `ui.source`,
|
|
* `owliver.capabilities` — and a value of `undefined` leaves that path alone.
|
|
* `null` writes `null`; to remove a key, pass `REMOVE`.
|
|
*
|
|
* The body below the closing `---` is never read and never rewritten. That is
|
|
* the property that makes this safe to run on every keystroke: an author's
|
|
* prose, their comments and the order they wrote their keys in all survive a
|
|
* change to a field they did not write.
|
|
*/
|
|
export const REMOVE = Symbol('remove');
|
|
|
|
export function patchFrontmatter(source, patch = {}) {
|
|
/* Normalized first, for the same reason the parser is: a fence this does not
|
|
recognise is a fence it writes a second copy of. */
|
|
const raw = normalizeDefinition(source);
|
|
const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?=\n|$)/.exec(raw);
|
|
|
|
/* No frontmatter to patch: give the definition one rather than silently
|
|
dropping the edit. */
|
|
if (!match) {
|
|
const lines = [];
|
|
for (const [path, value] of Object.entries(patch)) {
|
|
if (value === undefined || value === REMOVE) continue;
|
|
setPath(lines, path, value);
|
|
}
|
|
return `---\n${lines.join('\n')}\n---\n\n${raw.trim()}\n`;
|
|
}
|
|
|
|
const lines = match[1].split('\n');
|
|
|
|
for (const [path, value] of Object.entries(patch)) {
|
|
if (value === undefined) continue;
|
|
if (value === REMOVE) {
|
|
const parts = path.split('.');
|
|
const indent = (parts.length - 1) * 2;
|
|
/* Only a top-level or one-deep key is removable, which is every key the
|
|
editors own. */
|
|
let from = 0;
|
|
let to = lines.length;
|
|
if (parts.length > 1) {
|
|
const parent = findKey(lines, parts[0], 0, 0, lines.length);
|
|
if (parent === -1) continue;
|
|
from = parent + 1;
|
|
to = blockEnd(lines, parent, 0);
|
|
}
|
|
const at = findKey(lines, parts[parts.length - 1], indent, from, to);
|
|
if (at !== -1) lines.splice(at, blockEnd(lines, at, indent) - at);
|
|
continue;
|
|
}
|
|
setPath(lines, path, value);
|
|
}
|
|
|
|
return `---\n${lines.join('\n')}\n---${raw.slice(match[0].length)}`;
|
|
}
|
|
|
|
/* ── Writing fields back as one canonical patch ─────────────────────────── */
|
|
|
|
/**
|
|
* The frontmatter a set of Board fields means.
|
|
*
|
|
* Both composing paths go through this: a fresh draft applies it to an empty
|
|
* definition, an existing one applies it to the file already open. That is what
|
|
* makes "typed into the form" and "pasted as Markdown" the same artefact — the
|
|
* fields have one writer, so there is no second field set that only one path
|
|
* knows how to express.
|
|
*
|
|
* `existing` is the definition being edited, and only two questions are asked of
|
|
* it: whether its `pages:` are derived from its `ui:` entries, and whether its
|
|
* `ui:` block is one this form can represent at all. Both are refusals to
|
|
* overwrite what the fields cannot hold, never a second shape.
|
|
*/
|
|
export function boardPatch(fields, { existing = '' } = {}) {
|
|
const derived = existing ? pagesAreDerived(existing) : false;
|
|
const editable = existing ? uiIsEditableFromFields(existing) : true;
|
|
|
|
const section = editable ? {
|
|
'ui.type': fields.type || undefined,
|
|
'ui.placement': fields.placement || undefined,
|
|
'ui.title': fields.title || undefined,
|
|
'ui.source': fields.source || undefined,
|
|
/* Periods only where the reading has any. A source that counts nothing over
|
|
time keeps no period list, so switching to one cannot leave the previous
|
|
source's windows behind as a block that validates and does nothing. */
|
|
'ui.periods': fields.periods?.length && sourceSupportsOption(fields.source, 'periods')
|
|
? fields.periods
|
|
: REMOVE,
|
|
} : {};
|
|
|
|
return {
|
|
id: fields.id || undefined,
|
|
name: fields.name || undefined,
|
|
description: fields.description || undefined,
|
|
pages: fields.pages?.length && !derived ? fields.pages : undefined,
|
|
...section,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* One capability's response, as the format writes it.
|
|
*
|
|
* Returns null when the capability has no source. That is deliberate and is the
|
|
* point of the whole change: an unconfigured capability is left out of
|
|
* `responses:` so `normalizeSkillOwliver` reports it by name and
|
|
* `validateSkillSource` refuses the save. The alternative — inventing a source
|
|
* to make the block well-formed — is how a definition gets saved reading data
|
|
* its author never chose.
|
|
*/
|
|
function responseFor(fields, capability) {
|
|
const response = fields.responses?.[capability];
|
|
const source = response?.source || '';
|
|
if (!source) return null;
|
|
|
|
const entry = { source };
|
|
if (response.periods?.length && sourceSupportsOption(source, 'periods')) {
|
|
entry.periods = response.periods;
|
|
}
|
|
if (response.limit && sourceSupportsOption(source, 'limit')) {
|
|
entry.limit = Number(response.limit);
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
* The frontmatter a set of Owliver fields means.
|
|
*
|
|
* Every selected capability that has a source is written with its *own*
|
|
* `source:`, and its own periods or limit where the reading takes them. Nothing
|
|
* is inherited from a `ui:` block here: this editor owns definitions that have
|
|
* no `ui:` block at all, and a response that relies on inheriting one silently
|
|
* loses its reading the day the section is edited.
|
|
*/
|
|
export function owliverPatch(fields, { existing = '' } = {}) {
|
|
const derived = existing ? pagesAreDerived(existing) : false;
|
|
|
|
const responses = {};
|
|
for (const capability of fields.capabilities || []) {
|
|
const response = responseFor(fields, capability);
|
|
if (response) responses[capability] = response;
|
|
}
|
|
|
|
return {
|
|
id: fields.id || undefined,
|
|
name: fields.name || undefined,
|
|
description: fields.description || undefined,
|
|
pages: fields.pages?.length && !derived ? fields.pages : undefined,
|
|
triggers: fields.triggers?.length ? fields.triggers : REMOVE,
|
|
'owliver.enabled': true,
|
|
'owliver.suggestions': fields.suggestions?.length ? fields.suggestions : REMOVE,
|
|
'owliver.capabilities': fields.capabilities?.length ? fields.capabilities : REMOVE,
|
|
/* An empty mapping is not a mapping the parser will take — `owliver.responses`
|
|
must be a mapping of capability names — so nothing configured removes the
|
|
key rather than writing a header with nothing under it. */
|
|
'owliver.responses': Object.keys(responses).length ? responses : REMOVE,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Which selected capabilities are not configured, by name.
|
|
*
|
|
* The editor shows these against the capability rather than only as a refusal
|
|
* on save, so "Summary needs a source" is read where the source is chosen. The
|
|
* *refusal* is still `validateSkillSource`'s, on the same definition the
|
|
* registry reads — this only says the same thing earlier.
|
|
*/
|
|
export const unconfiguredCapabilities = (fields) =>
|
|
(fields.capabilities || []).filter((capability) => !responseFor(fields, capability));
|