From 419df87bbffd60bf772c92c67adf53e953dd6ba6 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Wed, 11 Nov 2026 08:00:48 +1100 Subject: [PATCH 1/3] Edit a task type's statuses, priorities and custom fields in one place patchTaskNotesMdbaseTypeSettings now takes statuses, priorities and custom field changes and rewrites every copy of them in the type together, giving what the generator writes for the same settings. The app and the Obsidian plugin each had their own way to do this. Also: read a configuration from a tasknotes.task implementation without the type document, report copies of the vocabulary that disagree, read values only the schema allows, share custom field names through the property title, and read the camel-case spellings early bindings used. 0.3.0-rc.21 --- CHANGELOG.md | 15 ++ README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/mdbase.ts | 492 ++++++++++++++++++++++++++++++++++---- src/vocabulary.ts | 219 +++++++++++++++++ test/mdbase.test.mjs | 2 +- test/vocabulary.test.mjs | 500 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 1182 insertions(+), 54 deletions(-) create mode 100644 src/vocabulary.ts create mode 100644 test/vocabulary.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 02656c5..8e1385e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.3.0-rc.21 + +### Added + +- `patchTaskNotesMdbaseTypeSettings` is now the one editor for a TaskNotes task type. It accepts `statuses` and `priorities` (complete lists in display order) and `userFields` (`add`, `rename`, `remove`), and rewrites every place the type repeats them: the schema's allowed values and defaults, the completion rule, the collection's read defaults, the binding's `values`, `completed_values`, `skipped_values`, `default_skipped` and `definitions`, and the generator's `managed_fields`. A default named in the same patch may be one of the new values. The result equals what `buildTaskNotesMdbaseResources` generates for the same settings, in canonical and migrated-v0.2 types; definition keys and properties the patch does not name are kept. +- Vocabulary helpers, exported from `@tasknotes/model/mdbase`: `taskNotesVocabularyProblem` (empty label, duplicate value, no open or no completed status, a status both completed and skipped, no priority, a removed default), `taskNotesVocabularyInUse` (the first removed status or priority tasks still hold), `taskNotesVocabularyValue` and `taskNotesUserFieldKey` (a stored value or property name from a label), `TaskNotesVocabularyError`, `TaskNotesVocabularyInUseError` and the `isTaskNotesVocabularyError` / `isTaskNotesVocabularyInUseError` guards. +- `taskNotesMdbaseTypeDisagreements` reports where a type states its statuses, priorities or defaults more than once and the copies differ (schema enum, definitions, completion rule, schema default, read default against the binding). +- `resolveTaskNotesModelConfigFromImplementation` resolves the configuration from a tasknotes.task implementation (`fields`, `binding`, the type's schema and `match`), which is what an authority's catalog returns, without the type document. `resolveTaskNotesModelConfigFromMdbaseType` now delegates to it. + +### Changed + +- A custom field's display name is written to the type as the property's `title`, and read from it before the host's remembered name. Names now reach other TaskNotes hosts; types written earlier keep the host's name until they are next written. +- A status or priority only the schema allows (not in the binding's `values`) is read after the described ones instead of being hidden, so hosts show it and saving the list repairs the type. +- The camel-case spellings early bindings used (`isCompleted`, `isSkipped`, `defaultMaterialization`, `defaultNextTrigger`, `pastHorizon`, `futureHorizon`) are read after the current ones. + ## 0.3.0-rc.20 Implements tasknotes-spec 0.3.0-rc.6 (`tasknotes.task` 0.3.0-rc.6). diff --git a/README.md b/README.md index fc84dde..5463bbf 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The package exports both the root module and focused subpath modules: | `@tasknotes/model/validation` | Task and time-entry validation | | `@tasknotes/model/operations` | Host-independent task mutation planning, including materialized occurrence plans | | `@tasknotes/model/frontmatter` | Markdown task document parse/serialize helpers | -| `@tasknotes/model/mdbase` | Canonical mdbase v0.3 config, contract, schema, and implementing-type generation | +| `@tasknotes/model/mdbase` | Canonical mdbase v0.3 config, contract, schema, and implementing-type generation; reading a type or implementation into a configuration; `patchTaskNotesMdbaseTypeSettings`, the one editor for a task type's settings, statuses, priorities and custom fields | | `@tasknotes/model/starter` | The published TaskNotes starter type (`tasknotes.task` pack seed), pinned to mdbase-contracts by the test suite. Use it, not the plugin defaults in `defaults`, to build starter types | | `@tasknotes/model/conformance` | tasknotes-spec conformance operation dispatcher | diff --git a/package-lock.json b/package-lock.json index 3e3e105..579820b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.20", + "version": "0.3.0-rc.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tasknotes/model", - "version": "0.3.0-rc.20", + "version": "0.3.0-rc.21", "license": "MIT", "dependencies": { "rrule": "^2.8.1", diff --git a/package.json b/package.json index fc82cd0..f96438a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.20", + "version": "0.3.0-rc.21", "description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.", "license": "MIT", "type": "module", diff --git a/src/mdbase.ts b/src/mdbase.ts index 1dcd940..31ba9fe 100644 --- a/src/mdbase.ts +++ b/src/mdbase.ts @@ -21,6 +21,17 @@ import type { TaskNotesModelConfig, UserMappedField, } from "./types"; +import { + TASKNOTES_USER_FIELD_KEY, + TaskNotesVocabularyError, + taskNotesVocabularyProblem, +} from "./vocabulary"; +import type { TaskNotesVocabularyPatch } from "./vocabulary"; + +// Each package entry is bundled on its own, so the vocabulary helpers are part +// of this entry: an error thrown by the editor is then an instance of the class +// a host imports beside it. +export * from "./vocabulary"; export const MDBASE_SPEC_VERSION = "0.3.0"; export const TASKNOTES_TASK_CONTRACT_VERSION = TASKNOTES_SPEC_VERSION; @@ -175,7 +186,7 @@ export const TASKNOTES_ACCEPTED_CONTRACT_VERSIONS: readonly string[] = [ ...TASKNOTES_LEGACY_CONTRACT_VERSIONS, ]; -export interface TaskNotesMdbaseTypeSettingsPatch { +export interface TaskNotesMdbaseTypeSettingsPatch extends TaskNotesVocabularyPatch { defaultStatus?: string; defaultPriority?: string; recurrence?: { @@ -203,11 +214,19 @@ interface FieldOptions { /** * Patch portable TaskNotes model settings in an existing mdbase type. * - * The patch is deliberately constrained to settings whose schema vocabulary - * does not change. Custom properties, collection paths, lifecycle rules, and - * host extensions remain untouched. App behaviour (templating, archive moves, - * time tracking, checkbox resets, link format, NLP triggers and per-status - * automation) lives in the TaskNotes settings record instead: see + * This is the one editor for a TaskNotes task type. Every part of the type + * that repeats a setting is rewritten together: the schema's allowed values + * and defaults, the completion rule, the collection's read defaults and the + * contract binding. Statuses and priorities are given as complete lists; a + * default named in the same patch may be one of the new values. Custom + * properties the patch does not name, collection paths, lifecycle rules and + * host extensions remain untouched. The result is what + * buildTaskNotesMdbaseResources generates for the same settings. + * + * It does not look at the collection's tasks: check taskNotesVocabularyInUse + * before removing a status or priority. App behaviour (templating, archive + * moves, time tracking, checkbox resets, link format, NLP triggers and + * per-status automation) lives in the TaskNotes settings record instead: see * buildTaskNotesSettingsPatch in ./settings. */ export function patchTaskNotesMdbaseTypeSettings( @@ -221,14 +240,10 @@ export function patchTaskNotesMdbaseTypeSettings( } const binding = isRecord(implementation.binding) ? implementation.binding : {}; implementation.binding = binding; - const model = resolveTaskNotesModelConfigFromMdbaseType(result); const status = contractSection(binding, "status"); const priority = contractSection(binding, "priority"); const recurrence = contractSection(binding, "recurrence"); const occurrences = contractSection(binding, "occurrences"); - const properties = isRecord(isRecord(result.schema) ? result.schema.value : undefined) - ? (result.schema as Record).value.properties - : undefined; const collection = isRecord(result.collection) ? result.collection : {}; const readDefaults = isRecord(collection.read_defaults) ? collection.read_defaults @@ -236,6 +251,14 @@ export function patchTaskNotesMdbaseTypeSettings( collection.read_defaults = readDefaults; result.collection = collection; + if (patch.statuses || patch.priorities || patch.userFields) { + applyVocabularyPatch(result, patch, { status, priority, readDefaults }); + } + const model = resolveTaskNotesModelConfigFromMdbaseType(result); + const properties = isRecord(isRecord(result.schema) ? result.schema.value : undefined) + ? (result.schema as Record).value.properties + : undefined; + if (patch.defaultStatus !== undefined) { const value = requiredConfiguredValue( patch.defaultStatus, @@ -285,6 +308,201 @@ export function patchTaskNotesMdbaseTypeSettings( return result; } +/** + * Rewrite the parts of a type that name its statuses, priorities and custom + * fields. `sections` are the already-resolved parts of `type` to write into. + */ +function applyVocabularyPatch( + type: Record, + patch: TaskNotesMdbaseTypeSettingsPatch, + sections: { + status: Record; + priority: Record; + readDefaults: Record; + } +): void { + const model = resolveTaskNotesModelConfigFromMdbaseType(type); + const { status, priority, readDefaults } = sections; + const schemaHolder = contractSection(type, "schema"); + const schema = contractSection(schemaHolder, "value"); + const properties = contractSection(schema, "properties"); + const generator = isRecord(type["x-tasknotes-generator"]) + ? type["x-tasknotes-generator"] + : undefined; + const defaults = { + status: patch.defaultStatus?.trim() ?? model.defaults.status, + priority: patch.defaultPriority?.trim() ?? model.defaults.priority, + }; + + const problem = taskNotesVocabularyProblem(patch, defaults); + if (problem) throw new TaskNotesVocabularyError(problem); + + if (patch.statuses) { + const field = model.fieldMapping.status; + const existing = definitionMap(status.definitions); + const values = patch.statuses.map(({ value }) => value); + const completed = patch.statuses + .filter((entry) => entry.isCompleted) + .map(({ value }) => value); + const wasSkipped = new Set(stringArray(status.skipped_values)); + const skipped = patch.statuses + .filter( + (entry) => + !entry.isCompleted && (entry.isSkipped ?? wasSkipped.has(entry.value)) + ) + .map(({ value }) => value); + status.values = values; + status.default = defaults.status; + status.completed_values = completed; + status.skipped_values = skipped; + if (skipped[0] === undefined) delete status.default_skipped; + else if ( + typeof status.default_skipped !== "string" || + !skipped.includes(status.default_skipped) + ) + status.default_skipped = skipped[0]; + status.definitions = patch.statuses.map((entry, index) => { + const previous = { ...(existing.get(entry.value) ?? {}) }; + const icon = entry.icon === undefined ? stringValue(previous.icon) : entry.icon.trim(); + for (const key of ["icon", "isCompleted", "isSkipped", "is_completed", "is_skipped", "order"]) + delete previous[key]; + return { + value: entry.value, + label: entry.label.trim(), + color: entry.color.trim() || FALLBACK_VOCABULARY_COLOR, + ...(icon ? { icon } : {}), + is_completed: entry.isCompleted, + is_skipped: skipped.includes(entry.value), + order: entry.order ?? index, + ...withoutKeys(previous, ["value", "label", "color"]), + }; + }); + setSchemaEnum(properties[field], values, defaults.status); + readDefaults[field] = defaults.status; + // The type requires a completion date for exactly the completed statuses. + if (Array.isArray(schema.allOf)) { + for (const rule of schema.allOf) { + const condition = + isRecord(rule) && isRecord(rule.if) && isRecord(rule.if.properties) + ? rule.if.properties[field] + : undefined; + if (isRecord(condition) && Array.isArray(condition.enum)) + condition.enum = [...completed]; + } + } + } + + if (patch.priorities) { + const field = model.fieldMapping.priority; + const existing = definitionMap(priority.definitions); + const values = patch.priorities.map(({ value }) => value); + priority.values = values; + priority.default = defaults.priority; + priority.definitions = patch.priorities.map((entry, index) => { + const previous = { ...(existing.get(entry.value) ?? {}) }; + const icon = entry.icon === undefined ? stringValue(previous.icon) : entry.icon.trim(); + return { + value: entry.value, + label: entry.label.trim(), + color: entry.color.trim() || FALLBACK_VOCABULARY_COLOR, + ...(icon ? { icon } : {}), + weight: entry.weight ?? index, + ...withoutKeys(previous, ["value", "label", "color", "icon", "weight"]), + }; + }); + setSchemaEnum(properties[field], values, defaults.priority); + readDefaults[field] = defaults.priority; + } + + const fields = patch.userFields; + if (!fields) return; + const reserved = new Set( + [ + ...Object.keys(model.fieldMapping), + ...Object.values(model.fieldMapping), + "id", + "tags", + "type", + "types", + ].map((key) => key.toLocaleLowerCase()) + ); + const legacyCompatibility = + generator?.legacy_compatibility === true || isRecord(type["x-legacy-v0.2"]); + const managed = + generator && Array.isArray(generator.managed_fields) + ? new Set(stringArray(generator.managed_fields)) + : undefined; + for (const key of fields.remove ?? []) { + if (reserved.has(key.toLocaleLowerCase())) continue; + delete properties[key]; + delete readDefaults[key]; + managed?.delete(key); + if (Array.isArray(schema.required)) + schema.required = schema.required.filter((name: unknown) => name !== key); + } + for (const { key, displayName } of fields.rename ?? []) { + const property = properties[key]; + if (!isRecord(property)) continue; + if (!displayName.trim()) + throw new TaskNotesVocabularyError({ kind: "field-label", key }); + properties[key] = { title: displayName.trim(), ...withoutKeys(property, ["title"]) }; + } + for (const field of fields.add ?? []) { + const key = field.key.trim(); + if (!TASKNOTES_USER_FIELD_KEY.test(key)) + throw new TaskNotesVocabularyError({ kind: "field-name", key }); + if ( + reserved.has(key.toLocaleLowerCase()) || + Object.keys(properties).some( + (name) => name.toLocaleLowerCase() === key.toLocaleLowerCase() + ) + ) + throw new TaskNotesVocabularyError({ kind: "field-taken", key }); + if (!field.displayName.trim()) + throw new TaskNotesVocabularyError({ kind: "field-label", key }); + properties[key] = userFieldProperty( + { id: key, key, displayName: field.displayName, type: field.type }, + legacyCompatibility + ); + managed?.add(key); + } + if (generator && managed) generator.managed_fields = [...managed].sort(); +} + +const FALLBACK_VOCABULARY_COLOR = "#808080"; + +/** Narrow or widen an enum property, wherever a nullable wrapper keeps it. */ +function setSchemaEnum(property: unknown, values: string[], fallback: string): void { + if (!isRecord(property)) return; + const holder = Array.isArray(property.enum) + ? property + : Array.isArray(property.anyOf) + ? property.anyOf.find((branch) => isRecord(branch) && Array.isArray(branch.enum)) + : undefined; + if (!isRecord(holder)) return; + holder.enum = [...values]; + if ("default" in holder) holder.default = fallback; +} + +function withoutKeys( + value: Record, + keys: readonly string[] +): Record { + return Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key))); +} + +/** A custom field's schema property, titled with the name people see. */ +function userFieldProperty( + field: UserMappedField, + legacyCompatibility: boolean +): Record { + const title = field.displayName?.trim(); + return { + ...(title ? { title } : {}), + ...userFieldSchema(field, legacyCompatibility), + }; +} + /** * Build the canonical mdbase v0.3 collection files shared by every TaskNotes host. * @@ -553,7 +771,7 @@ export function buildTaskNotesMdbaseResources( addField("sortOrder", mapping.sortOrder, stringSchema({}, legacyCompatibility)); for (const userField of modelConfig.userFields) { - properties[userField.key] = userFieldSchema(userField, legacyCompatibility); + properties[userField.key] = userFieldProperty(userField, legacyCompatibility); if (userField.defaultValue !== undefined) { applySchemaDefault(properties[userField.key], userField.defaultValue); readDefaults[userField.key] = cloneValue(userField.defaultValue); @@ -855,26 +1073,68 @@ export async function buildTaskNotesMdbaseTypePack( }; } +/** + * A type's implementation of tasknotes.task as an authority describes it: the + * contract's field mapping and binding, with the parts of the implementing + * type TaskNotes also reads. mdbase Connect's `describe()` returns each of + * these, so a client need not fetch and parse the type document to read it. + */ +export interface TaskNotesTaskImplementation { + /** Contract field name to the type's own property name. */ + fields?: Readonly>; + binding?: Readonly>; + /** The implementing type's JSON Schema: the value under `schema.value`. */ + schema?: Readonly>; + /** The implementing type's `match` rule. */ + match?: unknown; +} + +export interface TaskNotesModelConfigSources { + /** + * Frontmatter of the TaskNotes settings record, when there is one. Its + * fields win over an earlier binding's, which win over `fallback`. + */ + settings?: Readonly>; +} + /** Resolve the TaskNotes configuration advertised by an mdbase task type. */ export function resolveTaskNotesModelConfigFromMdbaseType( value: unknown, fallback: Partial = {}, - options: { - /** - * Frontmatter of the TaskNotes settings record, when there is one. Its - * fields win over an earlier binding's, which win over `fallback`. - */ - settings?: Readonly>; - } = {} + options: TaskNotesModelConfigSources = {} +): TaskNotesModelConfig { + const implementation = isRecord(value) ? taskNotesImplementation(value) : undefined; + if (!isRecord(value) || !implementation) { + return applyStoredAppSettings(resolveModelConfig(fallback), options.settings); + } + return resolveTaskNotesModelConfigFromImplementation( + { + fields: isRecord(implementation.fields) ? implementation.fields : undefined, + binding: isRecord(implementation.binding) ? implementation.binding : undefined, + schema: + isRecord(value.schema) && isRecord(value.schema.value) + ? value.schema.value + : undefined, + match: value.match, + }, + fallback, + options + ); +} + +/** + * Resolve the TaskNotes configuration from a tasknotes.task implementation, + * without the type document it came from. + */ +export function resolveTaskNotesModelConfigFromImplementation( + implementation: TaskNotesTaskImplementation, + fallback: Partial = {}, + options: TaskNotesModelConfigSources = {} ): TaskNotesModelConfig { const base = resolveModelConfig(fallback); - if (!isRecord(value)) return applyStoredAppSettings(base, options.settings); - const implementation = taskNotesImplementation(value); - if (!implementation) return applyStoredAppSettings(base, options.settings); - const extension = isRecord(implementation.binding) - ? implementation.binding - : {}; + const extension = isRecord(implementation.binding) ? implementation.binding : {}; const roles = isRecord(implementation.fields) ? implementation.fields : {}; + const value = { match: implementation.match }; const fieldMapping = { ...base.fieldMapping }; for (const key of Object.keys(fieldMapping) as (keyof FieldMapping)[]) { const candidate = roles[key]; @@ -882,8 +1142,8 @@ export function resolveTaskNotesModelConfigFromMdbaseType( } const schemaProperties: Record = - isRecord(value.schema) && isRecord(value.schema.value) && isRecord(value.schema.value.properties) - ? (value.schema.value.properties as Record) + isRecord(implementation.schema) && isRecord(implementation.schema.properties) + ? (implementation.schema.properties as Record) : {}; const status = isRecord(extension.status) ? extension.status : {}; const priority = isRecord(extension.priority) ? extension.priority : {}; @@ -895,14 +1155,17 @@ export function resolveTaskNotesModelConfigFromMdbaseType( const priorityProperty: Record = isRecord(rawPriorityProperty) ? rawPriorityProperty : {}; - const statusValues = firstNonEmpty( - stringArray(status.values), - stringArray(statusProperty.enum) - ); - const priorityValues = firstNonEmpty( - stringArray(priority.values), - stringArray(priorityProperty.enum) - ); + // The binding describes the vocabulary; the schema decides what a task may + // hold. A value only the schema allows is still a status or priority, so it + // is read after the described ones instead of being hidden. + const statusValues = uniqueStrings([ + ...stringArray(status.values), + ...schemaEnum(statusProperty), + ]); + const priorityValues = uniqueStrings([ + ...stringArray(priority.values), + ...schemaEnum(priorityProperty), + ]); const statuses = resolveStatuses(statusValues, status, base.statuses); const priorities = resolvePriorities(priorityValues, priority, base.priorities); const title = isRecord(extension.title) ? extension.title : {}; @@ -912,6 +1175,10 @@ export function resolveTaskNotesModelConfigFromMdbaseType( const taskIdentification = resolveTaskIdentification(value.match, base.taskIdentification); const userFields = resolveUserFields(schemaProperties, fieldMapping, base.userFields); const nlp = isRecord(extension.nlp) ? extension.nlp : {}; + // Early bindings spelled these in camel case. + const materialization = + occurrences.default_materialization ?? occurrences.defaultMaterialization; + const nextTrigger = occurrences.default_next_trigger ?? occurrences.defaultNextTrigger; const defaultStatus = stringValue(status.default) ?? base.defaults.status; const defaultPriority = stringValue(priority.default) ?? base.defaults.priority; @@ -948,19 +1215,23 @@ export function resolveTaskNotesModelConfigFromMdbaseType( occurrences: { ...base.occurrences, defaultMaterialization: - occurrences.default_materialization === "manual" || - occurrences.default_materialization === "on_completion" || - occurrences.default_materialization === "rolling" - ? occurrences.default_materialization + materialization === "manual" || + materialization === "on_completion" || + materialization === "rolling" + ? materialization : base.occurrences.defaultMaterialization, defaultNextTrigger: - occurrences.default_next_trigger === "completion" || - occurrences.default_next_trigger === "completion_or_skip" - ? occurrences.default_next_trigger + nextTrigger === "completion" || nextTrigger === "completion_or_skip" + ? nextTrigger : base.occurrences.defaultNextTrigger, - pastHorizon: stringValue(occurrences.past_horizon) ?? base.occurrences.pastHorizon, + pastHorizon: + stringValue(occurrences.past_horizon) ?? + stringValue(occurrences.pastHorizon) ?? + base.occurrences.pastHorizon, futureHorizon: - stringValue(occurrences.future_horizon) ?? base.occurrences.futureHorizon, + stringValue(occurrences.future_horizon) ?? + stringValue(occurrences.futureHorizon) ?? + base.occurrences.futureHorizon, }, timeTracking: { ...base.timeTracking, @@ -1019,6 +1290,106 @@ function applyStoredAppSettings( }); } +/** Two places in a task type that state the same vocabulary and differ. */ +export interface TaskNotesMdbaseTypeDisagreement { + list: "status" | "priority"; + /** The copy that differs from the binding's. */ + part: "schema-enum" | "definitions" | "completion-rule" | "schema-default" | "read-default"; + /** What the contract binding says. */ + binding: string[]; + /** What the other copy says. */ + found: string[]; +} + +/** + * Where a task type states its statuses, priorities or defaults more than + * once and the copies differ. A type written by patchTaskNotesMdbaseTypeSettings + * or the generator has none; a hand edit or another tool can leave some. An + * authority validates tasks against the schema, so a status only the binding + * names is refused when written, and one only the schema names has no label, + * colour or completion meaning. Saving the vocabulary through + * patchTaskNotesMdbaseTypeSettings rewrites every copy. + */ +export function taskNotesMdbaseTypeDisagreements( + type: unknown +): TaskNotesMdbaseTypeDisagreement[] { + if (!isRecord(type)) return []; + const implementation = taskNotesImplementation(type); + if (!implementation || !isRecord(implementation.binding)) return []; + const fields = isRecord(implementation.fields) ? implementation.fields : {}; + const schema = + isRecord(type.schema) && isRecord(type.schema.value) ? type.schema.value : {}; + const properties = isRecord(schema.properties) ? schema.properties : {}; + const readDefaults = + isRecord(type.collection) && isRecord(type.collection.read_defaults) + ? type.collection.read_defaults + : {}; + const found: TaskNotesMdbaseTypeDisagreement[] = []; + const compare = ( + list: TaskNotesMdbaseTypeDisagreement["list"], + part: TaskNotesMdbaseTypeDisagreement["part"], + binding: string[], + other: string[] | undefined + ): void => { + if (!other) return; + const same = + binding.length === other.length && binding.every((value) => other.includes(value)); + if (!same) found.push({ list, part, binding, found: other }); + }; + + for (const list of ["status", "priority"] as const) { + const section = isRecord(implementation.binding[list]) ? implementation.binding[list] : {}; + const field = stringValue(fields[list]) ?? DEFAULT_ROLE_FIELDS[list]; + const property = properties[field]; + const values = stringArray(section.values); + if (values.length) { + const allowed = schemaEnum(property); + compare(list, "schema-enum", values, allowed.length ? allowed : undefined); + compare( + list, + "definitions", + values, + Array.isArray(section.definitions) ? [...definitionMap(section.definitions).keys()] : undefined + ); + } + const fallback = stringValue(section.default); + if (fallback) { + const schemaDefault = readSchemaDefault(property); + compare( + list, + "schema-default", + [fallback], + typeof schemaDefault === "string" ? [schemaDefault] : undefined + ); + const readDefault = readDefaults[field]; + compare( + list, + "read-default", + [fallback], + typeof readDefault === "string" ? [readDefault] : undefined + ); + } + if (list === "status" && Array.isArray(section.completed_values) && Array.isArray(schema.allOf)) { + for (const rule of schema.allOf) { + const condition = + isRecord(rule) && isRecord(rule.if) && isRecord(rule.if.properties) + ? rule.if.properties[field] + : undefined; + if (isRecord(condition) && Array.isArray(condition.enum)) + compare( + list, + "completion-rule", + stringArray(section.completed_values), + stringArray(condition.enum) + ); + } + } + } + return found; +} + +const DEFAULT_ROLE_FIELDS = { status: "status", priority: "priority" } as const; + /** * The TaskNotes implementation of a type upgraded to the current contract: * the version raised and every setting rc.6 moved to the settings record @@ -1123,7 +1494,12 @@ function resolveUserFields( const defaultValue = readSchemaDefault(schema); resolved.push({ id: existing?.id ?? key, - displayName: existing?.displayName ?? humanize(key), + // The type's own title is shared by every host; a host's remembered + // name only fills in for types written before titles were. + displayName: + (isRecord(schema) ? stringValue(schema.title) : undefined) ?? + existing?.displayName ?? + humanize(key), key, type, ...(defaultValue !== undefined ? { defaultValue } : {}), @@ -1483,6 +1859,14 @@ function resolveStatuses( const completed = new Set(stringArray(policy.completed_values)); const skipped = new Set(stringArray(policy.skipped_values)); const definitions = definitionMap(policy.definitions); + const isSkipped = (value: string): boolean => { + const definition = definitions.get(value); + return ( + booleanValue(definition?.is_skipped) ?? + booleanValue(definition?.isSkipped) ?? + skipped.has(value) + ); + }; return values.length ? values.map((value, index) => { const existing = fallback.find((candidate) => candidate.value === value); @@ -1496,13 +1880,15 @@ function resolveStatuses( ? { icon: stringValue(definition?.icon) ?? existing?.icon } : {}), isCompleted: - booleanValue(definition?.is_completed) ?? completed.has(value), - isSkipped: booleanValue(definition?.is_skipped) ?? skipped.has(value), + booleanValue(definition?.is_completed) ?? + booleanValue(definition?.isCompleted) ?? + completed.has(value), + isSkipped: isSkipped(value), // rc.5 and earlier bindings; rc.6 keeps these in the settings record. excludeFromCycle: booleanValue(definition?.exclude_from_cycle) ?? existing?.excludeFromCycle ?? - (booleanValue(definition?.is_skipped) ?? skipped.has(value)), + isSkipped(value), ...(stringValue(definition?.next_status) ?? existing?.nextStatus ? { nextStatus: @@ -1730,6 +2116,14 @@ function humanize(value: string): string { return text ? `${text[0].toUpperCase()}${text.slice(1)}` : value; } -function firstNonEmpty(primary: string[], fallback: string[]): string[] { - return primary.length ? primary : fallback; +/** The strings a property may hold, wherever a nullable wrapper keeps them. */ +function schemaEnum(property: unknown): string[] { + if (!isRecord(property)) return []; + if (Array.isArray(property.enum)) return stringArray(property.enum); + if (Array.isArray(property.anyOf)) { + for (const branch of property.anyOf) { + if (isRecord(branch) && Array.isArray(branch.enum)) return stringArray(branch.enum); + } + } + return []; } diff --git a/src/vocabulary.ts b/src/vocabulary.ts new file mode 100644 index 0000000..015948a --- /dev/null +++ b/src/vocabulary.ts @@ -0,0 +1,219 @@ +import type { UserMappedFieldType } from "./types"; + +/** One status in a vocabulary change. */ +export interface TaskNotesStatusEdit { + value: string; + label: string; + color: string; + isCompleted: boolean; + /** Omitted, a status keeps whether it counted as skipped. */ + isSkipped?: boolean; + /** Omitted, a status keeps its icon. */ + icon?: string; + /** Omitted, a status is ordered by its position in the list. */ + order?: number; +} + +/** One priority in a vocabulary change. */ +export interface TaskNotesPriorityEdit { + value: string; + label: string; + color: string; + /** Omitted, a priority keeps its icon. */ + icon?: string; + /** Omitted, a priority is weighted by its position in the list. */ + weight?: number; +} + +/** One custom field in a vocabulary change. */ +export interface TaskNotesUserFieldEdit { + key: string; + displayName: string; + type: UserMappedFieldType; +} + +/** + * A change to the words a collection's tasks may use. Statuses and priorities + * are complete lists in display order. Custom fields are named changes, so a + * property the host cannot edit is never rewritten. + */ +export interface TaskNotesVocabularyPatch { + statuses?: TaskNotesStatusEdit[]; + priorities?: TaskNotesPriorityEdit[]; + userFields?: { + add?: TaskNotesUserFieldEdit[]; + rename?: Array>; + remove?: string[]; + }; +} + +export type TaskNotesVocabularyList = "status" | "priority"; + +export type TaskNotesVocabularyProblem = + | { kind: "empty-label"; list: TaskNotesVocabularyList } + | { kind: "duplicate-value"; list: TaskNotesVocabularyList; value: string } + | { kind: "no-open-status" } + | { kind: "no-completed-status" } + | { kind: "completed-and-skipped"; value: string } + | { kind: "no-priority" } + | { kind: "default-removed"; list: TaskNotesVocabularyList; value: string } + | { kind: "field-name"; key: string } + | { kind: "field-taken"; key: string } + | { kind: "field-label"; key: string }; + +/** A vocabulary change the collection cannot accept as it stands. */ +export class TaskNotesVocabularyError extends Error { + constructor(readonly problem: TaskNotesVocabularyProblem) { + super(`The task settings are not valid: ${problem.kind}.`); + this.name = "TaskNotesVocabularyError"; + } +} + +/** Tasks still hold a status or priority the change would remove. */ +export class TaskNotesVocabularyInUseError extends Error { + constructor( + readonly list: TaskNotesVocabularyList, + readonly value: string, + readonly count: number + ) { + super(`${count} task${count === 1 ? "" : "s"} still use the ${list} "${value}".`); + this.name = "TaskNotesVocabularyInUseError"; + } +} + +/** Whether a caught value is a TaskNotesVocabularyError, from any copy of this package. */ +export function isTaskNotesVocabularyError(value: unknown): value is TaskNotesVocabularyError { + return ( + value instanceof Error && + value.name === "TaskNotesVocabularyError" && + typeof (value as { problem?: unknown }).problem === "object" + ); +} + +/** Whether a caught value is a TaskNotesVocabularyInUseError, from any copy of this package. */ +export function isTaskNotesVocabularyInUseError( + value: unknown +): value is TaskNotesVocabularyInUseError { + return ( + value instanceof Error && + value.name === "TaskNotesVocabularyInUseError" && + typeof (value as { count?: unknown }).count === "number" + ); +} + +/** Property names a custom field may take. */ +export const TASKNOTES_USER_FIELD_KEY = /^[A-Za-z_][A-Za-z0-9_-]*$/; + +/** A stored value for a new status or priority, made from its label. */ +export function taskNotesVocabularyValue(label: string, taken: Iterable): string { + const used = new Set(taken); + const base = + unaccented(label) + .replace(/[^\p{Letter}\p{Number}]+/gu, "-") + .replace(/^-+|-+$/g, "") || "option"; + let value = base; + for (let suffix = 2; used.has(value); suffix += 1) value = `${base}-${suffix}`; + return value; +} + +/** A property name for a new custom field, made from its label. */ +export function taskNotesUserFieldKey(label: string, taken: Iterable): string { + const used = new Set([...taken].map((key) => key.toLocaleLowerCase())); + const base = + unaccented(label) + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .replace(/^([0-9])/, "_$1") || "field"; + let key = base; + for (let suffix = 2; used.has(key); suffix += 1) key = `${base}_${suffix}`; + return key; +} + +/** + * The first rule a status and priority list breaks, if any. `defaults` names + * the default status and priority the collection will have once the change is + * saved. + */ +export function taskNotesVocabularyProblem( + patch: Pick, + defaults: { status: string; priority: string } +): TaskNotesVocabularyProblem | null { + if (patch.statuses) { + const problem = listProblem("status", patch.statuses); + if (problem) return problem; + if (!patch.statuses.some((status) => !status.isCompleted)) + return { kind: "no-open-status" }; + if (!patch.statuses.some((status) => status.isCompleted)) + return { kind: "no-completed-status" }; + const both = patch.statuses.find( + (status) => status.isCompleted && status.isSkipped === true + ); + if (both) return { kind: "completed-and-skipped", value: both.value }; + if (!patch.statuses.some(({ value }) => value === defaults.status)) + return { kind: "default-removed", list: "status", value: defaults.status }; + } + if (patch.priorities) { + const problem = listProblem("priority", patch.priorities); + if (problem) return problem; + if (!patch.priorities.length) return { kind: "no-priority" }; + if (!patch.priorities.some(({ value }) => value === defaults.priority)) + return { kind: "default-removed", list: "priority", value: defaults.priority }; + } + return null; +} + +/** + * The first status or priority a change removes while tasks still hold it. + * Removing it would leave those tasks outside the type's own schema, and an + * authority that validates writes would then refuse later edits to them. + */ +export function taskNotesVocabularyInUse( + tasks: Iterable<{ status?: string | null; priority?: string | null }>, + current: { statuses: readonly string[]; priorities: readonly string[] }, + patch: Pick +): TaskNotesVocabularyInUseError | null { + const removed = (before: readonly string[], after?: ReadonlyArray<{ value: string }>) => { + if (!after) return new Map(); + const kept = new Set(after.map(({ value }) => value)); + return new Map(before.filter((value) => !kept.has(value)).map((value) => [value, 0])); + }; + const statuses = removed(current.statuses, patch.statuses); + const priorities = removed(current.priorities, patch.priorities); + if (!statuses.size && !priorities.size) return null; + for (const task of tasks) { + if (typeof task.status === "string") { + const count = statuses.get(task.status); + if (count !== undefined) statuses.set(task.status, count + 1); + } + if (typeof task.priority === "string") { + const count = priorities.get(task.priority); + if (count !== undefined) priorities.set(task.priority, count + 1); + } + } + for (const [value, count] of statuses) + if (count) return new TaskNotesVocabularyInUseError("status", value, count); + for (const [value, count] of priorities) + if (count) return new TaskNotesVocabularyInUseError("priority", value, count); + return null; +} + +function listProblem( + list: TaskNotesVocabularyList, + entries: ReadonlyArray<{ value: string; label: string }> +): TaskNotesVocabularyProblem | null { + const seen = new Set(); + for (const entry of entries) { + if (!entry.label.trim() || !entry.value.trim()) return { kind: "empty-label", list }; + if (seen.has(entry.value)) return { kind: "duplicate-value", list, value: entry.value }; + seen.add(entry.value); + } + return null; +} + +function unaccented(label: string): string { + return label + .trim() + .toLocaleLowerCase() + .normalize("NFKD") + .replace(/[̀-ͯ]/g, ""); +} diff --git a/test/mdbase.test.mjs b/test/mdbase.test.mjs index 18194ae..7a033e5 100644 --- a/test/mdbase.test.mjs +++ b/test/mdbase.test.mjs @@ -480,7 +480,7 @@ test("projects and resolves the complete portable TaskNotes settings snapshot", assert.deepEqual(resolved.userFields, [ { id: "effort_points", - displayName: "Effort points", + displayName: "Effort", key: "effort_points", type: "number", defaultValue: 3, diff --git a/test/vocabulary.test.mjs b/test/vocabulary.test.mjs new file mode 100644 index 0000000..ea272c7 --- /dev/null +++ b/test/vocabulary.test.mjs @@ -0,0 +1,500 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildTaskNotesMdbaseResources, + isTaskNotesVocabularyError, + patchTaskNotesMdbaseTypeSettings, + resolveTaskNotesModelConfigFromImplementation, + resolveTaskNotesModelConfigFromMdbaseType, + taskNotesMdbaseTypeDisagreements, + TaskNotesVocabularyError, + taskNotesUserFieldKey, + taskNotesVocabularyInUse, + taskNotesVocabularyProblem, + taskNotesVocabularyValue, +} from "../dist/esm/mdbase.js"; + +const status = (value, label, isCompleted, extra = {}) => ({ + id: value, + value, + label, + color: "#123456", + isCompleted, + order: 0, + autoArchive: false, + autoArchiveDelay: 5, + ...extra, +}); + +const AFTER = { + statuses: [ + status("open", "To do", false, { order: 0 }), + status("waiting", "Waiting", false, { order: 1, icon: "clock" }), + status("dropped", "Dropped", false, { order: 2, isSkipped: true }), + status("done", "Done", true, { order: 3 }), + status("shipped", "Shipped", true, { order: 4 }), + ], + priorities: [ + { id: "someday", value: "someday", label: "Someday", color: "#aaaaaa", weight: 0 }, + { id: "normal", value: "normal", label: "Normal", color: "#ffaa00", weight: 1 }, + { id: "urgent", value: "urgent", label: "Urgent", color: "#ff0000", weight: 2, icon: "flame" }, + ], + userFields: [ + { id: "client", key: "client", displayName: "Client", type: "text" }, + { id: "points", key: "points", displayName: "Story points", type: "number" }, + { id: "review_on", key: "review_on", displayName: "Review on", type: "date" }, + { id: "billable", key: "billable", displayName: "Billable", type: "boolean" }, + { id: "labels", key: "labels", displayName: "Labels", type: "list" }, + ], + defaults: { status: "waiting", priority: "urgent" }, +}; + +/** The patch a host would send to turn the stock vocabulary into AFTER. */ +function patchToAfter() { + return { + statuses: AFTER.statuses.map(({ value, label, color, isCompleted, isSkipped, icon }) => ({ + value, + label, + color, + isCompleted, + ...(isSkipped !== undefined ? { isSkipped } : {}), + ...(icon !== undefined ? { icon } : {}), + })), + priorities: AFTER.priorities.map(({ value, label, color, icon }) => ({ + value, + label, + color, + ...(icon !== undefined ? { icon } : {}), + })), + userFields: { + add: AFTER.userFields.map(({ key, displayName, type }) => ({ key, displayName, type })), + }, + defaultStatus: AFTER.defaults.status, + defaultPriority: AFTER.defaults.priority, + }; +} + +for (const legacyCompatibility of [false, true]) { + const mode = legacyCompatibility ? "a migrated v0.2 type" : "a canonical type"; + + test(`editing ${mode} gives what the generator writes for the same settings`, () => { + const before = buildTaskNotesMdbaseResources({ legacyCompatibility }).type; + const generated = buildTaskNotesMdbaseResources({ + legacyCompatibility, + modelConfig: AFTER, + }).type; + + const edited = patchTaskNotesMdbaseTypeSettings(before, patchToAfter()); + + assert.deepEqual(edited, generated); + }); + + test(`undoing an edit to ${mode} restores the generated type`, () => { + const stock = buildTaskNotesMdbaseResources({ legacyCompatibility }); + const edited = patchTaskNotesMdbaseTypeSettings(stock.type, patchToAfter()); + const config = stock.modelConfig; + + const restored = patchTaskNotesMdbaseTypeSettings(edited, { + statuses: config.statuses.map(({ value, label, color, isCompleted }) => ({ + value, + label, + color, + isCompleted, + isSkipped: false, + })), + priorities: config.priorities.map(({ value, label, color }) => ({ value, label, color })), + userFields: { remove: AFTER.userFields.map(({ key }) => key) }, + defaultStatus: config.defaults.status, + defaultPriority: config.defaults.priority, + }); + + assert.deepEqual(restored, stock.type); + }); +} + +test("every copy of the status list agrees after an edit", () => { + const type = buildTaskNotesMdbaseResources({ + modelConfig: { fieldMapping: { status: "state", completedDate: "finished" } }, + }).type; + + const edited = patchTaskNotesMdbaseTypeSettings(type, { + statuses: [ + { value: "open", label: "Open", color: "#808080", isCompleted: false }, + { value: "shipped", label: "Shipped", color: "#00aa00", isCompleted: true }, + ], + }); + const schema = edited.schema.value; + const binding = edited.implements[0].binding.status; + + assert.deepEqual(schema.properties.state.enum, ["open", "shipped"]); + assert.deepEqual(binding.values, ["open", "shipped"]); + assert.deepEqual(binding.definitions.map(({ value }) => value), ["open", "shipped"]); + assert.deepEqual(binding.completed_values, ["shipped"]); + assert.deepEqual(schema.allOf[0].if.properties.state.enum, ["shipped"]); + assert.deepEqual(schema.allOf[0].then, { required: ["finished"] }); + assert.equal(edited.collection.read_defaults.state, "open"); +}); + +test("an edit keeps what it does not name", () => { + const type = buildTaskNotesMdbaseResources().type; + const binding = type.implements[0].binding; + binding.status.definitions[1]["x-host-note"] = "kept"; + binding.status.definitions[1].icon = "circle"; + binding.status.skipped_values = ["none"]; + binding.status.default_skipped = "none"; + type.schema.value.properties.client = { type: "string", description: "Hand-written" }; + type["x-host"] = { custom: true }; + + const edited = patchTaskNotesMdbaseTypeSettings(type, { + statuses: [ + { value: "none", label: "None", color: "#cccccc", isCompleted: false }, + { value: "open", label: "Open", color: "#808080", isCompleted: false }, + { value: "done", label: "Done", color: "#00aa00", isCompleted: true }, + ], + userFields: { rename: [{ key: "client", displayName: "Customer" }] }, + }); + const status = edited.implements[0].binding.status; + + assert.equal(status.definitions[1]["x-host-note"], "kept"); + assert.equal(status.definitions[1].icon, "circle"); + assert.deepEqual(status.skipped_values, ["none"]); + assert.equal(status.default_skipped, "none"); + assert.equal(status.definitions[0].is_skipped, true); + assert.deepEqual(edited.schema.value.properties.client, { + title: "Customer", + type: "string", + description: "Hand-written", + }); + assert.deepEqual(edited["x-host"], { custom: true }); + assert.deepEqual(edited.implements[0].binding.priority, binding.priority); + // The type handed in is not changed. + assert.equal(type.schema.value.properties.client.title, undefined); +}); + +test("a status that stops being skipped leaves the skipped list", () => { + const type = buildTaskNotesMdbaseResources().type; + type.implements[0].binding.status.skipped_values = ["none"]; + type.implements[0].binding.status.default_skipped = "none"; + + const edited = patchTaskNotesMdbaseTypeSettings(type, { + statuses: [ + { value: "open", label: "Open", color: "#808080", isCompleted: false }, + { value: "done", label: "Done", color: "#00aa00", isCompleted: true }, + ], + }); + const status = edited.implements[0].binding.status; + + assert.deepEqual(status.skipped_values, []); + assert.equal("default_skipped" in status, false); +}); + +test("custom field names are shared through the type", () => { + const type = buildTaskNotesMdbaseResources({ + modelConfig: { + userFields: [{ id: "client", key: "client", displayName: "Customer", type: "text" }], + }, + }).type; + + assert.equal(type.schema.value.properties.client.title, "Customer"); + // A host that remembers another name still reads the type's. + const resolved = resolveTaskNotesModelConfigFromMdbaseType(type, { + userFields: [{ id: "f1", key: "client", displayName: "Old name", type: "text" }], + }); + assert.deepEqual(resolved.userFields, [ + { id: "f1", key: "client", displayName: "Customer", type: "text" }, + ]); + + // Types written before titles keep the host's name. + delete type.schema.value.properties.client.title; + assert.equal( + resolveTaskNotesModelConfigFromMdbaseType(type, { + userFields: [{ id: "f1", key: "client", displayName: "Old name", type: "text" }], + }).userFields[0].displayName, + "Old name" + ); +}); + +test("refuses vocabulary a collection cannot hold", () => { + const type = buildTaskNotesMdbaseResources().type; + const open = { value: "open", label: "Open", color: "#808080", isCompleted: false }; + const done = { value: "done", label: "Done", color: "#00aa00", isCompleted: true }; + const problem = (patch) => { + try { + patchTaskNotesMdbaseTypeSettings(type, patch); + } catch (error) { + assert.ok(error instanceof TaskNotesVocabularyError); + assert.ok(isTaskNotesVocabularyError(error)); + return error.problem; + } + return null; + }; + + assert.deepEqual(problem({ statuses: [open] }), { kind: "no-completed-status" }); + assert.deepEqual(problem({ statuses: [done] }), { kind: "no-open-status" }); + assert.deepEqual(problem({ statuses: [open, { ...done, label: " " }] }), { + kind: "empty-label", + list: "status", + }); + assert.deepEqual(problem({ statuses: [open, done, { ...done }] }), { + kind: "duplicate-value", + list: "status", + value: "done", + }); + assert.deepEqual(problem({ statuses: [open, { ...done, isSkipped: true }] }), { + kind: "completed-and-skipped", + value: "done", + }); + assert.deepEqual( + problem({ statuses: [{ ...open, value: "todo" }, done] }), + { kind: "default-removed", list: "status", value: "open" } + ); + assert.equal( + problem({ statuses: [{ ...open, value: "todo" }, done], defaultStatus: "todo" }), + null + ); + assert.deepEqual(problem({ priorities: [] }), { kind: "no-priority" }); + assert.deepEqual( + problem({ priorities: [{ value: "high", label: "High", color: "#f00" }] }), + { kind: "default-removed", list: "priority", value: "normal" } + ); + assert.deepEqual( + problem({ userFields: { add: [{ key: "my field", displayName: "My field", type: "text" }] } }), + { kind: "field-name", key: "my field" } + ); + assert.deepEqual( + problem({ userFields: { add: [{ key: "Status", displayName: "Status", type: "text" }] } }), + { kind: "field-taken", key: "Status" } + ); + assert.deepEqual( + problem({ userFields: { add: [{ key: "client", displayName: " ", type: "text" }] } }), + { kind: "field-label", key: "client" } + ); + // A contract field is never removed as though it were a custom field. + const kept = patchTaskNotesMdbaseTypeSettings(type, { userFields: { remove: ["due"] } }); + assert.deepEqual(kept.schema.value.properties.due, type.schema.value.properties.due); +}); + +test("finds tasks that hold a status or priority being removed", () => { + const current = { statuses: ["open", "waiting", "done"], priorities: ["low", "high"] }; + const tasks = [ + { status: "open", priority: "low" }, + { status: "waiting", priority: "high" }, + { status: "waiting" }, + { status: null, priority: null }, + ]; + const open = { value: "open", label: "Open", color: "", isCompleted: false }; + const done = { value: "done", label: "Done", color: "", isCompleted: true }; + + const inUse = taskNotesVocabularyInUse(tasks, current, { statuses: [open, done] }); + assert.equal(inUse.list, "status"); + assert.equal(inUse.value, "waiting"); + assert.equal(inUse.count, 2); + assert.match(inUse.message, /2 tasks still use the status "waiting"/); + + const priority = taskNotesVocabularyInUse(tasks, current, { + priorities: [{ value: "low", label: "Low", color: "" }], + }); + assert.equal(priority.list, "priority"); + assert.equal(priority.count, 1); + + assert.equal( + taskNotesVocabularyInUse(tasks, current, { + statuses: [open, { ...open, value: "waiting" }, done], + }), + null + ); + assert.equal(taskNotesVocabularyInUse(tasks, current, {}), null); +}); + +test("checks a vocabulary before it is saved", () => { + assert.equal( + taskNotesVocabularyProblem( + { + statuses: [ + { value: "open", label: "Open", color: "", isCompleted: false }, + { value: "done", label: "Done", color: "", isCompleted: true }, + ], + }, + { status: "open", priority: "normal" } + ), + null + ); +}); + +test("makes stored values and property names from labels", () => { + assert.equal(taskNotesVocabularyValue(" Waiting on Élodie ", []), "waiting-on-elodie"); + assert.equal(taskNotesVocabularyValue("Waiting", ["waiting", "waiting-2"]), "waiting-3"); + assert.equal(taskNotesVocabularyValue("保留", []), "保留"); + assert.equal(taskNotesVocabularyValue("!!!", []), "option"); + assert.equal(taskNotesUserFieldKey("Story points", []), "story_points"); + assert.equal(taskNotesUserFieldKey("2nd reviewer", []), "_2nd_reviewer"); + assert.equal(taskNotesUserFieldKey("Client", ["client", "CLIENT_2"]), "client_3"); + assert.equal(taskNotesUserFieldKey("保留", []), "field"); +}); + +test("reports where a type states its vocabulary twice and the copies differ", () => { + for (const legacyCompatibility of [false, true]) { + const generated = buildTaskNotesMdbaseResources({ legacyCompatibility }).type; + assert.deepEqual(taskNotesMdbaseTypeDisagreements(generated), []); + assert.deepEqual( + taskNotesMdbaseTypeDisagreements( + patchTaskNotesMdbaseTypeSettings(generated, patchToAfter()) + ), + [] + ); + } + + // The generator shares one list between the schema and the binding; a type + // read back from a collection does not. + const type = JSON.parse(JSON.stringify(buildTaskNotesMdbaseResources().type)); + const binding = type.implements[0].binding; + // A hand edit: a status and a priority the binding does not describe, a + // binding status the schema refuses, and defaults that moved in one place. + type.schema.value.properties.status.enum.push("blocked"); + binding.status.values.push("waiting"); + binding.status.completed_values.push("cancelled"); + binding.status.default = "none"; + type.schema.value.properties.priority.enum.push("urgent"); + type.collection.read_defaults.priority = "high"; + + assert.deepEqual(taskNotesMdbaseTypeDisagreements(type), [ + { + list: "status", + part: "schema-enum", + binding: ["none", "open", "in-progress", "done", "waiting"], + found: ["none", "open", "in-progress", "done", "blocked"], + }, + { + list: "status", + part: "definitions", + binding: ["none", "open", "in-progress", "done", "waiting"], + found: ["none", "open", "in-progress", "done"], + }, + { list: "status", part: "schema-default", binding: ["none"], found: ["open"] }, + { list: "status", part: "read-default", binding: ["none"], found: ["open"] }, + { + list: "status", + part: "completion-rule", + binding: ["done", "cancelled"], + found: ["done"], + }, + { + list: "priority", + part: "schema-enum", + binding: ["none", "low", "normal", "high"], + found: ["none", "low", "normal", "high", "urgent"], + }, + { list: "priority", part: "read-default", binding: ["normal"], found: ["high"] }, + ]); + assert.deepEqual(taskNotesMdbaseTypeDisagreements({ kind: "mdbase.type", name: "note" }), []); +}); + +test("a value only the schema allows is still read, and saving the list repairs the type", () => { + const type = JSON.parse(JSON.stringify(buildTaskNotesMdbaseResources().type)); + type.schema.value.properties.status.enum.push("blocked"); + type.schema.value.properties.priority.enum.push("urgent"); + + const config = resolveTaskNotesModelConfigFromMdbaseType(type); + assert.deepEqual(config.statuses.map(({ value }) => value), [ + "none", + "open", + "in-progress", + "done", + "blocked", + ]); + assert.deepEqual(config.statuses.at(-1), { + id: "blocked", + value: "blocked", + label: "Blocked", + color: "#808080", + isCompleted: false, + isSkipped: false, + excludeFromCycle: false, + order: 4, + autoArchive: false, + autoArchiveDelay: 5, + }); + assert.equal(config.priorities.at(-1).value, "urgent"); + + const repaired = patchTaskNotesMdbaseTypeSettings(type, { + statuses: config.statuses.map(({ value, label, color, isCompleted }) => ({ + value, + label, + color, + isCompleted, + })), + priorities: config.priorities.map(({ value, label, color }) => ({ value, label, color })), + }); + assert.deepEqual(taskNotesMdbaseTypeDisagreements(repaired), []); + assert.deepEqual(repaired.implements[0].binding.status.values.at(-1), "blocked"); +}); + +test("resolves a configuration from what an authority describes, without the type document", () => { + const resources = buildTaskNotesMdbaseResources({ + modelConfig: { + ...AFTER, + fieldMapping: { status: "state", due: "deadline" }, + taskIdentification: { method: "property", propertyName: "kind", propertyValue: "task" }, + }, + }); + const type = resources.type; + const implementation = type.implements[0]; + + const fromImplementation = resolveTaskNotesModelConfigFromImplementation({ + fields: implementation.fields, + binding: implementation.binding, + schema: type.schema.value, + match: type.match, + }); + + assert.deepEqual(fromImplementation, resolveTaskNotesModelConfigFromMdbaseType(type)); + assert.equal(fromImplementation.fieldMapping.status, "state"); + assert.equal(fromImplementation.taskIdentification.propertyName, "kind"); + assert.deepEqual( + fromImplementation.userFields.map(({ key, displayName }) => [key, displayName]), + AFTER.userFields.map(({ key, displayName }) => [key, displayName]) + ); + // Nothing but defaults when the authority describes nothing. + assert.deepEqual( + resolveTaskNotesModelConfigFromImplementation({}).statuses, + resolveTaskNotesModelConfigFromMdbaseType(undefined).statuses + ); +}); + +test("reads the camel-case spellings early bindings used", () => { + const type = buildTaskNotesMdbaseResources().type; + const binding = type.implements[0].binding; + binding.status.completed_values = []; + binding.status.definitions = binding.status.definitions.map( + ({ is_completed, is_skipped, ...definition }) => ({ + ...definition, + isCompleted: is_completed, + isSkipped: definition.value === "none", + }) + ); + binding.occurrences = { + defaultMaterialization: "rolling", + defaultNextTrigger: "completion_or_skip", + pastHorizon: "P2D", + futureHorizon: "P30D", + }; + + const config = resolveTaskNotesModelConfigFromMdbaseType(type); + + assert.equal(config.statuses.find(({ value }) => value === "done").isCompleted, true); + assert.equal(config.statuses.find(({ value }) => value === "none").isSkipped, true); + assert.deepEqual( + { + defaultMaterialization: config.occurrences.defaultMaterialization, + defaultNextTrigger: config.occurrences.defaultNextTrigger, + pastHorizon: config.occurrences.pastHorizon, + futureHorizon: config.occurrences.futureHorizon, + }, + { + defaultMaterialization: "rolling", + defaultNextTrigger: "completion_or_skip", + pastHorizon: "P2D", + futureHorizon: "P30D", + } + ); +}); From 08f2c357da9f1302df086f329656617da2cfdfa1 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Wed, 11 Nov 2026 08:03:52 +1100 Subject: [PATCH 2/3] Reconcile a type whose copies of the vocabulary disagree --- CHANGELOG.md | 2 +- src/mdbase.ts | 36 ++++++++++++++++++++++++++++++++ test/vocabulary.test.mjs | 45 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e1385e..60065f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - `patchTaskNotesMdbaseTypeSettings` is now the one editor for a TaskNotes task type. It accepts `statuses` and `priorities` (complete lists in display order) and `userFields` (`add`, `rename`, `remove`), and rewrites every place the type repeats them: the schema's allowed values and defaults, the completion rule, the collection's read defaults, the binding's `values`, `completed_values`, `skipped_values`, `default_skipped` and `definitions`, and the generator's `managed_fields`. A default named in the same patch may be one of the new values. The result equals what `buildTaskNotesMdbaseResources` generates for the same settings, in canonical and migrated-v0.2 types; definition keys and properties the patch does not name are kept. - Vocabulary helpers, exported from `@tasknotes/model/mdbase`: `taskNotesVocabularyProblem` (empty label, duplicate value, no open or no completed status, a status both completed and skipped, no priority, a removed default), `taskNotesVocabularyInUse` (the first removed status or priority tasks still hold), `taskNotesVocabularyValue` and `taskNotesUserFieldKey` (a stored value or property name from a label), `TaskNotesVocabularyError`, `TaskNotesVocabularyInUseError` and the `isTaskNotesVocabularyError` / `isTaskNotesVocabularyInUseError` guards. -- `taskNotesMdbaseTypeDisagreements` reports where a type states its statuses, priorities or defaults more than once and the copies differ (schema enum, definitions, completion rule, schema default, read default against the binding). +- `taskNotesMdbaseTypeDisagreements` reports where a type states its statuses, priorities or defaults more than once and the copies differ (schema enum, definitions, completion rule, schema default, read default against the binding). `reconcileTaskNotesMdbaseTypeVocabulary` returns the type with the copies agreeing, never taking away a value a task may hold. - `resolveTaskNotesModelConfigFromImplementation` resolves the configuration from a tasknotes.task implementation (`fields`, `binding`, the type's schema and `match`), which is what an authority's catalog returns, without the type document. `resolveTaskNotesModelConfigFromMdbaseType` now delegates to it. ### Changed diff --git a/src/mdbase.ts b/src/mdbase.ts index 31ba9fe..51d580a 100644 --- a/src/mdbase.ts +++ b/src/mdbase.ts @@ -22,6 +22,7 @@ import type { UserMappedField, } from "./types"; import { + isTaskNotesVocabularyError, TASKNOTES_USER_FIELD_KEY, TaskNotesVocabularyError, taskNotesVocabularyProblem, @@ -1390,6 +1391,41 @@ export function taskNotesMdbaseTypeDisagreements( const DEFAULT_ROLE_FIELDS = { status: "status", priority: "priority" } as const; +/** + * A type whose copies of the vocabulary agree. Nothing a task may hold today + * is taken away: a status or priority that only one copy names is kept, the + * binding's description of each value wins, and the binding's defaults and + * completed statuses are written to the schema. A type with no disagreement, + * or one whose vocabulary cannot be made valid, is returned as it is. + */ +export function reconcileTaskNotesMdbaseTypeVocabulary( + type: Record +): Record { + if (!taskNotesMdbaseTypeDisagreements(type).length) return type; + const config = resolveTaskNotesModelConfigFromMdbaseType(type); + try { + return patchTaskNotesMdbaseTypeSettings(type, { + statuses: config.statuses.map((status) => ({ + value: status.value, + label: status.label, + color: status.color, + isCompleted: status.isCompleted, + isSkipped: status.isSkipped === true, + order: status.order, + })), + priorities: config.priorities.map((priority) => ({ + value: priority.value, + label: priority.label, + color: priority.color, + weight: priority.weight, + })), + }); + } catch (error) { + if (isTaskNotesVocabularyError(error)) return type; + throw error; + } +} + /** * The TaskNotes implementation of a type upgraded to the current contract: * the version raised and every setting rc.6 moved to the settings record diff --git a/test/vocabulary.test.mjs b/test/vocabulary.test.mjs index ea272c7..537d4a6 100644 --- a/test/vocabulary.test.mjs +++ b/test/vocabulary.test.mjs @@ -4,6 +4,7 @@ import { buildTaskNotesMdbaseResources, isTaskNotesVocabularyError, patchTaskNotesMdbaseTypeSettings, + reconcileTaskNotesMdbaseTypeVocabulary, resolveTaskNotesModelConfigFromImplementation, resolveTaskNotesModelConfigFromMdbaseType, taskNotesMdbaseTypeDisagreements, @@ -498,3 +499,47 @@ test("reads the camel-case spellings early bindings used", () => { } ); }); + +test("reconciling a type makes its copies agree without taking a value away", () => { + const generated = buildTaskNotesMdbaseResources().type; + assert.equal(reconcileTaskNotesMdbaseTypeVocabulary(generated), generated); + + const type = JSON.parse(JSON.stringify(generated)); + const binding = type.implements[0].binding; + type.schema.value.properties.status.enum.push("blocked"); + binding.status.values.push("waiting"); + binding.status.completed_values.push("waiting"); + binding.status.definitions[1].order = 7; + binding.status.default = "none"; + type.schema.value.properties.priority.enum.push("urgent"); + binding.priority.definitions[0].icon = "minus"; + + const reconciled = reconcileTaskNotesMdbaseTypeVocabulary(type); + const status = reconciled.implements[0].binding.status; + + assert.deepEqual(taskNotesMdbaseTypeDisagreements(reconciled), []); + assert.deepEqual(status.values, ["none", "open", "in-progress", "done", "waiting", "blocked"]); + assert.deepEqual(reconciled.schema.value.properties.status.enum, status.values); + assert.deepEqual(status.completed_values, ["done", "waiting"]); + assert.deepEqual(reconciled.schema.value.allOf[0].if.properties.status.enum, ["done", "waiting"]); + assert.equal(reconciled.schema.value.properties.status.default, "none"); + assert.equal(reconciled.collection.read_defaults.status, "none"); + assert.equal(status.definitions[1].order, 7); + assert.equal(reconciled.implements[0].binding.priority.definitions[0].icon, "minus"); + assert.deepEqual(reconciled.implements[0].binding.priority.values, [ + "none", + "low", + "normal", + "high", + "urgent", + ]); + // The type handed in is not changed. + assert.equal(type.schema.value.properties.status.default, "open"); + + // A vocabulary that cannot be made valid is left for a person to fix. + const broken = JSON.parse(JSON.stringify(generated)); + broken.implements[0].binding.status.completed_values = []; + for (const definition of broken.implements[0].binding.status.definitions) + definition.is_completed = false; + assert.equal(reconcileTaskNotesMdbaseTypeVocabulary(broken), broken); +}); From 4984a938c53b510f075ab9324440d6c26a570564 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Wed, 11 Nov 2026 08:19:31 +1100 Subject: [PATCH 3/3] fix(mdbase): leave a collection's own status rules alone The editor rewrote the status condition of every allOf rule, and the disagreement check compared each one with the completed statuses. Only a rule that requires the completion date is the generated completion rule. --- CHANGELOG.md | 2 ++ src/mdbase.ts | 62 ++++++++++++++++++++++++---------------- test/vocabulary.test.mjs | 22 ++++++++++++++ 3 files changed, 62 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60065f0..a85489d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ - A custom field's display name is written to the type as the property's `title`, and read from it before the host's remembered name. Names now reach other TaskNotes hosts; types written earlier keep the host's name until they are next written. - A status or priority only the schema allows (not in the binding's `values`) is read after the described ones instead of being hidden, so hosts show it and saving the list repairs the type. - The camel-case spellings early bindings used (`isCompleted`, `isSkipped`, `defaultMaterialization`, `defaultNextTrigger`, `pastHorizon`, `futureHorizon`) are read after the current ones. +- Only a rule that requires the completion date is treated as the completion rule. Another `allOf` rule that tests the status is the collection's own: an edit to the statuses leaves it as written, and it is not reported as a disagreement. + ## 0.3.0-rc.20 diff --git a/src/mdbase.ts b/src/mdbase.ts index 51d580a..ce38eea 100644 --- a/src/mdbase.ts +++ b/src/mdbase.ts @@ -381,16 +381,12 @@ function applyVocabularyPatch( setSchemaEnum(properties[field], values, defaults.status); readDefaults[field] = defaults.status; // The type requires a completion date for exactly the completed statuses. - if (Array.isArray(schema.allOf)) { - for (const rule of schema.allOf) { - const condition = - isRecord(rule) && isRecord(rule.if) && isRecord(rule.if.properties) - ? rule.if.properties[field] - : undefined; - if (isRecord(condition) && Array.isArray(condition.enum)) - condition.enum = [...completed]; - } - } + for (const condition of completionRuleConditions( + schema, + field, + model.fieldMapping.completedDate + )) + condition.enum = [...completed]; } if (patch.priorities) { @@ -1370,20 +1366,15 @@ export function taskNotesMdbaseTypeDisagreements( typeof readDefault === "string" ? [readDefault] : undefined ); } - if (list === "status" && Array.isArray(section.completed_values) && Array.isArray(schema.allOf)) { - for (const rule of schema.allOf) { - const condition = - isRecord(rule) && isRecord(rule.if) && isRecord(rule.if.properties) - ? rule.if.properties[field] - : undefined; - if (isRecord(condition) && Array.isArray(condition.enum)) - compare( - list, - "completion-rule", - stringArray(section.completed_values), - stringArray(condition.enum) - ); - } + if (list === "status" && Array.isArray(section.completed_values)) { + const completedDate = stringValue(fields.completedDate) ?? "completedDate"; + for (const condition of completionRuleConditions(schema, field, completedDate)) + compare( + list, + "completion-rule", + stringArray(section.completed_values), + stringArray(condition.enum) + ); } } return found; @@ -1391,6 +1382,29 @@ export function taskNotesMdbaseTypeDisagreements( const DEFAULT_ROLE_FIELDS = { status: "status", priority: "priority" } as const; +/** + * The status conditions of the rules that require a completion date: the rule + * the generator writes, wherever a host has moved it. Another rule that + * happens to test the status is the collection's own and is left alone. + */ +function completionRuleConditions( + schema: Record, + statusField: string, + completedDateField: string +): Array & { enum: unknown[] }> { + if (!Array.isArray(schema.allOf)) return []; + const conditions: Array & { enum: unknown[] }> = []; + for (const rule of schema.allOf) { + if (!isRecord(rule) || !isRecord(rule.if) || !isRecord(rule.if.properties)) continue; + const required = isRecord(rule.then) ? rule.then.required : undefined; + if (!Array.isArray(required) || !required.includes(completedDateField)) continue; + const condition = rule.if.properties[statusField]; + if (isRecord(condition) && Array.isArray(condition.enum)) + conditions.push(condition as Record & { enum: unknown[] }); + } + return conditions; +} + /** * A type whose copies of the vocabulary agree. Nothing a task may hold today * is taken away: a status or priority that only one copy names is kept, the diff --git a/test/vocabulary.test.mjs b/test/vocabulary.test.mjs index 537d4a6..e7e4376 100644 --- a/test/vocabulary.test.mjs +++ b/test/vocabulary.test.mjs @@ -172,6 +172,28 @@ test("an edit keeps what it does not name", () => { assert.equal(type.schema.value.properties.client.title, undefined); }); +test("an edit leaves the collection's own rules about a status alone", () => { + const type = JSON.parse(JSON.stringify(buildTaskNotesMdbaseResources().type)); + const own = { + if: { required: ["status"], properties: { status: { enum: ["open"] } } }, + then: { required: ["scheduled"] }, + }; + type.schema.value.allOf.push(own); + + assert.deepEqual(taskNotesMdbaseTypeDisagreements(type), []); + const edited = patchTaskNotesMdbaseTypeSettings(type, { + statuses: [ + { value: "open", label: "Open", color: "#808080", isCompleted: false }, + { value: "done", label: "Done", color: "#00aa00", isCompleted: true }, + { value: "shipped", label: "Shipped", color: "#0000aa", isCompleted: true }, + ], + }); + + assert.deepEqual(edited.schema.value.allOf[0].if.properties.status.enum, ["done", "shipped"]); + assert.deepEqual(edited.schema.value.allOf[1], own); + assert.deepEqual(taskNotesMdbaseTypeDisagreements(edited), []); +}); + test("a status that stops being skipped leaves the skipped list", () => { const type = buildTaskNotesMdbaseResources().type; type.implements[0].binding.status.skipped_values = ["none"];