diff --git a/commands/edit.md b/commands/edit.md new file mode 100644 index 0000000..410e7e0 --- /dev/null +++ b/commands/edit.md @@ -0,0 +1,33 @@ +--- +description: Edit an existing Apple Note +allowed-tools: Bash(notes:*) +argument-hint: --body "new content" [--folder "Folder"] +--- + +# Edit Note + +Edit an existing note in Apple Notes. The created timestamp is preserved. + +## Instructions + +1. Check if the notes CLI is installed: +```bash +command -v notes || pnpm add -g @cardmagic/notes +``` + +2. Edit the note: +```bash +notes edit $ARGUMENTS +``` + +## Examples + +- `/notes:edit 123 --body "Updated content"` - Edit note by ID +- `/notes:edit --title "Meeting Notes" --body "New agenda"` - Edit by title +- `/notes:edit --title "Todo" --body "New tasks" --folder "Work"` - Edit with folder disambiguation + +## Workflow + +1. Find the note: `notes search "keyword"` or `notes recent` +2. Read current content: `notes read ` +3. Edit the note: `notes edit --body "new content"` diff --git a/src/applescript.ts b/src/applescript.ts index 65b199f..54848f8 100644 --- a/src/applescript.ts +++ b/src/applescript.ts @@ -17,8 +17,100 @@ export interface DeleteNoteResult { name: string; } +export interface EditNoteOptions { + title: string; + body: string; + folder?: string; +} + +export interface EditNoteResult { + success: boolean; + name: string; + folder: string; +} + +/** + * Escapes a string for safe use in AppleScript string literals. + * Handles backslashes, quotes, newlines, and control characters. + */ function escapeAppleScript(str: string): string { - return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return str + .replace(/\\/g, '\\\\') // Backslash must be first + .replace(/"/g, '\\"') // Double quotes + .replace(/\n/g, '\\n') // Newlines + .replace(/\r/g, '\\r') // Carriage returns + .replace(/\t/g, '\\t') // Tabs + .replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F]/g, ''); // Strip other control chars +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Executes an AppleScript by passing it via stdin to avoid shell injection. + * This is more secure than using -e flag with shell escaping. + * + * @param script - The complete AppleScript to execute + * @returns The output from the script + * @throws Error if execution fails + */ +function executeAppleScript(script: string): string { + return execSync('osascript -', { + input: script, + encoding: 'utf-8', + timeout: 30000, + maxBuffer: 10 * 1024 * 1024, // 10MB for large note bodies + }); +} + +/** + * Builds an AppleScript that finds a note by title and performs an operation on it. + * Handles folder scoping when provided. + * + * @param title - The note title to search for + * @param operation - AppleScript code to execute on the found note. The note is available + * as the `targetNote` variable. Should include a `return` statement. + * Example: `delete targetNote\nreturn "deleted"` + * @param folder - Optional folder name to scope the search + */ +function buildNoteOperationScript( + title: string, + operation: string, + folder?: string +): string { + const escapedTitle = escapeAppleScript(title); + + if (folder) { + const escapedFolder = escapeAppleScript(folder); + return ` + tell application "Notes" + set targetFolder to folder "${escapedFolder}" + set matchingNotes to notes of targetFolder whose name is "${escapedTitle}" + if (count of matchingNotes) is 0 then + error "Note not found" + end if + set targetNote to item 1 of matchingNotes + ${operation} + end tell + `; + } else { + return ` + tell application "Notes" + set matchingNotes to notes whose name is "${escapedTitle}" + if (count of matchingNotes) is 0 then + error "Note not found" + end if + set targetNote to item 1 of matchingNotes + ${operation} + end tell + `; + } } export function createNote(options: CreateNoteOptions): CreateNoteResult { @@ -37,10 +129,7 @@ export function createNote(options: CreateNoteOptions): CreateNoteResult { `; try { - const result = execSync(`osascript -e '${script.replace(/'/g, "'\"'\"'")}'`, { - encoding: 'utf-8', - timeout: 30000, - }); + const result = executeAppleScript(script); return { success: true, @@ -58,44 +147,56 @@ export function createNote(options: CreateNoteOptions): CreateNoteResult { export function deleteNote(title: string, folder?: string): DeleteNoteResult { const escapedTitle = escapeAppleScript(title); + const operation = ` + delete targetNote + return "${escapedTitle}" + `; - let script: string; + const script = buildNoteOperationScript(title, operation, folder); - if (folder) { - const escapedFolder = escapeAppleScript(folder); - script = ` - tell application "Notes" - set targetFolder to folder "${escapedFolder}" - set matchingNotes to notes of targetFolder whose name is "${escapedTitle}" - if (count of matchingNotes) is 0 then - error "Note not found" - end if - delete item 1 of matchingNotes - return "${escapedTitle}" - end tell - `; - } else { - script = ` - tell application "Notes" - set matchingNotes to notes whose name is "${escapedTitle}" - if (count of matchingNotes) is 0 then - error "Note not found" - end if - delete item 1 of matchingNotes - return "${escapedTitle}" - end tell - `; + try { + const result = executeAppleScript(script); + + return { + success: true, + name: result.trim(), + }; + } catch (error) { + const message = (error as Error).message; + if (message.includes('Note not found')) { + const folderInfo = folder ? ` in folder "${folder}"` : ''; + throw new Error(`Note "${title}" not found${folderInfo}.`); + } + if (message.includes('get folder')) { + throw new Error(`Folder "${folder}" not found. Use 'notes folders' to list available folders.`); + } + throw new Error(`Failed to delete note: ${message}`); } +} + +export function editNote(options: EditNoteOptions): EditNoteResult { + const { title, body, folder } = options; + + // Apple Notes uses the first line of the body as the title, so we prepend the title + // as an HTML heading to preserve it when setting the body + const fullBody = `

${escapeHtml(title)}


${escapeHtml(body)}`; + const escapedBody = escapeAppleScript(fullBody); + const targetFolder = folder || 'Notes'; + + const operation = ` + set body of targetNote to "${escapedBody}" + return name of targetNote + `; + + const script = buildNoteOperationScript(title, operation, folder); try { - const result = execSync(`osascript -e '${script.replace(/'/g, "'\"'\"'")}'`, { - encoding: 'utf-8', - timeout: 30000, - }); + const result = executeAppleScript(script); return { success: true, name: result.trim(), + folder: targetFolder, }; } catch (error) { const message = (error as Error).message; @@ -106,7 +207,7 @@ export function deleteNote(title: string, folder?: string): DeleteNoteResult { if (message.includes('get folder')) { throw new Error(`Folder "${folder}" not found. Use 'notes folders' to list available folders.`); } - throw new Error(`Failed to delete note: ${message}`); + throw new Error(`Failed to edit note: ${message}`); } } @@ -123,10 +224,7 @@ export function listNoteFolders(): string[] { `; try { - const result = execSync(`osascript -e '${script.replace(/'/g, "'\"'\"'")}'`, { - encoding: 'utf-8', - timeout: 30000, - }); + const result = executeAppleScript(script); return result.trim().split('\n').filter(Boolean); } catch (error) { diff --git a/src/cli.ts b/src/cli.ts index 71943b8..354a398 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,7 +23,7 @@ import { formatIndexProgress, formatNote, } from './formatter.js'; -import { createNote, deleteNote } from './applescript.js'; +import { createNote, deleteNote, editNote } from './applescript.js'; const program = new Command(); @@ -224,6 +224,61 @@ program } }); +program + .command('edit [id]') + .description('Edit an existing note by ID or title (ID takes precedence if both provided)') + .option('-t, --title ', 'Edit by title instead of ID') + .option('-b, --body ', 'New body content') + .option('-f, --folder ', 'Folder containing the note (for disambiguation)') + .action(async (id: string | undefined, options: { title?: string; body?: string; folder?: string }) => { + try { + if (!options.body) { + console.error('Error: --body is required'); + process.exit(1); + } + + if (!id && !options.title) { + console.error('Error: Either or --title is required'); + process.exit(1); + } + + let title: string; + let folder: string | undefined = options.folder; + + // ID takes precedence if both are provided + if (id) { + const noteId = parseInt(id, 10); + if (isNaN(noteId)) { + console.error('Invalid note ID'); + process.exit(1); + } + + const note = await getNoteById(noteId); + if (!note) { + console.error('Note not found'); + process.exit(1); + } + + title = note.title; + folder = folder || note.folder; + } else { + title = options.title!; + } + + const result = editNote({ + title, + body: options.body, + folder, + }); + console.log(`Updated note "${result.name}" in folder "${result.folder}"`); + } catch (error) { + console.error('Error:', (error as Error).message); + process.exit(1); + } finally { + closeConnections(); + } + }); + export function runCli(): void { program.parse(process.argv); } diff --git a/src/mcp.ts b/src/mcp.ts index a4a06fc..9fc3545 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -12,7 +12,7 @@ import { listFolders, getNoteStats, } from './searcher.js'; -import { createNote, deleteNote } from './applescript.js'; +import { createNote, deleteNote, editNote } from './applescript.js'; import type { IndexedNote, SearchResult } from './types.js'; function formatNoteForMcp(note: IndexedNote): string { @@ -204,6 +204,35 @@ export async function runMcpServer(): Promise { required: ['title'], }, }, + { + name: 'edit_note', + description: 'Edit an existing note in Apple Notes (preserves created timestamp). If both id and title are provided, id takes precedence.', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'number', + description: 'Note ID to edit (from search/recent results)', + }, + title: { + type: 'string', + description: 'Edit by title instead of ID', + }, + body: { + type: 'string', + description: 'New body content for the note', + }, + folder: { + type: 'string', + description: 'Folder containing the note (optional and only for disambiguation when editing by title)', + }, + }, + anyOf: [ + { required: ['id', 'body'] }, + { required: ['title', 'body'] }, + ], + }, + }, ], })); @@ -375,6 +404,43 @@ export async function runMcpServer(): Promise { }; } + case 'edit_note': { + const id = args?.id as number | undefined; + const titleArg = args?.title as string | undefined; + const body = args?.body as string; + let folder = args?.folder as string | undefined; + + if (id === undefined && !titleArg) { + return { + content: [{ type: 'text', text: 'Either id or title is required.' }], + isError: true, + }; + } + + let title: string; + + // ID takes precedence if both are provided + if (id !== undefined) { + const note = await getNoteById(id); + if (!note) { + return { + content: [{ type: 'text', text: `Note with ID ${id} not found.` }], + isError: true, + }; + } + title = note.title; + folder = folder || note.folder; + } else { + title = titleArg!; + } + + const result = editNote({ title, body, folder }); + + return { + content: [{ type: 'text', text: `✅ Updated note "${result.name}" in folder "${result.folder}"` }], + }; + } + default: return { content: [{ type: 'text', text: `Unknown tool: ${name}` }],