apple-notes-mcp 2.5.7 → 2.5.8
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/README.md +8 -5
- package/build/index.js +42669 -1077
- package/package.json +3 -3
- package/build/index.test.js +0 -446
- package/build/services/__fixtures__/notesNormalizedHtml.js +0 -32
- package/build/services/appleNotesManager.js +0 -2634
- package/build/services/appleNotesManager.test.js +0 -2416
- package/build/services/attachmentSave.test.js +0 -85
- package/build/services/fileConfig.js +0 -51
- package/build/services/fileConfig.test.js +0 -48
- package/build/services/notesHtmlMarkdown.test.js +0 -55
- package/build/tools/doctor.js +0 -50
- package/build/tools/doctor.test.js +0 -42
- package/build/tools/resourcesAndPrompts.js +0 -70
- package/build/tools/resourcesAndPrompts.test.js +0 -63
- package/build/types.js +0 -13
- package/build/utils/applescript.js +0 -421
- package/build/utils/applescript.test.js +0 -342
- package/build/utils/attachmentFs.js +0 -97
- package/build/utils/attachmentFs.test.js +0 -69
- package/build/utils/checklistParser.js +0 -259
- package/build/utils/checklistParser.test.js +0 -230
- package/build/utils/contentWarnings.js +0 -44
- package/build/utils/contentWarnings.test.js +0 -52
- package/build/utils/hashtags.js +0 -56
- package/build/utils/hashtags.test.js +0 -45
- package/build/utils/jxa.js +0 -139
- package/build/utils/jxa.test.js +0 -134
- package/build/utils/noteMetadata.js +0 -135
- package/build/utils/noteMetadata.test.js +0 -106
- package/build/utils/protobuf.js +0 -151
- package/build/utils/protobuf.test.js +0 -138
- package/build/utils/syncDetection.js +0 -242
- package/build/utils/syncDetection.test.js +0 -228
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { parseHashtags } from "../utils/hashtags.js";
|
|
3
|
-
describe("parseHashtags", () => {
|
|
4
|
-
it("returns [] for empty / nullish input", () => {
|
|
5
|
-
expect(parseHashtags("")).toEqual([]);
|
|
6
|
-
expect(parseHashtags(null)).toEqual([]);
|
|
7
|
-
expect(parseHashtags(undefined)).toEqual([]);
|
|
8
|
-
expect(parseHashtags("no tags here")).toEqual([]);
|
|
9
|
-
});
|
|
10
|
-
it("extracts a single tag", () => {
|
|
11
|
-
expect(parseHashtags("Buy milk #groceries")).toEqual(["groceries"]);
|
|
12
|
-
});
|
|
13
|
-
it("extracts multiple tags in document order", () => {
|
|
14
|
-
expect(parseHashtags("#work then #home then #travel")).toEqual(["work", "home", "travel"]);
|
|
15
|
-
});
|
|
16
|
-
it("strips the leading # and not the rest", () => {
|
|
17
|
-
expect(parseHashtags("#project_alpha")).toEqual(["project_alpha"]);
|
|
18
|
-
});
|
|
19
|
-
it("finds tags inside HTML bodies", () => {
|
|
20
|
-
expect(parseHashtags("<div>Plan <b>#q3</b> launch</div>")).toEqual(["q3"]);
|
|
21
|
-
});
|
|
22
|
-
it("de-duplicates case-insensitively, keeping first-seen casing", () => {
|
|
23
|
-
expect(parseHashtags("#Work and #work and #WORK")).toEqual(["Work"]);
|
|
24
|
-
});
|
|
25
|
-
it("ignores purely numeric tokens (matches Notes behaviour)", () => {
|
|
26
|
-
expect(parseHashtags("ticket #123 and #4you")).toEqual(["4you"]);
|
|
27
|
-
});
|
|
28
|
-
it("does not match mid-word or URL fragments", () => {
|
|
29
|
-
expect(parseHashtags("foo#bar")).toEqual([]);
|
|
30
|
-
expect(parseHashtags("see page.html#section for details")).toEqual([]);
|
|
31
|
-
});
|
|
32
|
-
it("does not treat numeric HTML entities as tags", () => {
|
|
33
|
-
// ’ is a right single quote entity, not a #8217 tag
|
|
34
|
-
expect(parseHashtags("It’s a #plan")).toEqual(["plan"]);
|
|
35
|
-
});
|
|
36
|
-
it("matches a tag at the very start of the body", () => {
|
|
37
|
-
expect(parseHashtags("#start of note")).toEqual(["start"]);
|
|
38
|
-
});
|
|
39
|
-
it("handles tags terminated by punctuation", () => {
|
|
40
|
-
expect(parseHashtags("done: #alpha, #beta; #gamma.")).toEqual(["alpha", "beta", "gamma"]);
|
|
41
|
-
});
|
|
42
|
-
it("supports unicode letters in tags", () => {
|
|
43
|
-
expect(parseHashtags("café trip #café")).toEqual(["café"]);
|
|
44
|
-
});
|
|
45
|
-
});
|
package/build/utils/jxa.js
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* JXA (JavaScript for Automation) Execution Utilities
|
|
3
|
-
*
|
|
4
|
-
* This module provides an alternative to AppleScript using JavaScript.
|
|
5
|
-
* JXA was introduced in OS X Yosemite and uses the same OSA infrastructure
|
|
6
|
-
* as AppleScript but with JavaScript syntax.
|
|
7
|
-
*
|
|
8
|
-
* Potential advantages over AppleScript:
|
|
9
|
-
* - Standard JavaScript string escaping (simpler than AppleScript)
|
|
10
|
-
* - Better Unicode handling
|
|
11
|
-
* - Familiar syntax for developers
|
|
12
|
-
* - Native JSON support
|
|
13
|
-
*
|
|
14
|
-
* @module utils/jxa
|
|
15
|
-
*/
|
|
16
|
-
import { execSync } from "child_process";
|
|
17
|
-
/**
|
|
18
|
-
* Output cap for osascript (JXA). Mirrors the AppleScript executor — Node's 1 MB
|
|
19
|
-
* default truncates large JXA output into an ENOBUFS failure. 64 MB default,
|
|
20
|
-
* overridable via APPLE_NOTES_MCP_MAX_BUFFER. (#16)
|
|
21
|
-
*/
|
|
22
|
-
const DEFAULT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
23
|
-
function getMaxBuffer() {
|
|
24
|
-
const raw = process.env.APPLE_NOTES_MCP_MAX_BUFFER;
|
|
25
|
-
if (raw !== undefined) {
|
|
26
|
-
const n = Number(raw);
|
|
27
|
-
if (Number.isFinite(n) && n > 0)
|
|
28
|
-
return n;
|
|
29
|
-
}
|
|
30
|
-
return DEFAULT_MAX_BUFFER_BYTES;
|
|
31
|
-
}
|
|
32
|
-
const DEFAULT_TIMEOUT_MS = 30000;
|
|
33
|
-
/**
|
|
34
|
-
* Escapes a string for safe inclusion in a JXA script.
|
|
35
|
-
*
|
|
36
|
-
* JXA uses standard JavaScript string escaping, which is simpler
|
|
37
|
-
* than AppleScript's escaping requirements.
|
|
38
|
-
*
|
|
39
|
-
* @param str - The string to escape
|
|
40
|
-
* @returns Escaped string safe for JXA embedding
|
|
41
|
-
*/
|
|
42
|
-
export function escapeForJXA(str) {
|
|
43
|
-
if (!str)
|
|
44
|
-
return "";
|
|
45
|
-
// Standard JavaScript string escaping
|
|
46
|
-
return str
|
|
47
|
-
.replace(/\\/g, "\\\\") // Backslashes first
|
|
48
|
-
.replace(/"/g, '\\"') // Double quotes
|
|
49
|
-
.replace(/\n/g, "\\n") // Newlines
|
|
50
|
-
.replace(/\r/g, "\\r") // Carriage returns
|
|
51
|
-
.replace(/\t/g, "\\t"); // Tabs
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Checks if an error is a timeout error.
|
|
55
|
-
*/
|
|
56
|
-
function isTimeoutError(error) {
|
|
57
|
-
if (error instanceof Error) {
|
|
58
|
-
const execError = error;
|
|
59
|
-
return execError.killed === true || execError.signal === "SIGTERM";
|
|
60
|
-
}
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Executes a JXA (JavaScript for Automation) script.
|
|
65
|
-
*
|
|
66
|
-
* JXA scripts are executed via `osascript -l JavaScript`.
|
|
67
|
-
*
|
|
68
|
-
* @param script - The JavaScript code to execute
|
|
69
|
-
* @param options - Execution options
|
|
70
|
-
* @returns Result with success status and output or error
|
|
71
|
-
*
|
|
72
|
-
* @example
|
|
73
|
-
* ```typescript
|
|
74
|
-
* const result = executeJXA(`
|
|
75
|
-
* const Notes = Application("Notes");
|
|
76
|
-
* Notes.accounts().map(a => a.name());
|
|
77
|
-
* `);
|
|
78
|
-
* ```
|
|
79
|
-
*/
|
|
80
|
-
export function executeJXA(script, options = {}) {
|
|
81
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
82
|
-
if (!script || !script.trim()) {
|
|
83
|
-
return {
|
|
84
|
-
success: false,
|
|
85
|
-
output: "",
|
|
86
|
-
error: "Cannot execute empty JXA script",
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
// Escape the script for shell embedding
|
|
90
|
-
// We use single quotes to wrap, so escape single quotes within
|
|
91
|
-
const escapedScript = script.trim().replace(/'/g, "'\\''");
|
|
92
|
-
const command = `osascript -l JavaScript -e '${escapedScript}'`;
|
|
93
|
-
try {
|
|
94
|
-
const output = execSync(command, {
|
|
95
|
-
encoding: "utf8",
|
|
96
|
-
timeout: timeoutMs,
|
|
97
|
-
killSignal: "SIGKILL", // reap a wedged osascript reliably (#17)
|
|
98
|
-
maxBuffer: getMaxBuffer(), // avoid ENOBUFS truncation on large output (#16)
|
|
99
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
100
|
-
});
|
|
101
|
-
return {
|
|
102
|
-
success: true,
|
|
103
|
-
output: output.trim(),
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
catch (error) {
|
|
107
|
-
let errorMessage;
|
|
108
|
-
if (isTimeoutError(error)) {
|
|
109
|
-
const timeoutSecs = Math.round(timeoutMs / 1000);
|
|
110
|
-
errorMessage = `Operation timed out after ${timeoutSecs} seconds`;
|
|
111
|
-
}
|
|
112
|
-
else if (error instanceof Error) {
|
|
113
|
-
// Extract meaningful error from stderr
|
|
114
|
-
const match = error.message.match(/Error: (.+)/);
|
|
115
|
-
errorMessage = match ? match[1] : error.message;
|
|
116
|
-
}
|
|
117
|
-
else {
|
|
118
|
-
errorMessage = "JXA execution failed with unknown error";
|
|
119
|
-
}
|
|
120
|
-
return {
|
|
121
|
-
success: false,
|
|
122
|
-
output: "",
|
|
123
|
-
error: errorMessage,
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* Builds a JXA script that interacts with Notes.app.
|
|
129
|
-
*
|
|
130
|
-
* @param code - JavaScript code to execute within Notes context
|
|
131
|
-
* @returns Complete JXA script
|
|
132
|
-
*/
|
|
133
|
-
export function buildNotesJXA(code) {
|
|
134
|
-
return `
|
|
135
|
-
const Notes = Application("Notes");
|
|
136
|
-
Notes.includeStandardAdditions = true;
|
|
137
|
-
${code}
|
|
138
|
-
`;
|
|
139
|
-
}
|
package/build/utils/jxa.test.js
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for JXA Execution Utilities
|
|
3
|
-
*
|
|
4
|
-
* These tests verify the JXA executor and compare behavior with AppleScript.
|
|
5
|
-
*/
|
|
6
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
7
|
-
import { executeJXA, escapeForJXA, buildNotesJXA } from "./jxa.js";
|
|
8
|
-
// Mock execSync to avoid actual osascript calls
|
|
9
|
-
vi.mock("child_process", () => ({
|
|
10
|
-
execSync: vi.fn(),
|
|
11
|
-
}));
|
|
12
|
-
import { execSync } from "child_process";
|
|
13
|
-
const mockExecSync = vi.mocked(execSync);
|
|
14
|
-
describe("escapeForJXA", () => {
|
|
15
|
-
beforeEach(() => {
|
|
16
|
-
vi.clearAllMocks();
|
|
17
|
-
});
|
|
18
|
-
it("returns empty string for null/undefined", () => {
|
|
19
|
-
expect(escapeForJXA("")).toBe("");
|
|
20
|
-
expect(escapeForJXA(null)).toBe("");
|
|
21
|
-
expect(escapeForJXA(undefined)).toBe("");
|
|
22
|
-
});
|
|
23
|
-
it("escapes backslashes", () => {
|
|
24
|
-
expect(escapeForJXA("path\\to\\file")).toBe("path\\\\to\\\\file");
|
|
25
|
-
});
|
|
26
|
-
it("escapes double quotes", () => {
|
|
27
|
-
expect(escapeForJXA('say "hello"')).toBe('say \\"hello\\"');
|
|
28
|
-
});
|
|
29
|
-
it("escapes newlines", () => {
|
|
30
|
-
expect(escapeForJXA("line1\nline2")).toBe("line1\\nline2");
|
|
31
|
-
});
|
|
32
|
-
it("escapes tabs", () => {
|
|
33
|
-
expect(escapeForJXA("col1\tcol2")).toBe("col1\\tcol2");
|
|
34
|
-
});
|
|
35
|
-
it("handles complex content", () => {
|
|
36
|
-
const input = 'John said "Hello\\World"\nNew line';
|
|
37
|
-
const expected = 'John said \\"Hello\\\\World\\"\\nNew line';
|
|
38
|
-
expect(escapeForJXA(input)).toBe(expected);
|
|
39
|
-
});
|
|
40
|
-
it("preserves single quotes (no escaping needed in double-quoted JS strings)", () => {
|
|
41
|
-
expect(escapeForJXA("it's working")).toBe("it's working");
|
|
42
|
-
});
|
|
43
|
-
it("preserves unicode characters", () => {
|
|
44
|
-
expect(escapeForJXA("日本語 🎉")).toBe("日本語 🎉");
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
describe("executeJXA", () => {
|
|
48
|
-
beforeEach(() => {
|
|
49
|
-
vi.clearAllMocks();
|
|
50
|
-
});
|
|
51
|
-
it("returns error for empty script", () => {
|
|
52
|
-
const result = executeJXA("");
|
|
53
|
-
expect(result.success).toBe(false);
|
|
54
|
-
expect(result.error).toContain("empty");
|
|
55
|
-
});
|
|
56
|
-
it("executes JXA script via osascript", () => {
|
|
57
|
-
mockExecSync.mockReturnValue("test output\n");
|
|
58
|
-
const result = executeJXA("JSON.stringify({test: true})");
|
|
59
|
-
expect(result.success).toBe(true);
|
|
60
|
-
expect(result.output).toBe("test output");
|
|
61
|
-
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("-l JavaScript"), expect.any(Object));
|
|
62
|
-
});
|
|
63
|
-
it("handles execution errors", () => {
|
|
64
|
-
mockExecSync.mockImplementation(() => {
|
|
65
|
-
throw new Error("Error: Cannot find note");
|
|
66
|
-
});
|
|
67
|
-
const result = executeJXA("Notes.notes()");
|
|
68
|
-
expect(result.success).toBe(false);
|
|
69
|
-
expect(result.error).toContain("Cannot find note");
|
|
70
|
-
});
|
|
71
|
-
describe("hardened executor (#16/#17)", () => {
|
|
72
|
-
afterEach(() => {
|
|
73
|
-
delete process.env.APPLE_NOTES_MCP_MAX_BUFFER;
|
|
74
|
-
});
|
|
75
|
-
it("passes SIGKILL and a large maxBuffer to execSync", () => {
|
|
76
|
-
mockExecSync.mockReturnValue("ok");
|
|
77
|
-
executeJXA("JSON.stringify({})");
|
|
78
|
-
const opts = mockExecSync.mock.calls[0][1];
|
|
79
|
-
expect(opts.killSignal).toBe("SIGKILL");
|
|
80
|
-
expect(opts.maxBuffer).toBe(64 * 1024 * 1024);
|
|
81
|
-
});
|
|
82
|
-
it("honors APPLE_NOTES_MCP_MAX_BUFFER override", () => {
|
|
83
|
-
process.env.APPLE_NOTES_MCP_MAX_BUFFER = "2097152";
|
|
84
|
-
mockExecSync.mockReturnValue("ok");
|
|
85
|
-
executeJXA("JSON.stringify({})");
|
|
86
|
-
const opts = mockExecSync.mock.calls[0][1];
|
|
87
|
-
expect(opts.maxBuffer).toBe(2097152);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
it("handles timeout errors", () => {
|
|
91
|
-
const error = new Error("Command failed");
|
|
92
|
-
error.killed = true;
|
|
93
|
-
mockExecSync.mockImplementation(() => {
|
|
94
|
-
throw error;
|
|
95
|
-
});
|
|
96
|
-
const result = executeJXA("longRunningScript()", { timeoutMs: 5000 });
|
|
97
|
-
expect(result.success).toBe(false);
|
|
98
|
-
expect(result.error).toContain("timed out");
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
describe("buildNotesJXA", () => {
|
|
102
|
-
it("wraps code with Notes application context", () => {
|
|
103
|
-
const code = "Notes.accounts().map(a => a.name())";
|
|
104
|
-
const script = buildNotesJXA(code);
|
|
105
|
-
expect(script).toContain('Application("Notes")');
|
|
106
|
-
expect(script).toContain(code);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
// =============================================================================
|
|
110
|
-
// Comparison Tests: JXA vs AppleScript Escaping
|
|
111
|
-
// =============================================================================
|
|
112
|
-
describe("JXA vs AppleScript escaping comparison", () => {
|
|
113
|
-
it("JXA handles single quotes without special escaping", () => {
|
|
114
|
-
// In AppleScript, single quotes need shell escaping: '\''
|
|
115
|
-
// In JXA, single quotes are fine in double-quoted strings
|
|
116
|
-
const input = "it's Rob's note";
|
|
117
|
-
const escaped = escapeForJXA(input);
|
|
118
|
-
expect(escaped).toBe("it's Rob's note"); // No change needed
|
|
119
|
-
});
|
|
120
|
-
it("JXA uses standard escape sequences for control chars", () => {
|
|
121
|
-
// AppleScript converts to HTML (<br>) for Notes.app
|
|
122
|
-
// JXA uses standard \n which may need conversion for Notes
|
|
123
|
-
const input = "line1\nline2";
|
|
124
|
-
const escaped = escapeForJXA(input);
|
|
125
|
-
expect(escaped).toBe("line1\\nline2");
|
|
126
|
-
});
|
|
127
|
-
it("JXA handles backslashes with standard escaping", () => {
|
|
128
|
-
// AppleScript needs HTML entity encoding (\) for Notes.app
|
|
129
|
-
// JXA uses standard \\ escaping
|
|
130
|
-
const input = "path\\to\\file";
|
|
131
|
-
const escaped = escapeForJXA(input);
|
|
132
|
-
expect(escaped).toBe("path\\\\to\\\\file");
|
|
133
|
-
});
|
|
134
|
-
});
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Apple Notes Read-Only Metadata Reader [BETA]
|
|
3
|
-
*
|
|
4
|
-
* Reads note metadata that the AppleScript dictionary does not expose (pinned
|
|
5
|
-
* state, checklist flags, trash/recovery, preview snippet, password hint) by
|
|
6
|
-
* querying the NoteStore SQLite database directly.
|
|
7
|
-
*
|
|
8
|
-
* Unlike the note body (a gzipped protobuf blob in ZICNOTEDATA.ZDATA), these are
|
|
9
|
-
* plain scalar columns on ZICCLOUDSYNCINGOBJECT, so no protobuf decoding is
|
|
10
|
-
* needed — an ordinary SELECT is enough.
|
|
11
|
-
*
|
|
12
|
-
* BETA / safety:
|
|
13
|
-
* - The database is opened READ-ONLY (`sqlite3 -readonly`). This code never
|
|
14
|
-
* writes to the live store; doing so would corrupt CloudKit sync state.
|
|
15
|
-
* - Requires Full Disk Access for the host process.
|
|
16
|
-
* - The schema changes between macOS releases, so the reader feature-detects
|
|
17
|
-
* which columns exist (PRAGMA table_info) and only selects those.
|
|
18
|
-
*
|
|
19
|
-
* @module utils/noteMetadata
|
|
20
|
-
* @see TECHNICAL_NOTES.md#read-only-metadata-columns-verified-macos-27--notes-413
|
|
21
|
-
*/
|
|
22
|
-
import { execFileSync } from "child_process";
|
|
23
|
-
import * as fs from "fs";
|
|
24
|
-
import * as path from "path";
|
|
25
|
-
import * as os from "os";
|
|
26
|
-
const NOTES_DB_PATH = path.join(os.homedir(), "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite");
|
|
27
|
-
const FDA_MESSAGE = "Full Disk Access is required to read note metadata. " +
|
|
28
|
-
"Grant access in System Settings > Privacy & Security > Full Disk Access, " +
|
|
29
|
-
"then add and restart this application.";
|
|
30
|
-
/**
|
|
31
|
-
* Friendly field name -> NoteStore column. This is a fixed allowlist; nothing
|
|
32
|
-
* here is built from user input, so the column names are never an injection
|
|
33
|
-
* vector. `bool` columns store 0/1; `text` columns store strings.
|
|
34
|
-
*/
|
|
35
|
-
const COLUMN_MAP = [
|
|
36
|
-
{ key: "pinned", column: "ZISPINNED", type: "bool" },
|
|
37
|
-
{ key: "hasChecklist", column: "ZHASCHECKLIST", type: "bool" },
|
|
38
|
-
{ key: "hasChecklistInProgress", column: "ZHASCHECKLISTINPROGRESS", type: "bool" },
|
|
39
|
-
{ key: "recoveringFromTrash", column: "ZISRECOVERINGFROMTRASH", type: "bool" },
|
|
40
|
-
{ key: "passwordProtected", column: "ZISPASSWORDPROTECTED", type: "bool" },
|
|
41
|
-
{ key: "passwordHint", column: "ZPASSWORDHINT", type: "text" },
|
|
42
|
-
{ key: "snippet", column: "ZSNIPPET", type: "text" },
|
|
43
|
-
{ key: "widgetSnippet", column: "ZWIDGETSNIPPET", type: "text" },
|
|
44
|
-
{ key: "smartFolderQuery", column: "ZSMARTFOLDERQUERYJSON", type: "text" },
|
|
45
|
-
];
|
|
46
|
-
/**
|
|
47
|
-
* Runs a read-only sqlite3 query against the live NoteStore and returns trimmed
|
|
48
|
-
* stdout. Throws on failure (callers classify the error).
|
|
49
|
-
*
|
|
50
|
-
* Uses execFileSync with an argument array (no shell), so the database path's
|
|
51
|
-
* spaces and the query string are passed verbatim and shell metacharacters are
|
|
52
|
-
* never interpreted. The only dynamic value in any query is the note's primary
|
|
53
|
-
* key, which callers constrain to digits before it reaches here.
|
|
54
|
-
*/
|
|
55
|
-
function runSqlite(query) {
|
|
56
|
-
return execFileSync("sqlite3", ["-readonly", NOTES_DB_PATH, query], {
|
|
57
|
-
encoding: "utf8",
|
|
58
|
-
timeout: 5000,
|
|
59
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
60
|
-
}).trim();
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Returns the set of column names present on ZICCLOUDSYNCINGOBJECT, so the
|
|
64
|
-
* reader can skip columns that do not exist on this macOS version.
|
|
65
|
-
*/
|
|
66
|
-
function presentColumns() {
|
|
67
|
-
const out = runSqlite("PRAGMA table_info(ZICCLOUDSYNCINGOBJECT);");
|
|
68
|
-
const cols = new Set();
|
|
69
|
-
for (const line of out.split("\n")) {
|
|
70
|
-
// Each row: cid|name|type|notnull|dflt_value|pk
|
|
71
|
-
const name = line.split("|")[1];
|
|
72
|
-
if (name)
|
|
73
|
-
cols.add(name);
|
|
74
|
-
}
|
|
75
|
-
return cols;
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Reads read-only metadata for a note by its CoreData ID.
|
|
79
|
-
*
|
|
80
|
-
* @param noteId - CoreData URL identifier (e.g., "x-coredata://ABC/ICNote/p123")
|
|
81
|
-
* @returns Structured result with the metadata, an error type, and a message
|
|
82
|
-
*/
|
|
83
|
-
export function getNoteMetadata(noteId) {
|
|
84
|
-
const pkMatch = noteId.match(/\/p(\d+)$/);
|
|
85
|
-
if (!pkMatch) {
|
|
86
|
-
return {
|
|
87
|
-
metadata: null,
|
|
88
|
-
error: "invalid_id",
|
|
89
|
-
message: `Invalid note ID format: "${noteId}". Expected format: x-coredata://UUID/ICNote/pNNN`,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
const pk = pkMatch[1];
|
|
93
|
-
if (!fs.existsSync(NOTES_DB_PATH)) {
|
|
94
|
-
return { metadata: null, error: "no_fda", message: FDA_MESSAGE };
|
|
95
|
-
}
|
|
96
|
-
try {
|
|
97
|
-
const available = presentColumns();
|
|
98
|
-
const selected = COLUMN_MAP.filter((c) => available.has(c.column));
|
|
99
|
-
if (selected.length === 0) {
|
|
100
|
-
// Schema has none of the known columns (very old or very new macOS).
|
|
101
|
-
return { metadata: {} };
|
|
102
|
-
}
|
|
103
|
-
const pairs = selected.map((c) => `'${c.key}', ${c.column}`).join(", ");
|
|
104
|
-
const row = runSqlite(`SELECT json_object(${pairs}) FROM ZICCLOUDSYNCINGOBJECT WHERE Z_PK = ${pk};`);
|
|
105
|
-
if (!row) {
|
|
106
|
-
return {
|
|
107
|
-
metadata: null,
|
|
108
|
-
error: "not_found",
|
|
109
|
-
message: `No note found in the database for ID "${noteId}".`,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
const raw = JSON.parse(row);
|
|
113
|
-
const metadata = {};
|
|
114
|
-
for (const c of selected) {
|
|
115
|
-
const value = raw[c.key];
|
|
116
|
-
if (value === null || value === undefined)
|
|
117
|
-
continue;
|
|
118
|
-
if (c.type === "bool") {
|
|
119
|
-
metadata[c.key] = value === 1 || value === "1" || value === true;
|
|
120
|
-
}
|
|
121
|
-
else {
|
|
122
|
-
metadata[c.key] = String(value);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return { metadata: metadata };
|
|
126
|
-
}
|
|
127
|
-
catch (error) {
|
|
128
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
129
|
-
if (message.includes("authorization denied") || message.includes("unable to open database")) {
|
|
130
|
-
return { metadata: null, error: "no_fda", message: FDA_MESSAGE };
|
|
131
|
-
}
|
|
132
|
-
console.error(`Failed to read note metadata: ${message}`);
|
|
133
|
-
return { metadata: null, error: "query_error", message: "Failed to read note metadata." };
|
|
134
|
-
}
|
|
135
|
-
}
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the read-only note metadata reader.
|
|
3
|
-
*
|
|
4
|
-
* The NoteStore SQLite access is mocked, so these exercise the pk extraction,
|
|
5
|
-
* column feature-detection, JSON parsing, boolean coercion, and error
|
|
6
|
-
* classification without touching a real database.
|
|
7
|
-
*/
|
|
8
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
9
|
-
vi.mock("child_process", () => ({
|
|
10
|
-
execFileSync: vi.fn(),
|
|
11
|
-
}));
|
|
12
|
-
vi.mock("fs", () => ({
|
|
13
|
-
existsSync: vi.fn(() => true),
|
|
14
|
-
}));
|
|
15
|
-
import { execFileSync } from "child_process";
|
|
16
|
-
import { existsSync } from "fs";
|
|
17
|
-
import { getNoteMetadata } from "./noteMetadata.js";
|
|
18
|
-
const mockExecFileSync = vi.mocked(execFileSync);
|
|
19
|
-
const mockExistsSync = vi.mocked(existsSync);
|
|
20
|
-
const NOTE_ID = "x-coredata://ABC/ICNote/p123";
|
|
21
|
-
/** Builds a PRAGMA table_info dump from a list of column names. */
|
|
22
|
-
function tableInfo(columns) {
|
|
23
|
-
return columns.map((name, i) => `${i}|${name}|INTEGER|0||0`).join("\n");
|
|
24
|
-
}
|
|
25
|
-
/** The query string is the third sqlite3 argument: [-readonly, dbPath, query]. */
|
|
26
|
-
function queryOf(call) {
|
|
27
|
-
const args = call[1];
|
|
28
|
-
return args[2];
|
|
29
|
-
}
|
|
30
|
-
describe("getNoteMetadata", () => {
|
|
31
|
-
beforeEach(() => {
|
|
32
|
-
vi.clearAllMocks();
|
|
33
|
-
mockExistsSync.mockReturnValue(true);
|
|
34
|
-
});
|
|
35
|
-
it("rejects a malformed note id without touching the database", () => {
|
|
36
|
-
const result = getNoteMetadata("not-a-real-id");
|
|
37
|
-
expect(result.metadata).toBeNull();
|
|
38
|
-
expect(result.error).toBe("invalid_id");
|
|
39
|
-
expect(mockExecFileSync).not.toHaveBeenCalled();
|
|
40
|
-
});
|
|
41
|
-
it("reads pinned/checklist/snippet and coerces 0/1 to booleans", () => {
|
|
42
|
-
mockExecFileSync.mockImplementation((_cmd, args) => {
|
|
43
|
-
const query = args[2];
|
|
44
|
-
if (query.includes("table_info")) {
|
|
45
|
-
return tableInfo(["Z_PK", "ZISPINNED", "ZHASCHECKLIST", "ZSNIPPET", "ZPASSWORDHINT"]);
|
|
46
|
-
}
|
|
47
|
-
return JSON.stringify({
|
|
48
|
-
pinned: 1,
|
|
49
|
-
hasChecklist: 0,
|
|
50
|
-
snippet: "Hello world",
|
|
51
|
-
passwordHint: null,
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
const result = getNoteMetadata(NOTE_ID);
|
|
55
|
-
expect(result.error).toBeUndefined();
|
|
56
|
-
expect(result.metadata).toEqual({
|
|
57
|
-
pinned: true,
|
|
58
|
-
hasChecklist: false,
|
|
59
|
-
snippet: "Hello world",
|
|
60
|
-
});
|
|
61
|
-
// A NULL column (passwordHint) is omitted, not included as null.
|
|
62
|
-
expect(result.metadata).not.toHaveProperty("passwordHint");
|
|
63
|
-
});
|
|
64
|
-
it("only selects columns that exist on this schema", () => {
|
|
65
|
-
mockExecFileSync.mockImplementation((_cmd, args) => {
|
|
66
|
-
const query = args[2];
|
|
67
|
-
if (query.includes("table_info"))
|
|
68
|
-
return tableInfo(["Z_PK", "ZISPINNED"]);
|
|
69
|
-
return JSON.stringify({ pinned: 1 });
|
|
70
|
-
});
|
|
71
|
-
const result = getNoteMetadata(NOTE_ID);
|
|
72
|
-
expect(result.metadata).toEqual({ pinned: true });
|
|
73
|
-
// The SELECT must not reference a column the schema lacks.
|
|
74
|
-
const selectCall = mockExecFileSync.mock.calls.find((c) => queryOf(c).includes("json_object"));
|
|
75
|
-
expect(selectCall).toBeDefined();
|
|
76
|
-
expect(queryOf(selectCall)).toContain("ZISPINNED");
|
|
77
|
-
expect(queryOf(selectCall)).not.toContain("ZSNIPPET");
|
|
78
|
-
});
|
|
79
|
-
it("returns not_found when no row matches the primary key", () => {
|
|
80
|
-
mockExecFileSync.mockImplementation((_cmd, args) => {
|
|
81
|
-
const query = args[2];
|
|
82
|
-
if (query.includes("table_info"))
|
|
83
|
-
return tableInfo(["Z_PK", "ZISPINNED"]);
|
|
84
|
-
return "";
|
|
85
|
-
});
|
|
86
|
-
const result = getNoteMetadata(NOTE_ID);
|
|
87
|
-
expect(result.metadata).toBeNull();
|
|
88
|
-
expect(result.error).toBe("not_found");
|
|
89
|
-
});
|
|
90
|
-
it("classifies a Full Disk Access denial", () => {
|
|
91
|
-
mockExecFileSync.mockImplementation(() => {
|
|
92
|
-
throw new Error("Error: authorization denied");
|
|
93
|
-
});
|
|
94
|
-
const result = getNoteMetadata(NOTE_ID);
|
|
95
|
-
expect(result.metadata).toBeNull();
|
|
96
|
-
expect(result.error).toBe("no_fda");
|
|
97
|
-
expect(result.message).toContain("Full Disk Access");
|
|
98
|
-
});
|
|
99
|
-
it("returns no_fda when the database file is missing", () => {
|
|
100
|
-
mockExistsSync.mockReturnValue(false);
|
|
101
|
-
const result = getNoteMetadata(NOTE_ID);
|
|
102
|
-
expect(result.metadata).toBeNull();
|
|
103
|
-
expect(result.error).toBe("no_fda");
|
|
104
|
-
expect(mockExecFileSync).not.toHaveBeenCalled();
|
|
105
|
-
});
|
|
106
|
-
});
|