diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec17c4..5fad921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Frontmatter handling in `bear-create-note`** — when the `text` parameter begins with a YAML frontmatter block (`---`…`---`), the tool assembles the note as a single text payload, bypassing Bear's own title/tag insertion so frontmatter stays at the top. Tags follow the configured convention: at the end by default, or after the title when `UI_ENABLE_NEW_NOTE_CONVENTION=true`. Notes without frontmatter behave exactly as before. +- **Frontmatter-safe tag insertion in `bear-add-tag`** — instead of blindly prepending tags to the beginning of the note (which overwrites YAML frontmatter), the tool now detects frontmatter and rewrites the note with tags at the configured location: at the end by default, or after the title when `UI_ENABLE_NEW_NOTE_CONVENTION=true`. Notes without frontmatter continue to use the original Bear tag insertion path. + ## [2.12.0] - 2026-04-21 ### Removed diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..eab7bba --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,24 @@ +# Progress: yaml-frontmatter-fix + +## Status: Complete + +## What was done + +### Task 1: API Validation (FINDINGS.md) +- Fetched official Bear x-callback-url docs +- Confirmed `mode=replace_all` is a documented, supported value for `/add-text` +- Confirmed `/replace-note` does not exist (BRIEF.md was mistaken about this alternative) +- No code changes required + +### Task 2: Commit split +- Commit 26fd648 (mixed bear-create-note + bear-add-tag changes) split into two atomic commits +- Note: TASK.md expected 3 commits from 26fd648, but the bear-urls.ts change was already in d86067b; the correct split yielded 2 commits from that mixed change +- Final branch structure: 8 commits on top of main, including this follow-up documentation refresh + +### Task 3: Documentation refresh +- SUMMARY.md updated with new commit SHAs, API validation result, resolved blockers +- PROGRESS.md updated (this file) + +## Remaining work (for PR author) +- Run system tests with Bear open: `npm run test:system` +- Manual verification: create a frontmatter note via MCP, add a tag, confirm structure in Bear diff --git a/README.md b/README.md index 64ff996..a6dfeab 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,33 @@ Example standalone configuration with the convention enabled: } ``` +### Frontmatter Handling + +The server understands [YAML frontmatter](https://jekyllrb.com/docs/front-matter/) — a `---`…`---` block at the start of a note — and keeps it intact across create and tag operations. + +**Creating a note with frontmatter** (`bear-create-note`): if the `text` parameter begins with a frontmatter block, the server assembles the final note itself before sending it to Bear. This prevents Bear from inserting the title or tags outside the frontmatter block. Tags follow the configured convention: by default they are placed at the end of the note; when `UI_ENABLE_NEW_NOTE_CONVENTION=true`, they are placed after the title. + +``` +# Input +text: "---\nstatus: draft\n---\nBody content." +title: "My Note" +tags: "project" + +# Stored note +--- +status: draft +--- +# My Note +Body content. +#project +``` + +Notes without frontmatter behave exactly as before — no behavior change. + +**Adding tags to a note with frontmatter** (`bear-add-tag`): instead of prepending tags at the very top of the note (which would overwrite the frontmatter), the tool rewrites the note so tags follow the configured convention: at the end by default, or after the title when `UI_ENABLE_NEW_NOTE_CONVENTION=true`. Notes without frontmatter use the original Bear tag insertion path. + +Frontmatter is detected only when `---` is the very first line of the note text **and** a closing `---` exists on its own line later. A `---` horizontal rule in the note body is never mistaken for frontmatter. + ### Content Replacement Enable the `bear-replace-text` tool to replace content in existing notes — either the full note body or a specific section under a header. diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..72e74fd --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,79 @@ +# Branch Summary: yaml-frontmatter-fix + +## What Changed + +8 commits on top of `main` (26fd648 was split into two atomic commits; see FINDINGS.md for why the expected 3-way split became 2 commits from that mixed change): + +| SHA | Description | +|-----|-------------| +| d86067b | feat(note-conventions): add parseFrontmatter and formatTagsAsInlineSyntax helpers; add replace_all to BearUrlParams.mode | +| 6b66bc0 | test(note-conventions): add unit tests for parseFrontmatter and formatTagsAsInlineSyntax | +| 88b6951 | feat(bear-create-note): auto-detect and preserve YAML frontmatter | +| a318830 | feat(bear-add-tag): preserve frontmatter when adding tags | +| 8878bf1 | test(system): add frontmatter integration tests for create-note and add-tag | +| 8bfc620 | docs: add Frontmatter handling section to README and update CHANGELOG | +| 0a21763 | chore: add SUMMARY.md / PROGRESS.md | +| a76da24 | chore: update progress and summary after API validation and commit split | + +## Files Modified + +- **`src/operations/note-conventions.ts`** — added `parseFrontmatter` and `formatTagsAsInlineSyntax`; refactored `applyNoteConventions` to use the extracted helper +- **`src/infra/bear-urls.ts`** — added `'replace_all'` to `BearUrlParams.mode` union type +- **`src/tools/note-tools.ts`** — Fix 1 (bear-create-note) and Fix 2 (bear-add-tag) +- **`src/operations/note-conventions.test.ts`** — 10 new unit tests for the two new helpers +- **`tests/system/frontmatter.test.ts`** — new integration test suite (4 tests, requires Bear) +- **`README.md`** — new "Frontmatter Handling" section under Configuration +- **`CHANGELOG.md`** — [Unreleased] entries for both fixes + +## Fix 1: bear-create-note + +When `text` starts with `---\n…\n---`, the handler now: +1. Parses the frontmatter block using `parseFrontmatter` +2. Assembles the note as `frontmatter → # title → #tag line → body` (joined with `\n`) +3. Passes the assembled string as `text` only — no separate `title` or `tags` URL params — so Bear uses the H1 as the title and does not insert anything outside the frontmatter block + +Backward compat: if no frontmatter is detected, the original code path runs unchanged. + +## Fix 2: bear-add-tag + +When the existing note text (read from SQLite) starts with `---\n`, the handler: +1. Parses the frontmatter block +2. Builds a new full-note text: `frontmatter\n\n` +3. Writes it back using `add-text?mode=replace_all` (replaces entire note content) + +When no frontmatter: original `mode=prepend` + `tags` URL param, unchanged. + +## API Validation (Task 1) + +`mode=replace_all` is **officially documented** in the Bear x-callback-url docs. The original concern in TASK.md was unfounded — no redesign is needed. See FINDINGS.md for the full verification. + +The `/replace-note` endpoint mentioned in BRIEF.md as an alternative does not exist in the Bear API. + +## Test Results + +``` +Unit tests: 47 passed / 0 failed (includes 10 new parseFrontmatter/formatTagsAsInlineSyntax tests) +Build: tsc clean, 0 errors +Integration: tests/system/frontmatter.test.ts — NOT run (requires Bear app running) +``` + +## Blockers / Open Questions + +1. **ZTEXT storage format**: the implementation assumes that for notes created with frontmatter (via Fix 1), Bear stores ZTEXT starting with `---` (i.e., Bear does not prepend a `# Title` line automatically). This needs system test validation. + +2. **Note title after replace_all**: the Bear docs don't specify whether `replace_all` updates ZTITLE from the first H1. If it doesn't, the SQLite title remains unchanged (which is fine — the title hasn't changed). Either way, the operation is safe. + +## Suggested PR Description + +### Summary +- `bear-create-note`: auto-detects YAML frontmatter (`---`…`---` at start of `text`) and assembles the note content so frontmatter, title (H1), and tags appear in the right order without Bear interfering +- `bear-add-tag`: detects frontmatter in the existing note text and inserts new tags immediately after the closing `---` instead of clobbering it with a blind prepend +- `mode=replace_all` is an officially documented Bear API parameter — verified against the Bear x-callback-url documentation +- Backward compatible: notes without frontmatter follow the exact same code paths as before + +### Test plan +- [x] All 47 unit tests pass (`npm test`) +- [x] TypeScript build clean (`npm run build`) +- [ ] Run `npm run test:system` with Bear open to validate integration tests in `tests/system/frontmatter.test.ts` +- [ ] Manually verify a note created with frontmatter shows the correct structure in Bear +- [ ] Manually verify `bear-add-tag` on a frontmatter note places tags after the `---` block diff --git a/src/infra/bear-urls.ts b/src/infra/bear-urls.ts index 9ac16d0..46df0fa 100644 --- a/src/infra/bear-urls.ts +++ b/src/infra/bear-urls.ts @@ -12,7 +12,7 @@ export interface BearUrlParams { tags?: string | undefined; id?: string | undefined; header?: string | undefined; - mode?: 'append' | 'prepend' | 'replace' | undefined; + mode?: 'append' | 'prepend' | 'replace' | 'replace_all' | undefined; new_line?: 'yes' | 'no' | undefined; file?: string | undefined; filename?: string | undefined; diff --git a/src/operations/note-conventions.test.ts b/src/operations/note-conventions.test.ts index 22343c3..859c9aa 100644 --- a/src/operations/note-conventions.test.ts +++ b/src/operations/note-conventions.test.ts @@ -1,6 +1,66 @@ import { describe, expect, it } from 'vitest'; -import { applyNoteConventions } from './note-conventions.js'; +import { + applyNoteConventions, + formatTagsAsInlineSyntax, + insertInlineTags, + parseFrontmatter, +} from './note-conventions.js'; + +describe('parseFrontmatter', () => { + it('returns null frontmatter when text does not start with ---', () => { + const text = '# Title\nbody'; + expect(parseFrontmatter(text)).toEqual({ frontmatter: null, body: text }); + }); + + it('detects frontmatter when --- is first line with closing ---', () => { + const result = parseFrontmatter('---\ntitle: Test\n---\nbody'); + expect(result.frontmatter).toBe('---\ntitle: Test\n---'); + expect(result.body).toBe('body'); + }); + + it('returns null frontmatter when no closing --- exists', () => { + const text = '---\nno closing line\ncontent'; + expect(parseFrontmatter(text)).toEqual({ frontmatter: null, body: text }); + }); + + it('ignores horizontal rules in body — --- not at line 1', () => { + const text = '# Title\n---\nhorizontal rule\n---\nbody'; + expect(parseFrontmatter(text)).toEqual({ frontmatter: null, body: text }); + }); + + it('handles empty body after frontmatter', () => { + const result = parseFrontmatter('---\nkey: val\n---\n'); + expect(result.frontmatter).toBe('---\nkey: val\n---'); + expect(result.body).toBe(''); + }); + + it('handles multi-key frontmatter with body including H1', () => { + const text = '---\ntitle: My Note\ntags: [work]\n---\n# My Note\ncontent'; + const result = parseFrontmatter(text); + expect(result.frontmatter).toBe('---\ntitle: My Note\ntags: [work]\n---'); + expect(result.body).toBe('# My Note\ncontent'); + }); + + it('returns null frontmatter when --- is present but not at line 1', () => { + const text = '\n---\nkey: val\n---\nbody'; + expect(parseFrontmatter(text)).toEqual({ frontmatter: null, body: text }); + }); +}); + +describe('formatTagsAsInlineSyntax', () => { + it('converts comma-separated tags to Bear inline syntax', () => { + expect(formatTagsAsInlineSyntax('work,urgent')).toBe('#work #urgent'); + }); + + it('adds closing hash for tags with spaces', () => { + expect(formatTagsAsInlineSyntax('my tag')).toBe('#my tag#'); + }); + + it('returns empty string for all-invalid tags', () => { + expect(formatTagsAsInlineSyntax('###,,,')).toBe(''); + }); +}); describe('applyNoteConventions', () => { describe('pass-through when no tags provided', () => { @@ -109,3 +169,76 @@ describe('applyNoteConventions', () => { }); }); }); + +describe('insertInlineTags', () => { + it('appends tags at the end for default placement', () => { + const result = insertInlineTags('# Title\nBody', '#work', 'end'); + + expect(result).toBe('# Title\nBody\n#work'); + }); + + it('inserts tags after the title without a separator by default', () => { + const result = insertInlineTags('# Title\nBody', '#work', 'after-title'); + + expect(result).toBe('# Title\n#work\nBody'); + }); + + it('can insert tags after the title with a separator for new note creation', () => { + const result = insertInlineTags('# Title\nBody', '#work', 'after-title', { + separatorAfterTags: true, + }); + + expect(result).toBe('# Title\n#work\n---\nBody'); + }); + + it('merges tags into an existing tag line after the title without adding a separator', () => { + const result = insertInlineTags('# Title\n#existing\nBody', '#work', 'after-title', { + separatorAfterTags: true, + }); + + expect(result).toBe('# Title\n#existing #work\nBody'); + }); + + it('preserves an existing separator after an existing tag line', () => { + const result = insertInlineTags('# Title\n#existing\n---\nBody', '#work', 'after-title', { + separatorAfterTags: true, + }); + + expect(result).toBe('# Title\n#existing #work\n---\nBody'); + }); + + it('does not insert tags before a title when body comes from frontmatter parsing', () => { + const parsed = parseFrontmatter('---\nstatus: draft\n---\n# Title\nBody'); + const body = insertInlineTags(parsed.body, '#work', 'after-title'); + + expect(`${parsed.frontmatter}\n${body}`).toBe('---\nstatus: draft\n---\n# Title\n#work\nBody'); + }); + + it('falls back to top-of-body placement without a separator when after-title has no H1', () => { + const result = insertInlineTags('Body without title', '#work', 'after-title'); + + expect(result).toBe('#work\nBody without title'); + }); + + it('can include a separator in the no-H1 fallback for new note creation', () => { + const result = insertInlineTags('Body without title', '#work', 'after-title', { + separatorAfterTags: true, + }); + + expect(result).toBe('#work\n---\nBody without title'); + }); + + it('merges with a leading tag line when after-title fallback has no H1', () => { + const result = insertInlineTags('#existing\nBody without title', '#work', 'after-title', { + separatorAfterTags: true, + }); + + expect(result).toBe('#existing #work\nBody without title'); + }); + + it('omits separator when inserting after a title-only body', () => { + const result = insertInlineTags('# Title', '#work', 'after-title'); + + expect(result).toBe('# Title\n#work'); + }); +}); diff --git a/src/operations/note-conventions.ts b/src/operations/note-conventions.ts index b4c1862..f11c27b 100644 --- a/src/operations/note-conventions.ts +++ b/src/operations/note-conventions.ts @@ -1,3 +1,41 @@ +/** + * Parses YAML frontmatter from note text. + * Frontmatter is only recognized when `---` is the very first line, + * followed by a closing `---` on its own line. This prevents horizontal + * rules inside a note body from being mistaken for frontmatter. + */ +export function parseFrontmatter(text: string): { frontmatter: string | null; body: string } { + if (!text.startsWith('---\n')) { + return { frontmatter: null, body: text }; + } + + const rest = text.slice(4); // skip opening ---\n + const match = rest.match(/^---$/m); + if (!match || match.index === undefined) { + return { frontmatter: null, body: text }; + } + + const frontmatter = `---\n${rest.slice(0, match.index)}---`; + const afterClosing = rest.slice(match.index + 3); // skip closing --- + const body = afterClosing.startsWith('\n') ? afterClosing.slice(1) : afterClosing; + + return { frontmatter, body }; +} + +/** + * Converts a comma-separated tag string into Bear inline tag syntax. + * Returns an empty string if all tags are invalid. + */ +export function formatTagsAsInlineSyntax(tags: string): string { + return tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + .map(toBearTagSyntax) + .filter(Boolean) + .join(' '); +} + /** * Applies note creation conventions by embedding tags as Bear inline syntax * at the start of the text body, rather than passing them as URL parameters @@ -11,13 +49,7 @@ export function applyNoteConventions(input: { return { text: input.text, tags: undefined }; } - const tagLine = input.tags - .split(',') - .map((t) => t.trim()) - .filter(Boolean) - .map(toBearTagSyntax) - .filter(Boolean) - .join(' '); + const tagLine = formatTagsAsInlineSyntax(input.tags); // All tags were invalid (e.g., "###,,,") — pass text through unchanged if (!tagLine) { @@ -29,6 +61,60 @@ export function applyNoteConventions(input: { return { text, tags: undefined }; } +/** + * Inserts a Bear inline tag line into text that may be the body following YAML frontmatter. + * The "after-title" placement inserts tags immediately after the opening H1. + * New note creation may request the same horizontal-rule separator used by applyNoteConventions(). + */ +export function insertInlineTags( + text: string, + tagLine: string, + placement: 'after-title' | 'end', + options: { separatorAfterTags?: boolean } = {} +): string { + if (!tagLine) return text; + + if (placement === 'end') { + return text ? `${text}\n${tagLine}` : tagLine; + } + + const titleMatch = text.match(/^(#\s+.+?)(?:\n|$)/); + if (!titleMatch) { + if (!text) return tagLine; + const merged = mergeWithLeadingTagLine(text, tagLine); + if (merged) return merged; + const separator = options.separatorAfterTags ? '\n---' : ''; + return `${tagLine}${separator}\n${text}`; + } + + const titleLine = titleMatch[1]; + const remainingBody = text.slice(titleMatch[0].length); + const merged = mergeWithLeadingTagLine(remainingBody, tagLine); + if (merged) return [titleLine, merged].join('\n'); + + const segments = [titleLine, tagLine]; + if (remainingBody) { + if (options.separatorAfterTags) segments.push('---'); + segments.push(remainingBody); + } + + return segments.join('\n'); +} + +function mergeWithLeadingTagLine(text: string, tagLine: string): string | null { + const lineEnd = text.indexOf('\n'); + const firstLine = lineEnd === -1 ? text : text.slice(0, lineEnd); + if (!isInlineTagLine(firstLine)) return null; + + const rest = lineEnd === -1 ? '' : text.slice(lineEnd); + return `${firstLine.trimEnd()} ${tagLine}${rest}`; +} + +function isInlineTagLine(line: string): boolean { + const trimmed = line.trimStart(); + return trimmed.startsWith('#') && !trimmed.startsWith('# ') && !trimmed.startsWith('##'); +} + /** * Bear uses `#tag` for simple tags and `#tag#` (closing hash) for * multi-word tags containing spaces. Slashes create hierarchy without diff --git a/src/tools/note-tools.ts b/src/tools/note-tools.ts index 06836bb..bf1feb3 100644 --- a/src/tools/note-tools.ts +++ b/src/tools/note-tools.ts @@ -7,7 +7,12 @@ import { z } from 'zod'; import { ENABLE_CONTENT_REPLACEMENT, ENABLE_NEW_NOTE_CONVENTIONS } from '../config.js'; import { logger } from '../logging.js'; -import { applyNoteConventions } from '../operations/note-conventions.js'; +import { + applyNoteConventions, + formatTagsAsInlineSyntax, + insertInlineTags, + parseFrontmatter, +} from '../operations/note-conventions.js'; import { cleanBase64 } from '../operations/bear-encoding.js'; import { awaitNoteCreation, @@ -265,16 +270,40 @@ Use bear-search-notes to find the correct note identifier.`); ); try { - // If ENABLE_NOTE_CONVENTIONS is true, embed tags in the text body using Bear's inline tag syntax, rather than passing as URL parameters - const { text: createText, tags: createTags } = ENABLE_NEW_NOTE_CONVENTIONS - ? applyNoteConventions({ text, tags }) - : { text, tags }; - - const url = buildBearUrl('create', { title, text: createText, tags: createTags }); + const parsed = text ? parseFrontmatter(text) : null; + + let url: string; + let pollTitle: string | undefined; + + if (parsed?.frontmatter !== null && parsed !== null) { + // Frontmatter path: assemble the full note content so Bear doesn't + // insert a title H1 or tags outside the frontmatter block. + const tagLine = tags ? formatTagsAsInlineSyntax(tags) : ''; + const bodySegments: string[] = []; + if (title) bodySegments.push(`# ${title}`); + if (parsed.body) bodySegments.push(parsed.body); + const body = insertInlineTags( + bodySegments.join('\n'), + tagLine, + ENABLE_NEW_NOTE_CONVENTIONS ? 'after-title' : 'end', + { separatorAfterTags: ENABLE_NEW_NOTE_CONVENTIONS } + ); + const assembled = body ? `${parsed.frontmatter}\n${body}` : parsed.frontmatter; + + url = buildBearUrl('create', { text: assembled }); + pollTitle = title; + } else { + // Standard path: no frontmatter detected + const { text: createText, tags: createTags } = ENABLE_NEW_NOTE_CONVENTIONS + ? applyNoteConventions({ text, tags }) + : { text, tags }; + url = buildBearUrl('create', { title, text: createText, tags: createTags }); + pollTitle = title; + } await executeBearXCallbackApi(url); - const createdNoteId = title ? await awaitNoteCreation(title) : undefined; + const createdNoteId = pollTitle ? await awaitNoteCreation(pollTitle) : undefined; const responseLines: string[] = ['Bear note created successfully!', '']; @@ -737,7 +766,7 @@ The file has been attached to your Bear note.`); { title: 'Add Tags to Note', description: - 'Add one or more tags to an existing Bear note. Tags are added at the beginning of the note. Use bear-list-tags to see available tags.', + 'Add one or more tags to an existing Bear note. Tags are inserted after any YAML frontmatter, preserving document structure. Use bear-list-tags to see available tags.', inputSchema: { id: z .string() @@ -767,16 +796,39 @@ The file has been attached to your Bear note.`); Use bear-search-notes to find the correct note identifier.`); } - const tagsString = tags.join(','); - - const url = buildBearUrl('add-text', { - id, - tags: tagsString, - mode: 'prepend', - open_note: 'no', - show_window: 'no', - new_window: 'no', - }); + const noteText = existingNote.text || ''; + const parsed = parseFrontmatter(noteText); + let url: string; + + if (parsed.frontmatter !== null) { + // Frontmatter present: rebuild the full note so tags follow the configured + // placement without clobbering the YAML block. + const tagLine = formatTagsAsInlineSyntax(tags.join(',')); + const body = insertInlineTags( + parsed.body, + tagLine, + ENABLE_NEW_NOTE_CONVENTIONS ? 'after-title' : 'end' + ); + const newText = body ? `${parsed.frontmatter}\n${body}` : parsed.frontmatter; + url = buildBearUrl('add-text', { + id, + text: newText, + mode: 'replace_all', + open_note: 'no', + show_window: 'no', + new_window: 'no', + }); + } else { + // No frontmatter: original prepend behavior + url = buildBearUrl('add-text', { + id, + tags: tags.join(','), + mode: 'prepend', + open_note: 'no', + show_window: 'no', + new_window: 'no', + }); + } await executeBearXCallbackApi(url); @@ -788,7 +840,7 @@ Note: "${existingNote.title}" ID: ${id} Tags: ${tagList} -The tags have been added to the beginning of the note.`); +The tags have been added to the note.`); } catch (error) { logger.error('bear-add-tag failed:', error); throw error; diff --git a/tests/system/frontmatter.test.ts b/tests/system/frontmatter.test.ts new file mode 100644 index 0000000..00ed673 --- /dev/null +++ b/tests/system/frontmatter.test.ts @@ -0,0 +1,302 @@ +import { afterAll, describe, expect, it } from 'vitest'; + +import { + callTool, + cleanupTestNotes, + extractNoteBody, + trashNote, + tryExtractNoteId, + uniqueTitle, +} from './inspector.js'; + +const TEST_PREFIX = '[Bear-MCP-stest-frontmatter]'; +const RUN_ID = Date.now(); + +afterAll(() => { + cleanupTestNotes(TEST_PREFIX); +}); + +describe('bear-create-note preserves YAML frontmatter', () => { + it('creates note with frontmatter intact and title as H1', () => { + const title = uniqueTitle(TEST_PREFIX, 'CreateFM', RUN_ID); + let noteId: string | undefined; + + try { + const fm = '---\nstatus: draft\nproject: test\n---'; + const text = `${fm}\nBody content here.`; + + const createResult = callTool({ + toolName: 'bear-create-note', + args: { title, text }, + }).content[0].text; + + noteId = tryExtractNoteId(createResult) ?? undefined; + expect(noteId, `Expected "Note ID: " in: ${createResult}`).toBeDefined(); + + const openResult = callTool({ + toolName: 'bear-open-note', + args: { id: noteId! }, + }).content[0].text; + + const body = extractNoteBody(openResult); + // Frontmatter block must appear before the title + expect(body.indexOf('---\nstatus: draft')).toBeLessThan(body.indexOf(`# ${title}`)); + expect(body).toContain('status: draft'); + expect(body).toContain('Body content here.'); + } finally { + if (noteId) trashNote(noteId); + } + }); + + it('creates note with frontmatter and tags at the end by default', () => { + const title = uniqueTitle(TEST_PREFIX, 'CreateFMTags', RUN_ID); + let noteId: string | undefined; + + try { + const fm = '---\nstatus: active\n---'; + const text = `${fm}\nNote body.`; + + const createResult = callTool({ + toolName: 'bear-create-note', + args: { title, text, tags: 'stest-frontmatter' }, + }).content[0].text; + + noteId = tryExtractNoteId(createResult) ?? undefined; + expect(noteId).toBeDefined(); + + const openResult = callTool({ + toolName: 'bear-open-note', + args: { id: noteId! }, + }).content[0].text; + + const body = extractNoteBody(openResult); + const tagPos = body.indexOf('#stest-frontmatter'); + expect(tagPos).toBeGreaterThan(body.indexOf(`# ${title}`)); + expect(tagPos).toBeGreaterThan(body.indexOf('Note body.')); + // Frontmatter must not be broken + expect(body).toContain('status: active'); + } finally { + if (noteId) trashNote(noteId); + } + }); + + it('creates note with frontmatter and tags after title when convention is enabled', () => { + const title = uniqueTitle(TEST_PREFIX, 'CreateFMTagsAfterTitle', RUN_ID); + let noteId: string | undefined; + + try { + const fm = '---\nstatus: active\n---'; + const text = `${fm}\nNote body.`; + + const createResult = callTool({ + toolName: 'bear-create-note', + args: { title, text, tags: 'stest-frontmatter' }, + env: { UI_ENABLE_NEW_NOTE_CONVENTION: 'true' }, + }).content[0].text; + + noteId = tryExtractNoteId(createResult) ?? undefined; + expect(noteId).toBeDefined(); + + const openResult = callTool({ + toolName: 'bear-open-note', + args: { id: noteId! }, + }).content[0].text; + + const body = extractNoteBody(openResult); + const titlePos = body.indexOf(`# ${title}`); + const tagPos = body.indexOf('#stest-frontmatter'); + expect(tagPos).toBeGreaterThan(titlePos); + expect(tagPos).toBeLessThan(body.indexOf('Note body.')); + expect(body).toContain(`# ${title}\n#stest-frontmatter\n---\nNote body.`); + } finally { + if (noteId) trashNote(noteId); + } + }); + + it('creates note with frontmatter by merging into an existing tag line', () => { + const title = uniqueTitle(TEST_PREFIX, 'CreateFMExistingTags', RUN_ID); + let noteId: string | undefined; + + try { + const fm = '---\nstatus: active\n---'; + const text = `${fm}\n#existing\nBody with existing tags.`; + + const createResult = callTool({ + toolName: 'bear-create-note', + args: { title, text, tags: 'stest-frontmatter' }, + env: { UI_ENABLE_NEW_NOTE_CONVENTION: 'true' }, + }).content[0].text; + + noteId = tryExtractNoteId(createResult) ?? undefined; + expect(noteId).toBeDefined(); + + const openResult = callTool({ + toolName: 'bear-open-note', + args: { id: noteId! }, + }).content[0].text; + + const body = extractNoteBody(openResult); + expect(body).toContain(`# ${title}\n#existing #stest-frontmatter\nBody with existing tags.`); + expect(body).not.toContain('#stest-frontmatter\n---\n'); + } finally { + if (noteId) trashNote(noteId); + } + }); + + it('non-frontmatter text is unaffected (backward compat)', () => { + const title = uniqueTitle(TEST_PREFIX, 'NoFM', RUN_ID); + let noteId: string | undefined; + + try { + const createResult = callTool({ + toolName: 'bear-create-note', + args: { title, text: 'Plain body without frontmatter.', tags: 'stest-frontmatter' }, + }).content[0].text; + + noteId = tryExtractNoteId(createResult) ?? undefined; + expect(noteId).toBeDefined(); + + const openResult = callTool({ + toolName: 'bear-open-note', + args: { id: noteId! }, + }).content[0].text; + + expect(openResult).toContain('Plain body without frontmatter.'); + } finally { + if (noteId) trashNote(noteId); + } + }); +}); + +describe('bear-add-tag on notes with YAML frontmatter', () => { + it('appends tags at the end by default without clobbering frontmatter', () => { + const title = uniqueTitle(TEST_PREFIX, 'AddTagFM', RUN_ID); + let noteId: string | undefined; + + try { + const fm = '---\nstatus: draft\n---'; + const text = `${fm}\nContent below frontmatter.`; + + callTool({ + toolName: 'bear-create-note', + args: { title, text }, + }); + + // Find the note created above + const searchResult = callTool({ + toolName: 'bear-search-notes', + args: { term: title }, + }).content[0].text; + + noteId = tryExtractNoteId(searchResult) ?? undefined; + expect(noteId).toBeDefined(); + + const tag = `stest-fm-tag-${RUN_ID}`; + const addTagResult = callTool({ + toolName: 'bear-add-tag', + args: { id: noteId!, tags: JSON.stringify([tag]) }, + }).content[0].text; + + expect(addTagResult).toContain('added successfully'); + + const openResult = callTool({ + toolName: 'bear-open-note', + args: { id: noteId! }, + }).content[0].text; + + const body = extractNoteBody(openResult); + // Frontmatter must still be intact + expect(body).toContain('status: draft'); + // Tag must be present + expect(body).toContain(`#${tag}`); + // Tag must not appear between frontmatter and the title/body + expect(body.indexOf(`#${tag}`)).toBeGreaterThan(body.indexOf('Content below frontmatter.')); + // --- must still be line 1 (frontmatter not clobbered) + expect(body.startsWith('---')).toBe(true); + } finally { + if (noteId) trashNote(noteId); + } + }); + + it('inserts tags after title when convention is enabled without clobbering frontmatter', () => { + const title = uniqueTitle(TEST_PREFIX, 'AddTagFMAfterTitle', RUN_ID); + let noteId: string | undefined; + + try { + const fm = '---\nstatus: draft\n---'; + const text = `${fm}\nContent below frontmatter.`; + + const createResult = callTool({ + toolName: 'bear-create-note', + args: { title, text }, + }).content[0].text; + + noteId = tryExtractNoteId(createResult) ?? undefined; + expect(noteId).toBeDefined(); + + const tag = `stest-fm-tag-after-title-${RUN_ID}`; + const addTagResult = callTool({ + toolName: 'bear-add-tag', + args: { id: noteId!, tags: JSON.stringify([tag]) }, + env: { UI_ENABLE_NEW_NOTE_CONVENTION: 'true' }, + }).content[0].text; + + expect(addTagResult).toContain('added successfully'); + + const openResult = callTool({ + toolName: 'bear-open-note', + args: { id: noteId! }, + }).content[0].text; + + const body = extractNoteBody(openResult); + const titlePos = body.indexOf(`# ${title}`); + const tagPos = body.indexOf(`#${tag}`); + expect(body.startsWith('---')).toBe(true); + expect(tagPos).toBeGreaterThan(titlePos); + expect(tagPos).toBeLessThan(body.indexOf('Content below frontmatter.')); + expect(body).toContain(`# ${title}\n#${tag}\nContent below frontmatter.`); + expect(body).not.toContain(`#${tag}\n---\n`); + } finally { + if (noteId) trashNote(noteId); + } + }); + + it('merges tags into an existing tag line when convention is enabled', () => { + const title = uniqueTitle(TEST_PREFIX, 'AddTagFMExistingTags', RUN_ID); + let noteId: string | undefined; + + try { + const fm = '---\nstatus: draft\n---'; + const text = `${fm}\n#existing\nContent below frontmatter.`; + + const createResult = callTool({ + toolName: 'bear-create-note', + args: { title, text }, + }).content[0].text; + + noteId = tryExtractNoteId(createResult) ?? undefined; + expect(noteId).toBeDefined(); + + const tag = `stest-fm-tag-existing-${RUN_ID}`; + const addTagResult = callTool({ + toolName: 'bear-add-tag', + args: { id: noteId!, tags: JSON.stringify([tag]) }, + env: { UI_ENABLE_NEW_NOTE_CONVENTION: 'true' }, + }).content[0].text; + + expect(addTagResult).toContain('added successfully'); + + const openResult = callTool({ + toolName: 'bear-open-note', + args: { id: noteId! }, + }).content[0].text; + + const body = extractNoteBody(openResult); + expect(body.startsWith('---')).toBe(true); + expect(body).toContain(`# ${title}\n#existing #${tag}\nContent below frontmatter.`); + expect(body).not.toContain(`#${tag}\n---\n`); + } finally { + if (noteId) trashNote(noteId); + } + }); +});