diff --git a/CHANGELOG.md b/CHANGELOG.md index 687a822..02656c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.3.0-rc.20 + +Implements tasknotes-spec 0.3.0-rc.6 (`tasknotes.task` 0.3.0-rc.6). + +### Changed + +- App behaviour moved out of the `tasknotes.task` binding into a TaskNotes settings record (`_settings/dev.tasknotes.md`, `tasknotes.settings` 1.0.0, which also implements `mdbase.app-settings` 1.0.0). The rc.6 binding no longer holds `nlp`, `templating`, `time_tracking`, `archive.move_on_archive`/`archive.folder`, `recurrence.reset_body_checkboxes`, `links.write_format`, or the per-status `next_status`, `exclude_from_cycle`, `auto_archive` and `auto_archive_delay_minutes`. +- `buildTaskNotesMdbaseResources` returns those settings as `appSettings`, writes the contract to `_contracts/tasknotes.task/0.3.0-rc.6.md` (so the rc.5 contract at `_contracts/tasknotes.task.md` can stay for types that still implement it), and generates type version 5. The starter is revision 6; `TASKNOTES_MDBASE_PACK_VERSION` is 0.3.0-rc.19. +- `patchTaskNotesMdbaseTypeSettings` no longer accepts `recurrence.resetCheckboxesOnRecurrence`, `timeTracking`, `links`, `archive`, `templating` or `statusAutomation`; write them with `buildTaskNotesSettingsPatch`. +- Types implementing rc.5 or rc.3 are still read. `resolveTaskNotesModelConfigFromMdbaseType(type, fallback, { settings })` reads each moved setting from the record, then the binding, then the fallback. A status without behaviour anywhere is out of the cycle exactly when it is skipped. + +### Added + +- `@tasknotes/model/settings`: the app-settings logic from mdbase Reader (resolve with defaults and warnings, patch only changed fields, conflicts, read-only on a newer `settings_version` or another app's record, new-record frontmatter, in-place Markdown writes that keep unknown fields, comments and the body) and the TaskNotes definition: `resolveTaskNotesAppSettings` (record, then legacy binding, then fallback), `taskNotesAppSettingsFromLegacyBinding`, `withoutTaskNotesAppSettings`, `absentTaskNotesSettingsPatch` (what a migration adds to a record another device created), `taskNotesAppSettingsFromModelConfig`, `applyTaskNotesAppSettingsToModelConfig`, and `buildTaskNotesSettingsResources` (contract, schema and `tasknotes-settings` type). +- `upgradeTaskNotesMdbaseTypeContract` lifts an rc.5/rc.3 type to rc.6 and returns the settings it removed. `TASKNOTES_ACCEPTED_CONTRACT_VERSIONS`, `TASKNOTES_LEGACY_CONTRACT_VERSIONS`, `TASKNOTES_LEGACY_CONTRACT_DIGESTS` and `taskNotesContractVersion` are exported. + ## 0.3.0-rc.19 ### Added diff --git a/package-lock.json b/package-lock.json index 4f4245d..3e3e105 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.19", + "version": "0.3.0-rc.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tasknotes/model", - "version": "0.3.0-rc.19", + "version": "0.3.0-rc.20", "license": "MIT", "dependencies": { "rrule": "^2.8.1", diff --git a/package.json b/package.json index 9f4420c..fc82cd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.19", + "version": "0.3.0-rc.20", "description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.", "license": "MIT", "type": "module", @@ -99,6 +99,11 @@ "import": "./dist/esm/mdbase.js", "require": "./dist/cjs/mdbase.cjs" }, + "./settings": { + "types": "./dist/types/settings.d.ts", + "import": "./dist/esm/settings.js", + "require": "./dist/cjs/settings.cjs" + }, "./starter": { "types": "./dist/types/starter.d.ts", "import": "./dist/esm/starter.js", diff --git a/scripts/build.mjs b/scripts/build.mjs index 482d964..ebbd2b3 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -17,6 +17,7 @@ const entries = [ "frontmatter", "mdbase", "starter", + "settings", "conformance", "calendar", "calendar-fixtures", diff --git a/src/generated/tasknotes-data-contract.ts b/src/generated/tasknotes-data-contract.ts index f7568bd..2a0a1c3 100644 --- a/src/generated/tasknotes-data-contract.ts +++ b/src/generated/tasknotes-data-contract.ts @@ -5,7 +5,7 @@ export const TASKNOTES_TASK_SCHEMA = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://tasknotes.dev/schemas/tasknotes-task.schema.json", "title": "TaskNotes portable task view", - "description": "The storage-neutral record view exposed by the tasknotes.task 0.3.0-rc.5 record contract.", + "description": "The storage-neutral record view exposed by the tasknotes.task 0.3.0-rc.6 record contract.", "type": "object", "required": [ "status", @@ -208,7 +208,7 @@ export const TASKNOTES_TASK_BINDING_SCHEMA = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://tasknotes.dev/schemas/tasknotes-task-binding.schema.json", "title": "TaskNotes task data-contract binding", - "description": "Semantic configuration supplied by an mdbase type that implements tasknotes.task 0.3.0-rc.3.", + "description": "Semantic configuration supplied by an mdbase type that implements tasknotes.task 0.3.0-rc.6. App behaviour, such as templates, archive moves, status cycling and natural-language triggers, lives in the app's tasknotes.settings record instead.", "type": "object", "required": [ "profiles", @@ -219,9 +219,7 @@ export const TASKNOTES_TASK_BINDING_SCHEMA = { "recurrence", "occurrences", "links", - "archive", - "time_tracking", - "templating" + "archive" ], "properties": { "profiles": { @@ -279,15 +277,6 @@ export const TASKNOTES_TASK_BINDING_SCHEMA = { }, "archive": { "$ref": "#/$defs/archivePolicy" - }, - "time_tracking": { - "$ref": "#/$defs/timeTrackingPolicy" - }, - "templating": { - "$ref": "#/$defs/templatingPolicy" - }, - "nlp": { - "$ref": "#/$defs/nlpPolicy" } }, "additionalProperties": false, @@ -362,10 +351,7 @@ export const TASKNOTES_TASK_BINDING_SCHEMA = { "color", "is_completed", "is_skipped", - "exclude_from_cycle", - "order", - "auto_archive", - "auto_archive_delay_minutes" + "order" ], "properties": { "value": { @@ -386,21 +372,8 @@ export const TASKNOTES_TASK_BINDING_SCHEMA = { "is_skipped": { "type": "boolean" }, - "exclude_from_cycle": { - "type": "boolean" - }, - "next_status": { - "$ref": "#/$defs/nonEmptyString" - }, "order": { "type": "number" - }, - "auto_archive": { - "type": "boolean" - }, - "auto_archive_delay_minutes": { - "type": "number", - "minimum": 0 } }, "additionalProperties": true @@ -492,8 +465,7 @@ export const TASKNOTES_TASK_BINDING_SCHEMA = { "type": "object", "required": [ "syntax", - "maintain_due_date_offset", - "reset_body_checkboxes" + "maintain_due_date_offset" ], "properties": { "syntax": { @@ -501,9 +473,6 @@ export const TASKNOTES_TASK_BINDING_SCHEMA = { }, "maintain_due_date_offset": { "type": "boolean" - }, - "reset_body_checkboxes": { - "type": "boolean" } }, "additionalProperties": false @@ -547,8 +516,7 @@ export const TASKNOTES_TASK_BINDING_SCHEMA = { "linkPolicy": { "type": "object", "required": [ - "accepted_formats", - "write_format" + "accepted_formats" ], "properties": { "accepted_formats": { @@ -561,12 +529,6 @@ export const TASKNOTES_TASK_BINDING_SCHEMA = { "markdown" ] } - }, - "write_format": { - "enum": [ - "wikilink", - "markdown" - ] } }, "additionalProperties": false @@ -574,99 +536,11 @@ export const TASKNOTES_TASK_BINDING_SCHEMA = { "archivePolicy": { "type": "object", "required": [ - "archived_tag", - "move_on_archive" + "archived_tag" ], "properties": { "archived_tag": { "$ref": "#/$defs/nonEmptyString" - }, - "move_on_archive": { - "type": "boolean" - }, - "folder": { - "$ref": "#/$defs/nonEmptyString" - } - }, - "additionalProperties": false - }, - "timeTrackingPolicy": { - "type": "object", - "required": [ - "auto_stop_on_complete" - ], - "properties": { - "auto_stop_on_complete": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - "templatingPolicy": { - "type": "object", - "required": [ - "enabled", - "occurrence_enabled" - ], - "properties": { - "enabled": { - "type": "boolean" - }, - "template_path": { - "$ref": "#/$defs/nonEmptyString" - }, - "occurrence_enabled": { - "type": "boolean" - }, - "occurrence_template_path": { - "$ref": "#/$defs/nonEmptyString" - }, - "failure_mode": { - "enum": [ - "error", - "warning_fallback" - ] - }, - "unknown_variable_policy": { - "enum": [ - "preserve", - "empty" - ] - } - }, - "additionalProperties": false - }, - "nlpTrigger": { - "type": "object", - "required": [ - "property_id", - "trigger", - "enabled" - ], - "properties": { - "property_id": { - "$ref": "#/$defs/nonEmptyString" - }, - "trigger": { - "$ref": "#/$defs/nonEmptyString" - }, - "enabled": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - "nlpPolicy": { - "type": "object", - "required": [ - "triggers" - ], - "properties": { - "triggers": { - "type": "array", - "items": { - "$ref": "#/$defs/nlpTrigger" - } } }, "additionalProperties": false diff --git a/src/index.ts b/src/index.ts index bf6dbfd..792d436 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,5 +12,6 @@ export * from "./operations"; export * from "./frontmatter"; export * from "./mdbase"; export * from "./starter"; +export * from "./settings"; export * from "./conformance"; export * from "./calendar"; diff --git a/src/mdbase.ts b/src/mdbase.ts index 39833ff..1dcd940 100644 --- a/src/mdbase.ts +++ b/src/mdbase.ts @@ -5,6 +5,14 @@ import { TASKNOTES_TASK_COMPLETED_EVENT_SCHEMA, TASKNOTES_TASK_SCHEMA, } from "./generated/tasknotes-data-contract"; +import { + resolveTaskNotesAppSettings, + TASKNOTES_DEFAULT_AUTO_ARCHIVE_DELAY, + taskNotesAppSettingsFromLegacyBinding, + taskNotesAppSettingsFromModelConfig, + withoutTaskNotesAppSettings, +} from "./settings"; +import type { TaskNotesAppSettingsValues } from "./settings"; import { TASKNOTES_SPEC_VERSION } from "./types"; import type { FieldMapping, @@ -112,6 +120,12 @@ export interface TaskNotesMdbaseResources { records: string; }; modelConfig: TaskNotesModelConfig; + /** + * The settings-record values these options describe. tasknotes.task rc.6 + * keeps them out of the binding; hosts write them to the TaskNotes + * settings record (see ./settings). + */ + appSettings: TaskNotesAppSettingsValues; } export interface TaskNotesMdbaseTypePack { @@ -138,19 +152,34 @@ export interface TaskNotesMdbaseTypePack { * type's version, so types written by TaskNotes and by the tasknotes.task pack * agree and pack upgrades do not see a conflicting version change. */ -export const TASKNOTES_MDBASE_TYPE_VERSION = 4; +export const TASKNOTES_MDBASE_TYPE_VERSION = 5; /** Version of the generated definition bundle, independent of contract version. */ -export const TASKNOTES_MDBASE_PACK_VERSION = "0.3.0-rc.17"; +export const TASKNOTES_MDBASE_PACK_VERSION = "0.3.0-rc.19"; export const TASKNOTES_CONTRACT_DIGEST = - "sha256:dde71d0d776aa0069ee627fe3c10b1a20f87ba36929a16790598ebfb4e8ebf04"; + "sha256:c400ead6e218b715d86ad31125bb3d4b2e276817da6ba8065449fd0947b24d67"; + +/** + * Earlier tasknotes.task versions whose types TaskNotes still reads. Their + * bindings may hold settings rc.6 moved to the settings record; those are + * read as the fallback after the record. + */ +export const TASKNOTES_LEGACY_CONTRACT_VERSIONS = ["0.3.0-rc.5", "0.3.0-rc.3"] as const; +/** Semantic digests of the published legacy contracts, by version. */ +export const TASKNOTES_LEGACY_CONTRACT_DIGESTS: Readonly> = { + "0.3.0-rc.5": "sha256:dde71d0d776aa0069ee627fe3c10b1a20f87ba36929a16790598ebfb4e8ebf04", +}; +/** Contract versions a TaskNotes type may implement, newest first. */ +export const TASKNOTES_ACCEPTED_CONTRACT_VERSIONS: readonly string[] = [ + TASKNOTES_SPEC_VERSION, + ...TASKNOTES_LEGACY_CONTRACT_VERSIONS, +]; export interface TaskNotesMdbaseTypeSettingsPatch { defaultStatus?: string; defaultPriority?: string; recurrence?: { maintainDueDateOffset?: boolean; - resetCheckboxesOnRecurrence?: boolean; }; occurrences?: { defaultMaterialization?: "manual" | "on_completion" | "rolling"; @@ -158,27 +187,6 @@ export interface TaskNotesMdbaseTypeSettingsPatch { pastHorizon?: string; futureHorizon?: string; }; - timeTracking?: { - autoStopOnComplete?: boolean; - }; - links?: { - writeFormat?: "wikilink" | "markdown"; - }; - archive?: { - moveOnArchive?: boolean; - folder?: string; - }; - templating?: { - enabled?: boolean; - templatePath?: string; - }; - statusAutomation?: Record< - string, - { - autoArchive?: boolean; - autoArchiveDelay?: number; - } - >; } type LifecycleEvent = "on_create" | "on_update"; @@ -197,7 +205,10 @@ interface FieldOptions { * * The patch is deliberately constrained to settings whose schema vocabulary * does not change. Custom properties, collection paths, lifecycle rules, and - * host extensions remain untouched. + * 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 + * buildTaskNotesSettingsPatch in ./settings. */ export function patchTaskNotesMdbaseTypeSettings( type: Record, @@ -215,10 +226,6 @@ export function patchTaskNotesMdbaseTypeSettings( const priority = contractSection(binding, "priority"); const recurrence = contractSection(binding, "recurrence"); const occurrences = contractSection(binding, "occurrences"); - const timeTracking = contractSection(binding, "time_tracking"); - const links = contractSection(binding, "links"); - const archive = contractSection(binding, "archive"); - const templating = contractSection(binding, "templating"); const properties = isRecord(isRecord(result.schema) ? result.schema.value : undefined) ? (result.schema as Record).value.properties : undefined; @@ -254,9 +261,6 @@ export function patchTaskNotesMdbaseTypeSettings( if (patch.recurrence?.maintainDueDateOffset !== undefined) recurrence.maintain_due_date_offset = patch.recurrence.maintainDueDateOffset; - if (patch.recurrence?.resetCheckboxesOnRecurrence !== undefined) - recurrence.reset_body_checkboxes = - patch.recurrence.resetCheckboxesOnRecurrence; if (patch.occurrences?.defaultMaterialization !== undefined) { occurrences.default_materialization = patch.occurrences.defaultMaterialization; @@ -278,59 +282,6 @@ export function patchTaskNotesMdbaseTypeSettings( patch.occurrences.futureHorizon, "future occurrence horizon" ); - if (patch.timeTracking?.autoStopOnComplete !== undefined) - timeTracking.auto_stop_on_complete = - patch.timeTracking.autoStopOnComplete; - if (patch.links?.writeFormat !== undefined) - links.write_format = patch.links.writeFormat; - if (patch.archive?.moveOnArchive !== undefined) - archive.move_on_archive = patch.archive.moveOnArchive; - if (patch.archive?.folder !== undefined) - archive.folder = cleanPath(patch.archive.folder, "archive folder"); - if (patch.templating?.templatePath !== undefined) { - const path = patch.templating.templatePath.trim(); - if (path) templating.template_path = cleanPath(path, "template path"); - else delete templating.template_path; - } - if (patch.templating?.enabled !== undefined) { - if ( - patch.templating.enabled && - !stringValue(templating.template_path) - ) - throw new Error( - "template path must be set before task templating is enabled." - ); - templating.enabled = patch.templating.enabled; - } - if (patch.statusAutomation) { - const supplied = new Map(Object.entries(patch.statusAutomation)); - const definitions = definitionMap(status.definitions); - for (const value of supplied.keys()) { - if (!model.statuses.some((candidate) => candidate.value === value)) - throw new Error(`Unknown task status "${value}".`); - } - status.definitions = model.statuses.map((entry) => { - const automation = supplied.get(entry.value); - const definition = { - ...(definitions.get(entry.value) ?? statusDefinition(entry)), - }; - if (automation?.autoArchive !== undefined) - definition.auto_archive = automation.autoArchive; - if (automation?.autoArchiveDelay !== undefined) { - if ( - !Number.isInteger(automation.autoArchiveDelay) || - automation.autoArchiveDelay < 0 - ) - throw new Error( - `Auto-archive delay for "${entry.value}" must be a non-negative integer.` - ); - definition.auto_archive_delay_minutes = - automation.autoArchiveDelay; - } - return definition; - }); - } - return result; } @@ -637,11 +588,11 @@ export function buildTaskNotesMdbaseResources( throw new Error("A custom TaskNotes filename format requires a template."); } const defaultSkipped = skippedValues[0]; - const templatePath = options.templating?.templatePath?.trim(); - const occurrenceTemplatePath = options.templating?.occurrenceTemplatePath?.trim(); - const templateEnabled = options.templating?.enabled === true && Boolean(templatePath); - const occurrenceTemplateEnabled = - options.templating?.occurrenceEnabled === true && Boolean(occurrenceTemplatePath); + const appSettings = taskNotesAppSettingsFromModelConfig(modelConfig, { + templating: options.templating, + archive: options.archive, + links: options.links, + }); const binding: Record = { profiles, capabilities, @@ -668,7 +619,6 @@ export function buildTaskNotesMdbaseResources( recurrence: { syntax: "tasknotes", maintain_due_date_offset: modelConfig.recurrence.maintainDueDateOffset, - reset_body_checkboxes: modelConfig.recurrence.resetCheckboxesOnRecurrence, }, occurrences: { identity_roles: ["recurrenceParent", "occurrenceDate"], @@ -683,34 +633,9 @@ export function buildTaskNotesMdbaseResources( }, links: { accepted_formats: ["wikilink", "markdown"], - write_format: options.links?.writeFormat ?? "wikilink", }, archive: { archived_tag: mapping.archiveTag, - move_on_archive: options.archive?.moveOnArchive === true, - ...(options.archive?.folder?.trim() ? { folder: options.archive.folder.trim() } : {}), - }, - time_tracking: { - auto_stop_on_complete: modelConfig.timeTracking.autoStopOnComplete, - }, - ...(modelConfig.nlp - ? { - nlp: { - triggers: modelConfig.nlp.triggers.map((trigger) => ({ - property_id: trigger.propertyId, - trigger: trigger.trigger, - enabled: trigger.enabled, - })), - }, - } - : {}), - templating: { - enabled: templateEnabled, - ...(templatePath ? { template_path: templatePath } : {}), - occurrence_enabled: occurrenceTemplateEnabled, - ...(occurrenceTemplatePath - ? { occurrence_template_path: occurrenceTemplatePath } - : {}), }, }; const generator: Record = { @@ -824,20 +749,25 @@ export function buildTaskNotesMdbaseResources( "TaskNotes task view and supplies TaskNotes behavior.", "", "Changes made here are loaded by TaskNotes. Portable changes made in TaskNotes", - "settings are written back while unknown extensions are preserved.", + "settings are written back while unknown extensions are preserved. App", + "behaviour, such as templates and archive moves, is kept in the TaskNotes", + "settings record instead.", "", ].join("\n"), taskSchemaDocument, bindingSchemaDocument, paths: { config: "mdbase.yaml", - contract: `${contractsFolder}/tasknotes.task.md`, + // Versioned, so the rc.5 contract at tasknotes.task.md stays for + // types that still implement it. + contract: `${contractsFolder}/tasknotes.task/${TASKNOTES_SPEC_VERSION}.md`, type: `${typesFolder}/${typeName}.md`, taskSchema: `${schemasFolder}/tasknotes-task.schema.json`, bindingSchema: `${schemasFolder}/tasknotes-task-binding.schema.json`, records: tasksFolder, }, modelConfig, + appSettings, }; } @@ -856,7 +786,7 @@ export async function buildTaskNotesMdbaseTypePack( { kind: "contract" as const, mode: "managed" as const, - source: "contracts/tasknotes.task.md", + source: `contracts/tasknotes.task/${TASKNOTES_SPEC_VERSION}.md`, target: resources.paths.contract, document: resources.contractDocument, }, @@ -928,12 +858,19 @@ export async function buildTaskNotesMdbaseTypePack( /** Resolve the TaskNotes configuration advertised by an mdbase task type. */ export function resolveTaskNotesModelConfigFromMdbaseType( value: unknown, - fallback: Partial = {} + 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>; + } = {} ): TaskNotesModelConfig { const base = resolveModelConfig(fallback); - if (!isRecord(value)) return base; + if (!isRecord(value)) return applyStoredAppSettings(base, options.settings); const implementation = taskNotesImplementation(value); - if (!implementation) return base; + if (!implementation) return applyStoredAppSettings(base, options.settings); const extension = isRecord(implementation.binding) ? implementation.binding : {}; @@ -978,7 +915,7 @@ export function resolveTaskNotesModelConfigFromMdbaseType( const defaultStatus = stringValue(status.default) ?? base.defaults.status; const defaultPriority = stringValue(priority.default) ?? base.defaults.priority; - return resolveModelConfig({ + return applyStoredAppSettings(resolveModelConfig({ ...base, fieldMapping, statuses, @@ -1032,7 +969,84 @@ export function resolveTaskNotesModelConfigFromMdbaseType( base.timeTracking.autoStopOnComplete, }, nlp: resolveNlpConfig(nlp, base.nlp), + }), options.settings); +} + +/** A configuration with the settings record's stored fields applied over it. */ +function applyStoredAppSettings( + config: TaskNotesModelConfig, + settings: Readonly> | undefined +): TaskNotesModelConfig { + if (!settings) return config; + const stored = resolveTaskNotesAppSettings(settings).stored; + const statuses = config.statuses.map((status) => { + const behavior = stored.status_behavior?.[status.value]; + if (!behavior) return status; + const next: StatusConfig = { ...status }; + if (behavior.exclude_from_cycle !== undefined) next.excludeFromCycle = behavior.exclude_from_cycle; + if (behavior.auto_archive !== undefined) next.autoArchive = behavior.auto_archive; + if (behavior.auto_archive_delay_minutes !== undefined) + next.autoArchiveDelay = behavior.auto_archive_delay_minutes; + if (behavior.next_status !== undefined) next.nextStatus = behavior.next_status; + return next; }); + return resolveModelConfig({ + ...config, + statuses, + recurrence: { + ...config.recurrence, + ...(stored.reset_body_checkboxes_on_recurrence !== undefined + ? { resetCheckboxesOnRecurrence: stored.reset_body_checkboxes_on_recurrence } + : {}), + }, + timeTracking: { + ...config.timeTracking, + ...(stored.auto_stop_time_tracking_on_complete !== undefined + ? { autoStopOnComplete: stored.auto_stop_time_tracking_on_complete } + : {}), + }, + ...(stored.nlp_triggers + ? { + nlp: { + triggers: stored.nlp_triggers.map((trigger) => ({ + propertyId: trigger.property_id, + trigger: trigger.trigger, + enabled: trigger.enabled, + })), + }, + } + : {}), + }); +} + +/** + * The TaskNotes implementation of a type upgraded to the current contract: + * the version raised and every setting rc.6 moved to the settings record + * removed from the binding. `appSettings` holds those settings, for the + * host to write to the record in the same step. Other content is preserved. + */ +export function upgradeTaskNotesMdbaseTypeContract(type: Record): { + type: Record; + appSettings: Partial; + upgraded: boolean; +} { + const result = cloneValue(type) as Record; + const implementation = taskNotesImplementation(result); + if (!implementation) throw new Error("The mdbase type is not a TaskNotes task contract."); + const appSettings = taskNotesAppSettingsFromLegacyBinding(implementation.binding); + const upgraded = implementation.version !== TASKNOTES_SPEC_VERSION; + implementation.version = TASKNOTES_SPEC_VERSION; + if (isRecord(implementation.binding)) { + implementation.binding = withoutTaskNotesAppSettings(implementation.binding); + } + return { type: result, appSettings, upgraded }; +} + +/** The contract version a TaskNotes type implements, if it is one TaskNotes reads. */ +export function taskNotesContractVersion(type: unknown): string | undefined { + if (!isRecord(type)) return undefined; + const version = taskNotesImplementation(type)?.version; + return typeof version === "string" ? version : undefined; } function resolveTaskIdentification( @@ -1447,11 +1461,7 @@ function statusDefinition(status: StatusConfig): Record { ...(status.icon?.trim() ? { icon: status.icon.trim() } : {}), is_completed: status.isCompleted, is_skipped: status.isSkipped === true, - exclude_from_cycle: status.excludeFromCycle === true, - ...(status.nextStatus?.trim() ? { next_status: status.nextStatus.trim() } : {}), order: status.order, - auto_archive: status.autoArchive, - auto_archive_delay_minutes: status.autoArchiveDelay, }; } @@ -1488,10 +1498,11 @@ function resolveStatuses( isCompleted: booleanValue(definition?.is_completed) ?? completed.has(value), isSkipped: booleanValue(definition?.is_skipped) ?? skipped.has(value), + // rc.5 and earlier bindings; rc.6 keeps these in the settings record. excludeFromCycle: booleanValue(definition?.exclude_from_cycle) ?? existing?.excludeFromCycle ?? - false, + (booleanValue(definition?.is_skipped) ?? skipped.has(value)), ...(stringValue(definition?.next_status) ?? existing?.nextStatus ? { nextStatus: @@ -1504,7 +1515,7 @@ function resolveStatuses( autoArchiveDelay: numberValue(definition?.auto_archive_delay_minutes) ?? existing?.autoArchiveDelay ?? - 5, + TASKNOTES_DEFAULT_AUTO_ARCHIVE_DELAY, }; }) : fallback.map((status) => ({ ...status })); @@ -1558,12 +1569,18 @@ function taskNotesImplementation( type: Record ): Record | undefined { if (!Array.isArray(type.implements)) return undefined; - return type.implements.find( + const candidates = type.implements.filter( (candidate): candidate is Record => isRecord(candidate) && candidate.contract === "tasknotes.task" && - candidate.version === TASKNOTES_SPEC_VERSION + TASKNOTES_ACCEPTED_CONTRACT_VERSIONS.includes(String(candidate.version)) ); + // Prefer the newest contract a type implements. + for (const version of TASKNOTES_ACCEPTED_CONTRACT_VERSIONS) { + const match = candidates.find((candidate) => candidate.version === version); + if (match) return match; + } + return undefined; } function requiredConfiguredValue( diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..20f43ba --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,895 @@ +import YAML from "yaml"; +import type { StatusConfig, TaskNotesModelConfig } from "./types"; + +/* + * App settings records (mdbase.app-settings 1.0.0): one record per app at + * `_settings/.md` whose flat frontmatter holds behaviour every client of + * that app honours in the collection. The generic part below follows mdbase + * Reader's implementation; the TaskNotes part defines the tasknotes.settings + * 1.0.0 fields, which tasknotes.task 0.3.0-rc.6 moved out of the type binding. + * + * - A missing record means defaults. Nothing here creates or repairs a record + * on read: an unusable field takes its default with a warning. + * - A save patches only the fields it changes, guarded by the revision read. + * - A field changed elsewhere since the edit started is a conflict. + * - A newer settings_version makes the settings read-only. + */ + +/** The folder collection-scope settings records live in. */ +export const APP_SETTINGS_FOLDER = "_settings"; + +export interface AppSettingField { + readonly default: Value; + /** Why a stored value is unusable; undefined when it is usable. */ + readonly problem: (value: unknown) => string | undefined; + /** How a usable stored value is read. Without it the value is used as written. */ + readonly read?: (value: unknown) => Value; +} + +export interface AppSettingsDefinition { + /** The app id, `^[a-z0-9]+([.-][a-z0-9]+)*$`. */ + readonly app: string; + /** The app's settings type, which the record is created as. */ + readonly type: string; + /** The newest settings version this client understands, and the one it writes. */ + readonly settingsVersion: number; + readonly fields: { readonly [Key in keyof Values]: AppSettingField }; +} + +export interface AppSettingsWarning { + readonly field?: string; + readonly message: string; +} + +export interface ResolvedAppSettings { + /** Every setting: the record's value when it is usable, else the default. */ + readonly values: Values; + /** The settings the record holds a usable value for. */ + readonly stored: Partial; + readonly warnings: readonly AppSettingsWarning[]; + /** Why these settings cannot be saved from this client, when they cannot. */ + readonly readOnly?: string; +} + +/** A save: a new value per changed setting, or null to go back to the default. */ +export type AppSettingsChanges = { + -readonly [Key in keyof Values]?: Values[Key] | null; +}; + +export function appSettingsPath(app: string): string { + return `${APP_SETTINGS_FOLDER}/${app}.md`; +} + +export function defaultAppSettings( + definition: AppSettingsDefinition +): ResolvedAppSettings { + return resolveAppSettings(definition, undefined); +} + +/** + * The settings a record's frontmatter gives, or the defaults when there is no + * record. A record whose settings_version is newer than this client + * understands, or that is not this app's collection-scope record, is used as + * written but read-only. + */ +export function resolveAppSettings( + definition: AppSettingsDefinition, + frontmatter: Readonly> | undefined +): ResolvedAppSettings { + const values: Record = {}; + const stored: Record = {}; + const warnings: AppSettingsWarning[] = []; + for (const [key, field] of fieldEntries(definition)) { + values[key] = cloneValue(field.default); + if (frontmatter === undefined || !hasOwn(frontmatter, key)) continue; + const value = frontmatter[key]; + const problem = field.problem(value); + if (problem) { + warnings.push({ field: key, message: problem }); + } else { + const read = field.read ? field.read(value) : cloneValue(value); + values[key] = read; + stored[key] = read; + } + } + const readOnly = frontmatter ? readOnlyReason(definition, frontmatter) : undefined; + return { + values: values as Values, + stored: stored as Partial, + warnings, + ...(readOnly ? { readOnly } : {}), + }; +} + +/** + * The frontmatter patch for a save: only the settings whose stored value + * changes, with null for a setting going back to its default (the field is + * removed). Other fields, including ones from newer clients, are left alone. + */ +export function appSettingsPatch( + definition: AppSettingsDefinition, + frontmatter: Readonly>, + changes: AppSettingsChanges +): Record { + const patch: Record = {}; + for (const [key, value] of Object.entries(changes as Record)) { + if (value === undefined || !hasOwn(definition.fields, key)) continue; + const present = hasOwn(frontmatter, key); + if (value === null ? present : !present || !sameValue(frontmatter[key], value)) { + patch[key] = cloneValue(value); + } + } + if ( + Object.keys(patch).length > 0 && + frontmatter.settings_version !== definition.settingsVersion + ) { + patch.settings_version = definition.settingsVersion; + } + return patch; +} + +/** The frontmatter of a new settings record holding `changes`. */ +export function newAppSettingsFrontmatter( + definition: AppSettingsDefinition, + changes: AppSettingsChanges +): Record { + const values = Object.fromEntries( + Object.entries(changes as Record) + .filter( + ([key, value]) => + value !== undefined && value !== null && hasOwn(definition.fields, key) + ) + .map(([key, value]) => [key, cloneValue(value)]) + ); + return { + app: definition.app, + scope: "collection", + settings_version: definition.settingsVersion, + ...values, + }; +} + +/** + * The settings `changes` would change that also changed between `base` and + * `current`: a save prepared against `base` would overwrite someone else's edit. + */ +export function conflictingAppSettings( + definition: AppSettingsDefinition, + base: Readonly> | undefined, + current: Readonly> | undefined, + changes: AppSettingsChanges +): string[] { + return Object.keys(changes).filter( + (key) => + hasOwn(definition.fields, key) && + (changes as Record)[key] !== undefined && + !sameValue(base?.[key], current?.[key]) + ); +} + +/** + * Apply a frontmatter patch (null removes a field) to a settings record's + * Markdown, keeping every other field, their order and comments, and the body. + * Without a record, `create` gives the new record's frontmatter and `typeKey` + * names the collection's explicit type key. + */ +export function writeAppSettingsDocument( + markdown: string | null, + patch: Readonly>, + create?: { frontmatter: Readonly>; type: string; typeKey: string; body?: string } +): string { + if (markdown === null) { + if (!create) throw new Error("A settings record can only be created with its frontmatter."); + const frontmatter = { [create.typeKey]: create.type, ...create.frontmatter }; + return `---\n${YAML.stringify(frontmatter, { lineWidth: 0 }).trimEnd()}\n---\n${create.body ?? ""}`; + } + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown); + if (!match) throw new Error("The settings record has no frontmatter."); + const document = YAML.parseDocument(match[1]); + if (document.errors.length > 0) { + throw new Error(document.errors.map((error) => error.message).join("; ")); + } + if (!isRecord(document.toJS())) throw new Error("The settings record frontmatter must be an object."); + for (const [key, value] of Object.entries(patch)) { + if (value === null) document.delete(key); + else document.set(key, document.createNode(cloneValue(value))); + } + const body = markdown.slice(match[0].length); + return `---\n${document.toString({ lineWidth: 0 }).trimEnd()}\n---\n${body}`; +} + +/** A settings record's frontmatter, or undefined when the Markdown has none that is an object. */ +export function readAppSettingsFrontmatter(markdown: string): Record | undefined { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown); + if (!match) return undefined; + const value = YAML.parse(match[1]) as unknown; + return isRecord(value) ? value : undefined; +} + +function readOnlyReason( + definition: AppSettingsDefinition, + frontmatter: Readonly> +): string | undefined { + const path = appSettingsPath(definition.app); + if (frontmatter.app !== undefined && frontmatter.app !== definition.app) { + return `${path} names another app, so it is left as it is.`; + } + if (frontmatter.scope !== undefined && frontmatter.scope !== "collection") { + return `${path} is not a collection-wide settings record, so it is left as it is.`; + } + const version = frontmatter.settings_version; + if (typeof version === "number" && version > definition.settingsVersion) { + return "A newer version of this app saved these settings. Update it to change them here."; + } + return undefined; +} + +// --------------------------------------------------------------------------- +// TaskNotes settings (tasknotes.settings 1.0.0) + +export const TASKNOTES_SETTINGS_APP = "dev.tasknotes"; +export const TASKNOTES_SETTINGS_TYPE = "tasknotes-settings"; +export const TASKNOTES_SETTINGS_CONTRACT_ID = "tasknotes.settings"; +export const TASKNOTES_SETTINGS_CONTRACT_VERSION = "1.0.0"; +export const APP_SETTINGS_CONTRACT_ID = "mdbase.app-settings"; +export const APP_SETTINGS_CONTRACT_VERSION = "1.0.0"; +export const TASKNOTES_SETTINGS_VERSION = 1; +/** Version of the generated tasknotes-settings type. */ +export const TASKNOTES_SETTINGS_TYPE_VERSION = 1; +export const TASKNOTES_SETTINGS_PATH = appSettingsPath(TASKNOTES_SETTINGS_APP); +/** Default auto-archive delay, in minutes, for a status without one. */ +export const TASKNOTES_DEFAULT_AUTO_ARCHIVE_DELAY = 5; + +export interface TaskNotesNlpTriggerSetting { + property_id: string; + trigger: string; + enabled: boolean; +} + +export interface TaskNotesStatusBehavior { + next_status?: string; + exclude_from_cycle?: boolean; + auto_archive?: boolean; + auto_archive_delay_minutes?: number; +} + +export interface TaskNotesAppSettingsValues { + /** Natural-language triggers; null means the app's own defaults. */ + nlp_triggers: TaskNotesNlpTriggerSetting[] | null; + template_enabled: boolean; + /** "" means no template. */ + template_path: string; + occurrence_template_enabled: boolean; + occurrence_template_path: string; + template_failure_mode: "error" | "warning_fallback" | null; + template_unknown_variable_policy: "preserve" | "empty" | null; + move_on_archive: boolean; + /** "" means the app's default archive folder. */ + archive_folder: string; + auto_stop_time_tracking_on_complete: boolean; + reset_body_checkboxes_on_recurrence: boolean; + link_write_format: "wikilink" | "markdown"; + /** Behaviour per status value. A status without an entry takes the defaults. */ + status_behavior: Record; +} + +export type TaskNotesAppSettingKey = keyof TaskNotesAppSettingsValues; +export type TaskNotesAppSettingsChanges = AppSettingsChanges; + +const booleanField = (label: string): AppSettingField => ({ + default: false, + problem: (value) => (typeof value === "boolean" ? undefined : `${label} is not true or false, so its default is used.`), +}); + +const pathField = (label: string): AppSettingField => ({ + default: "", + problem: (value) => (typeof value === "string" ? undefined : `${label} is not text, so its default is used.`), + read: (value) => (value as string).trim(), +}); + +function enumField( + label: string, + allowed: readonly Value[], + fallback: Value +): AppSettingField; +function enumField( + label: string, + allowed: readonly Value[], + fallback: null +): AppSettingField; +function enumField( + label: string, + allowed: readonly Value[], + fallback: Value | null +): AppSettingField { + return { + default: fallback, + problem: (value) => + allowed.includes(value as Value) ? undefined : `${label} must be one of ${allowed.join(", ")}, so its default is used.`, + }; +} + +function readNlpTriggers(value: unknown): TaskNotesNlpTriggerSetting[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!isRecord(entry)) return []; + const propertyId = nonEmptyString(entry.property_id) ?? nonEmptyString(entry.propertyId); + const trigger = nonEmptyString(entry.trigger); + if (!propertyId || !trigger) return []; + return [{ property_id: propertyId, trigger, enabled: typeof entry.enabled === "boolean" ? entry.enabled : true }]; + }); +} + +function readStatusBehavior(value: unknown): TaskNotesStatusBehavior | undefined { + if (!isRecord(value)) return undefined; + const behavior: TaskNotesStatusBehavior = {}; + const next = nonEmptyString(value.next_status); + if (next) behavior.next_status = next; + if (typeof value.exclude_from_cycle === "boolean") behavior.exclude_from_cycle = value.exclude_from_cycle; + if (typeof value.auto_archive === "boolean") behavior.auto_archive = value.auto_archive; + const delay = value.auto_archive_delay_minutes; + if (typeof delay === "number" && Number.isFinite(delay) && delay >= 0) behavior.auto_archive_delay_minutes = delay; + return behavior; +} + +function readStatusBehaviors(value: unknown): Record { + const result: Record = {}; + if (!isRecord(value)) return result; + for (const [status, entry] of Object.entries(value)) { + const behavior = readStatusBehavior(entry); + if (status.trim() && behavior) result[status] = behavior; + } + return result; +} + +export const tasknotesSettingsDefinition: AppSettingsDefinition = { + app: TASKNOTES_SETTINGS_APP, + type: TASKNOTES_SETTINGS_TYPE, + settingsVersion: TASKNOTES_SETTINGS_VERSION, + fields: { + nlp_triggers: { + default: null, + problem: (value) => (Array.isArray(value) ? undefined : "The natural-language triggers are not a list, so the defaults are used."), + read: readNlpTriggers, + }, + template_enabled: booleanField("Task templating"), + template_path: pathField("The task template path"), + occurrence_template_enabled: booleanField("Occurrence templating"), + occurrence_template_path: pathField("The occurrence template path"), + template_failure_mode: enumField("The template failure mode", ["error", "warning_fallback"] as const, null), + template_unknown_variable_policy: enumField("The unknown template variable policy", ["preserve", "empty"] as const, null), + move_on_archive: booleanField("Moving archived tasks"), + archive_folder: pathField("The archive folder"), + auto_stop_time_tracking_on_complete: booleanField("Stopping time tracking on completion"), + reset_body_checkboxes_on_recurrence: booleanField("Resetting checkboxes on recurrence"), + link_write_format: enumField("The link format", ["wikilink", "markdown"] as const, "wikilink"), + status_behavior: { + default: {}, + problem: (value) => (isRecord(value) ? undefined : "The status behaviour is not a map of statuses, so the defaults are used."), + read: readStatusBehaviors, + }, + }, +}; + +export const TASKNOTES_APP_SETTING_KEYS = Object.keys( + tasknotesSettingsDefinition.fields +) as TaskNotesAppSettingKey[]; + +/** The behaviour of one status, with defaults for anything unset. */ +export function taskNotesStatusBehavior( + values: Pick, + status: { value: string; isSkipped?: boolean } +): Required> & Pick { + const entry = values.status_behavior[status.value] ?? {}; + return { + ...(entry.next_status ? { next_status: entry.next_status } : {}), + // A skipped status is out of the cycle unless the collection says otherwise. + exclude_from_cycle: entry.exclude_from_cycle ?? status.isSkipped === true, + auto_archive: entry.auto_archive ?? false, + auto_archive_delay_minutes: entry.auto_archive_delay_minutes ?? TASKNOTES_DEFAULT_AUTO_ARCHIVE_DELAY, + }; +} + +/** + * The moved settings an earlier binding (tasknotes.task rc.3 to rc.5) still + * carries. Only usable values are returned. + */ +export function taskNotesAppSettingsFromLegacyBinding(binding: unknown): Partial { + if (!isRecord(binding)) return {}; + const section = (key: string): Record => (isRecord(binding[key]) ? binding[key] : {}); + const nlp = section("nlp"); + const templating = section("templating"); + const archive = section("archive"); + const timeTracking = section("time_tracking"); + const recurrence = section("recurrence"); + const links = section("links"); + const status = section("status"); + const raw: Record = { + nlp_triggers: nlp.triggers, + template_enabled: templating.enabled, + template_path: templating.template_path, + occurrence_template_enabled: templating.occurrence_enabled, + occurrence_template_path: templating.occurrence_template_path, + template_failure_mode: templating.failure_mode, + template_unknown_variable_policy: templating.unknown_variable_policy, + move_on_archive: archive.move_on_archive, + archive_folder: archive.folder, + auto_stop_time_tracking_on_complete: timeTracking.auto_stop_on_complete, + reset_body_checkboxes_on_recurrence: recurrence.reset_body_checkboxes, + link_write_format: links.write_format, + }; + if (Array.isArray(status.definitions)) { + const behaviors: Record = {}; + for (const definition of status.definitions) { + if (!isRecord(definition)) continue; + const value = nonEmptyString(definition.value); + const behavior = readStatusBehavior(definition); + if (value && behavior && Object.keys(behavior).length > 0) behaviors[value] = behavior; + } + if (Object.keys(behaviors).length > 0) raw.status_behavior = behaviors; + } + const frontmatter = Object.fromEntries(Object.entries(raw).filter(([, value]) => value !== undefined)); + return resolveAppSettings(tasknotesSettingsDefinition, frontmatter).stored; +} + +/** A binding with every setting tasknotes.task rc.6 moved to the settings record removed. */ +export function withoutTaskNotesAppSettings(binding: unknown): Record { + const result = isRecord(binding) ? (cloneValue(binding) as Record) : {}; + delete result.nlp; + delete result.templating; + delete result.time_tracking; + const strip = (key: string, fields: string[]) => { + const section = result[key]; + if (!isRecord(section)) return; + for (const field of fields) delete section[field]; + }; + strip("archive", ["move_on_archive", "folder"]); + strip("recurrence", ["reset_body_checkboxes"]); + strip("links", ["write_format"]); + const status = result.status; + if (isRecord(status) && Array.isArray(status.definitions)) { + for (const definition of status.definitions) { + if (!isRecord(definition)) continue; + delete definition.next_status; + delete definition.exclude_from_cycle; + delete definition.auto_archive; + delete definition.auto_archive_delay_minutes; + } + } + return result; +} + +export interface ResolvedTaskNotesAppSettings extends ResolvedAppSettings { + /** Settings taken from an earlier binding because the record does not hold them. */ + readonly legacy: Partial; + /** Whether there is a settings record at all. */ + readonly recorded: boolean; +} + +/** + * Resolve TaskNotes settings: the record's field, then an earlier binding's, + * then `fallback`, then the default. Status behaviour merges per status and + * per property in the same order, so a missing or late record never loses a + * value an earlier binding still holds. + */ +export function resolveTaskNotesAppSettings( + frontmatter: Readonly> | undefined, + options: { legacyBinding?: unknown; fallback?: Partial } = {} +): ResolvedTaskNotesAppSettings { + const resolved = resolveAppSettings(tasknotesSettingsDefinition, frontmatter); + const legacy = taskNotesAppSettingsFromLegacyBinding(options.legacyBinding); + const fallback = options.fallback ?? {}; + const values = { ...resolved.values } as Record; + const usedLegacy: Record = {}; + for (const key of TASKNOTES_APP_SETTING_KEYS) { + if (key === "status_behavior") continue; + if (hasOwn(resolved.stored, key)) continue; + if (hasOwn(legacy, key)) { + values[key] = cloneValue(legacy[key]); + usedLegacy[key] = cloneValue(legacy[key]); + } else if (fallback[key] !== undefined) { + values[key] = cloneValue(fallback[key]); + } + } + const behaviors: Record = {}; + for (const source of [fallback.status_behavior, legacy.status_behavior, resolved.stored.status_behavior]) { + for (const [status, behavior] of Object.entries(source ?? {})) { + behaviors[status] = { ...behaviors[status], ...cloneValue(behavior) as TaskNotesStatusBehavior }; + } + } + values.status_behavior = behaviors; + if (legacy.status_behavior) usedLegacy.status_behavior = cloneValue(legacy.status_behavior); + return { + ...resolved, + values: values as unknown as TaskNotesAppSettingsValues, + legacy: usedLegacy as Partial, + recorded: frontmatter !== undefined, + }; +} + +/** The fields whose value differs between two complete sets of settings. */ +export function changedTaskNotesAppSettings( + before: Readonly, + after: Readonly +): TaskNotesAppSettingsChanges { + const changes: Record = {}; + for (const key of TASKNOTES_APP_SETTING_KEYS) { + if (!sameValue(before[key], after[key])) changes[key] = cloneValue(after[key]); + } + return changes as TaskNotesAppSettingsChanges; +} + +/** Every setting as an explicit change, for writing a complete record. */ +export function completeTaskNotesAppSettingsChanges( + values: Readonly +): TaskNotesAppSettingsChanges { + return Object.fromEntries( + TASKNOTES_APP_SETTING_KEYS.map((key) => [key, cloneValue(values[key])]) + ) as TaskNotesAppSettingsChanges; +} + +/** The frontmatter patch for saving `changes` to a TaskNotes settings record. */ +export function buildTaskNotesSettingsPatch( + frontmatter: Readonly>, + changes: TaskNotesAppSettingsChanges +): Record { + return appSettingsPatch(tasknotesSettingsDefinition, frontmatter, changes); +} + +/** The frontmatter of a new TaskNotes settings record holding `changes`. */ +export function newTaskNotesSettingsFrontmatter(changes: TaskNotesAppSettingsChanges): Record { + return newAppSettingsFrontmatter(tasknotesSettingsDefinition, changes); +} + +/** The settings `changes` would change that someone else changed since `base`. */ +export function conflictingTaskNotesSettings( + base: Readonly> | undefined, + current: Readonly> | undefined, + changes: TaskNotesAppSettingsChanges +): string[] { + return conflictingAppSettings(tasknotesSettingsDefinition, base, current, changes); +} + +/** + * The patch that adds `values` to an existing record without changing any + * field it already holds: what a migration writes when another device created + * the record first. Status behaviour adds only statuses the record lacks. + */ +export function absentTaskNotesSettingsPatch( + frontmatter: Readonly>, + values: Partial +): Record { + const changes: Record = {}; + for (const key of TASKNOTES_APP_SETTING_KEYS) { + const value = values[key]; + if (value === undefined || value === null) continue; + if (key === "status_behavior") { + const current = frontmatter.status_behavior; + if (current !== undefined && !isRecord(current)) continue; + const merged = { ...(cloneValue(value) as Record), ...(cloneValue(current ?? {}) as Record) }; + if (Object.keys(merged).length > 0) changes[key] = merged; + continue; + } + if (!hasOwn(frontmatter, key)) changes[key] = cloneValue(value); + } + return appSettingsPatch(tasknotesSettingsDefinition, frontmatter, changes as TaskNotesAppSettingsChanges); +} + +/** Settings the plugin and app keep outside the model configuration. */ +export interface TaskNotesAppSettingsExtras { + templating?: { + enabled?: boolean; + templatePath?: string; + occurrenceEnabled?: boolean; + occurrenceTemplatePath?: string; + }; + archive?: { moveOnArchive?: boolean; folder?: string }; + links?: { writeFormat?: "wikilink" | "markdown" }; +} + +/** The settings record values a model configuration and its extras describe. */ +export function taskNotesAppSettingsFromModelConfig( + config: Pick, + extras: TaskNotesAppSettingsExtras = {} +): TaskNotesAppSettingsValues { + const templatePath = extras.templating?.templatePath?.trim() ?? ""; + const occurrenceTemplatePath = extras.templating?.occurrenceTemplatePath?.trim() ?? ""; + const statusBehavior: Record = {}; + for (const status of config.statuses) { + statusBehavior[status.value] = { + ...(status.nextStatus?.trim() ? { next_status: status.nextStatus.trim() } : {}), + exclude_from_cycle: status.excludeFromCycle === true, + auto_archive: status.autoArchive === true, + auto_archive_delay_minutes: Number.isFinite(status.autoArchiveDelay) + ? status.autoArchiveDelay + : TASKNOTES_DEFAULT_AUTO_ARCHIVE_DELAY, + }; + } + return { + nlp_triggers: config.nlp + ? config.nlp.triggers.map((trigger) => ({ + property_id: trigger.propertyId, + trigger: trigger.trigger, + enabled: trigger.enabled, + })) + : null, + template_enabled: extras.templating?.enabled === true && templatePath.length > 0, + template_path: templatePath, + occurrence_template_enabled: + extras.templating?.occurrenceEnabled === true && occurrenceTemplatePath.length > 0, + occurrence_template_path: occurrenceTemplatePath, + template_failure_mode: null, + template_unknown_variable_policy: null, + move_on_archive: extras.archive?.moveOnArchive === true, + archive_folder: extras.archive?.folder?.trim() ?? "", + auto_stop_time_tracking_on_complete: config.timeTracking.autoStopOnComplete === true, + reset_body_checkboxes_on_recurrence: config.recurrence.resetCheckboxesOnRecurrence === true, + link_write_format: extras.links?.writeFormat ?? "wikilink", + status_behavior: statusBehavior, + }; +} + +/** A model configuration with the settings record's behaviour applied. */ +export function applyTaskNotesAppSettingsToModelConfig>( + config: Config, + values: Readonly +): Config { + return { + ...config, + statuses: config.statuses.map((status): StatusConfig => { + const behavior = taskNotesStatusBehavior(values, status); + const result: StatusConfig = { + ...status, + excludeFromCycle: behavior.exclude_from_cycle, + autoArchive: behavior.auto_archive, + autoArchiveDelay: behavior.auto_archive_delay_minutes, + }; + if (behavior.next_status) result.nextStatus = behavior.next_status; + else delete result.nextStatus; + return result; + }), + recurrence: { + ...config.recurrence, + resetCheckboxesOnRecurrence: values.reset_body_checkboxes_on_recurrence, + }, + timeTracking: { + ...config.timeTracking, + autoStopOnComplete: values.auto_stop_time_tracking_on_complete, + }, + ...(values.nlp_triggers + ? { + nlp: { + triggers: values.nlp_triggers.map((trigger) => ({ + propertyId: trigger.property_id, + trigger: trigger.trigger, + enabled: trigger.enabled, + })), + }, + } + : {}), + }; +} + +// --------------------------------------------------------------------------- +// Published artifacts: the tasknotes.settings contract, schema and type. + +const nonEmptyStringSchema = { type: "string", minLength: 1 } as const; + +export const TASKNOTES_SETTINGS_SCHEMA = { + $schema: "https://json-schema.org/draft/2020-12/schema", + title: "tasknotes.settings 1.0.0", + description: + "Collection-wide behaviour that every TaskNotes client honours. Every field is optional and has a default, so a record with none of them behaves like no record at all.", + type: "object", + required: ["app", "scope", "settings_version"], + properties: { + app: { const: TASKNOTES_SETTINGS_APP, description: "Marks this record as TaskNotes' settings." }, + scope: { const: "collection", description: "These settings apply to everyone using TaskNotes in this collection." }, + settings_version: { + type: "integer", + minimum: 1, + description: "The version of these settings that last wrote the record. TaskNotes writes 1.", + }, + nlp_triggers: { + type: "array", + items: { + type: "object", + required: ["property_id", "trigger", "enabled"], + properties: { + property_id: nonEmptyStringSchema, + trigger: nonEmptyStringSchema, + enabled: { type: "boolean" }, + }, + additionalProperties: false, + }, + description: "Natural-language capture triggers, such as @ for contexts. Without the field each client uses its own defaults.", + }, + template_enabled: { type: "boolean", default: false, description: "Apply template_path to the body of new tasks." }, + template_path: { type: "string", description: "The template for new task bodies. Empty or absent means none." }, + occurrence_template_enabled: { type: "boolean", default: false, description: "Apply occurrence_template_path to new materialized occurrences." }, + occurrence_template_path: { type: "string", description: "The template for new occurrence bodies. Empty or absent means none." }, + template_failure_mode: { enum: ["error", "warning_fallback"], description: "What a client does when a template cannot be applied." }, + template_unknown_variable_policy: { enum: ["preserve", "empty"], description: "What a client writes for a template variable it does not know." }, + move_on_archive: { type: "boolean", default: false, description: "Move a task's file to archive_folder when it is archived." }, + archive_folder: { type: "string", description: "Where archived tasks move to. Empty or absent means the client's default." }, + auto_stop_time_tracking_on_complete: { type: "boolean", default: false, description: "Stop a running timer when its task is completed." }, + reset_body_checkboxes_on_recurrence: { type: "boolean", default: false, description: "Uncheck body checkboxes when a recurring task moves to its next occurrence." }, + link_write_format: { enum: ["wikilink", "markdown"], default: "wikilink", description: "How clients write links into task frontmatter. Both formats are always read." }, + status_behavior: { + type: "object", + description: + "Behaviour per status value. A status without an entry has no next status and no auto-archive, and is out of the status cycle exactly when it is a skipped status.", + additionalProperties: { + type: "object", + properties: { + next_status: { ...nonEmptyStringSchema, description: "The status the cycle moves to next." }, + exclude_from_cycle: { type: "boolean", description: "Leave this status out of the status cycle." }, + auto_archive: { type: "boolean", description: "Archive a task some time after it takes this status." }, + auto_archive_delay_minutes: { type: "number", minimum: 0, description: "How long after taking this status a task is archived." }, + }, + additionalProperties: false, + }, + default: {}, + }, + }, + additionalProperties: true, +} as const; + +export interface TaskNotesSettingsResources { + contract: Record; + contractDocument: string; + schemaDocument: string; + type: Record; + typeDocument: string; + paths: { + /** Pack source and target of the contract, schema and type. */ + contract: string; + schema: string; + type: string; + record: string; + }; +} + +/** + * The tasknotes.settings contract, its schema, and the tasknotes-settings + * type that implements it and mdbase.app-settings, laid out as published in + * mdbase-contracts. + */ +export function buildTaskNotesSettingsResources( + options: { typesFolder?: string; contractsFolder?: string } = {} +): TaskNotesSettingsResources { + const typesFolder = options.typesFolder ?? "_types"; + const contractsFolder = options.contractsFolder ?? "_contracts"; + const version = TASKNOTES_SETTINGS_CONTRACT_VERSION; + const contract = { + kind: "mdbase.contract", + contract_type: "record", + id: TASKNOTES_SETTINGS_CONTRACT_ID, + version, + name: "TaskNotes settings", + description: "Collection-wide TaskNotes behaviour, such as templates, archive moves and status cycling.", + record_schema: { + dialect: "json-schema-2020-12", + ref: `../../schemas/${TASKNOTES_SETTINGS_CONTRACT_ID}/${version}.schema.json`, + }, + }; + const contractDocument = [ + "---", + YAML.stringify(contract, { lineWidth: 0 }).trimEnd(), + "---", + "", + "# TaskNotes settings", + "", + `The collection's settings record for TaskNotes, at \`${TASKNOTES_SETTINGS_PATH}\`.`, + "It also implements `mdbase.app-settings`, whose reserved fields (`app`, `scope`,", + "`settings_version`) it shares.", + "", + "These settings change what TaskNotes clients do, never what a task record", + "means: that stays in the `tasknotes.task` binding of each task type.", + "`tasknotes.task` 0.3.0-rc.6 moved them here from the binding. A client reads", + "each setting from this record, then from an earlier binding that still holds", + "it, then uses its default, so a collection whose task type is still on rc.5", + "keeps its behaviour until it is upgraded.", + "", + "## Fields", + "", + "- `nlp_triggers`: natural-language capture triggers. Without it, each client", + " uses its own defaults.", + "- `template_enabled`, `template_path`, `occurrence_template_enabled`,", + " `occurrence_template_path`, `template_failure_mode`,", + " `template_unknown_variable_policy`: body templates for new tasks and", + " materialized occurrences.", + "- `move_on_archive`, `archive_folder`: whether archiving moves the file, and", + " where to.", + "- `auto_stop_time_tracking_on_complete`: stop a running timer on completion.", + "- `reset_body_checkboxes_on_recurrence`: uncheck body checkboxes when a", + " recurring task advances.", + "- `link_write_format`: `wikilink` or `markdown` for links written to task", + " frontmatter.", + "- `status_behavior`: per status value, `next_status`, `exclude_from_cycle`,", + " `auto_archive` and `auto_archive_delay_minutes`. A status without an entry", + " has no next status and no auto-archive (5 minute delay), and is out of the", + " cycle exactly when it is a skipped status.", + "", + "## Authority", + "", + "This artifact is passive and grants nothing.", + "", + ].join("\n"); + const reserved = { app: "app", scope: "scope", settings_version: "settings_version" }; + const type = { + kind: "mdbase.type", + name: TASKNOTES_SETTINGS_TYPE, + version: TASKNOTES_SETTINGS_TYPE_VERSION, + description: `TaskNotes' collection-wide settings, one record at ${TASKNOTES_SETTINGS_PATH}.`, + match: { where: { app: { eq: TASKNOTES_SETTINGS_APP } } }, + schema: { dialect: "json-schema-2020-12", value: cloneValue(TASKNOTES_SETTINGS_SCHEMA) }, + collection: { unique: [{ field: "app", scope: "type" }] }, + implements: [ + { contract: APP_SETTINGS_CONTRACT_ID, version: APP_SETTINGS_CONTRACT_VERSION, fields: { ...reserved } }, + { + contract: TASKNOTES_SETTINGS_CONTRACT_ID, + version, + fields: { + ...reserved, + ...Object.fromEntries(TASKNOTES_APP_SETTING_KEYS.map((key) => [key, key])), + }, + }, + ], + }; + const typeDocument = [ + "---", + YAML.stringify(type, { lineWidth: 0 }).trimEnd(), + "---", + "", + "# TaskNotes settings", + "", + "Settings that every TaskNotes client honours in this collection, such as body", + "templates, archive moves and status cycling. TaskNotes reads them from", + `\`${TASKNOTES_SETTINGS_PATH}\` and writes only the fields a save changes.`, + "", + ].join("\n"); + return { + contract, + contractDocument, + schemaDocument: `${JSON.stringify(TASKNOTES_SETTINGS_SCHEMA, null, 2)}\n`, + type, + typeDocument, + paths: { + contract: `${contractsFolder}/${TASKNOTES_SETTINGS_CONTRACT_ID}/${version}.md`, + schema: `schemas/${TASKNOTES_SETTINGS_CONTRACT_ID}/${version}.schema.json`, + type: `${typesFolder}/${TASKNOTES_SETTINGS_TYPE}.md`, + record: TASKNOTES_SETTINGS_PATH, + }, + }; +} + +function fieldEntries( + definition: AppSettingsDefinition +): [string, AppSettingField][] { + return Object.entries(definition.fields); +} + +function sameValue(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function hasOwn(value: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function cloneValue(value: T): T { + if (Array.isArray(value)) return value.map(cloneValue) as T; + if (isRecord(value)) { + return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneValue(child)])) as T; + } + return value; +} diff --git a/src/starter.ts b/src/starter.ts index 56edc89..fbc7833 100644 --- a/src/starter.ts +++ b/src/starter.ts @@ -28,7 +28,7 @@ export const TASKNOTES_STARTER_TYPE_VERSION = TASKNOTES_MDBASE_TYPE_VERSION; * type's data keeps TASKNOTES_STARTER_TYPE_VERSION, so collections that already * have that version do not see a conflicting version change on upgrade. */ -export const TASKNOTES_STARTER_REVISION = 5; +export const TASKNOTES_STARTER_REVISION = 6; /** * The generator bookkeeping published in the first starter. Collections keep diff --git a/src/types.ts b/src/types.ts index a6e63b2..572a9bc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -export const TASKNOTES_SPEC_VERSION = "0.3.0-rc.5"; +export const TASKNOTES_SPEC_VERSION = "0.3.0-rc.6"; export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; diff --git a/test/assignments.test.mjs b/test/assignments.test.mjs index 6ec6ee0..d414e46 100644 --- a/test/assignments.test.mjs +++ b/test/assignments.test.mjs @@ -20,8 +20,8 @@ test("ordinary task updates retain unresolved assignments and explicitly clear t test("the task contract declares assignments as links resolved by the collection", () => { const resources = buildTaskNotesMdbaseResources(); - assert.equal(resources.contract.version, "0.3.0-rc.5"); - assert.equal(resources.type.version, 4); + assert.equal(resources.contract.version, "0.3.0-rc.6"); + assert.equal(resources.type.version, 5); assert.equal(resources.taskSchema.properties.assignees.uniqueItems, true); assert.equal(resources.type.implements[0].fields.assignees, "assignees"); // No target_type: several local types may implement mdbase.person. diff --git a/test/mdbase.test.mjs b/test/mdbase.test.mjs index b8af74f..18194ae 100644 --- a/test/mdbase.test.mjs +++ b/test/mdbase.test.mjs @@ -11,7 +11,7 @@ import { function implementation(type) { return type.implements.find( (entry) => - entry.contract === "tasknotes.task" && entry.version === "0.3.0-rc.5" + entry.contract === "tasknotes.task" && entry.version === "0.3.0-rc.6" ); } @@ -30,10 +30,10 @@ test("builds one canonical TaskNotes and mdbase collection contract", () => { assert.equal(resources.config.settings.contracts_folder, "_contracts"); assert.equal(resources.contract.id, "tasknotes.task"); assert.equal(resources.contract.contract_type, "record"); - assert.equal(resources.contract.version, "0.3.0-rc.5"); + assert.equal(resources.contract.version, "0.3.0-rc.6"); assert.ok(resources.contract.record_schema); assert.equal(taskImplementation.contract, "tasknotes.task"); - assert.equal(taskImplementation.version, "0.3.0-rc.5"); + assert.equal(taskImplementation.version, "0.3.0-rc.6"); assert.deepEqual(extension.profiles, [ "core-lite", "recurrence", @@ -102,7 +102,7 @@ test("packages the contract, implementation, and schemas as one digest-pinned ty assert.deepEqual(pack.provides, [ { id: "tasknotes.task", - version: "0.3.0-rc.5", + version: "0.3.0-rc.6", digest: pack.provides[0].digest, }, ]); @@ -113,7 +113,7 @@ test("packages the contract, implementation, and schemas as one digest-pinned ty assert.deepEqual( pack.manifest.resources.map(({ kind, mode, target }) => [kind, mode, target]), [ - ["contract", "managed", "_contracts/tasknotes.task.md"], + ["contract", "managed", "_contracts/tasknotes.task/0.3.0-rc.6.md"], ["type", "seed", "_types/action.md"], ["schema", "managed", "_schemas/tasknotes/tasknotes-task.schema.json"], [ @@ -144,15 +144,17 @@ test("round-trips optional NLP trigger settings through the TaskNotes contract", }, }); - assert.deepEqual(binding(resources.type).nlp, { - triggers: [ - { property_id: "tags", trigger: "#", enabled: true }, - { property_id: "priority", trigger: "!", enabled: false }, - { property_id: "energy", trigger: "~", enabled: true }, - ], - }); + // rc.6 keeps the triggers in the settings record, not the binding. + assert.equal(binding(resources.type).nlp, undefined); + assert.deepEqual(resources.appSettings.nlp_triggers, [ + { property_id: "tags", trigger: "#", enabled: true }, + { property_id: "priority", trigger: "!", enabled: false }, + { property_id: "energy", trigger: "~", enabled: true }, + ]); assert.deepEqual( - resolveTaskNotesModelConfigFromMdbaseType(resources.type).nlp, + resolveTaskNotesModelConfigFromMdbaseType(resources.type, {}, { + settings: { app: "dev.tasknotes", scope: "collection", settings_version: 1, nlp_triggers: resources.appSettings.nlp_triggers }, + }).nlp, { triggers: [ { propertyId: "tags", trigger: "#", enabled: true }, @@ -184,7 +186,6 @@ test("patches portable model settings without replacing custom type content", () defaultPriority: "high", recurrence: { maintainDueDateOffset: false, - resetCheckboxesOnRecurrence: true, }, occurrences: { defaultMaterialization: "rolling", @@ -192,16 +193,6 @@ test("patches portable model settings without replacing custom type content", () pastHorizon: "p2d", futureHorizon: "P30D", }, - timeTracking: { autoStopOnComplete: true }, - links: { writeFormat: "markdown" }, - archive: { moveOnArchive: true, folder: "Tasks/Archive" }, - templating: { - templatePath: "Templates/Task.md", - enabled: true, - }, - statusAutomation: { - done: { autoArchive: true, autoArchiveDelay: 15 }, - }, }); const extension = binding(patched); const resolved = resolveTaskNotesModelConfigFromMdbaseType(patched); @@ -209,25 +200,11 @@ test("patches portable model settings without replacing custom type content", () assert.equal(resolved.defaults.status, "in-progress"); assert.equal(resolved.defaults.priority, "high"); assert.equal(resolved.recurrence.maintainDueDateOffset, false); - assert.equal(resolved.recurrence.resetCheckboxesOnRecurrence, true); assert.equal(resolved.occurrences.defaultMaterialization, "rolling"); assert.equal(resolved.occurrences.defaultNextTrigger, "completion_or_skip"); assert.equal(resolved.occurrences.pastHorizon, "P2D"); assert.equal(resolved.occurrences.futureHorizon, "P30D"); - assert.equal(resolved.timeTracking.autoStopOnComplete, true); - assert.equal(extension.links.write_format, "markdown"); - assert.deepEqual(extension.archive, { - archived_tag: "archived", - move_on_archive: true, - folder: "Tasks/Archive", - }); - assert.equal(extension.templating.enabled, true); - assert.equal(extension.templating.template_path, "Templates/Task.md"); - assert.equal( - extension.status.definitions.find(({ value }) => value === "done") - .auto_archive_delay_minutes, - 15 - ); + assert.deepEqual(extension.archive, { archived_tag: "archived" }); assert.equal(patched.schema.value.properties.status.default, "in-progress"); assert.equal(patched.schema.value.properties.priority.default, "high"); assert.equal(patched.collection.read_defaults.status, "in-progress"); @@ -253,13 +230,6 @@ test("rejects invalid contract setting patches", () => { }), /default status/ ); - assert.throws( - () => - patchTaskNotesMdbaseTypeSettings(type, { - templating: { enabled: true }, - }), - /template path/ - ); assert.throws( () => patchTaskNotesMdbaseTypeSettings(type, { @@ -460,7 +430,7 @@ test("projects and resolves the complete portable TaskNotes settings snapshot", assert.equal(resources.type.collection.path.template, "{{priority}}-{{title}}"); assert.equal(extension.title.filename_format, "custom"); assert.equal(extension.status.default_skipped, "cancelled"); - assert.equal(extension.status.definitions[1].auto_archive_delay_minutes, 15); + assert.equal(extension.status.definitions[1].auto_archive_delay_minutes, undefined); assert.equal(extension.priority.definitions[1].icon, "flame"); assert.deepEqual(extension.occurrences, { identity_roles: ["recurrenceParent", "occurrenceDate"], @@ -469,14 +439,39 @@ test("projects and resolves the complete portable TaskNotes settings snapshot", past_horizon: "P1D", future_horizon: "P21D", }); - assert.deepEqual(extension.templating, { - enabled: true, - template_path: "Templates/Task.md", - occurrence_enabled: true, - occurrence_template_path: "Templates/Occurrence.md", - }); + assert.equal(extension.templating, undefined); + assert.equal(extension.time_tracking, undefined); + assert.equal(extension.links.write_format, undefined); + assert.deepEqual( + { + template_enabled: resources.appSettings.template_enabled, + template_path: resources.appSettings.template_path, + occurrence_template_enabled: resources.appSettings.occurrence_template_enabled, + occurrence_template_path: resources.appSettings.occurrence_template_path, + move_on_archive: resources.appSettings.move_on_archive, + archive_folder: resources.appSettings.archive_folder, + link_write_format: resources.appSettings.link_write_format, + reset_body_checkboxes_on_recurrence: resources.appSettings.reset_body_checkboxes_on_recurrence, + done: resources.appSettings.status_behavior.done, + todo: resources.appSettings.status_behavior.todo, + }, + { + template_enabled: true, + template_path: "Templates/Task.md", + occurrence_template_enabled: true, + occurrence_template_path: "Templates/Occurrence.md", + move_on_archive: true, + archive_folder: "Archive/Tasks", + link_write_format: "markdown", + reset_body_checkboxes_on_recurrence: true, + done: { exclude_from_cycle: false, auto_archive: true, auto_archive_delay_minutes: 15 }, + todo: { next_status: "done", exclude_from_cycle: false, auto_archive: false, auto_archive_delay_minutes: 5 }, + } + ); assert.equal(resolved.fieldMapping.title, "summary"); - assert.equal(resolved.statuses.find(({ value }) => value === "done").autoArchiveDelay, 15); + // Without a record, behaviour falls back to the defaults; a skipped status stays out of the cycle. + assert.equal(resolved.statuses.find(({ value }) => value === "done").autoArchiveDelay, 5); + assert.equal(resolved.statuses.find(({ value }) => value === "cancelled").excludeFromCycle, true); assert.equal(resolved.statuses.find(({ value }) => value === "cancelled").isSkipped, true); assert.equal(resolved.priorities.find(({ value }) => value === "critical").icon, "flame"); assert.equal(resolved.taskIdentification.method, "property"); diff --git a/test/settings.test.mjs b/test/settings.test.mjs new file mode 100644 index 0000000..1ffb66c --- /dev/null +++ b/test/settings.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildTaskNotesMdbaseResources, resolveTaskNotesModelConfigFromMdbaseType, upgradeTaskNotesMdbaseTypeContract } from "../dist/esm/mdbase.js"; +import { + absentTaskNotesSettingsPatch, + buildTaskNotesSettingsPatch, + buildTaskNotesSettingsResources, + conflictingTaskNotesSettings, + newTaskNotesSettingsFrontmatter, + readAppSettingsFrontmatter, + resolveTaskNotesAppSettings, + TASKNOTES_SETTINGS_PATH, + writeAppSettingsDocument, +} from "../dist/esm/settings.js"; + +// An rc.5 binding still carries the settings rc.6 moved to the record. +function legacyBinding() { + return { + status: { + values: ["open", "done", "cancelled"], + definitions: [ + { value: "open", next_status: "done", exclude_from_cycle: false, auto_archive: false, auto_archive_delay_minutes: 5 }, + { value: "done", exclude_from_cycle: false, auto_archive: true, auto_archive_delay_minutes: 30 }, + ], + }, + archive: { archived_tag: "archived", move_on_archive: true, folder: "Archive" }, + templating: { enabled: true, template_path: "Templates/Task.md", occurrence_enabled: false }, + time_tracking: { auto_stop_on_complete: true }, + recurrence: { syntax: "tasknotes", reset_body_checkboxes: true }, + links: { accepted_formats: ["wikilink", "markdown"], write_format: "markdown" }, + nlp: { triggers: [{ property_id: "contexts", trigger: "@", enabled: true }] }, + }; +} + +const record = (fields) => ({ app: "dev.tasknotes", scope: "collection", settings_version: 1, ...fields }); + +test("reads each setting from the record, then an earlier binding, then the default", () => { + const missing = resolveTaskNotesAppSettings(undefined, { legacyBinding: legacyBinding() }); + assert.equal(missing.recorded, false); + assert.equal(missing.values.move_on_archive, true); + assert.equal(missing.values.archive_folder, "Archive"); + assert.equal(missing.values.link_write_format, "markdown"); + assert.deepEqual(missing.values.status_behavior.done, { exclude_from_cycle: false, auto_archive: true, auto_archive_delay_minutes: 30 }); + + const partial = resolveTaskNotesAppSettings( + record({ archive_folder: "Filed", status_behavior: { done: { auto_archive_delay_minutes: 60 } } }), + { legacyBinding: legacyBinding() }, + ); + assert.equal(partial.values.archive_folder, "Filed"); + assert.equal(partial.values.move_on_archive, true, "an absent record field falls back to the binding"); + assert.deepEqual(partial.values.status_behavior.done, { exclude_from_cycle: false, auto_archive: true, auto_archive_delay_minutes: 60 }); + assert.equal(partial.values.status_behavior.open.next_status, "done"); + + const plain = resolveTaskNotesAppSettings(record({ template_path: 42 })); + assert.equal(plain.values.template_path, ""); + assert.equal(plain.values.link_write_format, "wikilink"); + assert.equal(plain.warnings.length, 1); + + // The model config follows the same order. + const type = buildTaskNotesMdbaseResources().type; + const legacyType = structuredClone(type); + const implementation = legacyType.implements[0]; + implementation.version = "0.3.0-rc.5"; + implementation.binding.time_tracking = { auto_stop_on_complete: true }; + implementation.binding.recurrence.reset_body_checkboxes = true; + const fromBinding = resolveTaskNotesModelConfigFromMdbaseType(legacyType); + assert.equal(fromBinding.timeTracking.autoStopOnComplete, true); + const fromRecord = resolveTaskNotesModelConfigFromMdbaseType(legacyType, {}, { + settings: record({ auto_stop_time_tracking_on_complete: false }), + }); + assert.equal(fromRecord.timeTracking.autoStopOnComplete, false); + assert.equal(fromRecord.recurrence.resetCheckboxesOnRecurrence, true); +}); + +test("upgrades an rc.5 type by moving its settings out of the binding", () => { + const type = buildTaskNotesMdbaseResources().type; + type.implements[0].version = "0.3.0-rc.5"; + type.implements[0].binding = { ...type.implements[0].binding, ...legacyBinding(), "x-custom": 1 }; + const { type: upgraded, appSettings, upgraded: changed } = upgradeTaskNotesMdbaseTypeContract(type); + const binding = upgraded.implements[0].binding; + assert.equal(changed, true); + assert.equal(upgraded.implements[0].version, "0.3.0-rc.6"); + assert.equal(binding.templating, undefined); + assert.equal(binding.time_tracking, undefined); + assert.equal(binding.nlp, undefined); + assert.deepEqual(binding.archive, { archived_tag: "archived" }); + assert.equal(binding.status.definitions[0].next_status, undefined); + assert.equal(binding["x-custom"], 1); + assert.equal(appSettings.template_path, "Templates/Task.md"); + assert.equal(appSettings.auto_stop_time_tracking_on_complete, true); + assert.equal(appSettings.status_behavior.open.next_status, "done"); + assert.equal(upgradeTaskNotesMdbaseTypeContract(upgraded).upgraded, false); +}); + +test("saves patch only changed fields, keep unknown content, and report conflicts", () => { + const stored = record({ archive_folder: "Archive", "x-newer-client": true }); + assert.deepEqual(buildTaskNotesSettingsPatch(stored, { archive_folder: "Archive", move_on_archive: true }), { move_on_archive: true }); + assert.deepEqual(buildTaskNotesSettingsPatch(stored, { archive_folder: null }), { archive_folder: null }); + assert.deepEqual(buildTaskNotesSettingsPatch(stored, { archive_folder: "Archive" }), {}); + + const markdown = writeAppSettingsDocument(null, {}, { + frontmatter: newTaskNotesSettingsFrontmatter({ archive_folder: "Archive", template_path: undefined }), + type: "tasknotes-settings", + typeKey: "mdbase_type", + }); + const edited = `${markdown.replace("scope:", "# kept\nscope:")}Notes stay.\n`; + const next = writeAppSettingsDocument(edited, { move_on_archive: true, archive_folder: null }); + assert.match(next, /# kept/); + assert.match(next, /Notes stay\.\n$/); + assert.deepEqual(readAppSettingsFrontmatter(next), { + mdbase_type: "tasknotes-settings", + app: "dev.tasknotes", + scope: "collection", + settings_version: 1, + move_on_archive: true, + }); + + assert.deepEqual( + conflictingTaskNotesSettings(record({ archive_folder: "A" }), record({ archive_folder: "B" }), { archive_folder: "C", move_on_archive: true }), + ["archive_folder"], + ); + assert.equal(resolveTaskNotesAppSettings(record({ settings_version: 2 })).readOnly !== undefined, true); + assert.equal(resolveTaskNotesAppSettings(record({ app: "dev.other" })).readOnly !== undefined, true); +}); + +test("a migration adds only the fields an existing record lacks", () => { + const existing = record({ archive_folder: "Theirs", status_behavior: { done: { auto_archive: false } } }); + const patch = absentTaskNotesSettingsPatch(existing, { + archive_folder: "Mine", + move_on_archive: true, + status_behavior: { done: { auto_archive: true }, open: { next_status: "done" } }, + }); + assert.deepEqual(patch, { + move_on_archive: true, + status_behavior: { done: { auto_archive: false }, open: { next_status: "done" } }, + }); + assert.deepEqual(absentTaskNotesSettingsPatch({ ...existing, ...patch }, { archive_folder: "Mine", move_on_archive: true }), {}); +}); + +test("publishes the settings contract, schema and type", () => { + const resources = buildTaskNotesSettingsResources(); + assert.equal(resources.paths.record, TASKNOTES_SETTINGS_PATH); + assert.equal(TASKNOTES_SETTINGS_PATH, "_settings/dev.tasknotes.md"); + assert.deepEqual(resources.type.collection, { unique: [{ field: "app", scope: "type" }] }); + assert.deepEqual(resources.type.implements.map(({ contract, version }) => `${contract}@${version}`), [ + "mdbase.app-settings@1.0.0", + "tasknotes.settings@1.0.0", + ]); + assert.equal(resources.type.schema.value.title, "tasknotes.settings 1.0.0"); +}); diff --git a/test/starter.test.mjs b/test/starter.test.mjs index 33ae4b6..501e8ed 100644 --- a/test/starter.test.mjs +++ b/test/starter.test.mjs @@ -6,27 +6,28 @@ import { TASKNOTES_STARTER_TYPE_VERSION, } from "../dist/esm/starter.js"; -// The published tasknotes.task 0.3.0-rc.17 pack -// (https://mdbase.dev/contracts/packs/tasknotes.task/0.3.0-rc.17.json). If the -// starter changes, publish a new starter type version in mdbase-contracts and +// The tasknotes.task 0.3.0-rc.19 pack in mdbase-contracts +// (packs/tasknotes.task/0.3.0-rc.19.pack.yaml, published at +// https://mdbase.dev/contracts/packs/tasknotes.task/0.3.0-rc.19.json). If the +// starter changes, publish a new starter revision in mdbase-contracts and // update these digests together; collections upgrade by merging against the // previous starter, so an unintended change here reaches every collection. -const PUBLISHED_RC17_RESOURCES = { - "_contracts/tasknotes.task.md": - "sha256:ecadd00700e249940f4244aaa5b5985a96557d13340463ec491dfa5e89b032a4", +const PUBLISHED_RC19_RESOURCES = { + "_contracts/tasknotes.task/0.3.0-rc.6.md": + "sha256:3f4eb90782948ed68112ffcc99575911036020544cf501de3d2265b588b38456", "_types/task.md": - "sha256:b3cf8dbe8885b331c494d2eeb36abfb052890e06b757877d655c269e666487dc", + "sha256:87e52c25b474a06283b29e2983ae4fc6e618fd1a4cbcc5856ff9a67328df605b", "_schemas/tasknotes/tasknotes-task.schema.json": - "sha256:aec95578a093be95cbce0fb500964e7aeaeeacc611624fc2893074637dbd4125", + "sha256:ac78a55f46669d6e8bd08d608b7c25338fec8ba7606dbf54ebd6b8d4e66b7035", "_schemas/tasknotes/tasknotes-task-binding.schema.json": - "sha256:5b38e8a1c426b7f7fa92d8353fe35dd98e632305cbb15ffccde387473166b267", + "sha256:4f8f3288f593ce06a04525697308173147ff957e7066f83d9a922468d06be659", }; test("the starter reproduces the published TaskNotes pack byte for byte", async () => { const pack = await buildTaskNotesMdbaseTypePack(buildTaskNotesStarterResources()); assert.deepEqual( Object.fromEntries(pack.manifest.resources.map((resource) => [resource.target, resource.digest])), - PUBLISHED_RC17_RESOURCES, + PUBLISHED_RC19_RESOURCES, ); });