update the workspace and skills flow
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
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.
|
||||
@@ -62,13 +63,33 @@ 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: '', source: 'candidates.activity', periods: [],
|
||||
type: 'flow', placement: '', title: '', source: 'candidates.activity', periods: [],
|
||||
};
|
||||
|
||||
/** The Owliver editor's fields, before a definition is loaded into them. */
|
||||
/**
|
||||
* 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: [], source: '', periods: [],
|
||||
triggers: [], suggestions: [], capabilities: [], responses: {},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -94,6 +115,10 @@ export function boardFieldsFromSource(source) {
|
||||
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 || [],
|
||||
};
|
||||
@@ -113,8 +138,61 @@ export function owliverFieldsFromSource(source) {
|
||||
try {
|
||||
const skill = readSkill(source);
|
||||
if (!skill) throw new Error('no frontmatter');
|
||||
const first = skill.owliver.capabilities[0];
|
||||
const response = first ? skill.owliver.responses[first] : null;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -133,9 +211,8 @@ export function owliverFieldsFromSource(source) {
|
||||
*/
|
||||
triggers: skill.declaredTriggers ? skill.triggers : [],
|
||||
suggestions: skill.owliver.suggestions.map((s) => s.label),
|
||||
capabilities: skill.owliver.capabilities,
|
||||
source: response?.source || '',
|
||||
periods: response?.periods || [],
|
||||
capabilities,
|
||||
responses,
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_OWLIVER_FIELDS;
|
||||
@@ -404,3 +481,115 @@ export function patchFrontmatter(source, patch = {}) {
|
||||
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user