apple-notes-mcp 2.9.3 → 2.9.4
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 +70 -2
- package/build/index.js +314 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -634,6 +634,74 @@ stable code in brackets, such as `[encrypted]` or `[no-full-disk-access]`.
|
|
|
634
634
|
|
|
635
635
|
---
|
|
636
636
|
|
|
637
|
+
#### `list-note-paragraphs`
|
|
638
|
+
|
|
639
|
+
Lists one note's non-empty paragraphs in body order, read-only from the
|
|
640
|
+
NoteStore database. Each paragraph has `blockIndex` (its index in
|
|
641
|
+
`get-note-blocks`, where empty paragraphs also count), `text`, `style`,
|
|
642
|
+
`styleType`, `paragraphId` (the UUID Notes stores on the paragraph's first
|
|
643
|
+
text run), and `paragraphIdStatus`:
|
|
644
|
+
|
|
645
|
+
- `unique`: no other paragraph in the note carries this ID. The paragraph also
|
|
646
|
+
gets `url`, a direct
|
|
647
|
+
`applenotes://showNote?identifier=<note>¶graphID=<paragraph>` link that
|
|
648
|
+
opens Notes at that paragraph.
|
|
649
|
+
- `shared`: other paragraphs carry the same ID (`sharedWith` counts them).
|
|
650
|
+
Notes copies the ID when a paragraph is split, so this is common for body
|
|
651
|
+
text. No `url` is given, because it could open the wrong paragraph.
|
|
652
|
+
- `missing`: no ID is stored.
|
|
653
|
+
|
|
654
|
+
`mixedParagraphIds: true` marks a paragraph whose text runs carry more than
|
|
655
|
+
one ID; only the first-run ID is used. Titles and headings usually have unique
|
|
656
|
+
IDs.
|
|
657
|
+
|
|
658
|
+
**Requires:** Full Disk Access. Password-protected notes are refused.
|
|
659
|
+
|
|
660
|
+
| Parameter | Type | Required | Description |
|
|
661
|
+
|-----------|------|----------|-------------|
|
|
662
|
+
| `id` | string | One of `id`, `title` | Exact note ID, or the note's Notes UUID |
|
|
663
|
+
| `title` | string | One of `id`, `title` | Exact note title; must match one note. Notes in Recently Deleted are ignored, as in `list-notes` |
|
|
664
|
+
| `folder` | string | No | With `title`: the folder's name or full path as `list-folders` shows it, such as `Work/Clients` |
|
|
665
|
+
| `linkableOnly` | boolean | No | Return only paragraphs with a `url` |
|
|
666
|
+
| `offset` | number | No | First paragraph to return (default 0). Use `page.nextOffset` |
|
|
667
|
+
| `limit` | number | No | Maximum paragraphs per page (default 500, max 5000) |
|
|
668
|
+
|
|
669
|
+
---
|
|
670
|
+
|
|
671
|
+
#### `get-paragraph-link`
|
|
672
|
+
|
|
673
|
+
Returns a direct link to one paragraph of a note:
|
|
674
|
+
`applenotes://showNote?identifier=<note>¶graphID=<paragraph>`. It selects
|
|
675
|
+
the note like `list-note-paragraphs` and the paragraph by exactly one of:
|
|
676
|
+
|
|
677
|
+
- `contains`: a snippet of the paragraph. Case, runs of spaces, and Unicode
|
|
678
|
+
width are ignored.
|
|
679
|
+
- `match`: the whole paragraph, compared the same way.
|
|
680
|
+
- `blockIndex`: from `list-note-paragraphs`.
|
|
681
|
+
|
|
682
|
+
When `contains` or `match` hits several paragraphs, pass `occurrence` (1-based)
|
|
683
|
+
or a longer snippet.
|
|
684
|
+
|
|
685
|
+
The link is returned only when the paragraph's ID is `unique` in the note.
|
|
686
|
+
Otherwise the result is an error whose `structuredContent` carries the usual
|
|
687
|
+
`code` plus a `reason`: `paragraph-id-shared`, `paragraph-id-missing`,
|
|
688
|
+
`no-match`, `ambiguous-paragraph`, `occurrence-out-of-range`, `ambiguous-note`
|
|
689
|
+
(several notes have that title; the message lists their folders), `not-found`,
|
|
690
|
+
`encrypted`, or `no-body`. The tool never creates or changes a paragraph ID,
|
|
691
|
+
and an edit in Notes can later replace the ID and break the link.
|
|
692
|
+
|
|
693
|
+
**Requires:** Full Disk Access.
|
|
694
|
+
|
|
695
|
+
| Parameter | Type | Required | Description |
|
|
696
|
+
|-----------|------|----------|-------------|
|
|
697
|
+
| `id`, `title`, `folder` | string | One of `id`, `title` | Note selector, as in `list-note-paragraphs` |
|
|
698
|
+
| `contains` | string | One of `contains`, `match`, `blockIndex` | Snippet of the paragraph |
|
|
699
|
+
| `match` | string | One of `contains`, `match`, `blockIndex` | The whole paragraph |
|
|
700
|
+
| `blockIndex` | number | One of `contains`, `match`, `blockIndex` | The paragraph's `blockIndex` |
|
|
701
|
+
| `occurrence` | number | No | Which match to use when several paragraphs match |
|
|
702
|
+
|
|
703
|
+
---
|
|
704
|
+
|
|
637
705
|
#### `get-note-structure`
|
|
638
706
|
|
|
639
707
|
Returns a read-only overview of one note from the NoteStore database, in one
|
|
@@ -2258,7 +2326,7 @@ All configuration is optional — the server works out of the box. Override beha
|
|
|
2258
2326
|
| `APPLE_NOTES_MCP_CONFIG_FILE` | `~/Library/Application Support/apple-notes-mcp/config.json` | Path to the JSON config file (see below). |
|
|
2259
2327
|
| `APPLE_NOTES_MCP_TIMEOUT_MS` | `30000` (30 s) | Total AppleScript operation timeout, including retry attempts and delays. Raise it if full-library operations (large searches, exports) time out on a big Notes library. Per-call `timeoutMs` options still win, and a write tool's `timeoutSeconds` argument overrides it for that call. |
|
|
2260
2328
|
| `APPLE_NOTES_MCP_EXPORT_MAX_BYTES` | `8388608` (8 MB) | Largest response `export-notes-json` sends; a page closes early to stay under it. `export-notes-markdown` returns inline Markdown up to half of it. The default sits below the 10 MB per-message limit of MCP SDK stdio clients, which drop the connection on anything larger. Raise it only if your MCP client accepts bigger messages. |
|
|
2261
|
-
| `APPLE_NOTES_MCP_BLOCKS_MAX_BYTES` | `4194304` (4 MB) | Largest block payload one [`get-note-blocks`](#get-note-blocks) page returns; the page closes early to stay under it, and a single oversized paragraph comes back with `textOmitted: true`. [`get-note-structure`](#get-note-structure) also omits note text larger than this. |
|
|
2329
|
+
| `APPLE_NOTES_MCP_BLOCKS_MAX_BYTES` | `4194304` (4 MB) | Largest block payload one [`get-note-blocks`](#get-note-blocks) page returns; the page closes early to stay under it, and a single oversized paragraph comes back with `textOmitted: true`. [`get-note-structure`](#get-note-structure) also omits note text larger than this. A [`list-note-paragraphs`](#list-note-paragraphs) page also stops early to stay under it. |
|
|
2262
2330
|
| `APPLE_NOTES_MCP_MAX_RETRIES` | `2` | Maximum attempts for a read-only AppleScript call that fails with a **transient** error (Notes.app busy / not responding / lost connection). `2` means one retry; set `1` to fail fast with no retries. Retries share the single `APPLE_NOTES_MCP_TIMEOUT_MS` budget rather than each getting a fresh one, and a retry is skipped when under a second of that budget remains — so this is a ceiling, not a guarantee. In particular a call that exhausts the budget with a **timeout** has no time left to retry by construction. Mutating operations run once because a timeout can occur after Notes.app applied the change. Non-transient errors (e.g. "note not found") never retry. |
|
|
2263
2331
|
| `APPLE_NOTES_MCP_RETRY_DELAY_MS` | `1000` (1 s) | Base delay before the first retry; subsequent retries back off exponentially (1s, 2s, 4s, ...). |
|
|
2264
2332
|
| `APPLE_NOTES_MCP_ENABLE_PRIVATE` | unset | Set to `1` to allow the opt-in [private helper](#private-helper-opt-in-unsupported-apple-api). Any other value keeps it off. |
|
|
@@ -2291,7 +2359,7 @@ MCP stores no secrets, but as a general rule keep only non-secret config here.
|
|
|
2291
2359
|
|
|
2292
2360
|
## Full Disk Access
|
|
2293
2361
|
|
|
2294
|
-
Several tools read directly from the Apple Notes SQLite database, which lives in a macOS-protected directory. Those tools require **Full Disk Access** for the process running the MCP server: `get-checklist-state`, `get-note-metadata`, `get-note-blocks`, `get-note-structure`, `export-notes-markdown`, `export-notes-html`, `get-audio-transcripts`, `list-special-notes`, `list-native-tags`, `list-recent-notes`, `list-folder-tree`, `get-note-link`, the checklist annotations in `get-note-markdown`, `list-attachments` with `includePaths` or `firstImage`, `export-attachments`, `list-paper-attachments`, `export-paper-image`, and the database half of `get-sync-status`.
|
|
2362
|
+
Several tools read directly from the Apple Notes SQLite database, which lives in a macOS-protected directory. Those tools require **Full Disk Access** for the process running the MCP server: `get-checklist-state`, `get-note-metadata`, `get-note-blocks`, `list-note-paragraphs`, `get-paragraph-link`, `get-note-structure`, `export-notes-markdown`, `export-notes-html`, `get-audio-transcripts`, `list-special-notes`, `list-native-tags`, `list-recent-notes`, `list-folder-tree`, `get-note-link`, the checklist annotations in `get-note-markdown`, `list-attachments` with `includePaths` or `firstImage`, `export-attachments`, `list-paper-attachments`, `export-paper-image`, and the database half of `get-sync-status`.
|
|
2295
2363
|
|
|
2296
2364
|
> 📘 **For the full why-and-how walkthrough (which app to grant, verifying with `doctor`, graceful degradation), see the [Full Disk Access Setup Guide](https://github.com/sweetrb/apple-notes-mcp/blob/main/docs/FULL-DISK-ACCESS.md).** The summary below is the quick version.
|
|
2297
2365
|
|
package/build/index.js
CHANGED
|
@@ -49052,6 +49052,249 @@ function createShutdown(stream, exit, timeoutMs = SHUTDOWN_DRAIN_TIMEOUT_MS) {
|
|
|
49052
49052
|
};
|
|
49053
49053
|
}
|
|
49054
49054
|
|
|
49055
|
+
// src/utils/noteParagraphs.ts
|
|
49056
|
+
import { gunzipSync as gunzipSync9 } from "node:zlib";
|
|
49057
|
+
var ENVELOPE_CODE = {
|
|
49058
|
+
"invalid-argument": "validation_error",
|
|
49059
|
+
"not-found": "not_found",
|
|
49060
|
+
encrypted: "unsupported",
|
|
49061
|
+
"no-body": "unsupported",
|
|
49062
|
+
"ambiguous-note": "ambiguous",
|
|
49063
|
+
"no-match": "not_found",
|
|
49064
|
+
"ambiguous-paragraph": "ambiguous",
|
|
49065
|
+
"occurrence-out-of-range": "validation_error",
|
|
49066
|
+
"paragraph-id-missing": "unsupported",
|
|
49067
|
+
"paragraph-id-shared": "unsupported"
|
|
49068
|
+
};
|
|
49069
|
+
var ParagraphLinkError = class extends CodedError {
|
|
49070
|
+
reason;
|
|
49071
|
+
constructor(reason, message) {
|
|
49072
|
+
super(message, { code: ENVELOPE_CODE[reason], reason });
|
|
49073
|
+
this.name = "ParagraphLinkError";
|
|
49074
|
+
this.reason = reason;
|
|
49075
|
+
}
|
|
49076
|
+
};
|
|
49077
|
+
var paragraphUrl = (noteIdentifier, paragraphId) => `applenotes://showNote?identifier=${noteIdentifier.toUpperCase()}¶graphID=${paragraphId.toUpperCase()}`;
|
|
49078
|
+
var normalizeParagraphText = (text2) => text2.normalize("NFKC").replace(/\ufffc/g, "").split(/\s+/u).filter(Boolean).join(" ").toLowerCase();
|
|
49079
|
+
var bytesField = (fields, n) => fields.find((f) => f.fieldNumber === n && f.wireType === 2)?.bytes;
|
|
49080
|
+
var uuidOf = (bytes) => {
|
|
49081
|
+
if (bytes?.length !== 16) return void 0;
|
|
49082
|
+
const h = Buffer.from(bytes).toString("hex").toUpperCase();
|
|
49083
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
49084
|
+
};
|
|
49085
|
+
function runParagraphIds(data) {
|
|
49086
|
+
const document = decodeWireFields(bytesField(decodeWireFields(data), 2));
|
|
49087
|
+
const note = decodeWireFields(bytesField(document, 3));
|
|
49088
|
+
const runs = [];
|
|
49089
|
+
let start = 0;
|
|
49090
|
+
for (const field of note.filter((f) => f.fieldNumber === 5)) {
|
|
49091
|
+
const run = decodeWireFields(field.bytes);
|
|
49092
|
+
const length = Number(run.find((f) => f.fieldNumber === 1).varint);
|
|
49093
|
+
const style = bytesField(run, 2);
|
|
49094
|
+
const paragraphId = style ? uuidOf(bytesField(decodeWireFields(style), 9)) : void 0;
|
|
49095
|
+
runs.push({ start, length, ...paragraphId ? { paragraphId } : {} });
|
|
49096
|
+
start += length;
|
|
49097
|
+
}
|
|
49098
|
+
return runs;
|
|
49099
|
+
}
|
|
49100
|
+
function classifyParagraphIds(doc, runs) {
|
|
49101
|
+
const ranges = doc.blocks.map((block) => [block.start, block.start + block.length + 1]);
|
|
49102
|
+
const blocksOfId = /* @__PURE__ */ new Map();
|
|
49103
|
+
const idsOfBlock = doc.blocks.map(() => /* @__PURE__ */ new Set());
|
|
49104
|
+
let b = 0;
|
|
49105
|
+
for (const run of runs) {
|
|
49106
|
+
const end = run.start + run.length;
|
|
49107
|
+
while (b < ranges.length && ranges[b][1] <= run.start) b++;
|
|
49108
|
+
for (let i = b; i < ranges.length && ranges[i][0] < end; i++) {
|
|
49109
|
+
idsOfBlock[i].add(run.paragraphId);
|
|
49110
|
+
if (run.paragraphId) {
|
|
49111
|
+
const owners = blocksOfId.get(run.paragraphId) ?? /* @__PURE__ */ new Set();
|
|
49112
|
+
owners.add(i);
|
|
49113
|
+
blocksOfId.set(run.paragraphId, owners);
|
|
49114
|
+
}
|
|
49115
|
+
}
|
|
49116
|
+
}
|
|
49117
|
+
return doc.blocks.map((block, i) => {
|
|
49118
|
+
const paragraphId = block.paragraphUuid ?? null;
|
|
49119
|
+
const mixed = idsOfBlock[i].size > 1;
|
|
49120
|
+
if (!paragraphId) return { status: "missing", paragraphId, mixed };
|
|
49121
|
+
const owners = blocksOfId.get(paragraphId).size;
|
|
49122
|
+
return owners === 1 ? { status: "unique", paragraphId, mixed } : { status: "shared", paragraphId, sharedWith: owners - 1, mixed };
|
|
49123
|
+
});
|
|
49124
|
+
}
|
|
49125
|
+
function paragraphsOf(doc, runs, noteIdentifier) {
|
|
49126
|
+
const ids = classifyParagraphIds(doc, runs);
|
|
49127
|
+
const out = [];
|
|
49128
|
+
doc.blocks.forEach((block, i) => {
|
|
49129
|
+
if (!normalizeParagraphText(block.text)) return;
|
|
49130
|
+
const { status, paragraphId, sharedWith, mixed } = ids[i];
|
|
49131
|
+
out.push({
|
|
49132
|
+
blockIndex: block.index,
|
|
49133
|
+
text: block.text,
|
|
49134
|
+
style: block.style,
|
|
49135
|
+
styleType: block.styleType,
|
|
49136
|
+
paragraphId,
|
|
49137
|
+
paragraphIdStatus: status,
|
|
49138
|
+
...sharedWith !== void 0 ? { sharedWith } : {},
|
|
49139
|
+
...mixed ? { mixedParagraphIds: true } : {},
|
|
49140
|
+
...status === "unique" && noteIdentifier ? { url: paragraphUrl(noteIdentifier, paragraphId) } : {}
|
|
49141
|
+
});
|
|
49142
|
+
});
|
|
49143
|
+
return out;
|
|
49144
|
+
}
|
|
49145
|
+
function selectParagraph(paragraphs, selector) {
|
|
49146
|
+
const given = [selector.contains, selector.match, selector.blockIndex].filter(
|
|
49147
|
+
(value) => value !== void 0
|
|
49148
|
+
).length;
|
|
49149
|
+
if (given !== 1)
|
|
49150
|
+
throw new ParagraphLinkError(
|
|
49151
|
+
"invalid-argument",
|
|
49152
|
+
"Choose exactly one paragraph selector: contains, match, or blockIndex"
|
|
49153
|
+
);
|
|
49154
|
+
if (selector.blockIndex !== void 0) {
|
|
49155
|
+
const hit = paragraphs.find((p) => p.blockIndex === selector.blockIndex);
|
|
49156
|
+
if (!hit)
|
|
49157
|
+
throw new ParagraphLinkError(
|
|
49158
|
+
"no-match",
|
|
49159
|
+
`Block ${selector.blockIndex} is not a non-empty paragraph of this note`
|
|
49160
|
+
);
|
|
49161
|
+
return hit;
|
|
49162
|
+
}
|
|
49163
|
+
const wanted = normalizeParagraphText(selector.contains ?? selector.match);
|
|
49164
|
+
if (!wanted)
|
|
49165
|
+
throw new ParagraphLinkError("invalid-argument", "The paragraph selector has no visible text");
|
|
49166
|
+
const matches = paragraphs.filter(
|
|
49167
|
+
(p) => selector.match !== void 0 ? normalizeParagraphText(p.text) === wanted : normalizeParagraphText(p.text).includes(wanted)
|
|
49168
|
+
);
|
|
49169
|
+
if (!matches.length)
|
|
49170
|
+
throw new ParagraphLinkError("no-match", "No paragraph matches the selector");
|
|
49171
|
+
if (selector.occurrence === void 0 && matches.length > 1)
|
|
49172
|
+
throw new ParagraphLinkError(
|
|
49173
|
+
"ambiguous-paragraph",
|
|
49174
|
+
`The selector matches ${matches.length} paragraphs; use a longer snippet or pass occurrence (1-${matches.length})`
|
|
49175
|
+
);
|
|
49176
|
+
const occurrence = selector.occurrence ?? 1;
|
|
49177
|
+
if (occurrence > matches.length)
|
|
49178
|
+
throw new ParagraphLinkError(
|
|
49179
|
+
"occurrence-out-of-range",
|
|
49180
|
+
`Occurrence ${occurrence} requested but only ${matches.length} paragraphs match`
|
|
49181
|
+
);
|
|
49182
|
+
return matches[occurrence - 1];
|
|
49183
|
+
}
|
|
49184
|
+
var NOTE_ID = /^x-coredata:\/\/[0-9a-f-]+\/ICNote\/p([0-9]{1,15})$/i;
|
|
49185
|
+
function titleMatchSql(columns) {
|
|
49186
|
+
return `SELECT json_object('pk', n.Z_PK, 'folder', n.ZFOLDER) FROM ZICCLOUDSYNCINGOBJECT n LEFT JOIN ZICCLOUDSYNCINGOBJECT f ON f.Z_PK = n.ZFOLDER WHERE n.Z_ENT = ${entity("ICNote")} AND n.ZTITLE1 = CAST(@title AS TEXT) AND ${activeNoteSql(columns, "n", "f")} ORDER BY n.Z_PK;`;
|
|
49187
|
+
}
|
|
49188
|
+
function checkNoteSelector(selector) {
|
|
49189
|
+
const given = [selector.id, selector.title].filter((value) => value !== void 0).length;
|
|
49190
|
+
if (given !== 1)
|
|
49191
|
+
throw new ParagraphLinkError("invalid-argument", "Choose exactly one of id or title");
|
|
49192
|
+
if (selector.folder !== void 0 && selector.title === void 0)
|
|
49193
|
+
throw new ParagraphLinkError("invalid-argument", "folder only narrows a title lookup");
|
|
49194
|
+
if (selector.id === void 0) return void 0;
|
|
49195
|
+
const pk = NOTE_ID.exec(selector.id)?.[1];
|
|
49196
|
+
if (!pk)
|
|
49197
|
+
throw new ParagraphLinkError(
|
|
49198
|
+
"invalid-argument",
|
|
49199
|
+
"Invalid note ID: expected x-coredata://<store>/ICNote/p<number> or a Notes UUID"
|
|
49200
|
+
);
|
|
49201
|
+
return Number(pk);
|
|
49202
|
+
}
|
|
49203
|
+
function resolveNote(dbPath2, columns, selector) {
|
|
49204
|
+
const idPk = checkNoteSelector(selector);
|
|
49205
|
+
if (idPk !== void 0) return { pk: idPk, id: selector.id };
|
|
49206
|
+
requireColumns(columns, ["ZTITLE1", "ZFOLDER"], "title lookup");
|
|
49207
|
+
const context = readStoreContext(dbPath2, columns);
|
|
49208
|
+
const paths = folderPaths(context.folders);
|
|
49209
|
+
const names = new Map(context.folders.map((f) => [f.pk, escapeFolderName(f.name ?? "")]));
|
|
49210
|
+
let matches = parseJsonLines(
|
|
49211
|
+
runReadOnlySql(dbPath2, titleMatchSql(columns), {
|
|
49212
|
+
title: { blob: Buffer.from(selector.title, "utf8") }
|
|
49213
|
+
})
|
|
49214
|
+
);
|
|
49215
|
+
if (selector.folder !== void 0) {
|
|
49216
|
+
const wanted = selector.folder;
|
|
49217
|
+
matches = matches.filter(
|
|
49218
|
+
(n) => paths.get(n.folder) === wanted || names.get(n.folder) === wanted
|
|
49219
|
+
);
|
|
49220
|
+
}
|
|
49221
|
+
if (!matches.length) throw new ParagraphLinkError("not-found", "No note matches the selector");
|
|
49222
|
+
if (matches.length > 1)
|
|
49223
|
+
throw new ParagraphLinkError(
|
|
49224
|
+
"ambiguous-note",
|
|
49225
|
+
`${matches.length} notes match; pass folder (a name or a path as list-folders shows it) or use the note id. Folders: ${matches.map((n) => paths.get(n.folder)).join(", ")}`
|
|
49226
|
+
);
|
|
49227
|
+
return { pk: matches[0].pk, id: noteIdFor(context.uuid, matches[0].pk) };
|
|
49228
|
+
}
|
|
49229
|
+
function noteBodySql(columns) {
|
|
49230
|
+
return `SELECT json_object('isNote', n.Z_ENT = ${entity("ICNote")}, 'identifier', ${col(columns, "n", "ZIDENTIFIER")}, 'data', (SELECT hex(d.ZDATA) FROM ZICNOTEDATA d WHERE d.ZNOTE = n.Z_PK), 'encrypted', (SELECT d.ZCRYPTOINITIALIZATIONVECTOR IS NOT NULL FROM ZICNOTEDATA d WHERE d.ZNOTE = n.Z_PK), 'locked', ${col(columns, "n", "ZISPASSWORDPROTECTED")}) FROM ZICCLOUDSYNCINGOBJECT n WHERE n.Z_PK = @pk;`;
|
|
49231
|
+
}
|
|
49232
|
+
function readNoteParagraphs(selector, { dbPath: dbPath2 = NOTES_DB_PATH7 } = {}) {
|
|
49233
|
+
checkNoteSelector(selector);
|
|
49234
|
+
const columns = readColumns(dbPath2);
|
|
49235
|
+
const { pk, id: id2 } = resolveNote(dbPath2, columns, selector);
|
|
49236
|
+
const [row] = parseJsonLines(runReadOnlySql(dbPath2, noteBodySql(columns), { pk: { int: pk } }));
|
|
49237
|
+
if (!row?.isNote) throw new ParagraphLinkError("not-found", `No note found for ID "${id2}"`);
|
|
49238
|
+
if (row.encrypted || row.locked)
|
|
49239
|
+
throw new ParagraphLinkError(
|
|
49240
|
+
"encrypted",
|
|
49241
|
+
"This note is password-protected; its body is encrypted"
|
|
49242
|
+
);
|
|
49243
|
+
if (!row.data || !/^[0-9a-f]+$/i.test(row.data))
|
|
49244
|
+
throw new ParagraphLinkError("no-body", "No body data is stored for this note");
|
|
49245
|
+
let doc;
|
|
49246
|
+
let runs;
|
|
49247
|
+
try {
|
|
49248
|
+
const data = gunzipSync9(Buffer.from(row.data, "hex"), { maxOutputLength: 32 * 1024 * 1024 });
|
|
49249
|
+
doc = decodeNoteBlocks(data);
|
|
49250
|
+
runs = runParagraphIds(data);
|
|
49251
|
+
} catch (error2) {
|
|
49252
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
49253
|
+
throw new ParagraphLinkError("no-body", `The note body could not be decoded: ${message}`);
|
|
49254
|
+
}
|
|
49255
|
+
const paragraphs = paragraphsOf(doc, runs, row.identifier);
|
|
49256
|
+
const counts = { unique: 0, shared: 0, missing: 0 };
|
|
49257
|
+
for (const p of paragraphs) counts[p.paragraphIdStatus]++;
|
|
49258
|
+
return { id: id2, identifier: row.identifier, paragraphs, counts };
|
|
49259
|
+
}
|
|
49260
|
+
function pageParagraphs(paragraphs, { offset = 0, limit = 500, maxBytes = 4 * 1024 * 1024, linkableOnly = false } = {}) {
|
|
49261
|
+
const list = linkableOnly ? paragraphs.filter((p) => p.url) : paragraphs;
|
|
49262
|
+
const start = Math.min(Math.max(0, offset), list.length);
|
|
49263
|
+
const out = [];
|
|
49264
|
+
let bytes = 0;
|
|
49265
|
+
for (let i = start; i < list.length && out.length < limit; i++) {
|
|
49266
|
+
const size = Buffer.byteLength(JSON.stringify(list[i]));
|
|
49267
|
+
if (out.length && bytes + size > maxBytes) break;
|
|
49268
|
+
out.push(list[i]);
|
|
49269
|
+
bytes += size;
|
|
49270
|
+
}
|
|
49271
|
+
const next = start + out.length;
|
|
49272
|
+
return {
|
|
49273
|
+
paragraphs: out,
|
|
49274
|
+
page: {
|
|
49275
|
+
offset: start,
|
|
49276
|
+
returned: out.length,
|
|
49277
|
+
total: list.length,
|
|
49278
|
+
hasMore: next < list.length,
|
|
49279
|
+
...next < list.length ? { nextOffset: next } : {}
|
|
49280
|
+
}
|
|
49281
|
+
};
|
|
49282
|
+
}
|
|
49283
|
+
function paragraphLink(note, selector) {
|
|
49284
|
+
const paragraph = selectParagraph(note.paragraphs, selector);
|
|
49285
|
+
if (paragraph.paragraphIdStatus === "shared")
|
|
49286
|
+
throw new ParagraphLinkError(
|
|
49287
|
+
"paragraph-id-shared",
|
|
49288
|
+
`This paragraph's ID is shared with ${paragraph.sharedWith} other paragraph(s) in the note, so a link could open the wrong one`
|
|
49289
|
+
);
|
|
49290
|
+
if (!paragraph.url)
|
|
49291
|
+
throw new ParagraphLinkError(
|
|
49292
|
+
"paragraph-id-missing",
|
|
49293
|
+
paragraph.paragraphId ? "The note has no stored identifier, so no link can be built" : "This paragraph has no stored paragraph ID, so Notes cannot open it directly"
|
|
49294
|
+
);
|
|
49295
|
+
return { url: paragraph.url, paragraph };
|
|
49296
|
+
}
|
|
49297
|
+
|
|
49055
49298
|
// src/utils/noteStructure.ts
|
|
49056
49299
|
import { dirname as dirname4 } from "node:path";
|
|
49057
49300
|
|
|
@@ -53722,6 +53965,77 @@ registerTool(
|
|
|
53722
53965
|
);
|
|
53723
53966
|
}, "Error reading note blocks")
|
|
53724
53967
|
);
|
|
53968
|
+
var paragraphNoteSelector = {
|
|
53969
|
+
id: noteIdInput.optional().describe(`Exact note ID (${NOTE_ID_FORMS}); give id or title`),
|
|
53970
|
+
title: external_exports.string().min(1).max(MAX.TITLE).optional().describe("Exact note title; must match one note unless folder narrows it"),
|
|
53971
|
+
folder: external_exports.string().min(1).max(MAX.FOLDER).optional().describe("With title only: the note's folder name or full path as list-folders shows it")
|
|
53972
|
+
};
|
|
53973
|
+
registerTool(
|
|
53974
|
+
"list-note-paragraphs",
|
|
53975
|
+
{
|
|
53976
|
+
description: "Use when: you need a note's paragraphs with their style and stored paragraph ID, for example to choose one to link to.\nReturns: one page of non-empty paragraphs in body order, each with blockIndex (as in get-note-blocks), text, style, paragraphId, paragraphIdStatus (unique, shared, missing) and, only when unique, a direct applenotes:// url that opens that paragraph; plus counts per status and page info (call again with offset set to page.nextOffset while page.hasMore is true).\nDo not use when: you need inline formatting (get-note-blocks).\nSafety: read-only; reads the NoteStore database directly and requires Full Disk Access. Paragraph IDs repeat often (Notes copies them when a paragraph is split), so shared IDs get no url. Title lookups ignore Recently Deleted. Password-protected notes are refused.",
|
|
53977
|
+
inputSchema: {
|
|
53978
|
+
...paragraphNoteSelector,
|
|
53979
|
+
linkableOnly: external_exports.boolean().optional().describe("Return only paragraphs that have a direct url (default false)"),
|
|
53980
|
+
offset: external_exports.number().int().min(0).optional().describe("Index of the first paragraph to return (default 0); use page.nextOffset"),
|
|
53981
|
+
limit: external_exports.number().int().min(1).max(5e3).optional().describe("Maximum paragraphs to return (default 500, max 5000)")
|
|
53982
|
+
},
|
|
53983
|
+
outputSchema: {
|
|
53984
|
+
id: external_exports.string().optional(),
|
|
53985
|
+
identifier: external_exports.string().nullable().optional(),
|
|
53986
|
+
counts: external_exports.record(external_exports.unknown()).optional(),
|
|
53987
|
+
paragraphs: external_exports.array(external_exports.record(external_exports.unknown())).optional(),
|
|
53988
|
+
page: external_exports.record(external_exports.unknown()).optional()
|
|
53989
|
+
},
|
|
53990
|
+
annotations: { readOnlyHint: true }
|
|
53991
|
+
},
|
|
53992
|
+
withErrorHandling(({ id: id2, title, folder, linkableOnly, offset, limit }) => {
|
|
53993
|
+
const note = readNoteParagraphs({ id: id2, title, folder });
|
|
53994
|
+
const page = pageParagraphs(note.paragraphs, {
|
|
53995
|
+
offset,
|
|
53996
|
+
limit,
|
|
53997
|
+
linkableOnly,
|
|
53998
|
+
maxBytes: blocksMaxResponseBytes()
|
|
53999
|
+
});
|
|
54000
|
+
const { unique, shared, missing } = note.counts;
|
|
54001
|
+
return successResponse(
|
|
54002
|
+
`${note.paragraphs.length} paragraphs: ${unique} linkable, ${shared} with a shared ID, ${missing} without an ID; returned ${page.page.returned} from offset ${page.page.offset}` + (page.page.hasMore ? `; more at offset ${page.page.nextOffset}` : "") + ".",
|
|
54003
|
+
{ id: note.id, identifier: note.identifier, counts: note.counts, ...page }
|
|
54004
|
+
);
|
|
54005
|
+
}, "Error listing paragraphs")
|
|
54006
|
+
);
|
|
54007
|
+
registerTool(
|
|
54008
|
+
"get-paragraph-link",
|
|
54009
|
+
{
|
|
54010
|
+
description: "Use when: you need a link that opens Notes at one paragraph (for example a heading) of a note.\nReturns: a direct applenotes://showNote?identifier=<note>¶graphID=<paragraph> url and the selected paragraph, only when that paragraph's stored ID is present and appears in no other paragraph of the note. Otherwise an error whose structuredContent.reason says why: paragraph-id-shared, paragraph-id-missing, no-match, ambiguous-paragraph (pass occurrence or a longer snippet), occurrence-out-of-range, ambiguous-note, encrypted.\nDo not use when: you want a link to the whole note (get-note-link).\nSafety: read-only; never creates or changes a paragraph ID, so a paragraph without a unique ID cannot be linked. Requires Full Disk Access. A later edit in Notes can replace the ID and break the link.",
|
|
54011
|
+
inputSchema: {
|
|
54012
|
+
...paragraphNoteSelector,
|
|
54013
|
+
contains: external_exports.string().min(1).max(MAX.CONTENT).optional().describe(
|
|
54014
|
+
"Snippet of the paragraph (case, spacing and Unicode width are ignored); give one of contains, match, blockIndex"
|
|
54015
|
+
),
|
|
54016
|
+
match: external_exports.string().min(1).max(MAX.CONTENT).optional().describe("The whole paragraph text, compared the same way"),
|
|
54017
|
+
blockIndex: external_exports.number().int().min(0).optional().describe("The paragraph's blockIndex from list-note-paragraphs"),
|
|
54018
|
+
occurrence: external_exports.number().int().min(1).optional().describe("Which match to use (1-based) when contains or match hits several paragraphs")
|
|
54019
|
+
},
|
|
54020
|
+
outputSchema: {
|
|
54021
|
+
url: external_exports.string().optional(),
|
|
54022
|
+
id: external_exports.string().optional(),
|
|
54023
|
+
identifier: external_exports.string().nullable().optional(),
|
|
54024
|
+
paragraph: external_exports.record(external_exports.unknown()).optional()
|
|
54025
|
+
},
|
|
54026
|
+
annotations: { readOnlyHint: true }
|
|
54027
|
+
},
|
|
54028
|
+
withErrorHandling(({ id: id2, title, folder, contains, match, blockIndex, occurrence }) => {
|
|
54029
|
+
const note = readNoteParagraphs({ id: id2, title, folder });
|
|
54030
|
+
const result = paragraphLink(note, { contains, match, blockIndex, occurrence });
|
|
54031
|
+
return successResponse(`Paragraph link: ${result.url}`, {
|
|
54032
|
+
url: result.url,
|
|
54033
|
+
id: note.id,
|
|
54034
|
+
identifier: note.identifier,
|
|
54035
|
+
paragraph: { ...result.paragraph }
|
|
54036
|
+
});
|
|
54037
|
+
}, "No paragraph link")
|
|
54038
|
+
);
|
|
53725
54039
|
registerTool(
|
|
53726
54040
|
"get-note-structure",
|
|
53727
54041
|
{
|
package/package.json
CHANGED