diff --git a/extensions/apple-notes/CHANGELOG.md b/extensions/apple-notes/CHANGELOG.md index 6901df1ed8d..dbc3de0ae52 100644 --- a/extensions/apple-notes/CHANGELOG.md +++ b/extensions/apple-notes/CHANGELOG.md @@ -1,5 +1,11 @@ # Apple Notes Changelog +## [AI Extension Improvements] - 2026-09-01 + +- Add `delete-note`, `restore-note`, `move-note`, `append-to-note`, and `list-folders` AI tools +- Add tag filtering to the `search-notes` AI tool +- Let the "AI Note" command append AI-generated content to an existing note via a new optional `note` argument + ## [Bug Fixes] - 2026-06-07 - Fix tags not rendering in note detail view diff --git a/extensions/apple-notes/package.json b/extensions/apple-notes/package.json index b0d2e69b254..6f4679d672c 100644 --- a/extensions/apple-notes/package.json +++ b/extensions/apple-notes/package.json @@ -20,7 +20,8 @@ "andreaselia", "fhf1121", "charlesharries", - "janosorcsik" + "janosorcsik", + "bitforger" ], "pastContributors": [ "thomaslombart" @@ -72,7 +73,7 @@ "name": "ai", "title": "AI Note", "subtitle": "Apple Notes", - "description": "Create a new note filled with AI in your Apple notes.", + "description": "Create a new note filled with AI in your Apple notes, or add to an existing one.", "mode": "no-view", "arguments": [ { @@ -85,6 +86,11 @@ "name": "instructions", "type": "text", "placeholder": "Additional instructions" + }, + { + "name": "note", + "type": "text", + "placeholder": "Existing note (optional)" } ] }, @@ -150,10 +156,35 @@ "name": "update-note", "title": "Update Note", "description": "Update the content of a specific note in Apple Notes." + }, + { + "name": "append-to-note", + "title": "Append to Note", + "description": "Add content to the end of a specific note in Apple Notes, keeping its existing content." + }, + { + "name": "delete-note", + "title": "Delete Note", + "description": "Delete a specific note in Apple Notes." + }, + { + "name": "restore-note", + "title": "Restore Note", + "description": "Restore a previously deleted note in Apple Notes." + }, + { + "name": "move-note", + "title": "Move Note", + "description": "Move a specific note to a different folder in Apple Notes." + }, + { + "name": "list-folders", + "title": "List Folders", + "description": "List the available folders in Apple Notes." } ], "ai": { - "instructions": "- Always format note titles as Markdown links using the note's URL.\\n- If you can't access the Apple Notes database, tell the user he needs to give Raycast the full disk access permission in the system settings. Be detailed about the steps.\\n- When calling get-note-content or update-note from search-notes results, use the note's id field and not UUID.", + "instructions": "- Always format note titles as Markdown links using the note's URL.\\n- If you can't access the Apple Notes database, tell the user he needs to give Raycast the full disk access permission in the system settings. Be detailed about the steps.\\n- When calling get-note-content, update-note, append-to-note, delete-note, or move-note from search-notes results, use the note's id field and not UUID.\\n- Use update-note to replace a note's whole content, and append-to-note to add new content while keeping what's already there.\\n- Before calling move-note, call list-folders to find a valid folder name; move-note will fail if the folder doesn't exist. If list-folders shows the folder name in more than one account, pass that account as move-note's accountName, or it will fail with a duplicate-folder error.\\n- delete-note is destructive and asks the user to confirm; if a note was deleted by mistake, restore-note can bring it back.", "evals": [ { "input": "@apple-notes what's on my grocery list", @@ -363,6 +394,152 @@ } } ] + }, + { + "input": "@apple-notes delete my grocery list note", + "mocks": { + "search-notes": [ + { + "id": "grocery-list", + "folder": "Home", + "modifiedAt": "2025-01-21 13:15:50", + "snippet": "- Bananas - Carrots", + "title": "Grocery list" + } + ], + "delete-note": "Successfully called \"delete-note\"" + }, + "expected": [ + { + "callsTool": "search-notes" + }, + { + "callsTool": { + "name": "delete-note", + "arguments": { + "noteId": "grocery-list" + } + } + } + ] + }, + { + "input": "@apple-notes what folders do I have in Apple Notes?", + "mocks": { + "list-folders": [ + { + "account": "iCloud", + "folder": "Notes" + }, + { + "account": "iCloud", + "folder": "Work" + }, + { + "account": "iCloud", + "folder": "Recipes" + } + ] + }, + "expected": [ + { + "callsTool": "list-folders" + }, + { + "meetsCriteria": "Returns a list of 3 folders." + } + ] + }, + { + "input": "@apple-notes move my grocery list note to the Work folder", + "mocks": { + "search-notes": [ + { + "id": "grocery-list", + "folder": "Home", + "modifiedAt": "2025-01-21 13:15:50", + "snippet": "- Bananas - Carrots", + "title": "Grocery list" + } + ], + "list-folders": [ + { + "account": "iCloud", + "folder": "Home" + }, + { + "account": "iCloud", + "folder": "Work" + } + ], + "move-note": "Successfully called \"move-note\"" + }, + "expected": [ + { + "callsTool": "search-notes" + }, + { + "callsTool": { + "name": "move-note", + "arguments": { + "noteId": "grocery-list", + "folderName": "Work" + } + } + } + ] + }, + { + "input": "@apple-notes add milk to my grocery list without removing what's already there", + "mocks": { + "search-notes": [ + { + "id": "grocery-list", + "folder": "Home", + "modifiedAt": "2025-01-21 13:15:50", + "snippet": "- Bananas - Carrots", + "title": "Grocery list" + } + ], + "append-to-note": "Successfully called \"append-to-note\"" + }, + "expected": [ + { + "callsTool": "search-notes" + }, + { + "callsTool": { + "name": "append-to-note", + "arguments": { + "noteId": "grocery-list" + } + } + } + ] + }, + { + "input": "@apple-notes find my work notes tagged urgent", + "mocks": { + "search-notes": [ + { + "id": "project-milestones", + "folder": "Work", + "modifiedAt": "2025-01-19 16:45:22", + "snippet": "- Design review - User testing - Launch prep", + "title": "Q1 Project Milestones" + } + ] + }, + "expected": [ + { + "callsTool": { + "name": "search-notes", + "arguments": { + "tags": "urgent" + } + } + } + ] } ] }, @@ -483,4 +660,4 @@ "platforms": [ "macOS" ] -} +} \ No newline at end of file diff --git a/extensions/apple-notes/src/ai.ts b/extensions/apple-notes/src/ai.ts index ae9a167fa1e..38cac234ca7 100644 --- a/extensions/apple-notes/src/ai.ts +++ b/extensions/apple-notes/src/ai.ts @@ -1,16 +1,50 @@ import { AI, closeMainWindow, LaunchProps, showToast, Toast } from "@raycast/api"; import { showFailureToast } from "@raycast/utils"; -import { createNote } from "./api/applescript"; +import { appendNoteBody, createNote } from "./api/applescript"; +import { getNotes } from "./api/getNotes"; export default async (props: LaunchProps<{ arguments: Arguments.Ai }>) => { await closeMainWindow(); - await showToast({ style: Toast.Style.Animated, title: "Creating a note" }); - const text = props.fallbackText || props.arguments.text; - const instructions = props.arguments.instructions; + const noteQuery = props.arguments.note?.trim(); + + let targetNote: Awaited>[number] | undefined; + if (noteQuery) { + await showToast({ style: Toast.Style.Animated, title: "Looking for note" }); + + // Exact title match is verified with Unicode-aware comparison, so it can't be missed + // regardless of how many notes exist or what script the title uses. + const exactMatches = await getNotes(10, [], noteQuery, true); + + if (exactMatches.length === 1) { + targetNote = exactMatches[0]; + } + + if (exactMatches.length > 1) { + await showFailureToast( + new Error(`Multiple notes titled "${noteQuery}" were found. Please use a more specific title.`), + { + title: "Note title is ambiguous", + }, + ); + return; + } + + if (!targetNote) { + await showFailureToast(new Error(`No note matching "${noteQuery}" was found.`), { + title: "Could not find note", + }); + return; + } + } + + await showToast({ + style: Toast.Style.Animated, + title: targetNote ? "Adding to note" : "Creating a note", + }); try { const result = await AI.ask( @@ -25,8 +59,15 @@ Follow these instructions: ${instructions ? `- ${instructions}` : ""} `, ); - await createNote(result); + + if (targetNote) { + await appendNoteBody(targetNote.id, result); + } else { + await createNote(result); + } } catch (error) { - await showFailureToast(error, { title: "Could not create a new note." }); + await showFailureToast(error, { + title: targetNote ? "Could not add to the note." : "Could not create a new note.", + }); } }; diff --git a/extensions/apple-notes/src/api/applescript.ts b/extensions/apple-notes/src/api/applescript.ts index adb69dbfa58..0e03c9aae58 100644 --- a/extensions/apple-notes/src/api/applescript.ts +++ b/extensions/apple-notes/src/api/applescript.ts @@ -101,3 +101,75 @@ export async function getSelectedNote() { end tell `); } + +export async function appendNoteBody(id: string, content: string) { + return runAppleScript( + ` + tell application "Notes" + set theNote to note id "${escapeDoubleQuotes(id)}" + set body of theNote to (body of theNote) & "${escapeDoubleQuotes(content)}" + end tell + `, + { timeout: 30_000 }, + ); +} + +export async function moveNoteToFolder(id: string, folderName: string, accountName?: string) { + const escapedFolderName = escapeDoubleQuotes(folderName); + // Without an explicit account, require a single matching folder across accounts. + const findFolder = accountName + ? `set theFolder to folder "${escapedFolderName}" of account "${escapeDoubleQuotes(accountName)}"` + : ` + set theFolder to missing value + set matchingAccountNames to {} + repeat with acc in accounts + try + set candidateFolder to folder "${escapedFolderName}" of acc + copy (name of acc) to end of matchingAccountNames + if theFolder is missing value then + set theFolder to candidateFolder + end if + end try + end repeat + if (count of matchingAccountNames) is 0 then + error "Folder \\"${escapedFolderName}\\" not found" + end if + if (count of matchingAccountNames) > 1 then + set AppleScript's text item delimiters to ", " + set matchingAccountsText to matchingAccountNames as text + set AppleScript's text item delimiters to "" + error "Folder \\"${escapedFolderName}\\" exists in multiple accounts (" & matchingAccountsText & "). Provide accountName." + end if + `; + + return runAppleScript( + ` + tell application "Notes" + set theNote to note id "${escapeDoubleQuotes(id)}" + ${findFolder} + move theNote to theFolder + end tell + `, + { timeout: 30_000 }, + ); +} + +export async function getFolders() { + return runAppleScript( + ` + tell application "Notes" + set output to {} + repeat with acc in accounts + repeat with fld in folders of acc + copy ((name of acc) & "|" & (name of fld)) to end of output + end repeat + end repeat + set AppleScript's text item delimiters to linefeed + set resultText to output as text + set AppleScript's text item delimiters to "" + return resultText + end tell + `, + { timeout: 30_000 }, + ); +} diff --git a/extensions/apple-notes/src/api/getNotes.ts b/extensions/apple-notes/src/api/getNotes.ts index 8d7d144ca89..96b9ca8583f 100644 --- a/extensions/apple-notes/src/api/getNotes.ts +++ b/extensions/apple-notes/src/api/getNotes.ts @@ -2,14 +2,62 @@ import { executeSQL } from "@raycast/utils"; import { escapeSQLString, getOpenNoteURL, NOTES_DB, Link, Backlink, Tag, NoteItem } from "../helpers"; -export async function getNotes(maxQueryResults: number, filterByTags: string[] = [], searchText?: string) { +// SQLite's LOWER()/LIKE only fold ASCII case and never strip accents, so "cafe" wouldn't match +// "Café" in SQL. This is the authoritative, fully Unicode-aware check applied in JS; the SQL-side +// filter below only folds the common Latin accents, as a bound on how much data JS has to look at. +function normalizeForSearch(value: string): string { + return value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase(); +} + +// Common Latin accented characters mapped to their base letter, used to build a SQL expression +// that approximates normalizeForSearch well enough to prefilter and bound the query in SQL. +const SQL_DIACRITIC_REPLACEMENTS: [string, string][] = [ + ["àáâãäå", "a"], + ["èéêë", "e"], + ["ìíîï", "i"], + ["òóôõö", "o"], + ["ùúûü", "u"], + ["ýÿ", "y"], + ["ñ", "n"], + ["ç", "c"], +]; + +function foldSqlColumn(column: string): string { + const withDiacriticsFolded = SQL_DIACRITIC_REPLACEMENTS.reduce((expr, [accentedChars, base]) => { + return [...accentedChars, ...accentedChars.toUpperCase()].reduce( + (inner, accentedChar) => `REPLACE(${inner}, '${accentedChar}', '${base}')`, + expr, + ); + }, column); + return `LOWER(${withDiacriticsFolded})`; +} + +export async function getNotes( + maxQueryResults: number, + filterByTags: string[] = [], + searchText?: string, + exactTitleMatch = false, +) { const trimmedSearchText = searchText?.trim(); - const searchFilter = trimmedSearchText - ? ` AND ( - note.ztitle1 LIKE '%${escapeSQLString(trimmedSearchText)}%' OR - note.zsnippet LIKE '%${escapeSQLString(trimmedSearchText)}%' - )` - : ""; + const foldedSearchText = trimmedSearchText ? escapeSQLString(normalizeForSearch(trimmedSearchText)) : ""; + // SQLite's LOWER() only folds ASCII case, so a SQL-side fold can't be trusted to find exact + // matches that differ only by case in non-Latin scripts (e.g. "Привет" vs "ПРИВЕТ"). For those, + // skip the SQL filter and let the JS-side normalizeForSearch check below do the real matching. + const hasNonAsciiSearchText = trimmedSearchText + ? [...trimmedSearchText].some((char) => char.charCodeAt(0) > 127) + : false; + let searchFilter = ""; + if (trimmedSearchText && exactTitleMatch && !hasNonAsciiSearchText) { + searchFilter = ` AND ${foldSqlColumn("TRIM(note.ztitle1)")} = '${foldedSearchText}'`; + } else if (trimmedSearchText && !exactTitleMatch) { + searchFilter = ` AND ( + ${foldSqlColumn("note.ztitle1")} LIKE '%${foldedSearchText}%' OR + ${foldSqlColumn("note.zsnippet")} LIKE '%${foldedSearchText}%' + )`; + } const query = ` SELECT @@ -149,5 +197,18 @@ export async function getNotes(maxQueryResults: number, filterByTags: string[] = }); } + if (trimmedSearchText) { + const normalizedQuery = normalizeForSearch(trimmedSearchText); + notesWithAdditionalFields = notesWithAdditionalFields.filter((note) => + exactTitleMatch + ? normalizeForSearch(note.title.trim()) === normalizedQuery + : normalizeForSearch(note.title).includes(normalizedQuery) || + normalizeForSearch(note.snippet).includes(normalizedQuery), + ); + if (exactTitleMatch) { + notesWithAdditionalFields = notesWithAdditionalFields.slice(0, maxQueryResults); + } + } + return notesWithAdditionalFields; } diff --git a/extensions/apple-notes/src/helpers.ts b/extensions/apple-notes/src/helpers.ts index 09ffa1c77a9..55963482b63 100644 --- a/extensions/apple-notes/src/helpers.ts +++ b/extensions/apple-notes/src/helpers.ts @@ -71,6 +71,10 @@ export function getOpenNoteURL(uuid: string) { } export async function resolveAppleNoteId(noteId: string): Promise { + if (!noteId) { + throw new Error('A noteId is required. Use the "id" field returned by search-notes.'); + } + if (noteId.startsWith("x-coredata://")) { return noteId; } diff --git a/extensions/apple-notes/src/tools/append-to-note.ts b/extensions/apple-notes/src/tools/append-to-note.ts new file mode 100644 index 00000000000..1bc8de23724 --- /dev/null +++ b/extensions/apple-notes/src/tools/append-to-note.ts @@ -0,0 +1,21 @@ +import { appendNoteBody } from "../api/applescript"; +import { resolveAppleNoteId } from "../helpers"; + +type Input = { + /** The note identifier. Use the "id" value from search-notes when possible. */ + noteId: string; + /** + * The content to append to the note, formatted as HTML, so that it can be pasted into Apple Notes. + * + * - Don't repeat the existing content of the note, only provide the new content to add. + * - Use the same language as the existing note. + * - Break the content into paragraphs with line breaks. + * - Don't use Markdown links (e.g. [Link](https://example.com)), use HTML links (e.g. Link). + */ + content: string; +}; + +export default async function (input: Input) { + const noteId = await resolveAppleNoteId(input.noteId); + return appendNoteBody(noteId, input.content); +} diff --git a/extensions/apple-notes/src/tools/delete-note.ts b/extensions/apple-notes/src/tools/delete-note.ts new file mode 100644 index 00000000000..f96fb5a785e --- /dev/null +++ b/extensions/apple-notes/src/tools/delete-note.ts @@ -0,0 +1,72 @@ +import { Action, Tool } from "@raycast/api"; +import { executeSQL } from "@raycast/utils"; + +import { deleteNoteById } from "../api/applescript"; +import { escapeSQLString, NOTES_DB, resolveAppleNoteId } from "../helpers"; + +type Input = { + /** The note identifier. Use the "id" value from search-notes when possible. */ + noteId: string; + /** The title of the note, used to display in the confirmation dialog. */ + noteTitle?: string; +}; + +type NoteTitleRow = { + title: string | null; +}; + +function shortenNoteId(noteId: string) { + if (noteId.length <= 48) { + return noteId; + } + + return `${noteId.slice(0, 30)}...${noteId.slice(-12)}`; +} + +async function getNoteTitleByResolvedId(resolvedNoteId: string): Promise { + const escapedResolvedNoteId = escapeSQLString(resolvedNoteId); + const rows = await executeSQL( + NOTES_DB, + ` + SELECT + note.ztitle1 AS title + FROM + ziccloudsyncingobject AS note + LEFT JOIN z_metadata AS zmd ON 1=1 + WHERE + ('x-coredata://' || zmd.z_uuid || '/ICNote/p' || note.z_pk) = '${escapedResolvedNoteId}' + LIMIT 1 + `, + ); + + const title = rows?.[0]?.title?.trim(); + return title || undefined; +} + +export default async function (input: Input) { + const noteId = await resolveAppleNoteId(input.noteId); + return deleteNoteById(noteId); +} + +export const confirmation: Tool.Confirmation = async (input) => { + // Resolution failures must propagate: falling back to the raw input id here would let the + // confirmation show an unresolved identifier while execution independently re-resolves and + // deletes whatever note that resolves to. + const resolvedNoteId = await resolveAppleNoteId(input.noteId); + + let resolvedTitle: string | undefined; + try { + resolvedTitle = await getNoteTitleByResolvedId(resolvedNoteId); + } catch { + // Fall back to ID-only confirmation when the title lookup fails. + } + + const displayId = shortenNoteId(resolvedNoteId); + + return { + style: Action.Style.Destructive, + message: resolvedTitle + ? `Are you sure you want to delete note "${resolvedTitle}" (ID: ${displayId})?` + : `Are you sure you want to delete note ID "${displayId}"?`, + }; +}; diff --git a/extensions/apple-notes/src/tools/list-folders.ts b/extensions/apple-notes/src/tools/list-folders.ts new file mode 100644 index 00000000000..92c4ba91a64 --- /dev/null +++ b/extensions/apple-notes/src/tools/list-folders.ts @@ -0,0 +1,10 @@ +import { getFolders } from "../api/applescript"; + +export default async function () { + const output = await getFolders(); + return output + .split("\n") + .map((line) => line.split("|")) + .filter(([account, folder]) => account && folder) + .map(([account, folder]) => ({ account, folder })); +} diff --git a/extensions/apple-notes/src/tools/move-note.ts b/extensions/apple-notes/src/tools/move-note.ts new file mode 100644 index 00000000000..e6f34f06873 --- /dev/null +++ b/extensions/apple-notes/src/tools/move-note.ts @@ -0,0 +1,30 @@ +import { Tool } from "@raycast/api"; + +import { moveNoteToFolder } from "../api/applescript"; +import { resolveAppleNoteId } from "../helpers"; + +type Input = { + /** The note identifier. Use the "id" value from search-notes when possible. */ + noteId: string; + /** The name of the destination folder. Use list-folders to find a valid folder name. */ + folderName: string; + /** + * The name of the account that owns the destination folder. + * Required whenever list-folders shows more than one account with a folder of this name. + */ + accountName?: string; +}; + +export default async function (input: Input) { + const noteId = await resolveAppleNoteId(input.noteId); + return moveNoteToFolder(noteId, input.folderName, input.accountName); +} + +export const confirmation: Tool.Confirmation = async (input) => { + return { + info: [ + { name: "Destination folder", value: input.folderName }, + ...(input.accountName ? [{ name: "Destination account", value: input.accountName }] : []), + ], + }; +}; diff --git a/extensions/apple-notes/src/tools/restore-note.ts b/extensions/apple-notes/src/tools/restore-note.ts new file mode 100644 index 00000000000..334a624b160 --- /dev/null +++ b/extensions/apple-notes/src/tools/restore-note.ts @@ -0,0 +1,12 @@ +import { restoreNoteById } from "../api/applescript"; +import { resolveAppleNoteId } from "../helpers"; + +type Input = { + /** The note identifier. Use the "id" value from search-notes when possible. */ + noteId: string; +}; + +export default async function (input: Input) { + const noteId = await resolveAppleNoteId(input.noteId); + return restoreNoteById(noteId); +} diff --git a/extensions/apple-notes/src/tools/search-notes.ts b/extensions/apple-notes/src/tools/search-notes.ts index d31245a4488..c938e496243 100644 --- a/extensions/apple-notes/src/tools/search-notes.ts +++ b/extensions/apple-notes/src/tools/search-notes.ts @@ -5,11 +5,17 @@ import { getNotes } from "../api/getNotes"; type Input = { /** Optional text query used to search note titles and snippets. */ searchText?: string; + /** Optional comma-separated list of tags to filter notes by, e.g. "work,urgent". A note must have ALL given tags. */ + tags?: string; }; export default async function (input: Input = {}) { const { maxQueryResults } = getPreferenceValues(); const max = parseInt(maxQueryResults, 10) || 250; - const notes = await getNotes(max, [], input.searchText); + const tags = input.tags + ?.split(",") + .map((tag) => tag.trim()) + .filter(Boolean); + const notes = await getNotes(max, tags ?? [], input.searchText); return notes; }