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.
Files changed (34) hide show
  1. package/README.md +8 -5
  2. package/build/index.js +42669 -1077
  3. package/package.json +3 -3
  4. package/build/index.test.js +0 -446
  5. package/build/services/__fixtures__/notesNormalizedHtml.js +0 -32
  6. package/build/services/appleNotesManager.js +0 -2634
  7. package/build/services/appleNotesManager.test.js +0 -2416
  8. package/build/services/attachmentSave.test.js +0 -85
  9. package/build/services/fileConfig.js +0 -51
  10. package/build/services/fileConfig.test.js +0 -48
  11. package/build/services/notesHtmlMarkdown.test.js +0 -55
  12. package/build/tools/doctor.js +0 -50
  13. package/build/tools/doctor.test.js +0 -42
  14. package/build/tools/resourcesAndPrompts.js +0 -70
  15. package/build/tools/resourcesAndPrompts.test.js +0 -63
  16. package/build/types.js +0 -13
  17. package/build/utils/applescript.js +0 -421
  18. package/build/utils/applescript.test.js +0 -342
  19. package/build/utils/attachmentFs.js +0 -97
  20. package/build/utils/attachmentFs.test.js +0 -69
  21. package/build/utils/checklistParser.js +0 -259
  22. package/build/utils/checklistParser.test.js +0 -230
  23. package/build/utils/contentWarnings.js +0 -44
  24. package/build/utils/contentWarnings.test.js +0 -52
  25. package/build/utils/hashtags.js +0 -56
  26. package/build/utils/hashtags.test.js +0 -45
  27. package/build/utils/jxa.js +0 -139
  28. package/build/utils/jxa.test.js +0 -134
  29. package/build/utils/noteMetadata.js +0 -135
  30. package/build/utils/noteMetadata.test.js +0 -106
  31. package/build/utils/protobuf.js +0 -151
  32. package/build/utils/protobuf.test.js +0 -138
  33. package/build/utils/syncDetection.js +0 -242
  34. 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
- // &#8217; is a right single quote entity, not a #8217 tag
34
- expect(parseHashtags("It&#8217;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
- });
@@ -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
- }
@@ -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 (&#92;) 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
- });