diff --git a/__tests__/integration/mcp-tools.test.ts b/__tests__/integration/mcp-tools.test.ts index 682858c..fb2c52f 100644 --- a/__tests__/integration/mcp-tools.test.ts +++ b/__tests__/integration/mcp-tools.test.ts @@ -82,7 +82,7 @@ describe('MCP Tools Integration', () => { expect(result).toHaveProperty('tools'); expect(Array.isArray(result.tools)).toBe(true); - expect(result.tools).toHaveLength(11); + expect(result.tools).toHaveLength(28); const toolNames = result.tools.map((tool: any) => tool.name); expect(toolNames).toContain('evernote_create_note'); @@ -96,6 +96,7 @@ describe('MCP Tools Integration', () => { expect(toolNames).toContain('evernote_create_tag'); expect(toolNames).toContain('evernote_get_user_info'); expect(toolNames).toContain('evernote_health_check'); + expect(toolNames).toContain('evernote_patch_note'); }); it('should include proper tool schemas', async () => { @@ -929,6 +930,209 @@ describe('MCP Tools Integration', () => { }); }); + describe('Patch Note Operations', () => { + const sampleNoteWithContent = { + ...sampleNote, + content: 'Status: Pending
Task: TODO: Review code
Priority: High
' + }; + + beforeEach(() => { + mockNoteStore.getNote.mockResolvedValue(sampleNoteWithContent); + mockNoteStore.updateNote.mockImplementation((note: any) => Promise.resolve(note)); + }); + + it('should patch note with single replacement', async () => { + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: 'Status: Pending', replace: 'Status: Complete' } + ] + } + } + }; + + const result = await callToolHandler(request); + + expect(mockNoteStore.getNote).toHaveBeenCalledWith('note-123', true, true, false, false); + expect(mockNoteStore.updateNote).toHaveBeenCalled(); + expect(result.content[0].text).toContain('Note patched successfully'); + expect(result.content[0].text).toContain('found 1x'); + expect(result.content[0].text).toContain('replaced 1x'); + }); + + it('should patch note with multiple replacements', async () => { + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: 'Status: Pending', replace: 'Status: Complete' }, + { find: 'TODO:', replace: 'DONE:' } + ] + } + } + }; + + const result = await callToolHandler(request); + + expect(result.content[0].text).toContain('Note patched successfully'); + }); + + it('should return warning when no matches found', async () => { + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: 'Nonexistent text', replace: 'Replacement' } + ] + } + } + }; + + const result = await callToolHandler(request); + + expect(result.content[0].text).toContain('Note patch failed'); + expect(result.content[0].text).toContain('No matches found'); + expect(mockNoteStore.updateNote).not.toHaveBeenCalled(); + }); + + it('should replace only first occurrence when replaceAll is false', async () => { + const noteWithDuplicates = { + ...sampleNote, + content: 'TODO: First task
TODO: Second task
' + }; + mockNoteStore.getNote.mockResolvedValue(noteWithDuplicates); + + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: 'TODO:', replace: 'DONE:', replaceAll: false } + ] + } + } + }; + + const result = await callToolHandler(request); + + expect(result.content[0].text).toContain('found 2x'); + expect(result.content[0].text).toContain('replaced 1x'); + }); + + it('should reject patch that would result in empty content', async () => { + const noteWithMinimalContent = { + ...sampleNote, + content: 'Only content' + }; + mockNoteStore.getNote.mockResolvedValue(noteWithMinimalContent); + + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: 'Only content', replace: '' } + ] + } + } + }; + + const result = await callToolHandler(request); + + expect(result.content[0].text).toContain('Note patch failed'); + expect(result.content[0].text).toContain('empty note content'); + expect(mockNoteStore.updateNote).not.toHaveBeenCalled(); + }); + + it('should throw error when no replacements provided', async () => { + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [] + } + } + }; + + await expect(callToolHandler(request)).rejects.toThrow('At least one replacement must be provided'); + }); + + it('should throw error when find string is empty', async () => { + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: '', replace: 'replacement' } + ] + } + } + }; + + await expect(callToolHandler(request)).rejects.toThrow('Each replacement must have a non-empty "find" string'); + }); + + it('should preserve existing resources after patch', async () => { + const noteWithResources = { + ...sampleNote, + content: 'Status: Pending', + resources: [sampleResource] + }; + mockNoteStore.getNote.mockResolvedValue(noteWithResources); + mockNoteStore.updateNote.mockImplementation((note: any) => { + // Verify resources are preserved + expect(note.resources).toBeDefined(); + expect(note.resources.length).toBeGreaterThan(0); + return Promise.resolve(note); + }); + + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'note-123', + replacements: [ + { find: 'Status: Pending', replace: 'Status: Complete' } + ] + } + } + }; + + const result = await callToolHandler(request); + + expect(result.content[0].text).toContain('Note patched successfully'); + }); + + it('should handle note not found error', async () => { + mockNoteStore.getNote.mockRejectedValue(new Error('Note not found')); + + const request = { + params: { + name: 'evernote_patch_note', + arguments: { + guid: 'nonexistent-guid', + replacements: [ + { find: 'test', replace: 'replacement' } + ] + } + } + }; + + await expect(callToolHandler(request)).rejects.toThrow('Note not found'); + }); + }); + describe('Error Handling', () => { it('should handle unknown tool error', async () => { const request = { diff --git a/__tests__/unit/search-preview.test.ts b/__tests__/unit/search-preview.test.ts index 3209fbf..50902b4 100644 --- a/__tests__/unit/search-preview.test.ts +++ b/__tests__/unit/search-preview.test.ts @@ -17,13 +17,13 @@ function enmlToPlainText(enmlContent: string): string { // Remove all remaining HTML/XML tags text = text.replace(/<[^>]+>/g, ''); - // Decode common HTML entities + // Decode common HTML entities (decode & LAST to avoid double-unescaping) text = text.replace(/ /gi, ' '); - text = text.replace(/&/gi, '&'); text = text.replace(/</gi, '<'); text = text.replace(/>/gi, '>'); text = text.replace(/"/gi, '"'); text = text.replace(/'/gi, "'"); + text = text.replace(/&/gi, '&'); // Normalize whitespace text = text.replace(/\n\s*\n/g, '\n'); diff --git a/src/evernote-api.ts b/src/evernote-api.ts index da9729b..339837c 100644 --- a/src/evernote-api.ts +++ b/src/evernote-api.ts @@ -15,6 +15,8 @@ import { RecognitionData, RecognitionItem, ResourceInfo, + NoteReplacement, + PatchNoteResult, } from './types.js'; import { readFile } from 'fs/promises'; import { basename, extname } from 'path'; @@ -126,13 +128,13 @@ export class EvernoteAPI { // Remove all remaining HTML/XML tags text = text.replace(/<[^>]+>/g, ''); - // Decode common HTML entities + // Decode common HTML entities (decode & LAST to avoid double-unescaping) text = text.replace(/ /gi, ' '); - text = text.replace(/&/gi, '&'); text = text.replace(/</gi, '<'); text = text.replace(/>/gi, '>'); text = text.replace(/"/gi, '"'); text = text.replace(/'/gi, "'"); + text = text.replace(/&/gi, '&'); // Normalize whitespace text = text.replace(/\n\s*\n/g, '\n'); @@ -193,6 +195,100 @@ export class EvernoteAPI { await this.noteStore.deleteNote(guid); } + async patchNoteContent(guid: string, replacements: NoteReplacement[]): Promise { + // Validate inputs before performing I/O + if (!replacements || replacements.length === 0) { + return { + success: false, + noteGuid: guid, + changes: [], + warning: 'No replacements provided', + }; + } + + for (const replacement of replacements) { + if (!replacement.find || typeof replacement.find !== 'string' || replacement.find.length === 0) { + return { + success: false, + noteGuid: guid, + changes: [], + warning: 'Empty find string in replacements', + }; + } + } + + // Fetch existing note with content and resources + const note = await this.getNote(guid, true, true); + + // Convert ENML to markdown + let markdown = this.convertENMLToMarkdown(note.content, note.resources); + + // Track changes + const changes: PatchNoteResult['changes'] = []; + + // Apply replacements sequentially + for (const replacement of replacements) { + const { find, replace, replaceAll = true } = replacement; + + // Count occurrences + const regex = new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'); + const matches = markdown.match(regex); + const occurrences = matches ? matches.length : 0; + + // Perform replacement + let replaced = 0; + if (occurrences > 0) { + if (replaceAll) { + markdown = markdown.split(find).join(replace); + replaced = occurrences; + } else { + markdown = markdown.replace(find, replace); + replaced = 1; + } + } + + changes.push({ + find, + occurrences, + replaced, + }); + } + + // Check if any changes were made + const totalReplaced = changes.reduce((sum, c) => sum + c.replaced, 0); + if (totalReplaced === 0) { + return { + success: false, + noteGuid: guid, + changes, + warning: 'No matches found for any replacement patterns', + }; + } + + // Check if content would be empty after replacement + const trimmedMarkdown = markdown.trim(); + if (!trimmedMarkdown) { + return { + success: false, + noteGuid: guid, + changes, + warning: 'Replacement would result in empty note content - operation aborted', + }; + } + + // Apply updated markdown back to note, preserving existing resources + await this.applyMarkdownToNote(note, markdown, { preserveResources: true }); + + // Update the note + await this.updateNote(note); + + return { + success: true, + noteGuid: guid, + changes, + }; + } + async searchNotes(params: SearchParameters): Promise { // Handle ES module import where Evernote exports are under .default const EvernoteModule = (Evernote as any).default || Evernote; @@ -456,18 +552,48 @@ export class EvernoteAPI { return enmlToMarkdown(enmlContent, { resources: normalized }); } - async applyMarkdownToNote(note: any, markdown: string): Promise { + async applyMarkdownToNote(note: any, markdown: string, options?: { preserveResources?: boolean }): Promise { const EvernoteModule = (Evernote as any).default || Evernote; + const originalResources = options?.preserveResources ? (note.resources || []) : []; + const conversion = this.convertMarkdownToENML(markdown, note.resources); note.content = this.wrapEnml(conversion.enml); const attachmentResources = this.buildResourcesFromAttachments( conversion.attachments, EvernoteModule ); - if (attachmentResources.length > 0) { - note.resources = attachmentResources; - } else if (note.resources) { - delete note.resources; + + if (options?.preserveResources) { + // Merge: start with original resources, add any new attachments + const existingHashes = new Set( + originalResources.map((r: any) => + r.data?.bodyHash ? Buffer.from(r.data.bodyHash).toString('hex') : null + ).filter(Boolean) + ); + + // Add new attachments that aren't already in original resources + const mergedResources = [...originalResources]; + for (const resource of attachmentResources) { + const hash = resource.data?.bodyHash + ? Buffer.from(resource.data.bodyHash).toString('hex') + : null; + if (hash && !existingHashes.has(hash)) { + mergedResources.push(resource); + } + } + + if (mergedResources.length > 0) { + note.resources = mergedResources; + } else { + delete note.resources; + } + } else { + // Original behavior: replace resources with new attachments only + if (attachmentResources.length > 0) { + note.resources = attachmentResources; + } else if (note.resources) { + delete note.resources; + } } } diff --git a/src/index.ts b/src/index.ts index 9cb0099..581963c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -719,6 +719,44 @@ const tools: Tool[] = [ required: ['guid'], }, }, + // Patch note tool for targeted find-and-replace updates + { + name: 'evernote_patch_note', + description: 'Apply targeted find-and-replace edits to a note without regenerating full content. Useful for updating specific text like status fields, dates, or labels while preserving the rest of the note.', + inputSchema: { + type: 'object', + properties: { + guid: { + type: 'string', + description: 'Note GUID', + }, + replacements: { + type: 'array', + items: { + type: 'object', + properties: { + find: { + type: 'string', + description: 'Text to find (exact match)', + }, + replace: { + type: 'string', + description: 'Replacement text', + }, + replaceAll: { + type: 'boolean', + description: 'Replace all occurrences (default: true)', + default: true, + }, + }, + required: ['find', 'replace'], + }, + description: 'Array of find-and-replace operations to apply', + }, + }, + required: ['guid', 'replacements'], + }, + }, ]; // List tools handler @@ -1398,6 +1436,48 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case 'evernote_patch_note': { + const { guid, replacements } = args as any; + + if (!replacements || !Array.isArray(replacements) || replacements.length === 0) { + throw new Error('At least one replacement must be provided'); + } + + // Validate each replacement has a non-empty find string + for (const r of replacements) { + if (!r.find || typeof r.find !== 'string' || r.find.length === 0) { + throw new Error('Each replacement must have a non-empty "find" string'); + } + } + + const result = await evernoteApi.patchNoteContent(guid, replacements); + + // Format the response + const changesSummary = result.changes + .map(c => ` • "${c.find}" → found ${c.occurrences}x, replaced ${c.replaced}x`) + .join('\n'); + + if (result.success) { + return { + content: [ + { + type: 'text', + text: `✅ Note patched successfully!\nGUID: ${result.noteGuid}\n\nChanges:\n${changesSummary}`, + }, + ], + }; + } else { + return { + content: [ + { + type: 'text', + text: `⚠️ Note patch failed\nGUID: ${result.noteGuid}\nReason: ${result.warning}\n\nAttempted changes:\n${changesSummary}`, + }, + ], + }; + } + } + case 'evernote_health_check': { const { verbose = false } = args as any; diff --git a/src/types.ts b/src/types.ts index 27b982c..93f0133 100644 --- a/src/types.ts +++ b/src/types.ts @@ -192,4 +192,21 @@ export interface ResourceInfo { size: number; hash: string; hasRecognition: boolean; +} + +export interface NoteReplacement { + find: string; + replace: string; + replaceAll?: boolean; +} + +export interface PatchNoteResult { + success: boolean; + noteGuid: string; + changes: Array<{ + find: string; + occurrences: number; + replaced: number; + }>; + warning?: string; } \ No newline at end of file