diff --git a/package.json b/package.json index 96bed932..c76d42ba 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "@sqlite.org/sqlite-wasm": "3.53.4-build2", "@tanstack/react-virtual": "3.14.11", "@tasknotes/calendar": "0.1.10", - "@tasknotes/model": "0.3.0-rc.20", + "@tasknotes/model": "0.3.0-rc.21", "codemirror": "^6.0.2", "dexie": "4.4.4", "firebase": "12.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 650cf1f0..dea7e1f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,8 +84,8 @@ importers: specifier: 0.1.10 version: 0.1.10(react@19.2.8) '@tasknotes/model': - specifier: 0.3.0-rc.20 - version: 0.3.0-rc.20 + specifier: 0.3.0-rc.21 + version: 0.3.0-rc.21 codemirror: specifier: ^6.0.2 version: 6.0.2 @@ -1264,8 +1264,8 @@ packages: react: optional: true - '@tasknotes/model@0.3.0-rc.20': - resolution: {integrity: sha512-Id8bJjU0HhBJAqxJLkJOhtkWjq7MV1JWBm/q0yeTeQLiDw05jctceheh9xiM1hJ86PiH6Qu6E+MVwuxNfUnPJQ==} + '@tasknotes/model@0.3.0-rc.21': + resolution: {integrity: sha512-AYfIqaCeq0tlK3Xhavf43HEhFnVS2bPiFdULkur538xtpBH0wDWj7SwDt1y0trl11811c5dNJ6xUA7HXgjcIOQ==} '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} @@ -5476,7 +5476,7 @@ snapshots: optionalDependencies: react: 19.2.8 - '@tasknotes/model@0.3.0-rc.20': + '@tasknotes/model@0.3.0-rc.21': dependencies: rrule: 2.8.1 yaml: 2.9.0 diff --git a/src/components/task-model-draft.ts b/src/components/task-model-draft.ts index 5f54106e..614dcf66 100644 --- a/src/components/task-model-draft.ts +++ b/src/components/task-model-draft.ts @@ -2,10 +2,10 @@ import { useCallback, useSyncExternalStore } from "react"; import type { TaskCollectionConfiguration } from "../domain/task-configuration"; import type { - TaskCustomFieldEdit, - TaskPriorityEdit, - TaskStatusEdit, -} from "../domain/task-type-vocabulary"; + TaskNotesPriorityEdit, + TaskNotesStatusEdit, + TaskNotesUserFieldEdit, +} from "@tasknotes/model/mdbase"; /** * `id` names a row while it is edited. A saved entry's id is its stored @@ -32,10 +32,10 @@ export interface TaskModelSettingsDraft { string, { autoArchive: boolean; autoArchiveDelay: number } >; - statuses: DraftEntry[]; + statuses: DraftEntry[]; /** Lowest first; a priority's weight is its position. */ - priorities: DraftEntry[]; - customFields: DraftEntry[]; + priorities: DraftEntry[]; + customFields: DraftEntry[]; } type DraftPatch = Partial; diff --git a/src/components/task-model-settings.tsx b/src/components/task-model-settings.tsx index 8129481a..871ca74f 100644 --- a/src/components/task-model-settings.tsx +++ b/src/components/task-model-settings.tsx @@ -20,12 +20,13 @@ import { StatusListEditor, } from "./task-vocabulary-editor"; import { - TaskVocabularyError, - TaskVocabularyInUseError, - vocabularyProblem, - type TaskVocabularyPatch, - type TaskVocabularyProblem, -} from "../domain/task-type-vocabulary"; + isTaskNotesVocabularyError, + isTaskNotesVocabularyInUseError, + taskNotesVocabularyProblem, + type TaskNotesVocabularyInUseError, + type TaskNotesVocabularyPatch, + type TaskNotesVocabularyProblem, +} from "@tasknotes/model/mdbase"; import { useTaskModelDraft, type TaskModelSettingsDraft, @@ -157,19 +158,20 @@ export function TaskModelSettingsEditor() { if (!writable || !dirty || saveState === "saving") return; const vocabulary = vocabularyChanges(draft, original); const invalid = - vocabularyProblem(vocabulary, { + taskNotesVocabularyProblem(vocabulary, { status: draft.defaultStatus, priority: draft.defaultPriority, }) ?? - (vocabulary.customFields?.add?.some( + (vocabulary.userFields?.add?.some( ({ displayName }) => !displayName.trim(), ) ? ({ kind: "field-label", key: "" } as const) : null); - if (invalid) { + const invalidMessage = invalid && vocabularyProblemMessage(invalid); + if (invalidMessage) { setSaveState("error"); setError(""); - setProblem(vocabularyProblemMessage(invalid)); + setProblem(invalidMessage); // A missing name is the usual reason; put the keyboard on it. (event.currentTarget as HTMLElement) .querySelector( @@ -183,7 +185,7 @@ export function TaskModelSettingsEditor() { setProblem(""); try { await updateTaskModelSettings({ - ...(Object.keys(vocabulary).length ? { vocabulary } : {}), + ...vocabulary, defaultStatus: draft.defaultStatus, defaultPriority: draft.defaultPriority, recurrence: { @@ -217,10 +219,12 @@ export function TaskModelSettingsEditor() { setSaveState("saved"); } catch (reason) { setSaveState("error"); - if (reason instanceof TaskVocabularyInUseError) - setProblem(vocabularyInUseMessage(reason, original)); - else if (reason instanceof TaskVocabularyError) - setProblem(vocabularyProblemMessage(reason.problem)); + const message = isTaskNotesVocabularyInUseError(reason) + ? vocabularyInUseMessage(reason, original) + : isTaskNotesVocabularyError(reason) + ? vocabularyProblemMessage(reason.problem) + : null; + if (message) setProblem(message); else setError(reason instanceof Error ? reason.message : String(reason)); } } @@ -625,10 +629,10 @@ function SettingsGroup({ function vocabularyChanges( draft: TaskModelSettingsDraft, original: TaskModelSettingsDraft, -): TaskVocabularyPatch { +): TaskNotesVocabularyPatch { const changed = (key: "statuses" | "priorities" | "customFields") => JSON.stringify(draft[key]) !== JSON.stringify(original[key]); - const patch: TaskVocabularyPatch = {}; + const patch: TaskNotesVocabularyPatch = {}; if (changed("statuses")) patch.statuses = draft.statuses.map( ({ value, label, color, isCompleted }) => ({ @@ -649,7 +653,7 @@ function vocabularyChanges( original.customFields.map((field) => [field.id, field]), ); const kept = new Set(draft.customFields.map(({ id }) => id)); - patch.customFields = { + patch.userFields = { add: draft.customFields .filter(({ id }) => !before.has(id)) .map(({ key, displayName, type }) => ({ key, displayName, type })), @@ -668,7 +672,13 @@ function vocabularyChanges( return patch; } -function vocabularyProblemMessage(problem: TaskVocabularyProblem): string { +/** + * What to tell someone about a problem they can fix in this form, or null for + * one this form cannot cause. + */ +function vocabularyProblemMessage( + problem: TaskNotesVocabularyProblem, +): string | null { switch (problem.kind) { case "empty-label": return problem.list === "status" @@ -689,11 +699,13 @@ function vocabularyProblemMessage(problem: TaskVocabularyProblem): string { case "field-name": case "field-taken": return translate`“${problem.key}” can’t be used for a custom field. Try another name.`; + case "completed-and-skipped": + return null; } } function vocabularyInUseMessage( - problem: TaskVocabularyInUseError, + problem: TaskNotesVocabularyInUseError, original: TaskModelSettingsDraft, ): string { const entries = diff --git a/src/components/task-vocabulary-editor.tsx b/src/components/task-vocabulary-editor.tsx index 408264ba..c1359462 100644 --- a/src/components/task-vocabulary-editor.tsx +++ b/src/components/task-vocabulary-editor.tsx @@ -3,12 +3,12 @@ import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react"; import { useRef, type ReactNode } from "react"; import { - customFieldKey, - vocabularyValue, - type TaskCustomFieldEdit, - type TaskPriorityEdit, - type TaskStatusEdit, -} from "../domain/task-type-vocabulary"; + taskNotesUserFieldKey, + taskNotesVocabularyValue, + type TaskNotesPriorityEdit, + type TaskNotesStatusEdit, + type TaskNotesUserFieldEdit, +} from "@tasknotes/model/mdbase"; import { TaskNotesSelect } from "./tasknotes-controls"; import type { DraftEntry } from "./task-model-draft"; @@ -110,7 +110,7 @@ function OptionRows({ return; } // A new entry's stored value follows its name until saved. - const value = vocabularyValue( + const value = taskNotesVocabularyValue( label, entries .filter((candidate) => candidate.id !== entry.id) @@ -214,9 +214,9 @@ export function StatusListEditor({ onChange, onDefault, }: { - statuses: DraftEntry[]; + statuses: DraftEntry[]; defaultStatus: string; - onChange(statuses: DraftEntry[]): void; + onChange(statuses: DraftEntry[]): void; onDefault(value: string): void; }) { const { t } = useLingui(); @@ -256,7 +256,7 @@ export function StatusListEditor({ ...statuses, { id: newId(), - value: vocabularyValue( + value: taskNotesVocabularyValue( "", statuses.map(({ value }) => value), ), @@ -280,9 +280,9 @@ export function PriorityListEditor({ onChange, onDefault, }: { - priorities: DraftEntry[]; + priorities: DraftEntry[]; defaultPriority: string; - onChange(priorities: DraftEntry[]): void; + onChange(priorities: DraftEntry[]): void; onDefault(value: string): void; }) { const { t } = useLingui(); @@ -305,7 +305,7 @@ export function PriorityListEditor({ ...priorities, { id: newId(), - value: vocabularyValue( + value: taskNotesVocabularyValue( "", priorities.map(({ value }) => value), ), @@ -327,10 +327,10 @@ export function CustomFieldListEditor({ reservedKeys, onChange, }: { - fields: DraftEntry[]; + fields: DraftEntry[]; /** Property names a new field may not take. */ reservedKeys: readonly string[]; - onChange(fields: DraftEntry[]): void; + onChange(fields: DraftEntry[]): void; }) { const { t } = useLingui(); const root = useRef(null); @@ -357,7 +357,7 @@ export function CustomFieldListEditor({
    {fields.map((field) => { const name = field.displayName.trim() || t`Field name`; - const change = (patch: Partial) => + const change = (patch: Partial) => onChange( fields.map((candidate) => candidate.id === field.id @@ -378,7 +378,10 @@ export function CustomFieldListEditor({ isNew(field) ? { displayName, - key: customFieldKey(displayName, taken(field.id)), + key: taskNotesUserFieldKey( + displayName, + taken(field.id), + ), } : { displayName }, ); @@ -427,7 +430,7 @@ export function CustomFieldListEditor({ ...fields, { id, - key: customFieldKey("", taken(id)), + key: taskNotesUserFieldKey("", taken(id)), displayName: "", type: "text", }, diff --git a/src/demo/demo-task-repository.ts b/src/demo/demo-task-repository.ts index cd6cb305..d3c9a914 100644 --- a/src/demo/demo-task-repository.ts +++ b/src/demo/demo-task-repository.ts @@ -10,6 +10,7 @@ import { parseFrontmatter } from "@tasknotes/model/frontmatter"; import { buildTaskNotesMdbaseResources, patchTaskNotesMdbaseTypeSettings, + taskNotesVocabularyInUse, } from "@tasknotes/model/mdbase"; import { buildTaskNotesSettingsPatch, @@ -22,10 +23,6 @@ import { appendViewPage } from "../application/view-query-session"; import { materializeRollingWindow } from "../application/rolling-occurrences"; import { taskCompletion } from "../domain/task-completion"; import { runningTimers } from "../domain/running-timers"; -import { - patchTaskTypeVocabulary, - vocabularyInUse, -} from "../domain/task-type-vocabulary"; import { findOccurrenceParent, occurrenceRecordId, @@ -932,26 +929,20 @@ export class DemoTaskRepository implements TaskRepository { patch, this.model.configuration(), ); - const vocabulary = patch.vocabulary; - if (vocabulary) { + if (split.type.statuses || split.type.priorities) { const current = this.model.configuration(); - const inUse = vocabularyInUse( + const inUse = taskNotesVocabularyInUse( this.tasks.values(), { statuses: current.statuses.map(({ value }) => value), priorities: current.priorities.map(({ value }) => value), }, - vocabulary, + split.type, ); if (inUse) throw inUse; } const modelDefinition = patchTaskNotesMdbaseTypeSettings( - vocabulary - ? patchTaskTypeVocabulary(this.modelDefinition, vocabulary, { - status: split.type.defaultStatus, - priority: split.type.defaultPriority, - }) - : this.modelDefinition, + this.modelDefinition, split.type, ); if (Object.keys(split.app).length) { diff --git a/src/domain/task-configuration.ts b/src/domain/task-configuration.ts index 5b9329be..d1085b5c 100644 --- a/src/domain/task-configuration.ts +++ b/src/domain/task-configuration.ts @@ -9,8 +9,6 @@ import { } from "@tasknotes/model/settings"; import type { - PriorityConfig, - StatusConfig, TaskNotesModelConfig, UserMappedField, UserMappedFieldType, @@ -18,7 +16,6 @@ import type { import type { TaskNotesMdbaseTypeSettingsPatch } from "@tasknotes/model/mdbase"; import { editableFieldSchema } from "./editable-field-schema"; -import type { TaskVocabularyPatch } from "./task-type-vocabulary"; import { taskNotesImplementationOf } from "./tasknotes-contract"; /** @@ -44,12 +41,6 @@ export interface TaskModelSettingsPatch extends Omit< string, { autoArchive?: boolean; autoArchiveDelay?: number } >; - /** - * The statuses, priorities and custom fields themselves. Written to the - * task type before the other type settings, so a default saved in the same - * step may name a status or priority this adds. - */ - vocabulary?: TaskVocabularyPatch; } export interface TaskModelSettingsAccess { @@ -134,11 +125,6 @@ export function resolveTaskCollectionConfiguration( const properties = record(schema.properties); const required = new Set(stringList(schema.required)); const collectionLinks = record(record(value.collection).links); - const statuses = resolveStatuses(base.statuses, record(extension.status)); - const priorities = resolvePriorities( - base.priorities, - record(extension.priority), - ); const inferredFields = inferUserFields( properties, base.fieldMapping, @@ -156,12 +142,6 @@ export function resolveTaskCollectionConfiguration( applyTaskNotesAppSettingsToModelConfig( resolveModelConfig({ ...base, - statuses, - priorities, - occurrences: resolveOccurrences( - base.occurrences, - record(extension.occurrences), - ), userFields: [ ...explicitFields, ...inferredFields.filter((field) => !explicitKeys.has(field.key)), @@ -265,6 +245,12 @@ export function splitTaskModelSettingsPatch( } : {}), ...(patch.occurrences ? { occurrences: patch.occurrences } : {}), + // The statuses, priorities and custom fields themselves. The type editor + // writes them with the defaults, so a default saved in the same step may + // name a status or priority this adds. + ...(patch.statuses ? { statuses: patch.statuses } : {}), + ...(patch.priorities ? { priorities: patch.priorities } : {}), + ...(patch.userFields ? { userFields: patch.userFields } : {}), }; const app: TaskNotesAppSettingsChanges = {}; const reset = patch.recurrence?.resetCheckboxesOnRecurrence; @@ -404,99 +390,6 @@ function enumValues( return [...new Set([...direct, ...items])].map((value) => ({ value })); } -function resolveStatuses( - base: StatusConfig[], - status: Record, -): StatusConfig[] { - const completedValues = new Set(stringList(status.completed_values)); - const skippedValues = new Set(stringList(status.skipped_values)); - const definitions = objectList(status.definitions); - if (!definitions.length && !completedValues.size && !skippedValues.size) - return base; - const byValue = new Map( - definitions.flatMap((definition) => { - const value = string(definition.value); - return value ? [[value, definition] as const] : []; - }), - ); - return base.map((entry, index) => { - const definition = byValue.get(entry.value) ?? {}; - return { - ...entry, - label: string(definition.label) ?? entry.label, - color: string(definition.color) ?? entry.color, - icon: string(definition.icon) ?? entry.icon, - order: integer(definition.order) ?? entry.order ?? index, - isCompleted: - boolean(definition.is_completed) ?? - boolean(definition.isCompleted) ?? - (completedValues.size ? completedValues.has(entry.value) : undefined) ?? - entry.isCompleted, - isSkipped: - boolean(definition.is_skipped) ?? - boolean(definition.isSkipped) ?? - (skippedValues.size ? skippedValues.has(entry.value) : undefined) ?? - entry.isSkipped, - // Cycling and auto-archive are app settings: the settings record, then - // an earlier binding's definitions (see resolveTaskNotesAppSettings). - }; - }); -} - -function resolveOccurrences( - base: TaskNotesModelConfig["occurrences"], - occurrences: Record, -): TaskNotesModelConfig["occurrences"] { - const mode = - occurrences.default_materialization ?? occurrences.defaultMaterialization; - const trigger = - occurrences.default_next_trigger ?? occurrences.defaultNextTrigger; - return { - ...base, - defaultMaterialization: - mode === "manual" || mode === "on_completion" || mode === "rolling" - ? mode - : base.defaultMaterialization, - defaultNextTrigger: - trigger === "completion" || trigger === "completion_or_skip" - ? trigger - : base.defaultNextTrigger, - pastHorizon: - string(occurrences.past_horizon) ?? - string(occurrences.pastHorizon) ?? - base.pastHorizon, - futureHorizon: - string(occurrences.future_horizon) ?? - string(occurrences.futureHorizon) ?? - base.futureHorizon, - }; -} - -function resolvePriorities( - base: PriorityConfig[], - priority: Record, -): PriorityConfig[] { - const definitions = objectList(priority.definitions); - if (!definitions.length) return base; - const byValue = new Map( - definitions.flatMap((definition) => { - const value = string(definition.value); - return value ? [[value, definition] as const] : []; - }), - ); - return base.map((entry, index) => { - const definition = byValue.get(entry.value); - if (!definition) return entry; - return { - ...entry, - label: string(definition.label) ?? entry.label, - color: string(definition.color) ?? entry.color, - icon: string(definition.icon) ?? entry.icon, - weight: number(definition.weight) ?? entry.weight ?? index, - }; - }); -} - function inferUserFields( properties: Record, fieldMapping: TaskNotesModelConfig["fieldMapping"], @@ -579,10 +472,6 @@ function record(value: unknown): Record { : {}; } -function objectList(value: unknown): Record[] { - return Array.isArray(value) ? value.map(record) : []; -} - function stringList(value: unknown): string[] { return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") @@ -597,18 +486,6 @@ function boolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } -function number(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) - ? value - : undefined; -} - -function integer(value: unknown): number | undefined { - return typeof value === "number" && Number.isInteger(value) - ? value - : undefined; -} - function humanize(value: string): string { const words = value .replaceAll("_", " ") diff --git a/src/domain/task-type-vocabulary.test.ts b/src/domain/task-type-vocabulary.test.ts deleted file mode 100644 index 7c2148ac..00000000 --- a/src/domain/task-type-vocabulary.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { buildTaskNotesMdbaseResources } from "@tasknotes/model/mdbase"; -import { describe, expect, it } from "vitest"; - -import { resolveTaskCollectionConfiguration } from "./task-configuration"; -import { - TaskVocabularyError, - customFieldKey, - patchTaskTypeVocabulary, - vocabularyInUse, - vocabularyValue, - type TaskStatusEdit, -} from "./task-type-vocabulary"; - -function taskType(): Record { - return structuredClone(buildTaskNotesMdbaseResources({}).type) as Record< - string, - unknown - >; -} - -function statuses(type: Record): TaskStatusEdit[] { - return resolveTaskCollectionConfiguration(type).statuses.map( - ({ value, label, color, isCompleted }) => ({ - value, - label, - color, - isCompleted, - }), - ); -} - -function path(value: unknown, ...keys: Array): unknown { - return keys.reduce( - (current, key) => (current as Record)?.[key], - value, - ); -} - -describe("patchTaskTypeVocabulary", () => { - it("adds, relabels and reorders statuses everywhere the type names them", () => { - const type = taskType(); - const [none, open, progress, done] = statuses(type); - const patched = patchTaskTypeVocabulary(type, { - statuses: [ - open!, - { - value: "waiting", - label: "Waiting", - color: "#aa00aa", - isCompleted: false, - }, - { ...progress!, label: "Doing" }, - done!, - { - value: "dropped", - label: "Dropped", - color: "#333333", - isCompleted: true, - }, - none!, - ], - }); - - const values = [ - "open", - "waiting", - "in-progress", - "done", - "dropped", - "none", - ]; - expect( - path(patched, "schema", "value", "properties", "status", "enum"), - ).toEqual(values); - expect( - path( - patched, - "schema", - "value", - "allOf", - 0, - "if", - "properties", - "status", - "enum", - ), - ).toEqual(["done", "dropped"]); - const configuration = resolveTaskCollectionConfiguration(patched); - expect(configuration.statuses.map(({ value }) => value)).toEqual(values); - expect( - configuration.statuses.map(({ label, isCompleted }) => [ - label, - isCompleted, - ]), - ).toEqual([ - ["Open", false], - ["Waiting", false], - ["Doing", false], - ["Done", true], - ["Dropped", true], - ["None", false], - ]); - expect(configuration.statuses[1]).toMatchObject({ color: "#aa00aa" }); - expect(configuration.defaults.status).toBe("open"); - // The original is untouched and unrelated parts are carried over. - expect( - path(type, "schema", "value", "properties", "status", "enum"), - ).toHaveLength(4); - expect(patched.lifecycle).toEqual(type.lifecycle); - expect(path(patched, "implements", 0, "binding", "priority")).toEqual( - path(type, "implements", 0, "binding", "priority"), - ); - }); - - it("moves the default with a status that is removed in the same save", () => { - const type = taskType(); - const remaining = statuses(type).filter(({ value }) => value !== "open"); - expect(() => - patchTaskTypeVocabulary(type, { statuses: remaining }), - ).toThrow(TaskVocabularyError); - const patched = patchTaskTypeVocabulary( - type, - { statuses: remaining }, - { status: "in-progress" }, - ); - expect(resolveTaskCollectionConfiguration(patched).defaults.status).toBe( - "in-progress", - ); - expect(path(patched, "collection", "read_defaults", "status")).toBe( - "in-progress", - ); - expect( - path(patched, "schema", "value", "properties", "status", "default"), - ).toBe("in-progress"); - }); - - it("refuses lists a collection could not work with", () => { - const type = taskType(); - const all = statuses(type); - const problem = (run: () => unknown) => { - try { - run(); - } catch (reason) { - return (reason as TaskVocabularyError).problem; - } - return null; - }; - expect( - problem(() => - patchTaskTypeVocabulary(type, { - statuses: all.filter((status) => !status.isCompleted), - }), - ), - ).toEqual({ kind: "no-completed-status" }); - expect( - problem(() => - patchTaskTypeVocabulary( - type, - { statuses: all.filter((status) => status.isCompleted) }, - { status: "done" }, - ), - ), - ).toEqual({ kind: "no-open-status" }); - expect( - problem(() => - patchTaskTypeVocabulary(type, { statuses: [...all, { ...all[0]! }] }), - ), - ).toEqual({ kind: "duplicate-value", list: "status", value: "none" }); - expect( - problem(() => - patchTaskTypeVocabulary(type, { - statuses: all.map((status) => - status.value === "done" ? { ...status, label: " " } : status, - ), - }), - ), - ).toEqual({ kind: "empty-label", list: "status" }); - expect( - problem(() => patchTaskTypeVocabulary(type, { priorities: [] })), - ).toEqual({ - kind: "no-priority", - }); - }); - - it("orders priorities by position and keeps what it does not edit", () => { - const type = taskType(); - const patched = patchTaskTypeVocabulary(type, { - priorities: [ - { value: "low", label: "Someday", color: "#00aa00" }, - { value: "normal", label: "Normal", color: "#ffaa00" }, - { value: "urgent", label: "Urgent", color: "#990000" }, - ], - }); - const configuration = resolveTaskCollectionConfiguration(patched); - expect( - configuration.priorities.map(({ value, label, weight }) => [ - value, - label, - weight, - ]), - ).toEqual([ - ["low", "Someday", 0], - ["normal", "Normal", 1], - ["urgent", "Urgent", 2], - ]); - expect( - path(patched, "schema", "value", "properties", "priority", "enum"), - ).toEqual(["low", "normal", "urgent"]); - expect(configuration.statuses).toEqual( - resolveTaskCollectionConfiguration(type).statuses, - ); - }); - - it("adds, renames and removes custom fields without touching other properties", () => { - const type = taskType(); - const added = patchTaskTypeVocabulary(type, { - customFields: { - add: [ - { key: "effort", displayName: "Effort", type: "number" }, - { key: "client_name", displayName: "Client", type: "text" }, - { key: "review_on", displayName: "Review on", type: "date" }, - { key: "billable", displayName: "Billable", type: "boolean" }, - { key: "people", displayName: "People", type: "list" }, - ], - }, - }); - expect( - resolveTaskCollectionConfiguration(added).userFields.map( - ({ key, displayName, type }) => [key, displayName, type], - ), - ).toEqual([ - ["effort", "Effort", "number"], - ["client_name", "Client", "text"], - ["review_on", "Review on", "date"], - ["billable", "Billable", "boolean"], - ["people", "People", "list"], - ]); - - const changed = patchTaskTypeVocabulary(added, { - customFields: { - rename: [{ key: "client_name", displayName: "Customer" }], - remove: ["billable", "status"], - }, - }); - expect( - resolveTaskCollectionConfiguration(changed).userFields.map( - ({ key, displayName }) => [key, displayName], - ), - ).toEqual([ - ["effort", "Effort"], - ["client_name", "Customer"], - ["review_on", "Review on"], - ["people", "People"], - ]); - // A task role is never removed as though it were a custom field. - expect( - path(changed, "schema", "value", "properties", "status"), - ).toBeDefined(); - }); - - it("refuses a custom field whose name is unusable or already taken", () => { - const type = taskType(); - const add = (key: string) => () => - patchTaskTypeVocabulary(type, { - customFields: { add: [{ key, displayName: "Field", type: "text" }] }, - }); - expect(add("two words")).toThrow(TaskVocabularyError); - expect(add("Due")).toThrow(TaskVocabularyError); - expect(add("timeEstimate")).toThrow(TaskVocabularyError); - expect(add("tags")).toThrow(TaskVocabularyError); - }); -}); - -describe("vocabularyInUse", () => { - const current = { statuses: ["open", "done"], priorities: ["low", "high"] }; - const tasks = [ - { status: "open", priority: "low" }, - { status: "open", priority: "high" }, - { status: "done", priority: "high" }, - ]; - - it("names a removed value that tasks still hold, with their number", () => { - const problem = vocabularyInUse(tasks, current, { - statuses: [ - { value: "done", label: "Done", color: "", isCompleted: true }, - ], - }); - expect(problem).toMatchObject({ list: "status", value: "open", count: 2 }); - expect( - vocabularyInUse(tasks, current, { - priorities: [{ value: "low", label: "Low", color: "" }], - }), - ).toMatchObject({ list: "priority", value: "high", count: 2 }); - }); - - it("allows removing values no task holds", () => { - expect( - vocabularyInUse([{ status: "open", priority: "low" }], current, { - statuses: [ - { value: "open", label: "Open", color: "", isCompleted: false }, - ], - priorities: [{ value: "low", label: "Low", color: "" }], - }), - ).toBeNull(); - expect(vocabularyInUse(tasks, current, {})).toBeNull(); - }); -}); - -describe("generated names", () => { - it("makes a stored value from a label and avoids ones in use", () => { - expect(vocabularyValue("In Review", [])).toBe("in-review"); - expect(vocabularyValue(" Écrire! ", [])).toBe("ecrire"); - expect(vocabularyValue("Open", ["open", "open-2"])).toBe("open-3"); - expect(vocabularyValue("保留", [])).toBe("保留"); - expect(vocabularyValue("???", [])).toBe("option"); - }); - - it("makes a property name from a field label", () => { - expect(customFieldKey("Client name", [])).toBe("client_name"); - expect(customFieldKey("2nd pass", [])).toBe("_2nd_pass"); - expect(customFieldKey("Effort", ["effort", "Effort_2"])).toBe("effort_3"); - expect(customFieldKey("保留", [])).toBe("field"); - }); -}); diff --git a/src/domain/task-type-vocabulary.ts b/src/domain/task-type-vocabulary.ts deleted file mode 100644 index ff5c6cfe..00000000 --- a/src/domain/task-type-vocabulary.ts +++ /dev/null @@ -1,407 +0,0 @@ -import { resolveTaskNotesModelConfigFromMdbaseType } from "@tasknotes/model/mdbase"; - -import { taskNotesImplementationOf } from "./tasknotes-contract"; - -import type { UserMappedFieldType } from "@tasknotes/model/types"; -import type { TaskSummary } from "./task"; - -export interface TaskStatusEdit { - value: string; - label: string; - color: string; - isCompleted: boolean; -} - -export interface TaskPriorityEdit { - value: string; - label: string; - color: string; -} - -export interface TaskCustomFieldEdit { - key: string; - displayName: string; - type: UserMappedFieldType; -} - -/** - * A change to the words a collection's tasks may use. Statuses and priorities - * are complete lists in display order; a priority's weight is its position. - * Custom fields are named changes, so a property the app cannot edit is never - * rewritten. - */ -export interface TaskVocabularyPatch { - statuses?: TaskStatusEdit[]; - priorities?: TaskPriorityEdit[]; - customFields?: { - add?: TaskCustomFieldEdit[]; - rename?: Array>; - remove?: string[]; - }; -} - -export type TaskVocabularyProblem = - | { kind: "empty-label"; list: "status" | "priority" } - | { kind: "duplicate-value"; list: "status" | "priority"; value: string } - | { kind: "no-open-status" } - | { kind: "no-completed-status" } - | { kind: "no-priority" } - | { kind: "default-removed"; list: "status" | "priority"; value: string } - | { kind: "field-name"; key: string } - | { kind: "field-taken"; key: string } - | { kind: "field-label"; key: string }; - -/** A vocabulary change the collection cannot accept as it stands. */ -export class TaskVocabularyError extends Error { - constructor(readonly problem: TaskVocabularyProblem) { - super(`The task settings are not valid: ${problem.kind}.`); - this.name = "TaskVocabularyError"; - } -} - -/** Tasks still hold a status or priority the change would remove. */ -export class TaskVocabularyInUseError extends Error { - constructor( - readonly list: "status" | "priority", - readonly value: string, - readonly count: number, - ) { - super( - `${count} task${count === 1 ? "" : "s"} still use the ${list} "${value}".`, - ); - this.name = "TaskVocabularyInUseError"; - } -} - -const FIELD_KEY = /^[A-Za-z_][A-Za-z0-9_-]*$/; -const FALLBACK_COLOR = "#808080"; - -/** A stored value for a new status or priority, made from its label. */ -export function vocabularyValue(label: string, taken: Iterable) { - const used = new Set(taken); - const base = - label - .trim() - .toLocaleLowerCase() - .normalize("NFKD") - .replace(/[̀-ͯ]/g, "") - .replace(/[^\p{Letter}\p{Number}]+/gu, "-") - .replace(/^-+|-+$/g, "") || "option"; - let value = base; - for (let suffix = 2; used.has(value); suffix += 1) - value = `${base}-${suffix}`; - return value; -} - -/** A property name for a new custom field, made from its label. */ -export function customFieldKey(label: string, taken: Iterable) { - const used = new Set([...taken].map((key) => key.toLocaleLowerCase())); - const base = - label - .trim() - .toLocaleLowerCase() - .normalize("NFKD") - .replace(/[̀-ͯ]/g, "") - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, "") - .replace(/^([0-9])/, "_$1") || "field"; - let key = base; - for (let suffix = 2; used.has(key); suffix += 1) key = `${base}_${suffix}`; - return key; -} - -/** The first rule a status and priority list breaks, if any. */ -export function vocabularyProblem( - patch: Pick, - defaults: { status: string; priority: string }, -): TaskVocabularyProblem | null { - if (patch.statuses) { - const problem = listProblem("status", patch.statuses); - if (problem) return problem; - if (!patch.statuses.some((status) => !status.isCompleted)) - return { kind: "no-open-status" }; - if (!patch.statuses.some((status) => status.isCompleted)) - return { kind: "no-completed-status" }; - if (!patch.statuses.some(({ value }) => value === defaults.status)) - return { - kind: "default-removed", - list: "status", - value: defaults.status, - }; - } - if (patch.priorities) { - const problem = listProblem("priority", patch.priorities); - if (problem) return problem; - if (!patch.priorities.length) return { kind: "no-priority" }; - if (!patch.priorities.some(({ value }) => value === defaults.priority)) - return { - kind: "default-removed", - list: "priority", - value: defaults.priority, - }; - } - return null; -} - -function listProblem( - list: "status" | "priority", - entries: ReadonlyArray<{ value: string; label: string }>, -): TaskVocabularyProblem | null { - const seen = new Set(); - for (const entry of entries) { - if (!entry.label.trim() || !entry.value.trim()) - return { kind: "empty-label", list }; - if (seen.has(entry.value)) - return { kind: "duplicate-value", list, value: entry.value }; - seen.add(entry.value); - } - return null; -} - -/** - * The first status or priority a change removes while tasks still hold it. - * Removing it would leave those tasks outside the type's own schema. - */ -export function vocabularyInUse( - tasks: Iterable>, - current: { statuses: string[]; priorities: string[] }, - patch: Pick, -): TaskVocabularyInUseError | null { - const removed = (before: string[], after?: Array<{ value: string }>) => { - if (!after) return new Map(); - const kept = new Set(after.map(({ value }) => value)); - return new Map( - before.filter((value) => !kept.has(value)).map((value) => [value, 0]), - ); - }; - const statuses = removed(current.statuses, patch.statuses); - const priorities = removed(current.priorities, patch.priorities); - if (!statuses.size && !priorities.size) return null; - for (const task of tasks) { - const status = statuses.get(task.status); - if (status !== undefined) statuses.set(task.status, status + 1); - const priority = priorities.get(task.priority); - if (priority !== undefined) priorities.set(task.priority, priority + 1); - } - for (const [value, count] of statuses) - if (count) return new TaskVocabularyInUseError("status", value, count); - for (const [value, count] of priorities) - if (count) return new TaskVocabularyInUseError("priority", value, count); - return null; -} - -/** - * Rewrite the parts of a TaskNotes task type that name its statuses, - * priorities and custom fields. Everything else in the type, including - * properties and binding entries this app does not know, is preserved. - * - * `defaults` names the default status and priority being saved in the same - * step, so a default can move to a new value as its old one is removed. - */ -export function patchTaskTypeVocabulary( - type: Record, - patch: TaskVocabularyPatch, - defaults: { status?: string; priority?: string } = {}, -): Record { - const result = structuredClone(type); - const implementation = taskNotesImplementationOf(result); - if (!implementation) - throw new Error("The mdbase type is not a TaskNotes task contract."); - const model = resolveTaskNotesModelConfigFromMdbaseType(result); - const binding = section(implementation, "binding"); - const schema = section(section(result, "schema"), "value"); - const properties = section(schema, "properties"); - const readDefaults = section(section(result, "collection"), "read_defaults"); - - const problem = vocabularyProblem(patch, { - status: defaults.status ?? model.defaults.status, - priority: defaults.priority ?? model.defaults.priority, - }); - if (problem) throw new TaskVocabularyError(problem); - - if (patch.statuses) { - const field = model.fieldMapping.status; - const status = section(binding, "status"); - const existing = definitionsByValue(status.definitions); - const values = patch.statuses.map(({ value }) => value); - const kept = new Set(values); - const completed = patch.statuses - .filter((entry) => entry.isCompleted) - .map(({ value }) => value); - const skipped = strings(status.skipped_values).filter( - (value) => kept.has(value) && !completed.includes(value), - ); - const fallback = defaults.status ?? model.defaults.status; - status.values = values; - status.default = fallback; - status.completed_values = completed; - status.skipped_values = skipped; - if ( - typeof status.default_skipped === "string" && - !skipped.includes(status.default_skipped) - ) { - if (skipped[0]) status.default_skipped = skipped[0]; - else delete status.default_skipped; - } - status.definitions = patch.statuses.map((entry, order) => { - const previous = { ...(existing.get(entry.value) ?? {}) }; - delete previous.isCompleted; - delete previous.isSkipped; - return { - ...previous, - value: entry.value, - label: entry.label.trim(), - color: entry.color.trim() || FALLBACK_COLOR, - is_completed: entry.isCompleted, - is_skipped: skipped.includes(entry.value), - order, - }; - }); - setEnum(properties[field], values, fallback); - readDefaults[field] = fallback; - // The type requires a completion date for exactly the completed statuses. - for (const rule of records(schema.allOf)) { - const condition = record(record(record(rule.if).properties)[field]); - if (Array.isArray(condition.enum)) condition.enum = [...completed]; - } - } - - if (patch.priorities) { - const field = model.fieldMapping.priority; - const priority = section(binding, "priority"); - const existing = definitionsByValue(priority.definitions); - const values = patch.priorities.map(({ value }) => value); - const fallback = defaults.priority ?? model.defaults.priority; - priority.values = values; - priority.default = fallback; - priority.definitions = patch.priorities.map((entry, weight) => ({ - ...(existing.get(entry.value) ?? {}), - value: entry.value, - label: entry.label.trim(), - color: entry.color.trim() || FALLBACK_COLOR, - weight, - })); - setEnum(properties[field], values, fallback); - readDefaults[field] = fallback; - } - - const fields = patch.customFields; - if (fields) { - const reserved = new Set( - [ - ...Object.keys(model.fieldMapping), - ...Object.values(model.fieldMapping), - "id", - "tags", - "type", - "types", - ].map((key) => key.toLocaleLowerCase()), - ); - for (const key of fields.remove ?? []) { - if (reserved.has(key.toLocaleLowerCase())) continue; - delete properties[key]; - delete readDefaults[key]; - if (Array.isArray(schema.required)) - schema.required = schema.required.filter((name) => name !== key); - } - for (const { key, displayName } of fields.rename ?? []) { - const property = properties[key]; - if (!isRecord(property)) continue; - if (!displayName.trim()) - throw new TaskVocabularyError({ kind: "field-label", key }); - property.title = displayName.trim(); - } - const nullable = Object.values(properties).some( - (property) => - isRecord(property) && - Array.isArray(property.anyOf) && - property.anyOf.some((branch) => record(branch).type === "null"), - ); - for (const field of fields.add ?? []) { - const key = field.key.trim(); - if (!FIELD_KEY.test(key)) - throw new TaskVocabularyError({ kind: "field-name", key }); - if ( - reserved.has(key.toLocaleLowerCase()) || - Object.keys(properties).some( - (name) => name.toLocaleLowerCase() === key.toLocaleLowerCase(), - ) - ) - throw new TaskVocabularyError({ kind: "field-taken", key }); - if (!field.displayName.trim()) - throw new TaskVocabularyError({ kind: "field-label", key }); - const value = customFieldSchema(field.type); - properties[key] = { - title: field.displayName.trim(), - ...(nullable ? { anyOf: [value, { type: "null" }] } : value), - }; - } - } - - return result; -} - -function customFieldSchema(type: UserMappedFieldType): Record { - if (type === "number") return { type: "number" }; - if (type === "date") return { type: "string", format: "date" }; - if (type === "boolean") return { type: "boolean" }; - if (type === "list") return { type: "array", items: { type: "string" } }; - return { type: "string" }; -} - -/** Narrow or widen an enum property, wherever a nullable wrapper keeps it. */ -function setEnum(property: unknown, values: string[], fallback: string) { - if (!isRecord(property)) return; - const holder = Array.isArray(property.enum) - ? property - : Array.isArray(property.anyOf) - ? property.anyOf.find( - (branch): branch is Record => - isRecord(branch) && Array.isArray(branch.enum), - ) - : undefined; - if (!holder) return; - holder.enum = [...values]; - if ("default" in holder) holder.default = fallback; -} - -function definitionsByValue( - value: unknown, -): Map> { - return new Map( - records(value).flatMap((definition) => - typeof definition.value === "string" - ? [[definition.value, definition] as const] - : [], - ), - ); -} - -function section( - parent: Record, - key: string, -): Record { - const current = parent[key]; - if (isRecord(current)) return current; - const created: Record = {}; - parent[key] = created; - return created; -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function record(value: unknown): Record { - return isRecord(value) ? value : {}; -} - -function records(value: unknown): Array> { - return Array.isArray(value) ? value.filter(isRecord) : []; -} - -function strings(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === "string") - : []; -} diff --git a/src/storage/next-provider-settings.test.ts b/src/storage/next-provider-settings.test.ts index 6ff04f3a..57115ec7 100644 --- a/src/storage/next-provider-settings.test.ts +++ b/src/storage/next-provider-settings.test.ts @@ -3,7 +3,10 @@ import { parseFrontmatter, serializeMarkdownDocument, } from "@tasknotes/model/frontmatter"; -import { TaskVocabularyInUseError } from "../domain/task-type-vocabulary"; +import { + taskNotesMdbaseTypeDisagreements, + TaskNotesVocabularyInUseError, +} from "@tasknotes/model/mdbase"; import { nextTaskFixture, seedTask } from "../test/next-task-fixture"; const fixtures: Awaited>[] = []; @@ -93,23 +96,21 @@ it("adds a status, a priority and a custom field to the task type", async () => const f = await fixture(); const current = await vocabulary(f); const configuration = await f.repository.updateTaskModelSettings({ - vocabulary: { - statuses: [ - ...current.statuses, - { - value: "waiting", - label: "Waiting", - color: "#aa5500", - isCompleted: false, - }, - ], - priorities: [ - ...current.priorities, - { value: "urgent", label: "Urgent", color: "#cc0000" }, - ], - customFields: { - add: [{ key: "client", displayName: "Client", type: "text" }], + statuses: [ + ...current.statuses, + { + value: "waiting", + label: "Waiting", + color: "#aa5500", + isCompleted: false, }, + ], + priorities: [ + ...current.priorities, + { value: "urgent", label: "Urgent", color: "#cc0000" }, + ], + userFields: { + add: [{ key: "client", displayName: "Client", type: "text" }], }, }); expect(configuration.statuses.at(-1)).toMatchObject({ @@ -158,14 +159,10 @@ it("refuses to remove a status that tasks still hold", async () => { const submit = vi.spyOn(f.client, "submit"); const refusal = await f.repository .updateTaskModelSettings({ - vocabulary: { - statuses: current.statuses.filter( - ({ value }) => value !== removed.value, - ), - }, + statuses: current.statuses.filter(({ value }) => value !== removed.value), }) .catch((reason: unknown) => reason); - expect(refusal).toBeInstanceOf(TaskVocabularyInUseError); + expect(refusal).toBeInstanceOf(TaskNotesVocabularyInUseError); expect(refusal).toMatchObject({ list: "status", value: removed.value, @@ -183,10 +180,8 @@ it("removes an unused status and keeps the new priority order", async () => { )!; const reversed = [...current.priorities].reverse(); const configuration = await f.repository.updateTaskModelSettings({ - vocabulary: { - statuses: current.statuses.filter(({ value }) => value !== removed.value), - priorities: reversed, - }, + statuses: current.statuses.filter(({ value }) => value !== removed.value), + priorities: reversed, }); expect(configuration.statuses.map(({ value }) => value)).not.toContain( removed.value, @@ -202,3 +197,37 @@ it("removes an unused status and keeps the new priority order", async () => { (await f.repository.taskConfiguration()).statuses.map(({ value }) => value), ).toEqual(configuration.statuses.map(({ value }) => value)); }); +it("leaves a hand-edited type with one list of statuses when it saves settings", async () => { + const f = await fixture(); + const source = await f.client.resources.get("_types/task.md"); + const parsed = parseFrontmatter(source.text!); + const type = structuredClone(parsed.frontmatter) as { + schema: { value: { properties: { status: { enum: string[] } } } }; + }; + // Someone allowed a status in the schema and did not describe it. + type.schema.value.properties.status.enum = [ + ...type.schema.value.properties.status.enum, + "blocked", + ]; + f.replica.seedResource( + "_types/task.md", + serializeMarkdownDocument(type, parsed.body), + ); + const seeded = await f.client.resources.get("_types/task.md"); + expect( + taskNotesMdbaseTypeDisagreements( + parseFrontmatter(seeded.text!).frontmatter, + ), + ).not.toEqual([]); + + const { defaults } = await f.repository.taskConfiguration(); + const configuration = await f.repository.updateTaskModelSettings({ + defaultPriority: defaults.priority === "high" ? "low" : "high", + }); + + const saved = parseFrontmatter( + (await f.client.resources.get("_types/task.md")).text!, + ).frontmatter; + expect(taskNotesMdbaseTypeDisagreements(saved)).toEqual([]); + expect(configuration.statuses.map(({ value }) => value)).toContain("blocked"); +}); diff --git a/src/storage/next-repository.ts b/src/storage/next-repository.ts index be5f2286..ca806ec9 100644 --- a/src/storage/next-repository.ts +++ b/src/storage/next-repository.ts @@ -27,11 +27,11 @@ import { parseFrontmatter, serializeMarkdownDocument, } from "@tasknotes/model/frontmatter"; -import { patchTaskNotesMdbaseTypeSettings } from "@tasknotes/model/mdbase"; import { - patchTaskTypeVocabulary, - vocabularyInUse, -} from "../domain/task-type-vocabulary"; + patchTaskNotesMdbaseTypeSettings, + reconcileTaskNotesMdbaseTypeVocabulary, + taskNotesVocabularyInUse, +} from "@tasknotes/model/mdbase"; import { TaskDeletionNotStartedError, TaskRevisionChangedError, @@ -2724,20 +2724,20 @@ export class NextTaskRepository implements TaskRepository { patch, this.providers[0]!.model.configuration(), ); - const vocabulary = patch.vocabulary; - if (vocabulary?.statuses || vocabulary?.priorities) { + const typePatch = split.type; + if (typePatch.statuses || typePatch.priorities) { // A status or priority tasks still hold stays until they are moved off // it: removing it would put those tasks outside the type's schema. await this.ensureIndex(); owner.throwIfAborted(); const current = this.providers[0]!.model.configuration(); - const inUse = vocabularyInUse( + const inUse = taskNotesVocabularyInUse( [...this.cache.values()].map(({ task }) => task), { statuses: current.statuses.map(({ value }) => value), priorities: current.priorities.map(({ value }) => value), }, - vocabulary, + typePatch, ); if (inUse) throw inUse; } @@ -2756,10 +2756,9 @@ export class NextTaskRepository implements TaskRepository { await this.initialize({ deferTaskIndex: true }); owner.throwIfAborted(); } - const typePatch = split.type; await this.mutations.run( { - key: JSON.stringify(["task:settings", typePatch, vocabulary ?? null]), + key: JSON.stringify(["task:settings", typePatch]), prepare: async (mutationId, signal) => { const providers = await nextTaskProviders(this.client, signal); if (providers.length !== 1 || providers[0]!.sourcePath !== source) @@ -2770,20 +2769,21 @@ export class NextTaskRepository implements TaskRepository { if (resource.text === undefined) throw new Error("The replica did not supply the task type source."); const parsed = parseFrontmatter(resource.text); + const patched = patchTaskNotesMdbaseTypeSettings( + parsed.frontmatter, + typePatch, + ); + // Nothing the type keeps changed: leave its revision alone. + if (serializeMarkdownDocument(patched, parsed.body) === resource.text) + return { value: undefined }; + // The type is being written anyway, so it leaves with one + // vocabulary: a hand edit can make its schema and its binding + // list different statuses, and the authority then refuses tasks + // the app offers. const text = serializeMarkdownDocument( - patchTaskNotesMdbaseTypeSettings( - vocabulary - ? patchTaskTypeVocabulary(parsed.frontmatter, vocabulary, { - status: typePatch.defaultStatus, - priority: typePatch.defaultPriority, - }) - : parsed.frontmatter, - typePatch, - ), + reconcileTaskNotesMdbaseTypeVocabulary(patched), parsed.body, ); - // Nothing the type keeps changed: leave its revision alone. - if (text === resource.text) return { value: undefined }; return () => this.client.resources.put(source, text, { baseRevision: resource.revision,