@disco_trooper/apple-notes-mcp 1.1.0 → 1.2.0

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.
@@ -65,6 +65,68 @@ export function parseTable(html: string): TableData {
65
65
  return result;
66
66
  }
67
67
 
68
+ /**
69
+ * Find the HTML of a specific row in a table.
70
+ */
71
+ function findRowHtml(tableHtml: string, rowIndex: number): { match: RegExpMatchArray; content: string } | null {
72
+ const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
73
+ let currentRow = 0;
74
+ let rowMatch;
75
+
76
+ while ((rowMatch = rowRegex.exec(tableHtml)) !== null) {
77
+ if (currentRow === rowIndex) {
78
+ return { match: rowMatch, content: rowMatch[1] };
79
+ }
80
+ currentRow++;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Escape HTML special characters to prevent injection.
87
+ */
88
+ function escapeHtml(text: string): string {
89
+ return text
90
+ .replace(/&/g, "&amp;")
91
+ .replace(/</g, "&lt;")
92
+ .replace(/>/g, "&gt;")
93
+ .replace(/"/g, "&quot;");
94
+ }
95
+
96
+ /**
97
+ * Update a specific cell within a row's HTML.
98
+ */
99
+ function updateCellInRow(rowContent: string, columnIndex: number, value: string, isBold: boolean): string {
100
+ const cellRegex = /(<td[^>]*>[\s\S]*?<div[^>]*>)([\s\S]*?)(<\/div>[\s\S]*?<\/td>)/gi;
101
+ let currentCol = 0;
102
+ let result = rowContent;
103
+ let cellMatch;
104
+
105
+ const replacements: Array<{original: string; replacement: string}> = [];
106
+
107
+ // Escape HTML to prevent injection
108
+ const escapedValue = escapeHtml(value);
109
+
110
+ while ((cellMatch = cellRegex.exec(rowContent)) !== null) {
111
+ if (currentCol === columnIndex) {
112
+ const prefix = cellMatch[1];
113
+ const suffix = cellMatch[3];
114
+ const newContent = isBold ? `<b>${escapedValue}</b>` : escapedValue;
115
+ replacements.push({
116
+ original: cellMatch[0],
117
+ replacement: `${prefix}${newContent}${suffix}`
118
+ });
119
+ }
120
+ currentCol++;
121
+ }
122
+
123
+ for (const r of replacements) {
124
+ result = result.replace(r.original, r.replacement);
125
+ }
126
+
127
+ return result;
128
+ }
129
+
68
130
  /**
69
131
  * Update a specific cell in an Apple Notes table HTML.
70
132
  *
@@ -85,48 +147,15 @@ export function updateTableCell(html: string, row: number, column: number, value
85
147
  throw new Error(`Column ${column} out of bounds (row has ${parsed.rows[row].length} columns)`);
86
148
  }
87
149
 
88
- // Find and replace the specific cell
89
- let currentRow = 0;
90
- let result = html;
91
-
92
- const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
93
- let rowMatch;
94
-
95
- while ((rowMatch = rowRegex.exec(html)) !== null) {
96
- if (currentRow === row) {
97
- const rowContent = rowMatch[1];
98
- let currentCol = 0;
99
- let newRowContent = rowContent;
100
-
101
- const cellRegex = /(<td[^>]*>[\s\S]*?<div[^>]*>)([\s\S]*?)(<\/div>[\s\S]*?<\/td>)/gi;
102
- let cellMatch;
103
- const replacements: Array<{original: string; replacement: string}> = [];
104
-
105
- while ((cellMatch = cellRegex.exec(rowContent)) !== null) {
106
- if (currentCol === column) {
107
- const prefix = cellMatch[1];
108
- const suffix = cellMatch[3];
109
- const isBold = parsed.formatting[row][column].bold;
110
- const newContent = isBold ? `<b>${value}</b>` : value;
111
- replacements.push({
112
- original: cellMatch[0],
113
- replacement: `${prefix}${newContent}${suffix}`
114
- });
115
- }
116
- currentCol++;
117
- }
118
-
119
- for (const r of replacements) {
120
- newRowContent = newRowContent.replace(r.original, r.replacement);
121
- }
122
-
123
- result = result.replace(rowMatch[0], `<tr>${newRowContent}</tr>`);
124
- break;
125
- }
126
- currentRow++;
150
+ const rowData = findRowHtml(html, row);
151
+ if (!rowData) {
152
+ throw new Error(`Could not find row ${row} in table HTML`);
127
153
  }
128
154
 
129
- return result;
155
+ const isBold = parsed.formatting[row][column].bold;
156
+ const updatedRowContent = updateCellInRow(rowData.content, column, value, isBold);
157
+
158
+ return html.replace(rowData.match[0], `<tr>${updatedRowContent}</tr>`);
130
159
  }
131
160
 
132
161
  /**
@@ -37,9 +37,6 @@ export interface SearchOptions {
37
37
  include_content?: boolean;
38
38
  }
39
39
 
40
- // SearchResult is imported from ../types/index.js
41
- // RRF_K is imported from ../config/constants.js
42
-
43
40
  /**
44
41
  * Calculate RRF score for a result at a given rank.
45
42
  * Formula: 1 / (k + rank)
@@ -163,15 +160,16 @@ async function hybridSearch(
163
160
  const contentMap = new Map<string, DBSearchResult>();
164
161
 
165
162
  // Process vector search results
163
+ // Use id as key to avoid collisions with duplicate titles in different folders
166
164
  vectorResults.forEach((item, rank) => {
167
- const key = item.title;
165
+ const key = item.id ?? item.title;
168
166
  scoreMap.set(key, (scoreMap.get(key) || 0) + rrfScore(rank));
169
167
  contentMap.set(key, item);
170
168
  });
171
169
 
172
170
  // Process FTS results
173
171
  ftsResults.forEach((item, rank) => {
174
- const key = item.title;
172
+ const key = item.id ?? item.title;
175
173
  scoreMap.set(key, (scoreMap.get(key) || 0) + rrfScore(rank));
176
174
  if (!contentMap.has(key)) {
177
175
  contentMap.set(key, item);
@@ -257,6 +255,7 @@ export async function searchNotes(
257
255
  // Transform to SearchResult format
258
256
  const results: SearchResult[] = dbResults.map((r) => {
259
257
  const result: SearchResult = {
258
+ id: r.id,
260
259
  title: r.title,
261
260
  folder: r.folder,
262
261
  preview: generatePreview(r.content),
@@ -276,7 +275,6 @@ export async function searchNotes(
276
275
  }
277
276
 
278
277
  // Re-export types for convenience
279
- export type { SearchMode as Mode };
280
278
  export type { SearchResult } from "../types/index.js";
281
279
 
282
280
  // Export utility functions for testing
@@ -13,14 +13,14 @@ import { getAllNotes, getNoteByFolderAndTitle, getNoteByTitle, type NoteInfo } f
13
13
  import { createDebugLogger } from "../utils/debug.js";
14
14
  import { truncateForEmbedding } from "../utils/text.js";
15
15
  import { EMBEDDING_DELAY_MS } from "../config/constants.js";
16
+ import { NoteNotFoundError } from "../errors/index.js";
16
17
 
17
18
  /**
18
19
  * Extract note title from folder/title key.
19
20
  * Handles nested folders correctly by taking the last segment.
20
21
  */
21
22
  export function extractTitleFromKey(key: string): string {
22
- const parts = key.split("/");
23
- return parts[parts.length - 1];
23
+ return key.split("/").at(-1) ?? key;
24
24
  }
25
25
 
26
26
  // Debug logging
@@ -98,6 +98,7 @@ export async function fullIndex(): Promise<IndexResult> {
98
98
  const vector = await getEmbedding(content);
99
99
 
100
100
  const record: NoteRecord = {
101
+ id: noteDetails.id,
101
102
  title: noteDetails.title,
102
103
  content: noteDetails.content,
103
104
  vector,
@@ -234,6 +235,7 @@ export async function incrementalIndex(): Promise<IndexResult> {
234
235
  const vector = await getEmbedding(content);
235
236
 
236
237
  const record: NoteRecord = {
238
+ id: noteDetails.id,
237
239
  title: noteDetails.title,
238
240
  content: noteDetails.content,
239
241
  vector,
@@ -270,6 +272,12 @@ export async function incrementalIndex(): Promise<IndexResult> {
270
272
  }
271
273
  }
272
274
 
275
+ // Rebuild FTS index if any changes were made
276
+ if (toAdd.length > 0 || toUpdate.length > 0 || toDelete.length > 0) {
277
+ debug("Rebuilding FTS index after incremental changes");
278
+ await store.rebuildFtsIndex();
279
+ }
280
+
273
281
  const timeMs = Date.now() - startTime;
274
282
  debug(`Incremental index complete: ${timeMs}ms`);
275
283
 
@@ -296,7 +304,7 @@ export async function reindexNote(title: string): Promise<void> {
296
304
 
297
305
  const noteDetails = await getNoteByTitle(title);
298
306
  if (!noteDetails) {
299
- throw new Error(`Note not found: "${title}"`);
307
+ throw new NoteNotFoundError(title);
300
308
  }
301
309
 
302
310
  if (!noteDetails.content.trim()) {
@@ -307,6 +315,7 @@ export async function reindexNote(title: string): Promise<void> {
307
315
  const vector = await getEmbedding(content);
308
316
 
309
317
  const record: NoteRecord = {
318
+ id: noteDetails.id,
310
319
  title: noteDetails.title,
311
320
  content: noteDetails.content,
312
321
  vector,
@@ -319,6 +328,10 @@ export async function reindexNote(title: string): Promise<void> {
319
328
  const store = getVectorStore();
320
329
  await store.update(record);
321
330
 
331
+ // Rebuild FTS index after single note update
332
+ debug("Rebuilding FTS index after single note reindex");
333
+ await store.rebuildFtsIndex();
334
+
322
335
  debug(`Reindexed: ${title}`);
323
336
  }
324
337
 
@@ -7,6 +7,8 @@
7
7
  * Contains the full content of the note.
8
8
  */
9
9
  export interface DBSearchResult {
10
+ /** Apple Notes unique identifier */
11
+ id?: string;
10
12
  /** Note title */
11
13
  title: string;
12
14
  /** Folder containing the note */
@@ -24,6 +26,8 @@ export interface DBSearchResult {
24
26
  * Contains a preview instead of full content by default.
25
27
  */
26
28
  export interface SearchResult {
29
+ /** Apple Notes unique identifier */
30
+ id?: string;
27
31
  /** Note title */
28
32
  title: string;
29
33
  /** Folder containing the note */
@@ -10,8 +10,6 @@ const COLORS = {
10
10
  dim: "\x1b[2m",
11
11
  cyan: "\x1b[36m",
12
12
  yellow: "\x1b[33m",
13
- red: "\x1b[31m",
14
- green: "\x1b[32m",
15
13
  } as const;
16
14
 
17
15
  /**
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { truncateForEmbedding } from "./text.js";
3
+ import { MAX_INPUT_LENGTH } from "../config/constants.js";
4
+
5
+ describe("truncateForEmbedding", () => {
6
+ it("should return text unchanged if within limit", () => {
7
+ const text = "Short text";
8
+ expect(truncateForEmbedding(text)).toBe(text);
9
+ });
10
+
11
+ it("should truncate text exceeding default limit", () => {
12
+ const text = "a".repeat(MAX_INPUT_LENGTH + 100);
13
+ const result = truncateForEmbedding(text);
14
+ expect(result.length).toBe(MAX_INPUT_LENGTH);
15
+ expect(result).toBe("a".repeat(MAX_INPUT_LENGTH));
16
+ });
17
+
18
+ it("should use custom maxLength when provided", () => {
19
+ const text = "Hello World";
20
+ const result = truncateForEmbedding(text, 5);
21
+ expect(result).toBe("Hello");
22
+ });
23
+
24
+ it("should handle empty string", () => {
25
+ expect(truncateForEmbedding("")).toBe("");
26
+ });
27
+
28
+ it("should handle text exactly at limit", () => {
29
+ const text = "a".repeat(MAX_INPUT_LENGTH);
30
+ expect(truncateForEmbedding(text)).toBe(text);
31
+ });
32
+ });
package/CLAUDE.md DELETED
@@ -1,56 +0,0 @@
1
- # CLAUDE.md
2
-
3
- ## Project Overview
4
-
5
- MCP server for Apple Notes with semantic search and CRUD operations.
6
-
7
- ## Tech Stack
8
-
9
- - **Runtime**: Bun
10
- - **Language**: TypeScript
11
- - **Database**: LanceDB (vector store)
12
- - **Embeddings**: HuggingFace Transformers (local) or OpenRouter API
13
- - **Apple Notes**: JXA (JavaScript for Automation)
14
-
15
- ## Commands
16
-
17
- ```bash
18
- bun run start # Start MCP server
19
- bun run setup # Interactive setup wizard
20
- bun run dev # Watch mode
21
- bun run check # Type check
22
- bun run test # Run tests (uses vitest, NOT bun test)
23
- ```
24
-
25
- ## Project Structure
26
-
27
- ```
28
- src/
29
- ├── index.ts # MCP server entry (stdio transport)
30
- ├── server.ts # Smithery-compatible export
31
- ├── setup.ts # Interactive setup wizard
32
- ├── config/ # Constants and env validation
33
- ├── db/ # LanceDB vector store
34
- ├── embeddings/ # Local and OpenRouter embeddings
35
- ├── notes/ # Apple Notes CRUD via JXA
36
- ├── search/ # Hybrid search and indexing
37
- └── utils/ # Debug logging, errors, text utils
38
- ```
39
-
40
- ## Key Patterns
41
-
42
- - **Dual embedding support**: Detects `OPENROUTER_API_KEY` to choose provider
43
- - **Hybrid search**: Combines vector + keyword search with RRF fusion
44
- - **Incremental indexing**: Only re-embeds changed notes
45
- - **Folder/title disambiguation**: Use `Folder/Note Title` format for duplicates
46
-
47
- ## Testing
48
-
49
- Always use `bun run test` (vitest), never `bun test` (incompatible bun runner).
50
-
51
- ## Environment Variables
52
-
53
- See README.md for full list. Key ones:
54
- - `OPENROUTER_API_KEY` - Enables cloud embeddings
55
- - `READONLY_MODE` - Blocks write operations
56
- - `DEBUG` - Enables debug logging