diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index fddbbae5f..8e4e4f363 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -34,6 +34,9 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Changed +- A custom field's name is now stored in the mdbase task type, as the property's `title`, so TaskNotes on another device and the TaskNotes app show the name you gave it. A name changed elsewhere is picked up here. Types written before this keep each device's own name until the type is next saved. +- A status or priority added to the task type's schema by hand, or by another tool, is now read as a status instead of making TaskNotes report the type as inconsistent. TaskNotes reads every value either list names, and the next settings change writes one list to both places. A type whose lists cannot be reconciled is still reported and left alone. + - Brought the TaskNotes settings record (`_settings/dev.tasknotes.md`) in line with the `mdbase.app-settings` contract. Opening a vault no longer creates the record or adds this device's settings to it, so one device can't change a collection's settings just by opening it. A setting missing from the record, or invalid in it, now takes its default instead of this device's saved value, and removing the record returns every setting to its default. The record is created on the first change you make, and upgrades still move your settings into it. See [Migrating to v5](https://tasknotes.dev/migration-v4-to-v5/#tasknotes-settings-record). - The calendar toolbar is quieter. Views are chosen from one dropdown instead of a row of six buttons, and Select tasks and Refresh are in the toolbar's "…" menu. Select tasks stays in the toolbar while you are selecting. diff --git a/package-lock.json b/package-lock.json index 22f7c434f..0f9841167 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@lingui/core": "6.9.0", "@modelcontextprotocol/sdk": "1.32.1", "@tasknotes/calendar": "0.1.10", - "@tasknotes/model": "0.3.0-rc.20", + "@tasknotes/model": "0.3.0-rc.21", "ajv": "8.20.0", "ajv-formats": "3.0.1", "chrono-node": "^2.7.5", @@ -5096,9 +5096,9 @@ } }, "node_modules/@tasknotes/model": { - "version": "0.3.0-rc.20", - "resolved": "https://registry.npmjs.org/@tasknotes/model/-/model-0.3.0-rc.20.tgz", - "integrity": "sha512-Id8bJjU0HhBJAqxJLkJOhtkWjq7MV1JWBm/q0yeTeQLiDw05jctceheh9xiM1hJ86PiH6Qu6E+MVwuxNfUnPJQ==", + "version": "0.3.0-rc.21", + "resolved": "https://registry.npmjs.org/@tasknotes/model/-/model-0.3.0-rc.21.tgz", + "integrity": "sha512-AYfIqaCeq0tlK3Xhavf43HEhFnVS2bPiFdULkur538xtpBH0wDWj7SwDt1y0trl11811c5dNJ6xUA7HXgjcIOQ==", "license": "MIT", "dependencies": { "rrule": "^2.8.1", diff --git a/package.json b/package.json index 83a76f7fa..f8711d1b7 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "@codemirror/view": "^6.38.6", "@modelcontextprotocol/sdk": "1.32.1", "@tasknotes/calendar": "0.1.10", - "@tasknotes/model": "0.3.0-rc.20", + "@tasknotes/model": "0.3.0-rc.21", "ajv": "8.20.0", "ajv-formats": "3.0.1", "chrono-node": "^2.7.5", diff --git a/src/services/mdbase/checkCollection.ts b/src/services/mdbase/checkCollection.ts index aaa72705e..f490d787d 100644 --- a/src/services/mdbase/checkCollection.ts +++ b/src/services/mdbase/checkCollection.ts @@ -3,7 +3,7 @@ import { type TFile } from "obsidian"; import { createI18nService } from "../../i18n"; import YAML from "yaml"; import type TaskNotesPlugin from "../../main"; -import { parseMdbaseTaskTypeDocument, validateCanonicalTaskType, withCurrentTaskNotesContract } from "../mdbaseCanonicalConfig"; +import { parseMdbaseTaskTypeDocument, validateCanonicalTaskType, withAgreedVocabulary, withCurrentTaskNotesContract } from "../mdbaseCanonicalConfig"; import { compileRecordSchema, validateTaskRecord, validationIssues } from "./contractValidation"; import { taskExclusionExpression } from "./collectionConfig"; import { recordDocument } from "./frontmatter"; @@ -48,7 +48,10 @@ export async function checkCollection(plugin: TaskNotesPlugin): Promise value.contract === "tasknotes.task") : undefined; + const statusValues = object(object((agreedImplementation ?? implementation).binding).status).values as string[]; const match = object(type.match); try { assertSupportedMatch(type.match); } catch (error) { throw new Error(`${provider.path}: ${String(error)}`); } diff --git a/src/services/mdbase/preserveCanonical.ts b/src/services/mdbase/preserveCanonical.ts index 1b58b7616..0d9b018ac 100644 --- a/src/services/mdbase/preserveCanonical.ts +++ b/src/services/mdbase/preserveCanonical.ts @@ -109,7 +109,8 @@ export function preserveUnownedCanonicalSettings(existing: ObjectValue, generate const prior = previousProperties[field]; if (prior && typeof prior === "object") { const merged = { ...object(definition), ...object(prior) }; - for (const key of ["type", "enum", "default"]) { + // A custom field's name is a plugin setting, stored as the property's title. + for (const key of ["type", "enum", "default", "title"]) { if (object(definition)[key] !== undefined) merged[key] = object(definition)[key]; } properties[field] = merged; diff --git a/src/services/mdbaseCanonicalConfig.ts b/src/services/mdbaseCanonicalConfig.ts index edcc37208..e3cb366aa 100644 --- a/src/services/mdbaseCanonicalConfig.ts +++ b/src/services/mdbaseCanonicalConfig.ts @@ -1,5 +1,7 @@ import { + reconcileTaskNotesMdbaseTypeVocabulary, resolveTaskNotesModelConfigFromMdbaseType, + taskNotesMdbaseTypeDisagreements, upgradeTaskNotesMdbaseTypeContract, type TaskNotesMdbaseResources, } from "@tasknotes/model/mdbase"; @@ -100,7 +102,27 @@ export function hasLegacyAppSettings(binding: unknown): boolean { return Object.keys(taskNotesAppSettingsFromLegacyBinding(binding)).length > 0; } -export function validateCanonicalTaskType(type: UnknownRecord): CanonicalTypeValidationResult { +/** + * The type with one list of statuses and priorities. A type states them in its + * schema and again in its binding; after a hand edit, or a write by another + * tool, the copies can differ. Nothing a task may hold is taken away: a value + * either copy names is kept. A type whose copies agree, or cannot be made to, + * is returned as it is. + */ +export function withAgreedVocabulary(type: UnknownRecord): UnknownRecord { + try { + return reconcileTaskNotesMdbaseTypeVocabulary(type); + } catch { + return type; + } +} + +/** + * Validates the type as TaskNotes reads it: with its vocabulary agreed (see + * withAgreedVocabulary). The next write from settings stores that vocabulary. + */ +export function validateCanonicalTaskType(source: UnknownRecord): CanonicalTypeValidationResult { + const type = withAgreedVocabulary(source); const issues: string[] = []; const implementation = taskNotesImplementation(type); const extension = asRecord(implementation?.binding); @@ -151,10 +173,7 @@ export function validateCanonicalTaskType(type: UnknownRecord): CanonicalTypeVal } validateTaskIdentification(type, issues); - if (implementation) { - validateVocabularyMirror(type, implementation, "status", issues); - validateVocabularyMirror(type, implementation, "priority", issues); - } + issues.push(...vocabularyMirrorIssues(type, fieldRoles)); const statusField = fieldRoles?.status; const advertisedStatusValues = stringArray(extension?.status, "values"); @@ -243,7 +262,8 @@ export function applyCanonicalTaskTypeToSettings( settings.storeTitleInFilename = modelConfig.storeTitleInFilename; settings.userFields = mergeResolvedUserFields( settings.userFields ?? [], - modelConfig.userFields + modelConfig.userFields, + recordAt(type, ["schema", "value", "properties"]) ?? {} ); settings.maintainDueDateOffsetInRecurring = modelConfig.recurrence.maintainDueDateOffset; @@ -391,6 +411,7 @@ export function portableSettingsFingerprint(settings: TaskNotesSettings): string customPriorities: settings.customPriorities, userFields: (settings.userFields ?? []).map((field) => ({ key: field.key, + displayName: field.displayName, type: field.type, defaultValue: field.defaultValue, })), @@ -483,38 +504,16 @@ function validateTaskIdentification(type: UnknownRecord, issues: string[]): void } } -function validateVocabularyMirror( - type: UnknownRecord, - implementation: UnknownRecord, - role: "status" | "priority", - issues: string[] -): void { - const fieldName = asRecord(implementation.fields)?.[role]; - const policy = asRecord(asRecord(implementation.binding)?.[role]); - const extensionValues = stringArray(policy, "values"); - if (typeof fieldName !== "string" || extensionValues.length === 0) return; - const schemaValues = stringArrayAt(type, ["schema", "value", "properties", fieldName, "enum"]); - if (schemaValues.length > 0 && !sameStrings(extensionValues, schemaValues)) { - issues.push(`${role}.values contradicts schema enum for ${fieldName}`); - } - - const extensionDefault = policy?.default; - const schemaDefault = valueAt(type, ["schema", "value", "properties", fieldName, "default"]); - const collectionDefault = valueAt(type, ["collection", "read_defaults", fieldName]); - if ( - extensionDefault !== undefined && - schemaDefault !== undefined && - extensionDefault !== schemaDefault - ) { - issues.push(`${role}.default contradicts the schema default for ${fieldName}`); - } - if ( - extensionDefault !== undefined && - collectionDefault !== undefined && - extensionDefault !== collectionDefault - ) { - issues.push(`${role}.default contradicts collection.read_defaults for ${fieldName}`); - } +/** The copies of a status or priority list or default that still differ. */ +function vocabularyMirrorIssues(type: UnknownRecord, fieldRoles: UnknownRecord | null): string[] { + return taskNotesMdbaseTypeDisagreements(type).flatMap(({ list, part }) => { + const fieldName = typeof fieldRoles?.[list] === "string" ? fieldRoles[list] : list; + if (part === "schema-enum") return [`${list}.values contradicts schema enum for ${fieldName}`]; + if (part === "schema-default") return [`${list}.default contradicts the schema default for ${fieldName}`]; + if (part === "read-default") return [`${list}.default contradicts collection.read_defaults for ${fieldName}`]; + // Earlier releases did not compare the other copies; keep accepting those types. + return []; + }); } function validateUniqueStrings(values: string[], label: string, issues: string[]): void { @@ -582,15 +581,22 @@ function mergeResolvedUserFields( key: string; type: UserMappedField["type"]; defaultValue?: UserMappedField["defaultValue"]; - }> + }>, + properties: UnknownRecord ): UserMappedField[] { return resolved.map((field) => { // Saved keys may carry whitespace that settings load trims (or the type kept). const previous = existing.find((candidate) => candidate.key.trim() === field.key.trim()); + // The type carries the name every TaskNotes client shows; a type written + // before names were stored there leaves this device's name in place. + const title = asRecord(properties[field.key])?.title; return { ...field, id: previous?.id ?? field.id, - displayName: previous?.displayName ?? field.displayName, + displayName: + typeof title === "string" && title.trim() + ? title.trim() + : (previous?.displayName ?? field.displayName), ...(previous?.autosuggestFilter ? { autosuggestFilter: previous.autosuggestFilter } : {}), @@ -608,10 +614,6 @@ function isTaskFilenameFormat(value: unknown): value is TaskNotesSettings["taskF ); } -function sameStrings(left: string[], right: string[]): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]); -} - function taskNotesImplementation(type: UnknownRecord): UnknownRecord | null { if (!Array.isArray(type.implements)) return null; return ( diff --git a/tests/unit/services/mdbaseCanonicalConfig.test.ts b/tests/unit/services/mdbaseCanonicalConfig.test.ts index 2f994fbe3..6b41ed254 100644 --- a/tests/unit/services/mdbaseCanonicalConfig.test.ts +++ b/tests/unit/services/mdbaseCanonicalConfig.test.ts @@ -11,6 +11,7 @@ import { buildTaskNotesModelConfig, mergeCanonicalTaskTypeDocument, parseMdbaseTaskTypeDocument, + portableSettingsFingerprint, validateCanonicalTaskType, } from "../../../src/services/mdbaseCanonicalConfig"; @@ -117,13 +118,57 @@ describe("canonical mdbase TaskNotes configuration", () => { ); }); - it("rejects contradictory compatibility mirrors", () => { - const resources = buildTaskNotesMdbaseResources(); + it("reads a status only the schema allows, and writes one list back", () => { + const settings = clone(DEFAULT_SETTINGS); + const resources = buildTaskNotesMdbaseResources({ modelConfig: buildTaskNotesModelConfig(settings) }); const type = clone(resources.type); const schema = type.schema as { value: { properties: Record }; }; + const before = [...(schema.value.properties.status.enum ?? [])]; + schema.value.properties.status.enum = [...before, "blocked"]; + + expect(validateCanonicalTaskType(type)).toEqual({ valid: true }); + applyCanonicalTaskTypeToSettings(settings, type); + expect(settings.customStatuses.map((status) => status.value)).toEqual([...before, "blocked"]); + + const written = parseMdbaseTaskTypeDocument( + mergeCanonicalTaskTypeDocument( + `---\n${initialDocumentYaml(type)}---\n`, + buildTaskNotesMdbaseResources({ modelConfig: buildTaskNotesModelConfig(settings) }) + ) + ).type; + const status = implementation(written).binding as { status: { values: string[] } }; + expect(status.status.values).toEqual([...before, "blocked"]); + expect((written.schema as typeof schema).value.properties.status.enum).toEqual([...before, "blocked"]); + }); + + it("keeps a status the schema lost while the binding still describes it", () => { + const type = clone(buildTaskNotesMdbaseResources().type); + const schema = type.schema as { + value: { properties: Record }; + }; + const before = [...(schema.value.properties.status.enum ?? [])]; schema.value.properties.status.enum = ["open", "done"]; + const settings = clone(DEFAULT_SETTINGS); + + expect(validateCanonicalTaskType(type)).toEqual({ valid: true }); + applyCanonicalTaskTypeToSettings(settings, type); + expect(settings.customStatuses.map((status) => status.value)).toEqual(before); + }); + + it("rejects mirrors that cannot be made to agree", () => { + const type = clone(buildTaskNotesMdbaseResources().type); + const schema = type.schema as { + value: { properties: Record }; + }; + schema.value.properties.status.enum = ["open", "done"]; + // With every status completed there is no list a collection could hold. + const binding = implementation(type).binding as { + status: { values: string[]; completed_values: string[]; definitions: Array<{ is_completed: boolean }> }; + }; + binding.status.completed_values = [...binding.status.values]; + binding.status.definitions.forEach((definition) => (definition.is_completed = true)); expect(validateCanonicalTaskType(type)).toEqual({ valid: false, @@ -275,12 +320,46 @@ describe("canonical mdbase TaskNotes configuration", () => { ]); }); + it("shares a custom field's name through the type", () => { + const settings = clone(DEFAULT_SETTINGS); + settings.userFields = [{ id: "effort-id", displayName: "Effort", key: "effort", type: "number" }]; + const generate = () => buildTaskNotesMdbaseResources({ modelConfig: buildTaskNotesModelConfig(settings) }); + const first = generate(); + expect(titleOf(first.type, "effort")).toBe("Effort"); + + // Renamed here: the type follows, and the fingerprint notices. + const fingerprint = portableSettingsFingerprint(settings); + settings.userFields[0].displayName = "Effort level"; + expect(portableSettingsFingerprint(settings)).not.toBe(fingerprint); + const renamed = parseMdbaseTaskTypeDocument( + mergeCanonicalTaskTypeDocument(first.typeDocument, generate()) + ).type; + expect(titleOf(renamed, "effort")).toBe("Effort level"); + + // Renamed by another client: this device follows the type. + const elsewhere = clone(renamed); + (elsewhere.schema as { value: { properties: Record } }).value.properties.effort.title = "Size"; + applyCanonicalTaskTypeToSettings(settings, elsewhere); + expect(settings.userFields).toEqual([ + expect.objectContaining({ id: "effort-id", displayName: "Size", key: "effort" }), + ]); + + // A type written before names were stored keeps this device's name. + delete (elsewhere.schema as { value: { properties: Record } }).value.properties.effort.title; + applyCanonicalTaskTypeToSettings(settings, elsewhere); + expect(settings.userFields[0].displayName).toBe("Size"); + }); + }); function initialDocumentYaml(value: Record): string { return `${YAML.stringify(value, { lineWidth: 0 }).trimEnd()}\n`; } +function titleOf(type: Record, field: string): unknown { + return (type.schema as { value: { properties: Record } }).value.properties[field]?.title; +} + function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } diff --git a/vendor/README.md b/vendor/README.md index a61ad1e3d..764f4df76 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -6,6 +6,6 @@ SHA-256 and byte-for-byte embedded resources before production and development builds. Run the script without `--check` only when intentionally regenerating the embedded resource module. Updating the pack requires an explicit new digest. -`@tasknotes/model@0.3.0-rc.20` (tasknotes.task 0.3.0-rc.6 and the TaskNotes settings record) +`@tasknotes/model@0.3.0-rc.21` (tasknotes.task 0.3.0-rc.6 and the TaskNotes settings record) comes from npm, and TaskNotes App uses the same version. It generates the editable, collection-specific implementing type and the settings record. Immutable contracts and schemas come from the catalog provision instead.