@disco_trooper/apple-notes-mcp 1.0.1 → 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.
- package/package.json +2 -5
- package/src/db/lancedb.test.ts +45 -0
- package/src/db/lancedb.ts +40 -31
- package/src/embeddings/index.ts +1 -2
- package/src/embeddings/local.ts +0 -11
- package/src/errors/index.test.ts +64 -0
- package/src/errors/index.ts +62 -0
- package/src/index.ts +60 -14
- package/src/notes/conversion.ts +62 -0
- package/src/notes/crud.test.ts +76 -7
- package/src/notes/crud.ts +95 -46
- package/src/notes/read.test.ts +58 -3
- package/src/notes/read.ts +90 -173
- package/src/notes/resolve.ts +174 -0
- package/src/notes/tables.test.ts +70 -0
- package/src/notes/tables.ts +175 -0
- package/src/search/index.ts +4 -6
- package/src/search/indexer.ts +16 -3
- package/src/types/index.ts +4 -0
- package/src/utils/debug.ts +0 -2
- package/src/utils/text.test.ts +32 -0
- package/CLAUDE.md +0 -56
- package/src/server.ts +0 -386
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Note title resolution and disambiguation.
|
|
3
|
+
*
|
|
4
|
+
* Handles resolving user input (title, folder/title, id:xxx) to a specific note.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runJxa } from "run-jxa";
|
|
8
|
+
import { createDebugLogger } from "../utils/debug.js";
|
|
9
|
+
import type { NoteSuggestion } from "../errors/index.js";
|
|
10
|
+
import { getNoteById } from "./read.js";
|
|
11
|
+
|
|
12
|
+
const debug = createDebugLogger("NOTES");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Result of resolving a note title to a specific note.
|
|
16
|
+
*/
|
|
17
|
+
export interface ResolvedNote {
|
|
18
|
+
/** Whether resolution was successful */
|
|
19
|
+
success: boolean;
|
|
20
|
+
/** The matched note (if exactly one match) */
|
|
21
|
+
note?: {
|
|
22
|
+
title: string;
|
|
23
|
+
folder: string;
|
|
24
|
+
id: string;
|
|
25
|
+
};
|
|
26
|
+
/** Error message if resolution failed */
|
|
27
|
+
error?: string;
|
|
28
|
+
/** Rich suggestions when multiple matches found (includes ID and created date) */
|
|
29
|
+
suggestions?: NoteSuggestion[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Execute JXA code safely with error handling.
|
|
34
|
+
*/
|
|
35
|
+
async function executeJxa<T>(code: string): Promise<T> {
|
|
36
|
+
try {
|
|
37
|
+
const result = await runJxa(code);
|
|
38
|
+
return result as T;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
debug("JXA execution error:", error);
|
|
41
|
+
throw new Error(
|
|
42
|
+
`JXA execution failed: ${error instanceof Error ? error.message : String(error)}`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a note title input to a unique note.
|
|
49
|
+
*
|
|
50
|
+
* Handles:
|
|
51
|
+
* - "id:xxx" format for direct ID lookup
|
|
52
|
+
* - Exact title match
|
|
53
|
+
* - "folder/title" format for disambiguation
|
|
54
|
+
* - Returns suggestions when multiple matches exist
|
|
55
|
+
*
|
|
56
|
+
* @param input - The note title, "folder/title", or "id:xxx" string
|
|
57
|
+
* @returns Resolution result with success status, note info, or suggestions
|
|
58
|
+
*/
|
|
59
|
+
export async function resolveNoteTitle(input: string): Promise<ResolvedNote> {
|
|
60
|
+
debug(`Resolving note title: ${input}`);
|
|
61
|
+
|
|
62
|
+
// Check for id:xxx format for direct ID lookup
|
|
63
|
+
if (input.startsWith("id:")) {
|
|
64
|
+
const noteId = input.slice(3);
|
|
65
|
+
debug(`ID prefix detected, looking up note by ID: ${noteId}`);
|
|
66
|
+
const noteDetails = await getNoteById(noteId);
|
|
67
|
+
if (!noteDetails) {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
error: `Note not found with ID: "${noteId}"`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
success: true,
|
|
75
|
+
note: {
|
|
76
|
+
id: noteDetails.id,
|
|
77
|
+
title: noteDetails.title,
|
|
78
|
+
folder: noteDetails.folder,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check for folder/title format
|
|
84
|
+
let targetFolder: string | null = null;
|
|
85
|
+
let targetTitle = input;
|
|
86
|
+
|
|
87
|
+
if (input.includes("/")) {
|
|
88
|
+
const parts = input.split("/");
|
|
89
|
+
targetFolder = parts.slice(0, -1).join("/");
|
|
90
|
+
targetTitle = parts[parts.length - 1];
|
|
91
|
+
debug(`Parsed folder: ${targetFolder}, title: ${targetTitle}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const escapedTitle = JSON.stringify(targetTitle);
|
|
95
|
+
const escapedFolder = targetFolder ? JSON.stringify(targetFolder) : "null";
|
|
96
|
+
|
|
97
|
+
const jxaCode = `
|
|
98
|
+
const app = Application('Notes');
|
|
99
|
+
app.includeStandardAdditions = true;
|
|
100
|
+
|
|
101
|
+
const targetTitle = ${escapedTitle};
|
|
102
|
+
const targetFolder = ${escapedFolder};
|
|
103
|
+
|
|
104
|
+
let foundNotes = [];
|
|
105
|
+
const folders = app.folders();
|
|
106
|
+
|
|
107
|
+
for (const folder of folders) {
|
|
108
|
+
const folderName = folder.name();
|
|
109
|
+
|
|
110
|
+
// Skip if folder filter is specified and doesn't match
|
|
111
|
+
if (targetFolder !== null && folderName !== targetFolder) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const notes = folder.notes.whose({ name: targetTitle });
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < notes.length; i++) {
|
|
118
|
+
try {
|
|
119
|
+
const note = notes[i];
|
|
120
|
+
const props = note.properties();
|
|
121
|
+
foundNotes.push({
|
|
122
|
+
id: note.id(),
|
|
123
|
+
title: note.name(),
|
|
124
|
+
folder: folderName,
|
|
125
|
+
created: props.creationDate ? props.creationDate.toISOString() : ''
|
|
126
|
+
});
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// Skip notes that can't be accessed
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return JSON.stringify(foundNotes);
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
const result = await executeJxa<string>(jxaCode);
|
|
137
|
+
const notes = JSON.parse(result) as Array<{
|
|
138
|
+
id: string;
|
|
139
|
+
title: string;
|
|
140
|
+
folder: string;
|
|
141
|
+
created: string;
|
|
142
|
+
}>;
|
|
143
|
+
|
|
144
|
+
if (notes.length === 0) {
|
|
145
|
+
debug("No matching notes found");
|
|
146
|
+
return {
|
|
147
|
+
success: false,
|
|
148
|
+
error: `Note not found: "${input}"`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (notes.length === 1) {
|
|
153
|
+
debug(`Resolved to unique note: ${notes[0].folder}/${notes[0].title}`);
|
|
154
|
+
return {
|
|
155
|
+
success: true,
|
|
156
|
+
note: notes[0],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Multiple matches - return rich suggestions with ID and created date
|
|
161
|
+
const suggestions: NoteSuggestion[] = notes.map((n) => ({
|
|
162
|
+
id: n.id,
|
|
163
|
+
folder: n.folder,
|
|
164
|
+
title: n.title,
|
|
165
|
+
created: n.created,
|
|
166
|
+
}));
|
|
167
|
+
debug(`Multiple matches found: ${suggestions.map(s => `${s.folder}/${s.title}`).join(", ")}`);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
success: false,
|
|
171
|
+
error: `Multiple notes found with title "${targetTitle}". Use ID prefix to specify.`,
|
|
172
|
+
suggestions,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -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,175 @@
|
|
|
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
|
+
* 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, "&")
|
|
91
|
+
.replace(/</g, "<")
|
|
92
|
+
.replace(/>/g, ">")
|
|
93
|
+
.replace(/"/g, """);
|
|
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
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Update a specific cell in an Apple Notes table HTML.
|
|
132
|
+
*
|
|
133
|
+
* @param html - The table HTML
|
|
134
|
+
* @param row - Row index (0-based, 0 = header)
|
|
135
|
+
* @param column - Column index (0-based)
|
|
136
|
+
* @param value - New cell value
|
|
137
|
+
* @returns Updated HTML
|
|
138
|
+
*/
|
|
139
|
+
export function updateTableCell(html: string, row: number, column: number, value: string): string {
|
|
140
|
+
const parsed = parseTable(html);
|
|
141
|
+
|
|
142
|
+
if (row >= parsed.rows.length) {
|
|
143
|
+
throw new Error(`Row ${row} out of bounds (table has ${parsed.rows.length} rows)`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (column >= parsed.rows[row].length) {
|
|
147
|
+
throw new Error(`Column ${column} out of bounds (row has ${parsed.rows[row].length} columns)`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const rowData = findRowHtml(html, row);
|
|
151
|
+
if (!rowData) {
|
|
152
|
+
throw new Error(`Could not find row ${row} in table HTML`);
|
|
153
|
+
}
|
|
154
|
+
|
|
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>`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Find all tables in note HTML content.
|
|
163
|
+
* Returns array of table HTML strings.
|
|
164
|
+
*/
|
|
165
|
+
export function findTables(html: string): string[] {
|
|
166
|
+
const tables: string[] = [];
|
|
167
|
+
const tableRegex = /<object[^>]*>[\s\S]*?<table[\s\S]*?<\/table>[\s\S]*?<\/object>/gi;
|
|
168
|
+
let match;
|
|
169
|
+
|
|
170
|
+
while ((match = tableRegex.exec(html)) !== null) {
|
|
171
|
+
tables.push(match[0]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return tables;
|
|
175
|
+
}
|
package/src/search/index.ts
CHANGED
|
@@ -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
|
package/src/search/indexer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
package/src/types/index.ts
CHANGED
|
@@ -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 */
|
package/src/utils/debug.ts
CHANGED
|
@@ -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
|