@disco_trooper/apple-notes-mcp 1.0.1 → 1.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@disco_trooper/apple-notes-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server for Apple Notes with semantic search and CRUD operations",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -22,9 +22,15 @@ vi.mock("../utils/debug.js", () => ({
22
22
  createDebugLogger: vi.fn(() => vi.fn()),
23
23
  }));
24
24
 
25
+ vi.mock("./tables.js", () => ({
26
+ findTables: vi.fn(),
27
+ updateTableCell: vi.fn(),
28
+ }));
29
+
25
30
  import { runJxa } from "run-jxa";
26
- import { checkReadOnly, createNote, updateNote, deleteNote, moveNote } from "./crud.js";
31
+ import { checkReadOnly, createNote, updateNote, deleteNote, moveNote, editTable } from "./crud.js";
27
32
  import { resolveNoteTitle } from "./read.js";
33
+ import { findTables, updateTableCell } from "./tables.js";
28
34
 
29
35
  describe("checkReadOnly", () => {
30
36
  const originalEnv = process.env.READONLY_MODE;
@@ -197,3 +203,57 @@ describe("moveNote", () => {
197
203
  await expect(moveNote("Note", "Archive")).rejects.toThrow("Suggestions: Work/Note, Personal/Note");
198
204
  });
199
205
  });
206
+
207
+ describe("editTable", () => {
208
+ beforeEach(() => {
209
+ vi.clearAllMocks();
210
+ delete process.env.READONLY_MODE;
211
+ });
212
+
213
+ it("should throw if READONLY_MODE is enabled", async () => {
214
+ process.env.READONLY_MODE = "true";
215
+ await expect(editTable("Test", 0, [])).rejects.toThrow("read-only mode");
216
+ });
217
+
218
+ it("should throw if note not found", async () => {
219
+ vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
220
+ success: false,
221
+ error: "Note not found",
222
+ });
223
+ await expect(editTable("Missing", 0, [{ row: 0, column: 0, value: "x" }])).rejects.toThrow("Note not found");
224
+ });
225
+
226
+ it("should throw if table index out of bounds", async () => {
227
+ vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
228
+ success: true,
229
+ note: { id: "123", title: "Test", folder: "Work" },
230
+ });
231
+ vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify({ html: "<div>No tables</div>" }));
232
+ vi.mocked(findTables).mockReturnValueOnce([]);
233
+
234
+ await expect(editTable("Test", 0, [{ row: 0, column: 0, value: "x" }]))
235
+ .rejects.toThrow("Table index 0 out of bounds");
236
+ });
237
+
238
+ it("should update table cells and save", async () => {
239
+ vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
240
+ success: true,
241
+ note: { id: "123", title: "Test", folder: "Work" },
242
+ });
243
+ vi.mocked(runJxa)
244
+ .mockResolvedValueOnce(JSON.stringify({ html: "<div><object><table></table></object></div>" }))
245
+ .mockResolvedValueOnce("ok");
246
+ vi.mocked(findTables).mockReturnValueOnce(["<object><table></table></object>"]);
247
+ vi.mocked(updateTableCell).mockReturnValueOnce("<object><table>updated</table></object>");
248
+
249
+ await expect(editTable("Test", 0, [{ row: 1, column: 0, value: "✅ Done" }]))
250
+ .resolves.toBeUndefined();
251
+
252
+ expect(updateTableCell).toHaveBeenCalledWith(
253
+ "<object><table></table></object>",
254
+ 1,
255
+ 0,
256
+ "✅ Done"
257
+ );
258
+ });
259
+ });
package/src/notes/crud.ts CHANGED
@@ -9,6 +9,7 @@ import { runJxa } from "run-jxa";
9
9
  import { marked } from "marked";
10
10
  import { resolveNoteTitle } from "./read.js";
11
11
  import { createDebugLogger } from "../utils/debug.js";
12
+ import { findTables, updateTableCell } from "./tables.js";
12
13
 
13
14
  // Debug logging
14
15
  const debug = createDebugLogger("CRUD");
@@ -255,3 +256,78 @@ export async function moveNote(title: string, folder: string): Promise<void> {
255
256
 
256
257
  debug(`Note moved: "${title}" -> "${folder}"`);
257
258
  }
259
+
260
+ export interface TableEdit {
261
+ row: number;
262
+ column: number;
263
+ value: string;
264
+ }
265
+
266
+ /**
267
+ * Edit cells in a table within a note.
268
+ *
269
+ * @param title - Note title (supports folder prefix)
270
+ * @param tableIndex - Which table to edit (0-based)
271
+ * @param edits - Array of cell edits to apply
272
+ */
273
+ export async function editTable(
274
+ title: string,
275
+ tableIndex: number,
276
+ edits: TableEdit[]
277
+ ): Promise<void> {
278
+ checkReadOnly();
279
+
280
+ debug(`Editing table ${tableIndex} in note: "${title}"`);
281
+
282
+ // Resolve the note
283
+ const resolved = await resolveNoteTitle(title);
284
+ if (!resolved.success || !resolved.note) {
285
+ if (resolved.suggestions && resolved.suggestions.length > 0) {
286
+ throw new Error(
287
+ `${resolved.error} Suggestions: ${resolved.suggestions.join(", ")}`
288
+ );
289
+ }
290
+ throw new Error(resolved.error || `Note not found: "${title}"`);
291
+ }
292
+
293
+ // Get current HTML content
294
+ const escapedNoteId = JSON.stringify(resolved.note.id);
295
+ const htmlResult = await runJxa(`
296
+ const app = Application('Notes');
297
+ const note = app.notes.byId(${escapedNoteId});
298
+ if (!note.exists()) {
299
+ throw new Error("Note no longer exists");
300
+ }
301
+ return JSON.stringify({ html: note.body() });
302
+ `);
303
+
304
+ const { html } = JSON.parse(htmlResult as string);
305
+
306
+ // Find all tables
307
+ const tables = findTables(html);
308
+ if (tableIndex >= tables.length) {
309
+ throw new Error(
310
+ `Table index ${tableIndex} out of bounds (note has ${tables.length} tables)`
311
+ );
312
+ }
313
+
314
+ // Apply edits to the target table
315
+ let updatedTable = tables[tableIndex];
316
+ for (const edit of edits) {
317
+ updatedTable = updateTableCell(updatedTable, edit.row, edit.column, edit.value);
318
+ }
319
+
320
+ // Replace the table in the full HTML
321
+ const updatedHtml = html.replace(tables[tableIndex], updatedTable);
322
+
323
+ // Save back to Apple Notes
324
+ const escapedHtml = JSON.stringify(updatedHtml);
325
+ await runJxa(`
326
+ const app = Application('Notes');
327
+ const note = app.notes.byId(${escapedNoteId});
328
+ note.body = ${escapedHtml};
329
+ return "ok";
330
+ `);
331
+
332
+ debug(`Table ${tableIndex} updated in note: "${title}"`);
333
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseTable, updateTableCell, findTables } from "./tables.js";
3
+
4
+ const SAMPLE_TABLE_HTML = `<object><table cellspacing="0" cellpadding="0" style="border-collapse: collapse">
5
+ <tbody>
6
+ <tr><td valign="top" style="border-style: solid"><div><b>Typ</b></div></td><td valign="top" style="border-style: solid"><div><b>Částka</b></div></td></tr>
7
+ <tr><td valign="top" style="border-style: solid"><div>Sociální</div></td><td valign="top" style="border-style: solid"><div>9 154 Kč</div></td></tr>
8
+ <tr><td valign="top" style="border-style: solid"><div>Zdravotní</div></td><td valign="top" style="border-style: solid"><div>3 848 Kč</div></td></tr>
9
+ </tbody>
10
+ </table></object>`;
11
+
12
+ describe("parseTable", () => {
13
+ it("should parse table rows and cells", () => {
14
+ const result = parseTable(SAMPLE_TABLE_HTML);
15
+ expect(result.rows).toHaveLength(3);
16
+ expect(result.rows[0]).toEqual(["Typ", "Částka"]);
17
+ expect(result.rows[1]).toEqual(["Sociální", "9 154 Kč"]);
18
+ expect(result.rows[2]).toEqual(["Zdravotní", "3 848 Kč"]);
19
+ });
20
+
21
+ it("should preserve bold formatting info", () => {
22
+ const result = parseTable(SAMPLE_TABLE_HTML);
23
+ expect(result.formatting[0][0].bold).toBe(true);
24
+ expect(result.formatting[1][0].bold).toBe(false);
25
+ });
26
+
27
+ it("should return empty for non-table HTML", () => {
28
+ const result = parseTable("<div>Not a table</div>");
29
+ expect(result.rows).toHaveLength(0);
30
+ });
31
+ });
32
+
33
+ describe("updateTableCell", () => {
34
+ it("should update cell content", () => {
35
+ const updated = updateTableCell(SAMPLE_TABLE_HTML, 1, 0, "✅ Sociální");
36
+ expect(updated).toContain("✅ Sociální");
37
+ });
38
+
39
+ it("should preserve table structure", () => {
40
+ const updated = updateTableCell(SAMPLE_TABLE_HTML, 1, 0, "✅ Sociální");
41
+ expect(updated).toContain("<object>");
42
+ expect(updated).toContain("</table></object>");
43
+ });
44
+
45
+ it("should throw for out of bounds row", () => {
46
+ expect(() => updateTableCell(SAMPLE_TABLE_HTML, 10, 0, "test")).toThrow("Row 10 out of bounds");
47
+ });
48
+
49
+ it("should throw for out of bounds column", () => {
50
+ expect(() => updateTableCell(SAMPLE_TABLE_HTML, 0, 10, "test")).toThrow("Column 10 out of bounds");
51
+ });
52
+ });
53
+
54
+ describe("findTables", () => {
55
+ it("should find single table", () => {
56
+ const tables = findTables(SAMPLE_TABLE_HTML);
57
+ expect(tables).toHaveLength(1);
58
+ });
59
+
60
+ it("should find multiple tables", () => {
61
+ const html = `<div>${SAMPLE_TABLE_HTML}</div><p>text</p>${SAMPLE_TABLE_HTML}`;
62
+ const tables = findTables(html);
63
+ expect(tables).toHaveLength(2);
64
+ });
65
+
66
+ it("should return empty for no tables", () => {
67
+ const tables = findTables("<div>No tables here</div>");
68
+ expect(tables).toHaveLength(0);
69
+ });
70
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Apple Notes table HTML parsing and editing utilities.
3
+ *
4
+ * Apple Notes wraps tables in <object> tags with a specific structure:
5
+ * <object><table><tbody><tr><td><div>content</div></td>...</tr>...</tbody></table></object>
6
+ */
7
+
8
+ export interface CellFormatting {
9
+ bold: boolean;
10
+ }
11
+
12
+ export interface TableData {
13
+ rows: string[][];
14
+ formatting: CellFormatting[][];
15
+ raw: string;
16
+ }
17
+
18
+ /**
19
+ * Parse Apple Notes table HTML into structured data.
20
+ */
21
+ export function parseTable(html: string): TableData {
22
+ const result: TableData = {
23
+ rows: [],
24
+ formatting: [],
25
+ raw: html,
26
+ };
27
+
28
+ // Match table content inside <object>
29
+ const tableMatch = html.match(/<object[^>]*>[\s\S]*?<table[\s\S]*?<tbody>([\s\S]*?)<\/tbody>[\s\S]*?<\/table>[\s\S]*?<\/object>/i);
30
+ if (!tableMatch) {
31
+ return result;
32
+ }
33
+
34
+ const tbodyContent = tableMatch[1];
35
+
36
+ // Match all rows
37
+ const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
38
+ let rowMatch;
39
+
40
+ while ((rowMatch = rowRegex.exec(tbodyContent)) !== null) {
41
+ const rowContent = rowMatch[1];
42
+ const cells: string[] = [];
43
+ const cellFormats: CellFormatting[] = [];
44
+
45
+ // Match all cells in this row
46
+ const cellRegex = /<td[^>]*>[\s\S]*?<div[^>]*>([\s\S]*?)<\/div>[\s\S]*?<\/td>/gi;
47
+ let cellMatch;
48
+
49
+ while ((cellMatch = cellRegex.exec(rowContent)) !== null) {
50
+ const cellContent = cellMatch[1];
51
+ // Extract text, stripping HTML tags
52
+ const text = cellContent.replace(/<[^>]+>/g, "").trim();
53
+ const isBold = /<b>/i.test(cellContent);
54
+
55
+ cells.push(text);
56
+ cellFormats.push({ bold: isBold });
57
+ }
58
+
59
+ if (cells.length > 0) {
60
+ result.rows.push(cells);
61
+ result.formatting.push(cellFormats);
62
+ }
63
+ }
64
+
65
+ return result;
66
+ }
67
+
68
+ /**
69
+ * Update a specific cell in an Apple Notes table HTML.
70
+ *
71
+ * @param html - The table HTML
72
+ * @param row - Row index (0-based, 0 = header)
73
+ * @param column - Column index (0-based)
74
+ * @param value - New cell value
75
+ * @returns Updated HTML
76
+ */
77
+ export function updateTableCell(html: string, row: number, column: number, value: string): string {
78
+ const parsed = parseTable(html);
79
+
80
+ if (row >= parsed.rows.length) {
81
+ throw new Error(`Row ${row} out of bounds (table has ${parsed.rows.length} rows)`);
82
+ }
83
+
84
+ if (column >= parsed.rows[row].length) {
85
+ throw new Error(`Column ${column} out of bounds (row has ${parsed.rows[row].length} columns)`);
86
+ }
87
+
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++;
127
+ }
128
+
129
+ return result;
130
+ }
131
+
132
+ /**
133
+ * Find all tables in note HTML content.
134
+ * Returns array of table HTML strings.
135
+ */
136
+ export function findTables(html: string): string[] {
137
+ const tables: string[] = [];
138
+ const tableRegex = /<object[^>]*>[\s\S]*?<table[\s\S]*?<\/table>[\s\S]*?<\/object>/gi;
139
+ let match;
140
+
141
+ while ((match = tableRegex.exec(html)) !== null) {
142
+ tables.push(match[0]);
143
+ }
144
+
145
+ return tables;
146
+ }
package/src/server.ts CHANGED
@@ -18,7 +18,7 @@ import { validateEnv } from "./config/env.js";
18
18
  // Import implementations
19
19
  import { getVectorStore } from "./db/lancedb.js";
20
20
  import { getNoteByTitle, getAllFolders } from "./notes/read.js";
21
- import { createNote, updateNote, deleteNote, moveNote } from "./notes/crud.js";
21
+ import { createNote, updateNote, deleteNote, moveNote, editTable } from "./notes/crud.js";
22
22
  import { searchNotes } from "./search/index.js";
23
23
  import { indexNotes, reindexNote } from "./search/indexer.js";
24
24
 
@@ -72,6 +72,16 @@ const MoveNoteSchema = z.object({
72
72
  folder: z.string(),
73
73
  });
74
74
 
75
+ const EditTableSchema = z.object({
76
+ title: z.string(),
77
+ table_index: z.number().min(0).default(0),
78
+ edits: z.array(z.object({
79
+ row: z.number().min(0),
80
+ column: z.number().min(0),
81
+ value: z.string(),
82
+ })).min(1),
83
+ });
84
+
75
85
  // Helper to create text responses
76
86
  function textResponse(text: string) {
77
87
  return {
@@ -245,6 +255,31 @@ export default function createServer() {
245
255
  required: ["title", "folder"],
246
256
  },
247
257
  },
258
+ {
259
+ name: "edit-table",
260
+ description: "Edit cells in a table within a note. Use for updating table data without rewriting the entire note.",
261
+ inputSchema: {
262
+ type: "object",
263
+ properties: {
264
+ title: { type: "string", description: "Note title (use folder/title for disambiguation)" },
265
+ table_index: { type: "number", description: "Which table to edit (0 = first table, default: 0)" },
266
+ edits: {
267
+ type: "array",
268
+ description: "Array of cell edits",
269
+ items: {
270
+ type: "object",
271
+ properties: {
272
+ row: { type: "number", description: "Row index (0 = header row)" },
273
+ column: { type: "number", description: "Column index (0 = first column)" },
274
+ value: { type: "string", description: "New cell value" },
275
+ },
276
+ required: ["row", "column", "value"],
277
+ },
278
+ },
279
+ },
280
+ required: ["title", "edits"],
281
+ },
282
+ },
248
283
  ],
249
284
  };
250
285
  });
@@ -367,6 +402,12 @@ export default function createServer() {
367
402
  return textResponse(`Moved note: "${params.title}" to folder "${params.folder}"`);
368
403
  }
369
404
 
405
+ case "edit-table": {
406
+ const params = EditTableSchema.parse(args);
407
+ await editTable(params.title, params.table_index, params.edits);
408
+ return textResponse(`Updated ${params.edits.length} cell(s) in table ${params.table_index}`);
409
+ }
410
+
370
411
  default:
371
412
  return errorResponse(`Unknown tool: ${name}`);
372
413
  }