checkMapping takes kind absent, hybrid or draft. A draft has a name and one or more prefixes and no books, and comes back with books []. A prefix inside or holding another entry's book refuses library-playground-other-universe, which replaces library-playground-outside-book. A prefix equal to a book or holding its own refuses library-mapping-invalid, as does a draft source. classify returns a playground file's entry and the book holding its prefix, or null at universe level and in a draft.
154 lines
9.5 KiB
JavaScript
154 lines
9.5 KiB
JavaScript
// The library project entry of a business file (design 1.1): her folders mapped onto universes,
|
|
// books, type folders, manuscript folders and playground prefixes at three levels (ruling after r11,
|
|
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. A pure validator. Business file
|
|
// loading wires it in C2-04, which passes its own `fail` so the refusal reads as a business file
|
|
// problem. Agents can't write this entry (M9, C2-04).
|
|
import { raise } from './problems.mjs';
|
|
|
|
export const TYPES = Object.freeze(['character', 'place', 'research', 'tracker', 'summary']);
|
|
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
|
|
// C2-03a (sage-2021): the bot account sync answers git's Username prompt with. Row 199's NAME.
|
|
const USER = /^[A-Za-z0-9_.-]{1,100}$/;
|
|
const CONSTRAINT_KEYS = ['exclude', 'pin'];
|
|
const KINDS = ['hybrid', 'draft'];
|
|
|
|
// A folder segment from her vault: spaces and capitals are fine, separators, control characters
|
|
// and dot names are not.
|
|
function segment(value) {
|
|
return typeof value === 'string' && value.length > 0 && value.length <= 255 && value !== '.' && value !== '..'
|
|
&& !/[\/\\\u0000-\u001f\u007f]/.test(value) && value.trim() === value;
|
|
}
|
|
function folder(value) {
|
|
return typeof value === 'string' && value.length <= 1024 && value.split('/').every(segment);
|
|
}
|
|
const holds = (outer, inner) => inner.startsWith(outer.endsWith('/') ? outer : `${outer}/`);
|
|
|
|
export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
|
const bad = message => fail('library-mapping-invalid', `${where} ${message}`);
|
|
const object = (value, name) => {
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) bad(`${name} must be an object`);
|
|
};
|
|
const only = (value, keys, name) => {
|
|
for (const key of Object.keys(value)) if (!keys.includes(key)) bad(`${name} has unknown key ${JSON.stringify(key)}`);
|
|
};
|
|
const list = (value, name, check, what) => {
|
|
if (!Array.isArray(value)) bad(`${name} must be a list`);
|
|
for (const item of value) if (!check(item)) bad(`${name} holds ${JSON.stringify(item)}, which is not ${what}`);
|
|
if (new Set(value).size !== value.length) bad(`${name} names an entry twice`);
|
|
return [...value];
|
|
};
|
|
|
|
object(entry, '');
|
|
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'universes', 'types', 'manuscript'], 'entry');
|
|
if (entry.kind !== 'library') bad('kind must be "library"');
|
|
let url;
|
|
try { url = new URL(entry.remote); } catch { bad('remote must be an https:// URL'); }
|
|
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || entry.remote.includes('\0'))
|
|
bad('remote must be an https:// URL with no credentials, query or fragment');
|
|
if (typeof entry.branch !== 'string' || !BRANCH.test(entry.branch) || entry.branch.includes('..')
|
|
|| entry.branch.includes('//') || entry.branch.endsWith('/') || entry.branch.endsWith('.lock'))
|
|
bad('branch must be a plain branch name');
|
|
if (Object.hasOwn(entry, 'user') && (typeof entry.user !== 'string' || !USER.test(entry.user)))
|
|
bad('user must be a plain account name');
|
|
if (Object.hasOwn(entry, 'vaultPrefix') && !segment(entry.vaultPrefix)) bad('vaultPrefix must be one folder name');
|
|
|
|
object(entry.types, 'types');
|
|
for (const [name, type] of Object.entries(entry.types)) {
|
|
if (!segment(name)) bad(`types has ${JSON.stringify(name)}, which is not one folder name`);
|
|
if (!TYPES.includes(type)) bad(`types.${name} must be one of ${TYPES.join(', ')}`);
|
|
}
|
|
const manuscript = list(entry.manuscript, 'manuscript', segment, 'one folder name');
|
|
for (const name of manuscript) if (Object.hasOwn(entry.types, name)) bad(`manuscript folder ${JSON.stringify(name)} is also a type folder`);
|
|
|
|
object(entry.universes, 'universes');
|
|
if (!Object.keys(entry.universes).length) bad('universes must name at least one universe');
|
|
const universes = {}, bookOwner = new Map(), prefixes = [], prefixOwner = new Map();
|
|
for (const [id, universe] of Object.entries(entry.universes)) {
|
|
const name = `universes.${id}`;
|
|
if (!ID.test(id)) bad(`universe id ${JSON.stringify(id)} is not a plain id`);
|
|
object(universe, name);
|
|
if (Object.hasOwn(universe, 'kind') && !KINDS.includes(universe.kind)) bad(`${name}.kind must be "hybrid" or "draft"`);
|
|
const hybrid = universe.kind === 'hybrid', draft = universe.kind === 'draft';
|
|
if (draft && Object.hasOwn(universe, 'books')) bad(`${name} is a draft and takes no books`);
|
|
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'constraints', 'notes']
|
|
: draft ? ['kind', 'name', 'playground'] : ['name', 'books', 'playground'], name);
|
|
if (typeof universe.name !== 'string' || !universe.name.trim() || universe.name.length > 128 || /[\u0000-\u001f]/.test(universe.name))
|
|
bad(`${name}.name must be a short display name`);
|
|
const books = draft ? [] : list(universe.books, `${name}.books`, folder, 'a folder path');
|
|
if (!hybrid && !draft && !books.length) bad(`${name}.books must name at least one book folder`);
|
|
for (const book of books) {
|
|
if (bookOwner.has(book)) bad(`book folder ${JSON.stringify(book)} is in two universes`);
|
|
bookOwner.set(book, id);
|
|
}
|
|
const playground = list(universe.playground ?? [], `${name}.playground`,
|
|
value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1)), 'a folder path ending in /');
|
|
if (draft && !playground.length) bad(`${name}.playground must name at least one prefix`);
|
|
for (const prefix of playground) { prefixes.push(prefix); prefixOwner.set(prefix, id); }
|
|
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground }; continue; }
|
|
const out = { name: universe.name, books, playground };
|
|
if (hybrid) {
|
|
out.kind = 'hybrid';
|
|
out.sources = list(universe.sources, `${name}.sources`, value => typeof value === 'string' && ID.test(value), 'a universe id');
|
|
if (out.sources.length < 2) bad(`${name}.sources must name two or more universes`);
|
|
if (!Array.isArray(universe.constraints ?? [])) bad(`${name}.constraints must be a list`);
|
|
out.constraints = (universe.constraints ?? []).map((constraint, i) => {
|
|
const at = `${name}.constraints[${i}]`;
|
|
object(constraint, at);
|
|
only(constraint, CONSTRAINT_KEYS, at);
|
|
if (Object.keys(constraint).length !== 1) bad(`${at} must hold exactly one of ${CONSTRAINT_KEYS.join(', ')}`);
|
|
if (Object.hasOwn(constraint, 'exclude')) {
|
|
if (!folder(constraint.exclude)) bad(`${at}.exclude must be a source path`);
|
|
return { exclude: constraint.exclude };
|
|
}
|
|
object(constraint.pin, `${at}.pin`);
|
|
only(constraint.pin, ['entity', 'field', 'value'], `${at}.pin`);
|
|
if (!folder(constraint.pin.entity) || typeof constraint.pin.field !== 'string' || !constraint.pin.field
|
|
|| !['string', 'number', 'boolean'].includes(typeof constraint.pin.value))
|
|
bad(`${at}.pin needs an entity path, a field name and a scalar value`);
|
|
return { pin: { ...constraint.pin } };
|
|
});
|
|
if (Object.hasOwn(universe, 'notes') && (typeof universe.notes !== 'string' || universe.notes.length > 4000))
|
|
bad(`${name}.notes must be text of at most 4000 characters`);
|
|
if (Object.hasOwn(universe, 'notes')) out.notes = universe.notes;
|
|
}
|
|
universes[id] = out;
|
|
}
|
|
for (const [id, universe] of Object.entries(universes)) {
|
|
for (const source of universe.sources ?? []) {
|
|
if (!Object.hasOwn(universes, source) || Object.hasOwn(universes[source], 'kind'))
|
|
bad(`universes.${id}.sources names ${JSON.stringify(source)}, which is not a plain universe`);
|
|
}
|
|
}
|
|
const books = [...bookOwner.keys()];
|
|
for (const a of books) for (const b of books) if (a !== b && holds(a, b)) bad(`book folder ${JSON.stringify(a)} holds book folder ${JSON.stringify(b)}`);
|
|
for (const a of prefixes) for (const b of prefixes) if (a !== b && holds(a, b)) bad(`playground ${JSON.stringify(a)} holds playground ${JSON.stringify(b)}`);
|
|
if (new Set(prefixes).size !== prefixes.length) bad('a playground prefix is listed twice');
|
|
// A prefix's level comes from where it sits (ruling after r11). Inside one of its own entry's books
|
|
// is book level, outside every book is universe level, and a draft's prefixes are all outside. Scope
|
|
// checks prefixes first (scope.mjs), so a prefix equal to or holding a book would turn that book
|
|
// into playground.
|
|
for (const [prefix, id] of prefixOwner) {
|
|
for (const [book, owner] of bookOwner) {
|
|
const at = `universes.${id}.playground ${JSON.stringify(prefix)}`;
|
|
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
|
|
const inside = holds(book, prefix), holding = holds(prefix, book);
|
|
if ((inside || holding) && owner !== id)
|
|
fail('library-playground-other-universe', `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
|
|
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
|
|
}
|
|
}
|
|
|
|
return deepFreeze({
|
|
kind: 'library', remote: entry.remote, branch: entry.branch,
|
|
...(Object.hasOwn(entry, 'user') ? { user: entry.user } : {}),
|
|
...(Object.hasOwn(entry, 'vaultPrefix') ? { vaultPrefix: entry.vaultPrefix } : {}),
|
|
universes, types: { ...entry.types }, manuscript,
|
|
});
|
|
}
|
|
|
|
function deepFreeze(value) {
|
|
if (value && typeof value === 'object') { for (const item of Object.values(value)) deepFreeze(item); Object.freeze(value); }
|
|
return value;
|
|
}
|