Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l

## Changed

- A custom field's name is now stored in the mdbase task type, as the property's `title`, so TaskNotes on another device and the TaskNotes app show the name you gave it. A name changed elsewhere is picked up here. Types written before this keep each device's own name until the type is next saved.
- A status or priority added to the task type's schema by hand, or by another tool, is now read as a status instead of making TaskNotes report the type as inconsistent. TaskNotes reads every value either list names, and the next settings change writes one list to both places. A type whose lists cannot be reconciled is still reported and left alone.

- Brought the TaskNotes settings record (`_settings/dev.tasknotes.md`) in line with the `mdbase.app-settings` contract. Opening a vault no longer creates the record or adds this device's settings to it, so one device can't change a collection's settings just by opening it. A setting missing from the record, or invalid in it, now takes its default instead of this device's saved value, and removing the record returns every setting to its default. The record is created on the first change you make, and upgrades still move your settings into it. See [Migrating to v5](https://tasknotes.dev/migration-v4-to-v5/#tasknotes-settings-record).

- The calendar toolbar is quieter. Views are chosen from one dropdown instead of a row of six buttons, and Select tasks and Refresh are in the toolbar's "…" menu. Select tasks stays in the toolbar while you are selecting.
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@
"@codemirror/view": "^6.38.6",
"@modelcontextprotocol/sdk": "1.32.1",
"@tasknotes/calendar": "0.1.10",
"@tasknotes/model": "0.3.0-rc.20",
"@tasknotes/model": "0.3.0-rc.21",
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"chrono-node": "^2.7.5",
Expand Down
7 changes: 5 additions & 2 deletions src/services/mdbase/checkCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type TFile } from "obsidian";
import { createI18nService } from "../../i18n";
import YAML from "yaml";
import type TaskNotesPlugin from "../../main";
import { parseMdbaseTaskTypeDocument, validateCanonicalTaskType, withCurrentTaskNotesContract } from "../mdbaseCanonicalConfig";
import { parseMdbaseTaskTypeDocument, validateCanonicalTaskType, withAgreedVocabulary, withCurrentTaskNotesContract } from "../mdbaseCanonicalConfig";
import { compileRecordSchema, validateTaskRecord, validationIssues } from "./contractValidation";
import { taskExclusionExpression } from "./collectionConfig";
import { recordDocument } from "./frontmatter";
Expand Down Expand Up @@ -48,7 +48,10 @@ export async function checkCollection(plugin: TaskNotesPlugin): Promise<Collecti
const validate = compileRecordSchema(schema);
const statusField = fields.status as string;
const dateCreatedField = fields.dateCreated as string;
const statusValues = object(object(implementation.binding).status).values as string[];
// The statuses TaskNotes reads from this type, which include one only its schema allows.
const agreed = withAgreedVocabulary(type);
const agreedImplementation = Array.isArray(agreed.implements) ? agreed.implements.map(object).find((value) => value.contract === "tasknotes.task") : undefined;
const statusValues = object(object((agreedImplementation ?? implementation).binding).status).values as string[];
const match = object(type.match);
try { assertSupportedMatch(type.match); }
catch (error) { throw new Error(`${provider.path}: ${String(error)}`); }
Expand Down
3 changes: 2 additions & 1 deletion src/services/mdbase/preserveCanonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ export function preserveUnownedCanonicalSettings(existing: ObjectValue, generate
const prior = previousProperties[field];
if (prior && typeof prior === "object") {
const merged = { ...object(definition), ...object(prior) };
for (const key of ["type", "enum", "default"]) {
// A custom field's name is a plugin setting, stored as the property's title.
for (const key of ["type", "enum", "default", "title"]) {
if (object(definition)[key] !== undefined) merged[key] = object(definition)[key];
}
properties[field] = merged;
Expand Down
90 changes: 46 additions & 44 deletions src/services/mdbaseCanonicalConfig.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
reconcileTaskNotesMdbaseTypeVocabulary,
resolveTaskNotesModelConfigFromMdbaseType,
taskNotesMdbaseTypeDisagreements,
upgradeTaskNotesMdbaseTypeContract,
type TaskNotesMdbaseResources,
} from "@tasknotes/model/mdbase";
Expand Down Expand Up @@ -100,7 +102,27 @@ export function hasLegacyAppSettings(binding: unknown): boolean {
return Object.keys(taskNotesAppSettingsFromLegacyBinding(binding)).length > 0;
}

export function validateCanonicalTaskType(type: UnknownRecord): CanonicalTypeValidationResult {
/**
* The type with one list of statuses and priorities. A type states them in its
* schema and again in its binding; after a hand edit, or a write by another
* tool, the copies can differ. Nothing a task may hold is taken away: a value
* either copy names is kept. A type whose copies agree, or cannot be made to,
* is returned as it is.
*/
export function withAgreedVocabulary(type: UnknownRecord): UnknownRecord {
try {
return reconcileTaskNotesMdbaseTypeVocabulary(type);
} catch {
return type;
}
}

/**
* Validates the type as TaskNotes reads it: with its vocabulary agreed (see
* withAgreedVocabulary). The next write from settings stores that vocabulary.
*/
export function validateCanonicalTaskType(source: UnknownRecord): CanonicalTypeValidationResult {
const type = withAgreedVocabulary(source);
const issues: string[] = [];
const implementation = taskNotesImplementation(type);
const extension = asRecord(implementation?.binding);
Expand Down Expand Up @@ -151,10 +173,7 @@ export function validateCanonicalTaskType(type: UnknownRecord): CanonicalTypeVal
}

validateTaskIdentification(type, issues);
if (implementation) {
validateVocabularyMirror(type, implementation, "status", issues);
validateVocabularyMirror(type, implementation, "priority", issues);
}
issues.push(...vocabularyMirrorIssues(type, fieldRoles));

const statusField = fieldRoles?.status;
const advertisedStatusValues = stringArray(extension?.status, "values");
Expand Down Expand Up @@ -243,7 +262,8 @@ export function applyCanonicalTaskTypeToSettings(
settings.storeTitleInFilename = modelConfig.storeTitleInFilename;
settings.userFields = mergeResolvedUserFields(
settings.userFields ?? [],
modelConfig.userFields
modelConfig.userFields,
recordAt(type, ["schema", "value", "properties"]) ?? {}
);
settings.maintainDueDateOffsetInRecurring = modelConfig.recurrence.maintainDueDateOffset;

Expand Down Expand Up @@ -391,6 +411,7 @@ export function portableSettingsFingerprint(settings: TaskNotesSettings): string
customPriorities: settings.customPriorities,
userFields: (settings.userFields ?? []).map((field) => ({
key: field.key,
displayName: field.displayName,
type: field.type,
defaultValue: field.defaultValue,
})),
Expand Down Expand Up @@ -483,38 +504,16 @@ function validateTaskIdentification(type: UnknownRecord, issues: string[]): void
}
}

function validateVocabularyMirror(
type: UnknownRecord,
implementation: UnknownRecord,
role: "status" | "priority",
issues: string[]
): void {
const fieldName = asRecord(implementation.fields)?.[role];
const policy = asRecord(asRecord(implementation.binding)?.[role]);
const extensionValues = stringArray(policy, "values");
if (typeof fieldName !== "string" || extensionValues.length === 0) return;
const schemaValues = stringArrayAt(type, ["schema", "value", "properties", fieldName, "enum"]);
if (schemaValues.length > 0 && !sameStrings(extensionValues, schemaValues)) {
issues.push(`${role}.values contradicts schema enum for ${fieldName}`);
}

const extensionDefault = policy?.default;
const schemaDefault = valueAt(type, ["schema", "value", "properties", fieldName, "default"]);
const collectionDefault = valueAt(type, ["collection", "read_defaults", fieldName]);
if (
extensionDefault !== undefined &&
schemaDefault !== undefined &&
extensionDefault !== schemaDefault
) {
issues.push(`${role}.default contradicts the schema default for ${fieldName}`);
}
if (
extensionDefault !== undefined &&
collectionDefault !== undefined &&
extensionDefault !== collectionDefault
) {
issues.push(`${role}.default contradicts collection.read_defaults for ${fieldName}`);
}
/** The copies of a status or priority list or default that still differ. */
function vocabularyMirrorIssues(type: UnknownRecord, fieldRoles: UnknownRecord | null): string[] {
return taskNotesMdbaseTypeDisagreements(type).flatMap(({ list, part }) => {
const fieldName = typeof fieldRoles?.[list] === "string" ? fieldRoles[list] : list;
if (part === "schema-enum") return [`${list}.values contradicts schema enum for ${fieldName}`];
if (part === "schema-default") return [`${list}.default contradicts the schema default for ${fieldName}`];
if (part === "read-default") return [`${list}.default contradicts collection.read_defaults for ${fieldName}`];
// Earlier releases did not compare the other copies; keep accepting those types.
return [];
});
}

function validateUniqueStrings(values: string[], label: string, issues: string[]): void {
Expand Down Expand Up @@ -582,15 +581,22 @@ function mergeResolvedUserFields(
key: string;
type: UserMappedField["type"];
defaultValue?: UserMappedField["defaultValue"];
}>
}>,
properties: UnknownRecord
): UserMappedField[] {
return resolved.map((field) => {
// Saved keys may carry whitespace that settings load trims (or the type kept).
const previous = existing.find((candidate) => candidate.key.trim() === field.key.trim());
// The type carries the name every TaskNotes client shows; a type written
// before names were stored there leaves this device's name in place.
const title = asRecord(properties[field.key])?.title;
return {
...field,
id: previous?.id ?? field.id,
displayName: previous?.displayName ?? field.displayName,
displayName:
typeof title === "string" && title.trim()
? title.trim()
: (previous?.displayName ?? field.displayName),
...(previous?.autosuggestFilter
? { autosuggestFilter: previous.autosuggestFilter }
: {}),
Expand All @@ -608,10 +614,6 @@ function isTaskFilenameFormat(value: unknown): value is TaskNotesSettings["taskF
);
}

function sameStrings(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}

function taskNotesImplementation(type: UnknownRecord): UnknownRecord | null {
if (!Array.isArray(type.implements)) return null;
return (
Expand Down
83 changes: 81 additions & 2 deletions tests/unit/services/mdbaseCanonicalConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
buildTaskNotesModelConfig,
mergeCanonicalTaskTypeDocument,
parseMdbaseTaskTypeDocument,
portableSettingsFingerprint,
validateCanonicalTaskType,
} from "../../../src/services/mdbaseCanonicalConfig";

Expand Down Expand Up @@ -117,13 +118,57 @@ describe("canonical mdbase TaskNotes configuration", () => {
);
});

it("rejects contradictory compatibility mirrors", () => {
const resources = buildTaskNotesMdbaseResources();
it("reads a status only the schema allows, and writes one list back", () => {
const settings = clone(DEFAULT_SETTINGS);
const resources = buildTaskNotesMdbaseResources({ modelConfig: buildTaskNotesModelConfig(settings) });
const type = clone(resources.type);
const schema = type.schema as {
value: { properties: Record<string, { enum?: string[] }> };
};
const before = [...(schema.value.properties.status.enum ?? [])];
schema.value.properties.status.enum = [...before, "blocked"];

expect(validateCanonicalTaskType(type)).toEqual({ valid: true });
applyCanonicalTaskTypeToSettings(settings, type);
expect(settings.customStatuses.map((status) => status.value)).toEqual([...before, "blocked"]);

const written = parseMdbaseTaskTypeDocument(
mergeCanonicalTaskTypeDocument(
`---\n${initialDocumentYaml(type)}---\n`,
buildTaskNotesMdbaseResources({ modelConfig: buildTaskNotesModelConfig(settings) })
)
).type;
const status = implementation(written).binding as { status: { values: string[] } };
expect(status.status.values).toEqual([...before, "blocked"]);
expect((written.schema as typeof schema).value.properties.status.enum).toEqual([...before, "blocked"]);
});

it("keeps a status the schema lost while the binding still describes it", () => {
const type = clone(buildTaskNotesMdbaseResources().type);
const schema = type.schema as {
value: { properties: Record<string, { enum?: string[] }> };
};
const before = [...(schema.value.properties.status.enum ?? [])];
schema.value.properties.status.enum = ["open", "done"];
const settings = clone(DEFAULT_SETTINGS);

expect(validateCanonicalTaskType(type)).toEqual({ valid: true });
applyCanonicalTaskTypeToSettings(settings, type);
expect(settings.customStatuses.map((status) => status.value)).toEqual(before);
});

it("rejects mirrors that cannot be made to agree", () => {
const type = clone(buildTaskNotesMdbaseResources().type);
const schema = type.schema as {
value: { properties: Record<string, { enum?: string[] }> };
};
schema.value.properties.status.enum = ["open", "done"];
// With every status completed there is no list a collection could hold.
const binding = implementation(type).binding as {
status: { values: string[]; completed_values: string[]; definitions: Array<{ is_completed: boolean }> };
};
binding.status.completed_values = [...binding.status.values];
binding.status.definitions.forEach((definition) => (definition.is_completed = true));

expect(validateCanonicalTaskType(type)).toEqual({
valid: false,
Expand Down Expand Up @@ -275,12 +320,46 @@ describe("canonical mdbase TaskNotes configuration", () => {
]);
});

it("shares a custom field's name through the type", () => {
const settings = clone(DEFAULT_SETTINGS);
settings.userFields = [{ id: "effort-id", displayName: "Effort", key: "effort", type: "number" }];
const generate = () => buildTaskNotesMdbaseResources({ modelConfig: buildTaskNotesModelConfig(settings) });
const first = generate();
expect(titleOf(first.type, "effort")).toBe("Effort");

// Renamed here: the type follows, and the fingerprint notices.
const fingerprint = portableSettingsFingerprint(settings);
settings.userFields[0].displayName = "Effort level";
expect(portableSettingsFingerprint(settings)).not.toBe(fingerprint);
const renamed = parseMdbaseTaskTypeDocument(
mergeCanonicalTaskTypeDocument(first.typeDocument, generate())
).type;
expect(titleOf(renamed, "effort")).toBe("Effort level");

// Renamed by another client: this device follows the type.
const elsewhere = clone(renamed);
(elsewhere.schema as { value: { properties: Record<string, { title?: string }> } }).value.properties.effort.title = "Size";
applyCanonicalTaskTypeToSettings(settings, elsewhere);
expect(settings.userFields).toEqual([
expect.objectContaining({ id: "effort-id", displayName: "Size", key: "effort" }),
]);

// A type written before names were stored keeps this device's name.
delete (elsewhere.schema as { value: { properties: Record<string, { title?: string }> } }).value.properties.effort.title;
applyCanonicalTaskTypeToSettings(settings, elsewhere);
expect(settings.userFields[0].displayName).toBe("Size");
});

});

function initialDocumentYaml(value: Record<string, unknown>): string {
return `${YAML.stringify(value, { lineWidth: 0 }).trimEnd()}\n`;
}

function titleOf(type: Record<string, unknown>, field: string): unknown {
return (type.schema as { value: { properties: Record<string, { title?: string }> } }).value.properties[field]?.title;
}

function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
Expand Down
2 changes: 1 addition & 1 deletion vendor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ SHA-256 and byte-for-byte embedded resources before production and development
builds. Run the script without `--check` only when intentionally regenerating
the embedded resource module. Updating the pack requires an explicit new digest.

`@tasknotes/model@0.3.0-rc.20` (tasknotes.task 0.3.0-rc.6 and the TaskNotes settings record)
`@tasknotes/model@0.3.0-rc.21` (tasknotes.task 0.3.0-rc.6 and the TaskNotes settings record)
comes from npm, and TaskNotes App uses the same version. It generates the editable, collection-specific implementing type and
the settings record. Immutable contracts and schemas come from the catalog provision instead.