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