diff --git a/CHANGELOG.md b/CHANGELOG.md index 02656c5..a85489d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # 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). `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 + +- 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 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..ce38eea 100644 --- a/src/mdbase.ts +++ b/src/mdbase.ts @@ -21,6 +21,18 @@ import type { TaskNotesModelConfig, UserMappedField, } from "./types"; +import { + isTaskNotesVocabularyError, + 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 +187,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 +215,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 +241,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 +252,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 +309,197 @@ 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. + for (const condition of completionRuleConditions( + schema, + field, + model.fieldMapping.completedDate + )) + 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 +768,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 +1070,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 +1139,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 +1152,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 +1172,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 +1212,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 +1287,159 @@ 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)) { + 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; +} + +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 + * 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 @@ -1123,7 +1544,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 +1909,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 +1930,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 +2166,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..e7e4376 --- /dev/null +++ b/test/vocabulary.test.mjs @@ -0,0 +1,567 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildTaskNotesMdbaseResources, + isTaskNotesVocabularyError, + patchTaskNotesMdbaseTypeSettings, + reconcileTaskNotesMdbaseTypeVocabulary, + 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("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"]; + 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", + } + ); +}); + +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); +});