bear-notes-mcp 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/bear-urls.js +3 -4
- package/dist/config.js +2 -1
- package/dist/database.js +1 -1
- package/dist/main.js +51 -4
- package/dist/notes.js +27 -12
- package/dist/tags.js +1 -0
- package/dist/utils.js +62 -12
- package/dist/utils.test.js +74 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -6,10 +6,11 @@ Search, read, create, and update your Bear Notes from any AI assistant.
|
|
|
6
6
|
|
|
7
7
|
## Key Features
|
|
8
8
|
|
|
9
|
-
- **
|
|
9
|
+
- **10 MCP tools** for full Bear Notes integration
|
|
10
10
|
- **OCR search** across images and PDFs attached to notes
|
|
11
11
|
- **Date-based search** with relative dates ("yesterday", "last week", etc.)
|
|
12
12
|
- **Configurable new note convention** for tag placement (opt-in)
|
|
13
|
+
- **Content replacement** for replacing note body or specific sections (opt-in)
|
|
13
14
|
- **Local-only** — no network calls, all data stays on your Mac
|
|
14
15
|
|
|
15
16
|
## Tools
|
|
@@ -19,6 +20,7 @@ Search, read, create, and update your Bear Notes from any AI assistant.
|
|
|
19
20
|
- **`bear-create-note`** - Create a new note in your Bear library with optional title, content, and tags
|
|
20
21
|
- **`bear-search-notes`** - Find notes by searching text content, filtering by tags, or date ranges. Includes OCR search in attachments
|
|
21
22
|
- **`bear-add-text`** - Add text to an existing Bear note at the beginning or end, optionally targeting a specific section
|
|
23
|
+
- **`bear-replace-text`** - Replace content in an existing Bear note — either the full body or a specific section. Requires content replacement to be enabled in settings.
|
|
22
24
|
- **`bear-add-file`** - Attach a file (image, PDF, Excel, etc.) to an existing Bear note using base64-encoded content
|
|
23
25
|
- **`bear-list-tags`** - List all tags in your Bear library as a hierarchical tree with note counts
|
|
24
26
|
- **`bear-find-untagged-notes`** - Find notes in your Bear library that have no tags assigned
|
|
@@ -56,6 +58,7 @@ Add to your MCP configuration file:
|
|
|
56
58
|
|---|---|---|
|
|
57
59
|
| `UI_DEBUG_TOGGLE` | `false` | Enable debug logging for troubleshooting |
|
|
58
60
|
| `UI_ENABLE_NEW_NOTE_CONVENTION` | `false` | Place tags right after the note title instead of at the bottom |
|
|
61
|
+
| `UI_ENABLE_CONTENT_REPLACEMENT` | `false` | Enable the `bear-replace-text` tool for replacing note content |
|
|
59
62
|
|
|
60
63
|
Example with configuration:
|
|
61
64
|
```json
|
|
@@ -66,6 +69,7 @@ Example with configuration:
|
|
|
66
69
|
"args": ["-y", "bear-notes-mcp@latest"],
|
|
67
70
|
"env": {
|
|
68
71
|
"UI_ENABLE_NEW_NOTE_CONVENTION": "true",
|
|
72
|
+
"UI_ENABLE_CONTENT_REPLACEMENT": "true",
|
|
69
73
|
"UI_DEBUG_TOGGLE": "true"
|
|
70
74
|
}
|
|
71
75
|
}
|
package/dist/bear-urls.js
CHANGED
|
@@ -27,14 +27,13 @@ export function buildBearUrl(action, params = {}) {
|
|
|
27
27
|
if (params.mode !== undefined) {
|
|
28
28
|
urlParams.set('mode', params.mode);
|
|
29
29
|
}
|
|
30
|
+
if (params.new_line !== undefined) {
|
|
31
|
+
urlParams.set('new_line', params.new_line);
|
|
32
|
+
}
|
|
30
33
|
// UX params with defaults
|
|
31
34
|
urlParams.set('open_note', params.open_note ?? 'yes');
|
|
32
35
|
urlParams.set('new_window', params.new_window ?? 'no');
|
|
33
36
|
urlParams.set('show_window', params.show_window ?? 'yes');
|
|
34
|
-
// Add required Bear API parameters for add-text action
|
|
35
|
-
if (action === 'add-text') {
|
|
36
|
-
urlParams.set('new_line', 'yes'); // Ensures text appears on new line
|
|
37
|
-
}
|
|
38
37
|
// Convert URLSearchParams to proper URL encoding (Bear expects %20 not +)
|
|
39
38
|
const queryString = urlParams.toString().replace(/\+/g, '%20');
|
|
40
39
|
const finalUrl = `${baseUrl}?${queryString}`;
|
package/dist/config.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
export const APP_VERSION = '2.
|
|
1
|
+
export const APP_VERSION = '2.5.0';
|
|
2
2
|
export const BEAR_URL_SCHEME = 'bear://x-callback-url/';
|
|
3
3
|
export const CORE_DATA_EPOCH_OFFSET = 978307200; // 2001-01-01 to Unix epoch
|
|
4
4
|
export const DEFAULT_SEARCH_LIMIT = 50;
|
|
5
5
|
export const BEAR_DATABASE_PATH = 'Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear/Application Data/database.sqlite';
|
|
6
6
|
export const ENABLE_NEW_NOTE_CONVENTIONS = process.env.UI_ENABLE_NEW_NOTE_CONVENTION === 'true';
|
|
7
|
+
export const ENABLE_CONTENT_REPLACEMENT = process.env.UI_ENABLE_CONTENT_REPLACEMENT === 'true';
|
|
7
8
|
export const ERROR_MESSAGES = {
|
|
8
9
|
BEAR_DATABASE_NOT_FOUND: 'Bear database not found. Please ensure Bear Notes is installed and has been opened at least once.',
|
|
9
10
|
};
|
package/dist/database.js
CHANGED
|
@@ -29,7 +29,7 @@ export function openBearDatabase() {
|
|
|
29
29
|
const databasePath = getBearDatabasePath();
|
|
30
30
|
logger.info(`Opening Bear database at: ${databasePath}`);
|
|
31
31
|
try {
|
|
32
|
-
const db = new DatabaseSync(databasePath);
|
|
32
|
+
const db = new DatabaseSync(databasePath, { readOnly: true });
|
|
33
33
|
logger.debug('Bear database opened successfully');
|
|
34
34
|
return db;
|
|
35
35
|
}
|
package/dist/main.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { APP_VERSION, ENABLE_NEW_NOTE_CONVENTIONS } from './config.js';
|
|
5
|
+
import { APP_VERSION, ENABLE_CONTENT_REPLACEMENT, ENABLE_NEW_NOTE_CONVENTIONS } from './config.js';
|
|
6
6
|
import { applyNoteConventions } from './note-conventions.js';
|
|
7
|
-
import { cleanBase64, createToolResponse,
|
|
7
|
+
import { cleanBase64, createToolResponse, handleNoteTextUpdate, logger } from './utils.js';
|
|
8
8
|
import { getNoteContent, searchNotes } from './notes.js';
|
|
9
9
|
import { findUntaggedNotes, listTags } from './tags.js';
|
|
10
10
|
import { buildBearUrl, executeBearXCallbackApi } from './bear-urls.js';
|
|
@@ -225,13 +225,60 @@ server.registerTool('bear-add-text', {
|
|
|
225
225
|
},
|
|
226
226
|
annotations: {
|
|
227
227
|
readOnlyHint: false,
|
|
228
|
-
destructiveHint:
|
|
228
|
+
destructiveHint: false,
|
|
229
229
|
idempotentHint: false,
|
|
230
230
|
openWorldHint: true,
|
|
231
231
|
},
|
|
232
232
|
}, async ({ id, text, header, position }) => {
|
|
233
233
|
const mode = position === 'beginning' ? 'prepend' : 'append';
|
|
234
|
-
return
|
|
234
|
+
return handleNoteTextUpdate(mode, { id, text, header });
|
|
235
|
+
});
|
|
236
|
+
server.registerTool('bear-replace-text', {
|
|
237
|
+
title: 'Replace Note Content',
|
|
238
|
+
description: 'Replace content in an existing Bear note — either the full body or a specific section. Requires content replacement to be enabled in extension settings. Use bear-search-notes first to get the note ID.',
|
|
239
|
+
inputSchema: {
|
|
240
|
+
id: z
|
|
241
|
+
.string()
|
|
242
|
+
.trim()
|
|
243
|
+
.min(1, 'Note ID is required')
|
|
244
|
+
.describe('Note identifier (ID) from bear-search-notes'),
|
|
245
|
+
scope: z
|
|
246
|
+
.enum(['section', 'full-note-body'])
|
|
247
|
+
.describe("Replacement target: 'section' replaces under a specific header (requires header), 'full-note-body' replaces the entire note body (header must not be set)"),
|
|
248
|
+
text: z
|
|
249
|
+
.string()
|
|
250
|
+
.trim()
|
|
251
|
+
.min(1, 'Text content is required')
|
|
252
|
+
.describe('Replacement text content'),
|
|
253
|
+
header: z
|
|
254
|
+
.string()
|
|
255
|
+
.trim()
|
|
256
|
+
.optional()
|
|
257
|
+
.describe('Section header to target — required when scope is "section", forbidden when scope is "full-note-body"'),
|
|
258
|
+
},
|
|
259
|
+
annotations: {
|
|
260
|
+
readOnlyHint: false,
|
|
261
|
+
destructiveHint: true,
|
|
262
|
+
idempotentHint: true,
|
|
263
|
+
openWorldHint: true,
|
|
264
|
+
},
|
|
265
|
+
}, async ({ id, scope, text, header }) => {
|
|
266
|
+
if (!ENABLE_CONTENT_REPLACEMENT) {
|
|
267
|
+
return createToolResponse(`Content replacement is not enabled.
|
|
268
|
+
|
|
269
|
+
To use replace mode, enable "Content Replacement" in the Bear Notes extension settings.`);
|
|
270
|
+
}
|
|
271
|
+
if (scope === 'section' && !header) {
|
|
272
|
+
return createToolResponse(`scope is "section" but no header was provided.
|
|
273
|
+
|
|
274
|
+
Set the header parameter to the section heading you want to replace.`);
|
|
275
|
+
}
|
|
276
|
+
if (scope === 'full-note-body' && header) {
|
|
277
|
+
return createToolResponse(`scope is "full-note-body" but a header was provided.
|
|
278
|
+
|
|
279
|
+
Remove the header parameter to replace the full note body, or change scope to "section".`);
|
|
280
|
+
}
|
|
281
|
+
return handleNoteTextUpdate('replace', { id, text, header });
|
|
235
282
|
});
|
|
236
283
|
server.registerTool('bear-add-file', {
|
|
237
284
|
title: 'Add File to Note',
|
package/dist/notes.js
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { DEFAULT_SEARCH_LIMIT } from './config.js';
|
|
2
2
|
import { convertCoreDataTimestamp, convertDateToCoreDataTimestamp, logAndThrow, logger, parseDateString, } from './utils.js';
|
|
3
3
|
import { openBearDatabase } from './database.js';
|
|
4
|
+
// SQL equivalent of decodeTagName() in tags.ts — both MUST apply the same transformations
|
|
5
|
+
const DECODED_TAG_TITLE = "LOWER(TRIM(REPLACE(t.ZTITLE, '+', ' ')))";
|
|
6
|
+
/**
|
|
7
|
+
* Builds a SQL WHERE clause that matches a tag exactly or its nested children.
|
|
8
|
+
* Escapes LIKE wildcards (%, _) in the tag name to prevent unintended pattern matching.
|
|
9
|
+
*/
|
|
10
|
+
function buildTagMatchClause(tag) {
|
|
11
|
+
const normalizedTag = tag.trim().toLowerCase();
|
|
12
|
+
const escapedTag = normalizedTag.replace(/[%_\\]/g, '\\$&');
|
|
13
|
+
return {
|
|
14
|
+
sql: ` AND (
|
|
15
|
+
${DECODED_TAG_TITLE} = ?
|
|
16
|
+
OR ${DECODED_TAG_TITLE} LIKE ? || '/%' ESCAPE '\\'
|
|
17
|
+
)`,
|
|
18
|
+
params: [normalizedTag, escapedTag],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
4
21
|
function formatBearNote(row) {
|
|
5
22
|
const title = row.title || 'Untitled';
|
|
6
23
|
const identifier = row.identifier;
|
|
@@ -150,6 +167,11 @@ export function searchNotes(searchTerm, tag, limit, dateFilter, pinned) {
|
|
|
150
167
|
innerQuery += `
|
|
151
168
|
JOIN Z_5PINNEDINTAGS pt ON pt.Z_5PINNEDNOTES = note.Z_PK
|
|
152
169
|
JOIN ZSFNOTETAG t ON t.Z_PK = pt.Z_13PINNEDINTAGS`;
|
|
170
|
+
}
|
|
171
|
+
else if (hasTag) {
|
|
172
|
+
innerQuery += `
|
|
173
|
+
JOIN Z_5TAGS nt ON nt.Z_5NOTES = note.Z_PK
|
|
174
|
+
JOIN ZSFNOTETAG t ON t.Z_PK = nt.Z_13TAGS`;
|
|
153
175
|
}
|
|
154
176
|
innerQuery += `
|
|
155
177
|
WHERE note.ZARCHIVED = 0
|
|
@@ -162,24 +184,17 @@ export function searchNotes(searchTerm, tag, limit, dateFilter, pinned) {
|
|
|
162
184
|
innerQuery += ' AND (note.ZTITLE LIKE ? OR note.ZTEXT LIKE ? OR f.ZSEARCHTEXT LIKE ?)';
|
|
163
185
|
queryParams.push(searchPattern, searchPattern, searchPattern);
|
|
164
186
|
}
|
|
165
|
-
//
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
queryParams.push(tagPattern);
|
|
187
|
+
// Tag clause applies to both pinned+tag and tag-only paths (JOINs differ above)
|
|
188
|
+
if (hasTag) {
|
|
189
|
+
const tagClause = buildTagMatchClause(tag);
|
|
190
|
+
innerQuery += tagClause.sql;
|
|
191
|
+
queryParams.push(...tagClause.params);
|
|
171
192
|
}
|
|
172
193
|
else if (hasPinnedFilter) {
|
|
173
194
|
// All pinned notes: globally pinned OR pinned in any tag (matches Bear's "Pinned" section)
|
|
174
195
|
innerQuery +=
|
|
175
196
|
' AND (note.ZPINNED = 1 OR EXISTS (SELECT 1 FROM Z_5PINNEDINTAGS pt WHERE pt.Z_5PINNEDNOTES = note.Z_PK))';
|
|
176
197
|
}
|
|
177
|
-
else if (hasTag) {
|
|
178
|
-
// Text-based tag search
|
|
179
|
-
const tagPattern = `%#${tag.trim()}%`;
|
|
180
|
-
innerQuery += ' AND note.ZTEXT LIKE ?';
|
|
181
|
-
queryParams.push(tagPattern);
|
|
182
|
-
}
|
|
183
198
|
// Add date filtering
|
|
184
199
|
if (hasDateFilter && dateFilter) {
|
|
185
200
|
if (dateFilter.createdAfter) {
|
package/dist/tags.js
CHANGED
|
@@ -5,6 +5,7 @@ import { openBearDatabase } from './database.js';
|
|
|
5
5
|
* - Replaces '+' with spaces (Bear's URL encoding)
|
|
6
6
|
* - Converts to lowercase (matches Bear UI behavior)
|
|
7
7
|
* - Trims whitespace
|
|
8
|
+
* Keep in sync with DECODED_TAG_TITLE in notes.ts — both MUST apply the same transformations.
|
|
8
9
|
*/
|
|
9
10
|
function decodeTagName(encodedName) {
|
|
10
11
|
return encodedName.replace(/\+/g, ' ').trim().toLowerCase();
|
package/dist/utils.js
CHANGED
|
@@ -134,16 +134,43 @@ export function createToolResponse(text) {
|
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
/**
|
|
137
|
-
*
|
|
137
|
+
* Strips a matching markdown heading from the start of text to prevent header duplication.
|
|
138
|
+
* Bear's add-text API with mode=replace keeps the original section header, so if the
|
|
139
|
+
* replacement text also starts with that header, it appears twice in the note.
|
|
140
|
+
*
|
|
141
|
+
* @param text - The replacement text that may start with a duplicate heading
|
|
142
|
+
* @param header - The cleaned header name (no # prefix) to match against
|
|
143
|
+
* @returns Text with the leading heading removed if it matched, otherwise unchanged
|
|
144
|
+
*/
|
|
145
|
+
export function stripLeadingHeader(text, header) {
|
|
146
|
+
if (!header)
|
|
147
|
+
return text;
|
|
148
|
+
const escaped = header.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
149
|
+
const leadingHeaderRegex = new RegExp(`^#{1,6}\\s+${escaped}\\s*\\n?`, 'i');
|
|
150
|
+
return text.replace(leadingHeaderRegex, '');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Checks whether a markdown heading matching the given header text exists in the note.
|
|
154
|
+
* Strips markdown prefix from input (e.g., "## Foo" → "Foo") and matches case-insensitively.
|
|
155
|
+
* Escapes regex special characters so headers like "Q&A" or "Details (v2)" match literally.
|
|
156
|
+
*/
|
|
157
|
+
export function noteHasHeader(noteText, header) {
|
|
158
|
+
const cleanHeader = header.replace(/^#+\s*/, '');
|
|
159
|
+
const escaped = cleanHeader.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
160
|
+
const headerRegex = new RegExp(`^#{1,6}\\s+${escaped}\\s*$`, 'mi');
|
|
161
|
+
return headerRegex.test(noteText);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Shared handler for note text operations (append, prepend, or replace).
|
|
138
165
|
* Consolidates common validation, execution, and response logic.
|
|
139
166
|
*
|
|
140
|
-
* @param mode - Whether to append or
|
|
167
|
+
* @param mode - Whether to append, prepend, or replace text
|
|
141
168
|
* @param params - Note ID, text content, and optional header
|
|
142
169
|
* @returns Formatted response indicating success or failure
|
|
143
170
|
*/
|
|
144
|
-
export async function
|
|
145
|
-
const action = mode === 'append' ? 'appended' : 'prepended';
|
|
146
|
-
logger.info(`
|
|
171
|
+
export async function handleNoteTextUpdate(mode, { id, text, header }) {
|
|
172
|
+
const action = mode === 'append' ? 'appended' : mode === 'prepend' ? 'prepended' : 'replaced';
|
|
173
|
+
logger.info(`handleNoteTextUpdate(${mode}) id: ${id}, text length: ${text.length}, header: ${header || 'none'}`);
|
|
147
174
|
try {
|
|
148
175
|
const existingNote = getNoteContent(id);
|
|
149
176
|
if (!existingNote) {
|
|
@@ -151,28 +178,51 @@ export async function handleAddText(mode, { id, text, header }) {
|
|
|
151
178
|
|
|
152
179
|
Use bear-search-notes to find the correct note identifier.`);
|
|
153
180
|
}
|
|
154
|
-
// Strip markdown header syntax
|
|
181
|
+
// Strip markdown header syntax once — reused for both validation and Bear API
|
|
155
182
|
const cleanHeader = header?.replace(/^#+\s*/, '');
|
|
183
|
+
// Bear silently ignores replace-with-header when the section doesn't exist — fail early with a clear message
|
|
184
|
+
if (mode === 'replace' && cleanHeader) {
|
|
185
|
+
if (!existingNote.text || !noteHasHeader(existingNote.text, cleanHeader)) {
|
|
186
|
+
return createToolResponse(`Section "${cleanHeader}" not found in note "${existingNote.title}".
|
|
187
|
+
|
|
188
|
+
Check the note content with bear-open-note to see available sections.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Bear's replace mode preserves the original heading (section header or note title),
|
|
192
|
+
// so if the AI includes it in the replacement text, the result has a duplicate.
|
|
193
|
+
const cleanText = mode === 'replace' ? stripLeadingHeader(text, cleanHeader || existingNote.title) : text;
|
|
156
194
|
const url = buildBearUrl('add-text', {
|
|
157
195
|
id,
|
|
158
|
-
text,
|
|
196
|
+
text: cleanText,
|
|
159
197
|
header: cleanHeader,
|
|
160
198
|
mode,
|
|
199
|
+
// Ensures appended/prepended text starts on its own line, not glued to existing content.
|
|
200
|
+
// Not needed for replace — there's no preceding content to separate from.
|
|
201
|
+
new_line: mode !== 'replace' ? 'yes' : undefined,
|
|
161
202
|
});
|
|
162
203
|
logger.debug(`Executing Bear URL: ${url}`);
|
|
163
204
|
await executeBearXCallbackApi(url);
|
|
164
|
-
const
|
|
205
|
+
const preposition = mode === 'replace' ? 'in' : 'to';
|
|
206
|
+
const responseLines = [
|
|
207
|
+
`Text ${action} ${preposition} note "${existingNote.title}" successfully!`,
|
|
208
|
+
'',
|
|
209
|
+
];
|
|
165
210
|
responseLines.push(`Text: ${text.length} characters`);
|
|
166
|
-
if (
|
|
167
|
-
responseLines.push(`Section: ${
|
|
211
|
+
if (cleanHeader) {
|
|
212
|
+
responseLines.push(`Section: ${cleanHeader}`);
|
|
168
213
|
}
|
|
169
214
|
responseLines.push(`Note ID: ${id}`);
|
|
215
|
+
const trailingMessage = mode === 'replace'
|
|
216
|
+
? cleanHeader
|
|
217
|
+
? 'The section content has been replaced in your Bear note.'
|
|
218
|
+
: 'The note content has been replaced in your Bear note.'
|
|
219
|
+
: 'The text has been added to your Bear note.';
|
|
170
220
|
return createToolResponse(`${responseLines.join('\n')}
|
|
171
221
|
|
|
172
|
-
|
|
222
|
+
${trailingMessage}`);
|
|
173
223
|
}
|
|
174
224
|
catch (error) {
|
|
175
|
-
logger.error(`
|
|
225
|
+
logger.error(`handleNoteTextUpdate(${mode}) failed: ${error}`);
|
|
176
226
|
throw error;
|
|
177
227
|
}
|
|
178
228
|
}
|
package/dist/utils.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { convertCoreDataTimestamp, parseDateString } from './utils.js';
|
|
2
|
+
import { convertCoreDataTimestamp, noteHasHeader, parseDateString, stripLeadingHeader, } from './utils.js';
|
|
3
3
|
describe('parseDateString', () => {
|
|
4
4
|
beforeEach(() => {
|
|
5
5
|
// Fix "now" to January 15, 2026 for predictable tests
|
|
@@ -25,6 +25,79 @@ describe('parseDateString', () => {
|
|
|
25
25
|
expect(result.getSeconds()).toBe(59);
|
|
26
26
|
});
|
|
27
27
|
});
|
|
28
|
+
describe('noteHasHeader', () => {
|
|
29
|
+
const noteText = [
|
|
30
|
+
'# Title',
|
|
31
|
+
'Intro paragraph',
|
|
32
|
+
'',
|
|
33
|
+
'## Details',
|
|
34
|
+
'Some details here',
|
|
35
|
+
'',
|
|
36
|
+
'### Q&A',
|
|
37
|
+
'Questions and answers',
|
|
38
|
+
'',
|
|
39
|
+
'## Details (v2)',
|
|
40
|
+
'Updated details',
|
|
41
|
+
'',
|
|
42
|
+
'## v1.0 Release',
|
|
43
|
+
'Release notes',
|
|
44
|
+
].join('\n');
|
|
45
|
+
it('finds an exact header match', () => {
|
|
46
|
+
expect(noteHasHeader(noteText, 'Details')).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it('strips markdown prefix from header input', () => {
|
|
49
|
+
expect(noteHasHeader(noteText, '## Details')).toBe(true);
|
|
50
|
+
expect(noteHasHeader(noteText, '### Q&A')).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
it('matches case-insensitively', () => {
|
|
53
|
+
expect(noteHasHeader(noteText, 'details')).toBe(true);
|
|
54
|
+
expect(noteHasHeader(noteText, 'DETAILS')).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
it('rejects partial header name', () => {
|
|
57
|
+
expect(noteHasHeader(noteText, 'Detail')).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
it('handles parentheses in header name', () => {
|
|
60
|
+
expect(noteHasHeader(noteText, 'Details (v2)')).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it('handles ampersand in header name', () => {
|
|
63
|
+
expect(noteHasHeader(noteText, 'Q&A')).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
it('handles dots in header name', () => {
|
|
66
|
+
expect(noteHasHeader(noteText, 'v1.0 Release')).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
it('returns false for empty note text', () => {
|
|
69
|
+
expect(noteHasHeader('', 'Details')).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
it('returns false for empty header input', () => {
|
|
72
|
+
expect(noteHasHeader(noteText, '')).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('stripLeadingHeader', () => {
|
|
76
|
+
it('strips matching header with exact case', () => {
|
|
77
|
+
expect(stripLeadingHeader('## Details\nNew content', 'Details')).toBe('New content');
|
|
78
|
+
});
|
|
79
|
+
it('strips matching header case-insensitively', () => {
|
|
80
|
+
expect(stripLeadingHeader('## DETAILS\nNew content', 'Details')).toBe('New content');
|
|
81
|
+
expect(stripLeadingHeader('## details\nNew content', 'Details')).toBe('New content');
|
|
82
|
+
});
|
|
83
|
+
it('strips matching header at any heading level', () => {
|
|
84
|
+
expect(stripLeadingHeader('### Details\nNew content', 'Details')).toBe('New content');
|
|
85
|
+
expect(stripLeadingHeader('#### Details\nNew content', 'Details')).toBe('New content');
|
|
86
|
+
});
|
|
87
|
+
it('does not strip when header text does not match', () => {
|
|
88
|
+
expect(stripLeadingHeader('## Other\nNew content', 'Details')).toBe('## Other\nNew content');
|
|
89
|
+
});
|
|
90
|
+
it('does not strip when text does not start with a header', () => {
|
|
91
|
+
expect(stripLeadingHeader('New content', 'Details')).toBe('New content');
|
|
92
|
+
});
|
|
93
|
+
it('handles special characters in header name', () => {
|
|
94
|
+
expect(stripLeadingHeader('## Details (v2)\nNew content', 'Details (v2)')).toBe('New content');
|
|
95
|
+
expect(stripLeadingHeader('## Q&A\nNew content', 'Q&A')).toBe('New content');
|
|
96
|
+
});
|
|
97
|
+
it('returns text unchanged when header is empty string', () => {
|
|
98
|
+
expect(stripLeadingHeader('## Details\nNew content', '')).toBe('## Details\nNew content');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
28
101
|
describe('convertCoreDataTimestamp', () => {
|
|
29
102
|
it('converts Core Data timestamp to correct ISO string', () => {
|
|
30
103
|
// Core Data timestamp 0 = 2001-01-01 00:00:00 UTC
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bear-notes-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Bear Notes MCP server with TypeScript and native SQLite",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/main.js",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"@modelcontextprotocol/inspector": "^0.20.0",
|
|
31
31
|
"@types/debug": "^4.1.12",
|
|
32
32
|
"@types/node": "^24.10.13",
|
|
33
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
34
|
-
"@typescript-eslint/parser": "^8.
|
|
35
|
-
"eslint": "^9.39.
|
|
33
|
+
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
|
34
|
+
"@typescript-eslint/parser": "^8.56.0",
|
|
35
|
+
"eslint": "^9.39.3",
|
|
36
36
|
"eslint-plugin-import": "^2.32.0",
|
|
37
37
|
"prettier": "^3.8.1",
|
|
38
38
|
"tsx": "^4.21.0",
|