bear-notes-mcp-xq7k 2.0.1

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/dist/main.js ADDED
@@ -0,0 +1,422 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { APP_VERSION, ERROR_MESSAGES } from './config.js';
6
+ import { cleanBase64, createToolResponse, handleAddText, logger } from './utils.js';
7
+ import { getNoteContent, searchNotes } from './notes.js';
8
+ import { findUntaggedNotes, listTags } from './tags.js';
9
+ import { buildBearUrl, executeBearXCallbackApi } from './bear-urls.js';
10
+ const server = new McpServer({
11
+ name: 'bear-notes-mcp',
12
+ version: APP_VERSION,
13
+ });
14
+ server.registerTool('bear-open-note', {
15
+ title: 'Open Bear Note',
16
+ description: 'Read the full text content of a Bear note from your library. Always includes text extracted from attached images and PDFs (aka OCR search) with clear labeling.',
17
+ inputSchema: {
18
+ identifier: z.string().describe('Exact note identifier (ID) obtained from bear-search-notes'),
19
+ },
20
+ annotations: {
21
+ readOnlyHint: true,
22
+ idempotentHint: true,
23
+ openWorldHint: false,
24
+ },
25
+ }, async ({ identifier }) => {
26
+ logger.info(`bear-open-note called with identifier: ${identifier}, includeFiles: always`);
27
+ if (!identifier || !identifier.trim()) {
28
+ throw new Error(ERROR_MESSAGES.MISSING_NOTE_ID);
29
+ }
30
+ try {
31
+ const noteWithContent = getNoteContent(identifier.trim());
32
+ if (!noteWithContent) {
33
+ return createToolResponse(`Note with ID '${identifier}' not found. The note may have been deleted, archived, or the ID may be incorrect.
34
+
35
+ Use bear-search-notes to find the correct note identifier.`);
36
+ }
37
+ const noteInfo = [
38
+ `**${noteWithContent.title}**`,
39
+ `Modified: ${noteWithContent.modification_date}`,
40
+ `ID: ${noteWithContent.identifier}`,
41
+ ];
42
+ const noteText = noteWithContent.text || '*This note appears to be empty.*';
43
+ return createToolResponse(`${noteInfo.join('\n')}
44
+
45
+ ---
46
+
47
+ ${noteText}`);
48
+ }
49
+ catch (error) {
50
+ logger.error(`bear-open-note failed: ${error}`);
51
+ throw error;
52
+ }
53
+ });
54
+ server.registerTool('bear-create-note', {
55
+ title: 'Create New Note',
56
+ description: 'Create a new note in your Bear library with optional title, content, and tags. The note will be immediately available in Bear app.',
57
+ inputSchema: {
58
+ title: z
59
+ .string()
60
+ .optional()
61
+ .describe('Note title, e.g., "Meeting Notes" or "Research Ideas"'),
62
+ text: z.string().optional().describe('Note content in markdown format'),
63
+ tags: z.string().optional().describe('Tags separated by commas, e.g., "work,project,urgent"'),
64
+ },
65
+ annotations: {
66
+ readOnlyHint: false,
67
+ destructiveHint: false,
68
+ idempotentHint: false,
69
+ openWorldHint: true,
70
+ },
71
+ }, async ({ title, text, tags }) => {
72
+ logger.debug(`bear-create-note called with title: ${title ? '"' + title + '"' : 'none'}, text length: ${text ? text.length : 0}, tags: ${tags || 'none'}`);
73
+ try {
74
+ const url = buildBearUrl('create', { title, text, tags });
75
+ await executeBearXCallbackApi(url);
76
+ const responseLines = ['Bear note created successfully!', ''];
77
+ if (title?.trim()) {
78
+ responseLines.push(`Title: "${title.trim()}"`);
79
+ }
80
+ if (text?.trim()) {
81
+ responseLines.push(`Content: ${text.trim().length} characters`);
82
+ }
83
+ if (tags?.trim()) {
84
+ responseLines.push(`Tags: ${tags.trim()}`);
85
+ }
86
+ const hasContent = title?.trim() || text?.trim() || tags?.trim();
87
+ const finalMessage = hasContent ? responseLines.join('\n') : 'Empty note created';
88
+ return createToolResponse(`${finalMessage}
89
+
90
+ The note has been added to your Bear Notes library.`);
91
+ }
92
+ catch (error) {
93
+ logger.error(`bear-create-note failed: ${error}`);
94
+ throw error;
95
+ }
96
+ });
97
+ server.registerTool('bear-search-notes', {
98
+ title: 'Find Bear Notes',
99
+ description: 'Find notes in your Bear library by searching text content, filtering by tags, or date ranges. Always searches within attached images and PDF files via OCR. Returns a list with titles and IDs - use "Open Bear Note" to read full content.',
100
+ inputSchema: {
101
+ term: z.string().optional().describe('Text to search for in note titles and content'),
102
+ tag: z.string().optional().describe('Tag to filter notes by (without # symbol)'),
103
+ limit: z.number().optional().describe('Maximum number of results to return (default: 50)'),
104
+ createdAfter: z
105
+ .string()
106
+ .optional()
107
+ .describe('Filter notes created on or after this date. Supports: relative dates ("today", "yesterday", "last week", "start of last month"), ISO format (YYYY-MM-DD). Use "start of last month" for the beginning of the previous month.'),
108
+ createdBefore: z
109
+ .string()
110
+ .optional()
111
+ .describe('Filter notes created on or before this date. Supports: relative dates ("today", "yesterday", "last week", "end of last month"), ISO format (YYYY-MM-DD). Use "end of last month" for the end of the previous month.'),
112
+ modifiedAfter: z
113
+ .string()
114
+ .optional()
115
+ .describe('Filter notes modified on or after this date. Supports: relative dates ("today", "yesterday", "last week", "start of last month"), ISO format (YYYY-MM-DD). Use "start of last month" for the beginning of the previous month.'),
116
+ modifiedBefore: z
117
+ .string()
118
+ .optional()
119
+ .describe('Filter notes modified on or before this date. Supports: relative dates ("today", "yesterday", "last week", "end of last month"), ISO format (YYYY-MM-DD). Use "end of last month" for the end of the previous month.'),
120
+ },
121
+ annotations: {
122
+ readOnlyHint: true,
123
+ idempotentHint: true,
124
+ openWorldHint: false,
125
+ },
126
+ }, async ({ term, tag, limit, createdAfter, createdBefore, modifiedAfter, modifiedBefore, }) => {
127
+ logger.info(`bear-search-notes called with term: "${term || 'none'}", tag: "${tag || 'none'}", limit: ${limit || 'default'}, createdAfter: "${createdAfter || 'none'}", createdBefore: "${createdBefore || 'none'}", modifiedAfter: "${modifiedAfter || 'none'}", modifiedBefore: "${modifiedBefore || 'none'}", includeFiles: always`);
128
+ try {
129
+ const dateFilter = {
130
+ ...(createdAfter && { createdAfter }),
131
+ ...(createdBefore && { createdBefore }),
132
+ ...(modifiedAfter && { modifiedAfter }),
133
+ ...(modifiedBefore && { modifiedBefore }),
134
+ };
135
+ const notes = searchNotes(term, tag, limit, Object.keys(dateFilter).length > 0 ? dateFilter : undefined);
136
+ if (notes.length === 0) {
137
+ const searchCriteria = [];
138
+ if (term?.trim())
139
+ searchCriteria.push(`term "${term.trim()}"`);
140
+ if (tag?.trim())
141
+ searchCriteria.push(`tag "${tag.trim()}"`);
142
+ if (createdAfter)
143
+ searchCriteria.push(`created after "${createdAfter}"`);
144
+ if (createdBefore)
145
+ searchCriteria.push(`created before "${createdBefore}"`);
146
+ if (modifiedAfter)
147
+ searchCriteria.push(`modified after "${modifiedAfter}"`);
148
+ if (modifiedBefore)
149
+ searchCriteria.push(`modified before "${modifiedBefore}"`);
150
+ return createToolResponse(`No notes found matching ${searchCriteria.join(', ')}.
151
+
152
+ Try different search criteria or check if notes exist in Bear Notes.`);
153
+ }
154
+ const resultLines = [`Found ${notes.length} note${notes.length === 1 ? '' : 's'}:`, ''];
155
+ notes.forEach((note, index) => {
156
+ const noteTitle = note.title || 'Untitled';
157
+ const modifiedDate = new Date(note.modification_date).toLocaleDateString();
158
+ const createdDate = new Date(note.creation_date).toLocaleDateString();
159
+ resultLines.push(`${index + 1}. **${noteTitle}**`);
160
+ resultLines.push(` Created: ${createdDate}`);
161
+ resultLines.push(` Modified: ${modifiedDate}`);
162
+ resultLines.push(` ID: ${note.identifier}`);
163
+ resultLines.push('');
164
+ });
165
+ resultLines.push('Use bear-open-note with an ID to read the full content of any note.');
166
+ return createToolResponse(resultLines.join('\n'));
167
+ }
168
+ catch (error) {
169
+ logger.error(`bear-search-notes failed: ${error}`);
170
+ throw error;
171
+ }
172
+ });
173
+ server.registerTool('bear-add-text', {
174
+ title: 'Add Text to Note',
175
+ description: 'Add text to an existing Bear note at the beginning or end. Can target a specific section using header. Use bear-search-notes first to get the note ID.',
176
+ inputSchema: {
177
+ id: z.string().describe('Note identifier (ID) from bear-search-notes'),
178
+ text: z.string().describe('Text content to add to the note'),
179
+ header: z
180
+ .string()
181
+ .optional()
182
+ .describe('Optional section header to target (adds text within that section)'),
183
+ position: z
184
+ .enum(['beginning', 'end'])
185
+ .optional()
186
+ .describe("Where to insert: 'end' (default) for appending, logs, updates; 'beginning' for prepending, summaries, top of mind, etc."),
187
+ },
188
+ annotations: {
189
+ readOnlyHint: false,
190
+ destructiveHint: true,
191
+ idempotentHint: false,
192
+ openWorldHint: true,
193
+ },
194
+ }, async ({ id, text, header, position }) => {
195
+ const mode = position === 'beginning' ? 'prepend' : 'append';
196
+ return handleAddText(mode, { id, text, header });
197
+ });
198
+ server.registerTool('bear-add-file', {
199
+ title: 'Add File to Note',
200
+ description: 'Attach a file to an existing Bear note. Encode the file to base64 using shell commands (e.g., base64 /path/to/file.xlsx) and provide the encoded content. Use bear-search-notes first to get the note ID.',
201
+ inputSchema: {
202
+ base64_content: z.string().describe('Base64-encoded file content'),
203
+ filename: z.string().describe('Filename with extension (e.g., budget.xlsx, report.pdf)'),
204
+ id: z
205
+ .string()
206
+ .optional()
207
+ .describe('Exact note identifier (ID) obtained from bear-search-notes'),
208
+ title: z.string().optional().describe('Note title if ID is not available'),
209
+ },
210
+ annotations: {
211
+ readOnlyHint: false,
212
+ destructiveHint: true,
213
+ idempotentHint: false,
214
+ openWorldHint: true,
215
+ },
216
+ }, async ({ base64_content, filename, id, title }) => {
217
+ logger.info(`bear-add-file called with base64_content: ${base64_content ? 'provided' : 'none'}, filename: ${filename || 'none'}, id: ${id || 'none'}, title: ${title || 'none'}`);
218
+ if (!base64_content || !base64_content.trim()) {
219
+ throw new Error('base64_content is required');
220
+ }
221
+ if (!filename || !filename.trim()) {
222
+ throw new Error('filename is required');
223
+ }
224
+ if (!id && !title) {
225
+ throw new Error('Either note ID or title is required. Use bear-search-notes to find the note ID.');
226
+ }
227
+ try {
228
+ // base64 CLI adds line breaks that break URL encoding
229
+ const cleanedBase64 = cleanBase64(base64_content);
230
+ // Fail fast with helpful message rather than cryptic Bear error
231
+ if (id) {
232
+ const existingNote = getNoteContent(id.trim());
233
+ if (!existingNote) {
234
+ return createToolResponse(`Note with ID '${id}' not found. The note may have been deleted, archived, or the ID may be incorrect.
235
+
236
+ Use bear-search-notes to find the correct note identifier.`);
237
+ }
238
+ }
239
+ const url = buildBearUrl('add-file', {
240
+ id: id?.trim(),
241
+ title: title?.trim(),
242
+ file: cleanedBase64,
243
+ filename: filename.trim(),
244
+ mode: 'append',
245
+ });
246
+ logger.debug(`Executing Bear add-file URL for: ${filename.trim()}`);
247
+ await executeBearXCallbackApi(url);
248
+ const noteIdentifier = id ? `Note ID: ${id.trim()}` : `Note title: "${title.trim()}"`;
249
+ return createToolResponse(`File "${filename.trim()}" added successfully!
250
+
251
+ ${noteIdentifier}
252
+
253
+ The file has been attached to your Bear note.`);
254
+ }
255
+ catch (error) {
256
+ logger.error(`bear-add-file failed: ${error}`);
257
+ throw error;
258
+ }
259
+ });
260
+ /**
261
+ * Formats tag hierarchy as tree-style text output.
262
+ * Uses box-drawing characters for visual tree structure.
263
+ */
264
+ function formatTagTree(tags, isLast = []) {
265
+ const lines = [];
266
+ for (let i = 0; i < tags.length; i++) {
267
+ const tag = tags[i];
268
+ const isLastItem = i === tags.length - 1;
269
+ // Build the prefix using box-drawing characters
270
+ let linePrefix = '';
271
+ for (let j = 0; j < isLast.length; j++) {
272
+ linePrefix += isLast[j] ? ' ' : '│ ';
273
+ }
274
+ linePrefix += isLastItem ? '└── ' : '├── ';
275
+ lines.push(`${linePrefix}${tag.name} (${tag.noteCount})`);
276
+ if (tag.children.length > 0) {
277
+ lines.push(...formatTagTree(tag.children, [...isLast, isLastItem]));
278
+ }
279
+ }
280
+ return lines;
281
+ }
282
+ server.registerTool('bear-list-tags', {
283
+ title: 'List Bear Tags',
284
+ description: 'List all tags in your Bear library as a hierarchical tree. Shows tag names with note counts. Useful for understanding your tag structure and finding tags to apply to untagged notes.',
285
+ inputSchema: {},
286
+ annotations: {
287
+ readOnlyHint: true,
288
+ idempotentHint: true,
289
+ openWorldHint: false,
290
+ },
291
+ }, async () => {
292
+ logger.info('bear-list-tags called');
293
+ try {
294
+ const { tags, totalCount } = listTags();
295
+ if (totalCount === 0) {
296
+ return createToolResponse('No tags found in your Bear library.');
297
+ }
298
+ // Format root tags with their children as trees
299
+ const lines = [];
300
+ for (const rootTag of tags) {
301
+ lines.push(`${rootTag.name} (${rootTag.noteCount})`);
302
+ if (rootTag.children.length > 0) {
303
+ lines.push(...formatTagTree(rootTag.children));
304
+ }
305
+ }
306
+ const header = `Found ${totalCount} tag${totalCount === 1 ? '' : 's'}:\n`;
307
+ return createToolResponse(header + '\n' + lines.join('\n'));
308
+ }
309
+ catch (error) {
310
+ logger.error(`bear-list-tags failed: ${error}`);
311
+ throw error;
312
+ }
313
+ });
314
+ server.registerTool('bear-find-untagged-notes', {
315
+ title: 'Find Untagged Notes',
316
+ description: 'Find notes in your Bear library that have no tags. Useful for organizing and categorizing notes.',
317
+ inputSchema: {
318
+ limit: z.number().optional().describe('Maximum number of results (default: 50)'),
319
+ },
320
+ annotations: {
321
+ readOnlyHint: true,
322
+ idempotentHint: true,
323
+ openWorldHint: false,
324
+ },
325
+ }, async ({ limit }) => {
326
+ logger.info(`bear-find-untagged-notes called with limit: ${limit || 'default'}`);
327
+ try {
328
+ const notes = findUntaggedNotes(limit);
329
+ if (notes.length === 0) {
330
+ return createToolResponse('No untagged notes found. All your notes have tags!');
331
+ }
332
+ const lines = [`Found ${notes.length} untagged note${notes.length === 1 ? '' : 's'}:`, ''];
333
+ notes.forEach((note, index) => {
334
+ const modifiedDate = new Date(note.modification_date).toLocaleDateString();
335
+ lines.push(`${index + 1}. **${note.title}**`);
336
+ lines.push(` Modified: ${modifiedDate}`);
337
+ lines.push(` ID: ${note.identifier}`);
338
+ lines.push('');
339
+ });
340
+ lines.push('You can also use bear-list-tags to see available tags.');
341
+ return createToolResponse(lines.join('\n'));
342
+ }
343
+ catch (error) {
344
+ logger.error(`bear-find-untagged-notes failed: ${error}`);
345
+ throw error;
346
+ }
347
+ });
348
+ server.registerTool('bear-add-tag', {
349
+ title: 'Add Tags to Note',
350
+ 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.',
351
+ inputSchema: {
352
+ id: z
353
+ .string()
354
+ .describe('Note identifier (ID) from bear-search-notes or bear-find-untagged-notes'),
355
+ tags: z
356
+ .array(z.string())
357
+ .describe('Tag names without # symbol (e.g., ["career", "career/meetings"])'),
358
+ },
359
+ annotations: {
360
+ readOnlyHint: false,
361
+ destructiveHint: false,
362
+ idempotentHint: false,
363
+ openWorldHint: true,
364
+ },
365
+ }, async ({ id, tags }) => {
366
+ logger.info(`bear-add-tag called with id: ${id}, tags: [${tags.join(', ')}]`);
367
+ if (!id || !id.trim()) {
368
+ throw new Error(ERROR_MESSAGES.MISSING_NOTE_ID);
369
+ }
370
+ if (!tags || tags.length === 0) {
371
+ throw new Error('At least one tag is required');
372
+ }
373
+ try {
374
+ const existingNote = getNoteContent(id.trim());
375
+ if (!existingNote) {
376
+ return createToolResponse(`Note with ID '${id}' not found. The note may have been deleted, archived, or the ID may be incorrect.
377
+
378
+ Use bear-search-notes to find the correct note identifier.`);
379
+ }
380
+ const tagsString = tags.join(',');
381
+ const url = buildBearUrl('add-text', {
382
+ id: id.trim(),
383
+ tags: tagsString,
384
+ mode: 'prepend',
385
+ open_note: 'no',
386
+ show_window: 'no',
387
+ new_window: 'no',
388
+ });
389
+ await executeBearXCallbackApi(url);
390
+ const tagList = tags.map((t) => `#${t}`).join(', ');
391
+ return createToolResponse(`Tags added successfully!
392
+
393
+ Note: "${existingNote.title}"
394
+ Tags: ${tagList}
395
+
396
+ The tags have been added to the beginning of the note.`);
397
+ }
398
+ catch (error) {
399
+ logger.error(`bear-add-tag failed: ${error}`);
400
+ throw error;
401
+ }
402
+ });
403
+ async function main() {
404
+ logger.info(`Bear Notes MCP Server initializing... Version: ${APP_VERSION}`);
405
+ logger.debug(`Debug logs enabled: ${logger.debug.enabled}`);
406
+ logger.debug(`Node.js version: ${process.version}`);
407
+ logger.debug(`App version: ${APP_VERSION}`);
408
+ // Handle process errors
409
+ process.on('uncaughtException', (error) => {
410
+ logger.error('Uncaught exception:', error);
411
+ });
412
+ process.on('unhandledRejection', (reason, promise) => {
413
+ logger.error('Unhandled rejection at:', promise, 'reason:', reason);
414
+ });
415
+ const transport = new StdioServerTransport();
416
+ await server.connect(transport);
417
+ logger.info('Bear Notes MCP Server connected and ready');
418
+ }
419
+ main().catch((error) => {
420
+ logger.error('Server startup failed:', error);
421
+ process.exit(1);
422
+ });
package/dist/notes.js ADDED
@@ -0,0 +1,224 @@
1
+ import { DEFAULT_SEARCH_LIMIT } from './config.js';
2
+ import { convertCoreDataTimestamp, convertDateToCoreDataTimestamp, logAndThrow, logger, parseDateString, } from './utils.js';
3
+ import { openBearDatabase } from './database.js';
4
+ function formatBearNote(row) {
5
+ const title = row.title || 'Untitled';
6
+ const identifier = row.identifier;
7
+ const modificationDate = row.modificationDate;
8
+ const creationDate = row.creationDate;
9
+ const pinned = row.pinned;
10
+ const text = row.text;
11
+ if (!identifier) {
12
+ logAndThrow('Database error: Note identifier is missing from database row');
13
+ }
14
+ if (typeof modificationDate !== 'number' || typeof creationDate !== 'number') {
15
+ logAndThrow('Database error: Note date fields are invalid in database row');
16
+ }
17
+ const modification_date = convertCoreDataTimestamp(modificationDate);
18
+ const creation_date = convertCoreDataTimestamp(creationDate);
19
+ // Bear stores pinned as integer; API expects string literal (only needed when pinned is queried)
20
+ const pin = pinned ? 'yes' : 'no';
21
+ return {
22
+ title,
23
+ identifier,
24
+ modification_date,
25
+ creation_date,
26
+ pin,
27
+ ...(text !== undefined && { text }),
28
+ };
29
+ }
30
+ /**
31
+ * Retrieves a Bear note with its full content from the database.
32
+ *
33
+ * @param identifier - The unique identifier of the Bear note
34
+ * @returns The note with content, or null if not found
35
+ * @throws Error if database access fails or identifier is invalid
36
+ * Note: Always includes OCR'd text from attached images and PDFs with clear labeling
37
+ */
38
+ export function getNoteContent(identifier) {
39
+ logger.info(`getNoteContent called with identifier: ${identifier}, includeFiles: always`);
40
+ if (!identifier || typeof identifier !== 'string' || !identifier.trim()) {
41
+ logAndThrow('Database error: Invalid note identifier provided');
42
+ }
43
+ const db = openBearDatabase();
44
+ try {
45
+ logger.debug(`Fetching the note content from the database, note identifier: ${identifier}`);
46
+ // Query with file content - always includes OCR'd text from attached files with clear labeling
47
+ const query = `
48
+ SELECT note.ZTITLE as title,
49
+ note.ZUNIQUEIDENTIFIER as identifier,
50
+ note.ZCREATIONDATE as creationDate,
51
+ note.ZMODIFICATIONDATE as modificationDate,
52
+ note.ZPINNED as pinned,
53
+ note.ZTEXT as text,
54
+ f.ZFILENAME as filename,
55
+ f.ZSEARCHTEXT as fileContent
56
+ FROM ZSFNOTE note
57
+ LEFT JOIN ZSFNOTEFILE f ON f.ZNOTE = note.Z_PK
58
+ WHERE note.ZUNIQUEIDENTIFIER = ?
59
+ AND note.ZARCHIVED = 0
60
+ AND note.ZTRASHED = 0
61
+ AND note.ZENCRYPTED = 0
62
+ `;
63
+ const stmt = db.prepare(query);
64
+ const rows = stmt.all(identifier);
65
+ if (!rows || rows.length === 0) {
66
+ logger.info(`Note not found for identifier: ${identifier}`);
67
+ return null;
68
+ }
69
+ // Process multiple rows (note + files) into single note object
70
+ const firstRow = rows[0];
71
+ const formattedNote = formatBearNote(firstRow);
72
+ // Collect file content from all rows with clear source labeling
73
+ const fileContents = [];
74
+ for (const row of rows) {
75
+ const rowData = row;
76
+ const filename = rowData.filename;
77
+ const fileContent = rowData.fileContent;
78
+ if (filename && fileContent && fileContent.trim()) {
79
+ fileContents.push(`##${filename}\n\n${fileContent.trim()}`);
80
+ }
81
+ }
82
+ // Always append file content section, even if empty, to show structure
83
+ const originalText = formattedNote.text || '';
84
+ const filesSectionHeader = '\n\n---\n\n#Attached Files\n\n';
85
+ if (fileContents.length > 0) {
86
+ const fileSection = `${filesSectionHeader}${fileContents.join('\n\n---\n\n')}`;
87
+ formattedNote.text = originalText + fileSection;
88
+ }
89
+ else {
90
+ // Add a note that no files are attached for clarity
91
+ formattedNote.text = originalText + `${filesSectionHeader}*No files attached to this note.*`;
92
+ }
93
+ logger.info(`Retrieved note content with ${fileContents.length} attached files for: ${formattedNote.title}`);
94
+ return formattedNote;
95
+ }
96
+ catch (error) {
97
+ logger.error(`SQLite query failed: ${error}`);
98
+ logAndThrow(`Database error: Failed to retrieve note content: ${error instanceof Error ? error.message : String(error)}`);
99
+ }
100
+ finally {
101
+ try {
102
+ db.close();
103
+ logger.debug('Database connection closed');
104
+ }
105
+ catch (closeError) {
106
+ logger.error(`Failed to close database connection: ${closeError}`);
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+ /**
112
+ * Searches Bear notes by content or tags with optional filtering.
113
+ * Returns a list of notes without full content for performance.
114
+ *
115
+ * @param searchTerm - Text to search for in note titles and content (optional)
116
+ * @param tag - Tag to filter notes by (optional)
117
+ * @param limit - Maximum number of results to return (default from config)
118
+ * @param dateFilter - Date range filters for creation and modification dates (optional)
119
+ * @returns Array of matching notes without full text content
120
+ * @throws Error if database access fails or no search criteria provided
121
+ * Note: Always searches within text extracted from attached images and PDF files via OCR for comprehensive results
122
+ */
123
+ export function searchNotes(searchTerm, tag, limit, dateFilter) {
124
+ logger.info(`searchNotes called with term: "${searchTerm || 'none'}", tag: "${tag || 'none'}", limit: ${limit || DEFAULT_SEARCH_LIMIT}, dateFilter: ${dateFilter ? JSON.stringify(dateFilter) : 'none'}, includeFiles: always`);
125
+ // Validate search parameters - at least one must be provided
126
+ const hasSearchTerm = searchTerm && typeof searchTerm === 'string' && searchTerm.trim();
127
+ const hasTag = tag && typeof tag === 'string' && tag.trim();
128
+ const hasDateFilter = dateFilter && Object.keys(dateFilter).length > 0;
129
+ if (!hasSearchTerm && !hasTag && !hasDateFilter) {
130
+ logAndThrow('Search error: Please provide a search term, tag, or date filter to search for notes');
131
+ }
132
+ const db = openBearDatabase();
133
+ const queryLimit = limit || DEFAULT_SEARCH_LIMIT;
134
+ try {
135
+ let query;
136
+ const queryParams = [];
137
+ // Query with file search - uses LEFT JOIN to include OCR'd content for comprehensive search
138
+ query = `
139
+ SELECT DISTINCT note.ZTITLE as title,
140
+ note.ZUNIQUEIDENTIFIER as identifier,
141
+ note.ZCREATIONDATE as creationDate,
142
+ note.ZMODIFICATIONDATE as modificationDate
143
+ FROM ZSFNOTE note
144
+ LEFT JOIN ZSFNOTEFILE f ON f.ZNOTE = note.Z_PK
145
+ WHERE note.ZARCHIVED = 0
146
+ AND note.ZTRASHED = 0
147
+ AND note.ZENCRYPTED = 0`;
148
+ // Add search term filtering
149
+ if (hasSearchTerm) {
150
+ const searchPattern = `%${searchTerm.trim()}%`;
151
+ // Search in note title, text, and file OCR content
152
+ query += ' AND (note.ZTITLE LIKE ? OR note.ZTEXT LIKE ? OR f.ZSEARCHTEXT LIKE ?)';
153
+ queryParams.push(searchPattern, searchPattern, searchPattern);
154
+ }
155
+ // Add tag filtering
156
+ if (hasTag) {
157
+ const tagPattern = `%#${tag.trim()}%`;
158
+ query += ' AND note.ZTEXT LIKE ?';
159
+ queryParams.push(tagPattern);
160
+ }
161
+ // Add date filtering
162
+ if (hasDateFilter && dateFilter) {
163
+ if (dateFilter.createdAfter) {
164
+ const afterDate = parseDateString(dateFilter.createdAfter);
165
+ // Set to start of day (00:00:00) to include notes from the entire specified day onwards
166
+ afterDate.setHours(0, 0, 0, 0);
167
+ const timestamp = convertDateToCoreDataTimestamp(afterDate);
168
+ query += ' AND note.ZCREATIONDATE >= ?';
169
+ queryParams.push(timestamp);
170
+ }
171
+ if (dateFilter.createdBefore) {
172
+ const beforeDate = parseDateString(dateFilter.createdBefore);
173
+ // Set to end of day (23:59:59.999) to include notes through the entire specified day
174
+ beforeDate.setHours(23, 59, 59, 999);
175
+ const timestamp = convertDateToCoreDataTimestamp(beforeDate);
176
+ query += ' AND note.ZCREATIONDATE <= ?';
177
+ queryParams.push(timestamp);
178
+ }
179
+ if (dateFilter.modifiedAfter) {
180
+ const afterDate = parseDateString(dateFilter.modifiedAfter);
181
+ // Set to start of day (00:00:00) to include notes from the entire specified day onwards
182
+ afterDate.setHours(0, 0, 0, 0);
183
+ const timestamp = convertDateToCoreDataTimestamp(afterDate);
184
+ query += ' AND note.ZMODIFICATIONDATE >= ?';
185
+ queryParams.push(timestamp);
186
+ }
187
+ if (dateFilter.modifiedBefore) {
188
+ const beforeDate = parseDateString(dateFilter.modifiedBefore);
189
+ // Set to end of day (23:59:59.999) to include notes through the entire specified day
190
+ beforeDate.setHours(23, 59, 59, 999);
191
+ const timestamp = convertDateToCoreDataTimestamp(beforeDate);
192
+ query += ' AND note.ZMODIFICATIONDATE <= ?';
193
+ queryParams.push(timestamp);
194
+ }
195
+ }
196
+ // Add ordering and limit
197
+ query += ' ORDER BY note.ZMODIFICATIONDATE DESC LIMIT ?';
198
+ queryParams.push(queryLimit);
199
+ logger.debug(`Executing search query with ${queryParams.length} parameters`);
200
+ // Use parameter binding to prevent SQL injection attacks
201
+ const stmt = db.prepare(query);
202
+ const rows = stmt.all(...queryParams);
203
+ if (!rows || rows.length === 0) {
204
+ logger.info('No notes found matching search criteria');
205
+ return [];
206
+ }
207
+ const notes = rows.map((row) => formatBearNote(row));
208
+ logger.info(`Found ${notes.length} notes matching search criteria`);
209
+ return notes;
210
+ }
211
+ catch (error) {
212
+ logAndThrow(`SQLite search query failed: ${error instanceof Error ? error.message : String(error)}`);
213
+ }
214
+ finally {
215
+ try {
216
+ db.close();
217
+ logger.debug('Database connection closed');
218
+ }
219
+ catch (closeError) {
220
+ logger.error(`Failed to close database connection: ${closeError}`);
221
+ }
222
+ }
223
+ return [];
224
+ }