diff --git a/.changeset/obsidian-empty-tags-frontmatter.md b/.changeset/obsidian-empty-tags-frontmatter.md new file mode 100644 index 000000000..988c9b5ee --- /dev/null +++ b/.changeset/obsidian-empty-tags-frontmatter.md @@ -0,0 +1,6 @@ +--- +"@inkeep/open-knowledge-core": patch +"@inkeep/open-knowledge": patch +--- + +Fix the Properties panel rejecting Obsidian-style empty `tags:` / `aliases:` frontmatter. Files that use Obsidian's default "no tags" shape (an empty `tags:` list whose only item is a blank `- `, or a bare `tags:` with no value) parse to YAML `null`, which the read schema previously rejected for the whole frontmatter block — so the panel showed nothing (or an error banner) and refused every edit, even to the file's other valid properties. The read path now coerces these empty shapes to empty values (`tags:\n- ` reads as an empty list, a bare `tags:` as empty text), so the panel reads and edits these files normally. Files are not rewritten on disk until you actually edit a property. diff --git a/packages/core/src/frontmatter/schema.test.ts b/packages/core/src/frontmatter/schema.test.ts index 9163dd7f2..29b14d68b 100644 --- a/packages/core/src/frontmatter/schema.test.ts +++ b/packages/core/src/frontmatter/schema.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { FRONTMATTER_TYPES, + FrontmatterMapSchema, frontmatterValuesEqual, inferType, isFrontmatterValueEmpty, @@ -122,3 +123,37 @@ describe('frontmatterValuesEqual', () => { expect(frontmatterValuesEqual({ a: 1, b: 2 }, { a: 1, c: 2 })).toBe(false); }); }); + +describe('FrontmatterMapSchema — Obsidian null coercion', () => { + test('drops null sequence elements ([null] → [])', () => { + const result = FrontmatterMapSchema.safeParse({ tags: [null] }); + expect(result.success).toBe(true); + if (result.success) expect(result.data).toEqual({ tags: [] }); + }); + + test('drops null elements but keeps real items in a mixed sequence', () => { + const result = FrontmatterMapSchema.safeParse({ aliases: ['Real', null, 'Also'] }); + expect(result.success).toBe(true); + if (result.success) expect(result.data).toEqual({ aliases: ['Real', 'Also'] }); + }); + + test('coerces a bare-key null scalar to an empty string (key stays visible)', () => { + const result = FrontmatterMapSchema.safeParse({ tags: null, author: 'Alice' }); + expect(result.success).toBe(true); + if (result.success) expect(result.data).toEqual({ tags: '', author: 'Alice' }); + }); + + test('coerces null nested inside a mapping subtree', () => { + const result = FrontmatterMapSchema.safeParse({ metadata: { version: null, name: 'x' } }); + expect(result.success).toBe(true); + if (result.success) expect(result.data).toEqual({ metadata: { version: '', name: 'x' } }); + }); + + test('leaves null-free maps untouched (no behavior change for valid input)', () => { + const input = { title: 'Hello', tags: ['a', 'b'], meta: { n: 1 } }; + const result = FrontmatterMapSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) + expect(result.data).toEqual({ title: 'Hello', tags: ['a', 'b'], meta: { n: 1 } }); + }); +}); diff --git a/packages/core/src/frontmatter/schema.ts b/packages/core/src/frontmatter/schema.ts index 2b1a17a96..747da4260 100644 --- a/packages/core/src/frontmatter/schema.ts +++ b/packages/core/src/frontmatter/schema.ts @@ -48,7 +48,29 @@ export function inferType(value: FrontmatterValue): FrontmatterType { return 'text'; } -export const FrontmatterMapSchema = z.record(z.string(), FrontmatterValueSchema); +function coerceNullFrontmatter(value: unknown): unknown { + if (Array.isArray(value)) { + const out: unknown[] = []; + for (const element of value) { + if (element === null) continue; + out.push(coerceNullFrontmatter(element)); + } + return out; + } + if (value !== null && typeof value === 'object') { + const out: Record = {}; + for (const [key, child] of Object.entries(value as Record)) { + out[key] = child === null ? '' : coerceNullFrontmatter(child); + } + return out; + } + return value; +} + +export const FrontmatterMapSchema = z.preprocess( + coerceNullFrontmatter, + z.record(z.string(), FrontmatterValueSchema), +); export type FrontmatterMap = Record; export const FrontmatterPatchSchema = z.record( diff --git a/packages/core/src/frontmatter/tags.test.ts b/packages/core/src/frontmatter/tags.test.ts index ba54e4900..184d7407c 100644 --- a/packages/core/src/frontmatter/tags.test.ts +++ b/packages/core/src/frontmatter/tags.test.ts @@ -75,6 +75,14 @@ describe('extractFrontmatterTags', () => { expect(extractFrontmatterTags('tags: null\n')).toEqual([]); }); + test("returns empty for Obsidian's empty-list shape (`tags:\\n- `)", () => { + expect(extractFrontmatterTags('tags:\n- \n')).toEqual([]); + }); + + test('drops a null entry but keeps real tags in a mixed block sequence', () => { + expect(extractFrontmatterTags('tags:\n - real\n - \n - also\n')).toEqual(['real', 'also']); + }); + test('drops object array elements rather than stringifying them', () => { const yaml = 'tags:\n - valid\n - {nested: "object"}\n - alsoValid\n'; expect(extractFrontmatterTags(yaml)).toEqual(['valid', 'alsoValid']); diff --git a/packages/core/src/frontmatter/yaml-codec.test.ts b/packages/core/src/frontmatter/yaml-codec.test.ts index 09125328b..339f79aac 100644 --- a/packages/core/src/frontmatter/yaml-codec.test.ts +++ b/packages/core/src/frontmatter/yaml-codec.test.ts @@ -60,6 +60,14 @@ describe('parseFrontmatterYaml', () => { expect(parseFrontmatterYaml('"just a string"').map).toBeNull(); }); + test("coerces Obsidian's empty-list / bare-key null shapes instead of rejecting the map", () => { + expect(parseFrontmatterYaml('tags:\n- \n').map).toEqual({ tags: [] }); + expect(parseFrontmatterYaml('tags:\n').map).toEqual({ tags: '' }); + const mixed = parseFrontmatterYaml('plugin-id: dataview\ntags:\n- \npublish: true\n'); + expect(mixed.parseError).toBeUndefined(); + expect(mixed.map).toEqual({ 'plugin-id': 'dataview', tags: [], publish: true }); + }); + test('parses nested objects into a populated map with no parseError', () => { const yaml = 'name: skill\nmetadata:\n version: 1.0.0\n author: Inkeep\n'; const { map, parseError } = parseFrontmatterYaml(yaml); diff --git a/packages/server/src/api-agent-frontmatter.test.ts b/packages/server/src/api-agent-frontmatter.test.ts index 5142b4db8..3dbb93e24 100644 --- a/packages/server/src/api-agent-frontmatter.test.ts +++ b/packages/server/src/api-agent-frontmatter.test.ts @@ -698,6 +698,45 @@ describe('POST /api/agent-write-md (write_document) — malformed-FM refusal (PR } }); + test("Obsidian's empty `tags:` / `aliases:` null shapes write through (200), not refused at the gate", async () => { + const { contentDir, hocuspocus, sessionManager, cleanup } = setup(); + try { + const session = await sessionManager.getSession('test-doc'); + + const obsidianShape = [ + '---', + 'plugin-id: dataview', + 'tags:', + '- ', + 'aliases:', + '- ', + 'publish: true', + '---', + '', + '# Body', + '', + ].join('\n'); + + const response = await callApi( + hocuspocus, + sessionManager, + contentDir, + '/api/agent-write-md', + { docName: 'test-doc', markdown: obsidianShape, position: 'replace' }, + ); + + expect(response.status).toBe(200); + expect(fmMap(session.dc.document)).toEqual({ + 'plugin-id': 'dataview', + tags: [], + aliases: [], + publish: true, + }); + } finally { + await cleanup(); + } + }); + test('append AND prepend skip the gate even when the existing FM is already malformed', async () => { const { contentDir, hocuspocus, sessionManager, cleanup } = setup(); try {