obsidian-mcp-server 3.5.3 → 3.5.5
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/AGENTS.md +47 -6
- package/CLAUDE.md +47 -6
- package/Dockerfile +3 -2
- package/README.md +144 -100
- package/changelog/3.5.x/3.5.3.md +4 -3
- package/changelog/3.5.x/3.5.4.md +42 -0
- package/changelog/3.5.x/3.5.5.md +20 -0
- package/changelog/template.md +7 -7
- package/dist/config/server-config.d.ts +1 -1
- package/dist/config/server-config.d.ts.map +1 -1
- package/dist/config/server-config.js +9 -2
- package/dist/config/server-config.js.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.d.ts.map +1 -1
- package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js +19 -1
- package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared/schemas.js +1 -1
- package/dist/mcp-server/tools/definitions/_shared/schemas.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +157 -65
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +13 -2
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +14 -3
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +9 -0
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +9 -0
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.d.ts +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.js +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +9 -0
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +19 -26
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +3 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +3 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts +13 -0
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +19 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +17 -2
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +22 -3
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +4 -0
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +4 -0
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +13 -2
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +14 -3
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts +10 -2
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +10 -2
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +12 -5
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +29 -24
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +12 -2
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +20 -10
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
- package/dist/services/obsidian/frontmatter-ops.d.ts +44 -11
- package/dist/services/obsidian/frontmatter-ops.d.ts.map +1 -1
- package/dist/services/obsidian/frontmatter-ops.js +370 -69
- package/dist/services/obsidian/frontmatter-ops.js.map +1 -1
- package/dist/services/obsidian/obsidian-service.d.ts +26 -5
- package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
- package/dist/services/obsidian/obsidian-service.js +469 -85
- package/dist/services/obsidian/obsidian-service.js.map +1 -1
- package/dist/services/obsidian/section-extractor.d.ts +33 -3
- package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
- package/dist/services/obsidian/section-extractor.js +115 -75
- package/dist/services/obsidian/section-extractor.js.map +1 -1
- package/dist/services/obsidian/types.d.ts +11 -4
- package/dist/services/obsidian/types.d.ts.map +1 -1
- package/manifest.json +1 -1
- package/package.json +9 -9
- package/server.json +3 -3
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* `target` discriminator, and classifies errors for the framework.
|
|
5
5
|
* @module services/obsidian/obsidian-service
|
|
6
6
|
*/
|
|
7
|
-
import { conflict, forbidden, JsonRpcErrorCode, McpError, notFound, serviceUnavailable, unauthorized, validationError, } from '@cyanheads/mcp-ts-core/errors';
|
|
7
|
+
import { configurationError, conflict, forbidden, JsonRpcErrorCode, McpError, notFound, serviceUnavailable, unauthorized, validationError, } from '@cyanheads/mcp-ts-core/errors';
|
|
8
8
|
import { httpStatusToErrorCode, withRetry } from '@cyanheads/mcp-ts-core/utils';
|
|
9
9
|
import { Agent, fetch as undiciFetch } from 'undici';
|
|
10
10
|
import { getServerConfig } from '../../config/server-config.js';
|
|
11
11
|
import { PathPolicy } from './path-policy.js';
|
|
12
|
+
import { listHeadingPaths } from './section-extractor.js';
|
|
12
13
|
/** Per-call timeout for the startup probe — covers the 4-tuple TCP handshake + a tiny GET. */
|
|
13
14
|
const OMNISEARCH_PROBE_TIMEOUT_MS = 500;
|
|
14
15
|
/**
|
|
@@ -44,12 +45,58 @@ const MARKDOWN_PATCH_VERSION_HEADER = 'Markdown-Patch-Version';
|
|
|
44
45
|
const MARKDOWN_PATCH_VERSION = '1';
|
|
45
46
|
/** Delimiter joining ancestor headings into a single PATCH heading target. */
|
|
46
47
|
const HEADING_DELIMITER = '::';
|
|
48
|
+
/**
|
|
49
|
+
* The suffix markdown-patch 2.0 appends to the key of the second and later
|
|
50
|
+
* occurrences of a sibling heading: U+FC750, then the occurrence index minus
|
|
51
|
+
* one in hex, each digit written as one of U+F6440–U+F644F. The plugin refuses
|
|
52
|
+
* a note whose own heading text ends this way, so stripping it is exact.
|
|
53
|
+
*/
|
|
54
|
+
const DUPLICATE_SUFFIX = /\u{FC750}[\u{F6440}-\u{F644F}]+$/u;
|
|
47
55
|
/**
|
|
48
56
|
* Methods safe to retry on transient errors. POST/PATCH are excluded — a
|
|
49
57
|
* successful upstream write with a lost response would double-apply on retry
|
|
50
58
|
* (duplicate `append`, re-run Obsidian command).
|
|
51
59
|
*/
|
|
52
60
|
const RETRY_SAFE_METHODS = new Set(['GET', 'PUT', 'DELETE']);
|
|
61
|
+
/**
|
|
62
|
+
* Codes a TLS stack reports when it refuses the server's certificate. Both
|
|
63
|
+
* runtimes use OpenSSL's names. The first four were each produced on Bun 1.4.0
|
|
64
|
+
* and Node 26.5.0 (a self-signed leaf, an untrusted issuer, a SAN mismatch, an
|
|
65
|
+
* expired leaf); the rest are the same verifier's documented neighbours.
|
|
66
|
+
*/
|
|
67
|
+
const CERTIFICATE_REJECTION_CODES = new Set([
|
|
68
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
69
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
70
|
+
'ERR_TLS_CERT_ALTNAME_INVALID',
|
|
71
|
+
'CERT_HAS_EXPIRED',
|
|
72
|
+
'CERT_NOT_YET_VALID',
|
|
73
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
74
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
75
|
+
]);
|
|
76
|
+
/**
|
|
77
|
+
* Codes for a connection that failed before any response. Node reports errno
|
|
78
|
+
* names plus undici's own (`UND_ERR_SOCKET` is a socket closed with no bytes);
|
|
79
|
+
* Bun reports a refused connection as the non-errno `ConnectionRefused` and the
|
|
80
|
+
* others by errno name. Refused, host-not-found, and reset were produced on
|
|
81
|
+
* both runtimes; the unreachable-network and connect-timeout codes are the same
|
|
82
|
+
* failure reached over a route that drops packets instead of refusing them.
|
|
83
|
+
*/
|
|
84
|
+
const CONNECTION_FAILURE_CODES = new Set([
|
|
85
|
+
'ECONNREFUSED',
|
|
86
|
+
'ConnectionRefused',
|
|
87
|
+
'ENOTFOUND',
|
|
88
|
+
'EAI_AGAIN',
|
|
89
|
+
'ECONNRESET',
|
|
90
|
+
'UND_ERR_SOCKET',
|
|
91
|
+
'EHOSTUNREACH',
|
|
92
|
+
'ENETUNREACH',
|
|
93
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
94
|
+
]);
|
|
95
|
+
/**
|
|
96
|
+
* Route prefixes that address a note. An error on one of these echoes the
|
|
97
|
+
* caller's note under `data.path`; no other route carries a vault path.
|
|
98
|
+
*/
|
|
99
|
+
const NOTE_ROUTE_PREFIXES = ['/vault/', '/open/', '/active/', '/periodic/'];
|
|
53
100
|
export class ObsidianService {
|
|
54
101
|
#config;
|
|
55
102
|
#dispatcher;
|
|
@@ -73,16 +120,10 @@ export class ObsidianService {
|
|
|
73
120
|
this.#policy = new PathPolicy(config);
|
|
74
121
|
this.#omnisearchUrl = deriveOmnisearchUrl(config);
|
|
75
122
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* `
|
|
79
|
-
* (set below), so the env var fallback is scoped to Bun to avoid mutating
|
|
80
|
-
* process-wide TLS behavior on Node. Default Obsidian Local REST API ships
|
|
81
|
-
* a self-signed cert, so most users run with `OBSIDIAN_VERIFY_SSL=false`.
|
|
123
|
+
* Node honors this dispatcher option and ignores the per-request `tls` one;
|
|
124
|
+
* Bun's `fetch` does the reverse. Both are sent on every request (see
|
|
125
|
+
* `#tlsInit`), so one code path covers both runtimes with no runtime sniff.
|
|
82
126
|
*/
|
|
83
|
-
if (!config.verifySsl && typeof globalThis.Bun !== 'undefined') {
|
|
84
|
-
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
85
|
-
}
|
|
86
127
|
this.#dispatcher = new Agent({
|
|
87
128
|
connect: { rejectUnauthorized: config.verifySsl },
|
|
88
129
|
headersTimeout: config.requestTimeoutMs,
|
|
@@ -98,6 +139,19 @@ export class ObsidianService {
|
|
|
98
139
|
get omnisearchUrl() {
|
|
99
140
|
return this.#omnisearchUrl;
|
|
100
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Close the undici dispatcher, releasing its keep-alive sockets to the Local
|
|
144
|
+
* REST API and Omnisearch. Wired through `createApp({ teardown })`; waits for
|
|
145
|
+
* in-flight requests to settle rather than aborting them.
|
|
146
|
+
*
|
|
147
|
+
* Bun resolves `undici` to a built-in shim whose `Agent` is an inert object
|
|
148
|
+
* with no `close` — Bun's `fetch` pools sockets itself and ignores the
|
|
149
|
+
* dispatcher — so there is nothing to release there.
|
|
150
|
+
*/
|
|
151
|
+
async close() {
|
|
152
|
+
const dispatcher = this.#dispatcher;
|
|
153
|
+
await dispatcher.close?.();
|
|
154
|
+
}
|
|
101
155
|
// ── Status ───────────────────────────────────────────────────────────────
|
|
102
156
|
/**
|
|
103
157
|
* The plugin's root capability report: reachability, versions, manifest, and
|
|
@@ -245,16 +299,15 @@ export class ObsidianService {
|
|
|
245
299
|
this.#policy.assertReadable(target.path);
|
|
246
300
|
}
|
|
247
301
|
const url = this.#targetToPath(target);
|
|
248
|
-
const
|
|
302
|
+
const requestUrl = `${this.#config.baseUrl}${url}`;
|
|
303
|
+
const res = await this.#send(ctx, requestUrl, {
|
|
249
304
|
method: 'HEAD',
|
|
250
305
|
headers: { Authorization: `Bearer ${this.#config.apiKey}` },
|
|
251
|
-
dispatcher: this.#dispatcher,
|
|
252
|
-
signal: ctx.signal,
|
|
253
306
|
});
|
|
254
307
|
if (res.status === 404)
|
|
255
308
|
return null;
|
|
256
309
|
if (!res.ok)
|
|
257
|
-
await this.#throwForStatus(res, url, ctx);
|
|
310
|
+
await this.#throwForStatus(res, url, ctx, { requestedUrl: requestUrl });
|
|
258
311
|
this.#assertNotDirectory(res, url, ctx);
|
|
259
312
|
return parseContentLength(res, url);
|
|
260
313
|
}
|
|
@@ -307,11 +360,15 @@ export class ObsidianService {
|
|
|
307
360
|
}
|
|
308
361
|
// ── Search ───────────────────────────────────────────────────────────────
|
|
309
362
|
/**
|
|
310
|
-
* Text search.
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
363
|
+
* Text search. Each upstream span gets its `context` offsets resolved
|
|
364
|
+
* (`contextRelativeSpan`), then a multi-token query's spans are merged into
|
|
365
|
+
* match locations (`mergeIntoLocations`).
|
|
366
|
+
*
|
|
367
|
+
* The upstream materializes one context window per matching note before it
|
|
368
|
+
* responds, so an oversized `contextLength` on a broad query exhausts V8's
|
|
369
|
+
* string capacity and comes back as an opaque 500 — caught here and
|
|
370
|
+
* re-thrown typed, the way `searchOmnisearch` re-throws its own reachability
|
|
371
|
+
* failure.
|
|
315
372
|
*/
|
|
316
373
|
async searchText(ctx, query, contextLength = 100) {
|
|
317
374
|
const params = new URLSearchParams({ query, contextLength: String(contextLength) });
|
|
@@ -335,15 +392,13 @@ export class ObsidianService {
|
|
|
335
392
|
}, { cause: err });
|
|
336
393
|
}
|
|
337
394
|
const raw = (await res.json());
|
|
395
|
+
const tokens = queryTokens(query);
|
|
338
396
|
// Upstream returns a constant `score` that carries no ranking signal for
|
|
339
397
|
// text mode — drop it on the way out so consumers don't mistake it for
|
|
340
398
|
// relevance. Omnisearch is the source of real BM25 ranking.
|
|
341
399
|
return raw.map((h) => ({
|
|
342
400
|
filename: h.filename,
|
|
343
|
-
matches: h.matches.map((m) => ({
|
|
344
|
-
context: m.context,
|
|
345
|
-
match: { ...m.match, ...contextRelativeSpan(m, h.filename, contextLength, query) },
|
|
346
|
-
})),
|
|
401
|
+
matches: mergeIntoLocations(h.matches.map((m) => contextRelativeSpan(m, h.filename, contextLength, tokens)), tokens.length, contextLength).map(({ context, match }) => ({ context, match })),
|
|
347
402
|
}));
|
|
348
403
|
}
|
|
349
404
|
async searchJsonLogic(ctx, logic) {
|
|
@@ -365,10 +420,12 @@ export class ObsidianService {
|
|
|
365
420
|
*/
|
|
366
421
|
async probeOmnisearch(signal) {
|
|
367
422
|
const probeSignal = signal ?? AbortSignal.timeout(OMNISEARCH_PROBE_TIMEOUT_MS);
|
|
423
|
+
const url = `${this.#omnisearchUrl}/search?q=`;
|
|
368
424
|
try {
|
|
369
|
-
const res = await this.#fetch(
|
|
425
|
+
const res = await this.#fetch(url, {
|
|
370
426
|
method: 'GET',
|
|
371
427
|
dispatcher: this.#dispatcher,
|
|
428
|
+
...this.#tlsInit(url),
|
|
372
429
|
signal: probeSignal,
|
|
373
430
|
});
|
|
374
431
|
if (!res.ok)
|
|
@@ -398,6 +455,7 @@ export class ObsidianService {
|
|
|
398
455
|
res = await this.#fetch(url, {
|
|
399
456
|
method: 'GET',
|
|
400
457
|
dispatcher: this.#dispatcher,
|
|
458
|
+
...this.#tlsInit(url),
|
|
401
459
|
signal: ctx.signal,
|
|
402
460
|
});
|
|
403
461
|
}
|
|
@@ -498,31 +556,86 @@ export class ObsidianService {
|
|
|
498
556
|
* leaf name reaches the same section on writes that it already reaches on
|
|
499
557
|
* reads. `extractSection` matches a heading at any depth, so an agent that
|
|
500
558
|
* read `## Sibling` by its bare name carries that name to a write, where
|
|
501
|
-
* upstream targeting wants the whole `Parent::Child` chain.
|
|
559
|
+
* upstream targeting wants the whole `Parent::Child` chain. Locators that
|
|
560
|
+
* already carry the delimiter pass through unexpanded; non-heading targets
|
|
561
|
+
* skip resolution entirely.
|
|
502
562
|
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
563
|
+
* The resolved path must then name exactly one heading. The 1.x map the
|
|
564
|
+
* PATCH resolves against keys headings by full path, so a path that repeats
|
|
565
|
+
* in the note is listed once, at its last occurrence — the PATCH would land
|
|
566
|
+
* on the last repeat while a section read returns the first. A write to a
|
|
567
|
+
* repeated path is rejected as ambiguous. The count comes from the plugin's
|
|
568
|
+
* own parse (`#headingIndex`), not a local scan: the two disagree on
|
|
569
|
+
* headings inside list items, HTML blocks, and setext underlines, and a local
|
|
570
|
+
* count there rejected writes the PATCH would have landed correctly.
|
|
510
571
|
*/
|
|
511
572
|
async #resolveHeadingTarget(ctx, target, headers) {
|
|
512
|
-
if (headers.targetType !== 'heading'
|
|
573
|
+
if (headers.targetType !== 'heading')
|
|
513
574
|
return headers.target;
|
|
575
|
+
const { headings, occurrences } = await this.#headingIndex(ctx, target);
|
|
576
|
+
const resolved = headers.target.includes(HEADING_DELIMITER)
|
|
577
|
+
? headers.target
|
|
578
|
+
: this.#expandHeadingLeaf(target, headers.target, headings);
|
|
579
|
+
const repeats = occurrences.filter((p) => p === resolved);
|
|
580
|
+
if (repeats.length <= 1)
|
|
581
|
+
return resolved;
|
|
582
|
+
const display = displayPath(this.#targetToPath(target));
|
|
583
|
+
const named = resolved === headers.target ? `'${resolved}'` : `'${headers.target}' (${resolved})`;
|
|
584
|
+
throw conflict(`Heading ${named} occurs ${repeats.length} times in ${display}, so a section write cannot tell which one to edit.`, {
|
|
585
|
+
path: display,
|
|
586
|
+
reason: 'ambiguous_section',
|
|
587
|
+
candidates: repeats,
|
|
588
|
+
recovery: {
|
|
589
|
+
hint: `Every heading in \`candidates\` has the same full path, so no section locator picks one. Rename all but one of them so each path is unique — obsidian_replace_in_note can rewrite a heading line — then retry. obsidian_get_note with format "section" reads the first occurrence.`,
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* The note's heading paths as the plugin parses them, in one request:
|
|
595
|
+
* `headings` lists each distinct path the way the 1.x document map does
|
|
596
|
+
* (what a leaf expands against), and `occurrences` lists every heading,
|
|
597
|
+
* repeats included (what the repeat count reads).
|
|
598
|
+
*
|
|
599
|
+
* Both come from the markdown-patch 2.0 document map, whose tree keeps a key
|
|
600
|
+
* per repeat; flattened with the repeat suffixes stripped and collapsed, it
|
|
601
|
+
* is the 1.x `headings` array exactly, since both formats read the same
|
|
602
|
+
* parser's top-level heading tokens. Plugin v4.x ignores the version header
|
|
603
|
+
* and answers with the 1.x array, which cannot count repeats, so there the
|
|
604
|
+
* note body is fetched and scanned locally (`listHeadingPaths`).
|
|
605
|
+
*/
|
|
606
|
+
async #headingIndex(ctx, target) {
|
|
607
|
+
const res = await this.#request(ctx, this.#targetToPath(target), {
|
|
608
|
+
method: 'GET',
|
|
609
|
+
headers: { Accept: DOCUMENT_MAP_ACCEPT, [MARKDOWN_PATCH_VERSION_HEADER]: '2' },
|
|
610
|
+
noteRead: true,
|
|
611
|
+
});
|
|
612
|
+
const map = (await res.json());
|
|
613
|
+
if (Array.isArray(map.headings)) {
|
|
614
|
+
const { content } = await this.#rawGetNoteJson(ctx, target);
|
|
615
|
+
return { headings: map.headings, occurrences: listHeadingPaths(content) };
|
|
514
616
|
}
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
617
|
+
const occurrences = flattenHeadingTree(map.headings);
|
|
618
|
+
return { headings: [...new Set(occurrences)].filter(Boolean), occurrences };
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Expand a bare heading leaf against the note's heading paths. Resolution
|
|
622
|
+
* order: an exact entry wins (preserving upstream's own interpretation),
|
|
623
|
+
* then a unique leaf match is expanded, then several leaf matches are
|
|
624
|
+
* rejected as ambiguous rather than silently writing to the first. A leaf
|
|
625
|
+
* absent from the paths passes through untouched so `Create-Target-If-Missing`
|
|
626
|
+
* still creates it and the upstream's own target-miss error still surfaces.
|
|
627
|
+
*/
|
|
628
|
+
#expandHeadingLeaf(target, leaf, headings) {
|
|
629
|
+
if (headings.includes(leaf))
|
|
630
|
+
return leaf;
|
|
631
|
+
const matches = headings.filter((h) => h.split(HEADING_DELIMITER).pop() === leaf);
|
|
519
632
|
const [first] = matches;
|
|
520
633
|
if (first === undefined)
|
|
521
|
-
return
|
|
634
|
+
return leaf;
|
|
522
635
|
if (matches.length === 1)
|
|
523
636
|
return first;
|
|
524
637
|
const display = displayPath(this.#targetToPath(target));
|
|
525
|
-
throw conflict(`Heading '${
|
|
638
|
+
throw conflict(`Heading '${leaf}' is ambiguous in ${display} — ${matches.length} headings share that name: ${matches.join(', ')}.`, {
|
|
526
639
|
path: display,
|
|
527
640
|
reason: 'ambiguous_section',
|
|
528
641
|
candidates: matches,
|
|
@@ -578,6 +691,60 @@ export class ObsidianService {
|
|
|
578
691
|
headers['Trim-Target-Whitespace'] = 'true';
|
|
579
692
|
return headers;
|
|
580
693
|
}
|
|
694
|
+
/**
|
|
695
|
+
* The TLS relaxation for one request, scoped to that request.
|
|
696
|
+
*
|
|
697
|
+
* `OBSIDIAN_VERIFY_SSL=false` exists because the Local REST API's always-on
|
|
698
|
+
* HTTPS port ships a self-signed certificate, so the relaxation belongs on
|
|
699
|
+
* the requests that reach it and nowhere else — a process-wide
|
|
700
|
+
* `NODE_TLS_REJECT_UNAUTHORIZED=0` would also disable verification for every
|
|
701
|
+
* unrelated HTTPS call the process makes (a JWKS fetch, say). Issue #131.
|
|
702
|
+
*
|
|
703
|
+
* Verified on Bun 1.4.0: Bun's `fetch` honors this per-request `tls` option
|
|
704
|
+
* and ignores the dispatcher, while Node's undici honors the dispatcher's
|
|
705
|
+
* `connect.rejectUnauthorized` and ignores `tls`. Sending both unconditionally
|
|
706
|
+
* is therefore correct on both, with no runtime sniffing. A plain-HTTP URL
|
|
707
|
+
* gets neither — there is no certificate to relax, and the Omnisearch leg is
|
|
708
|
+
* plain HTTP even when the vault endpoint is not.
|
|
709
|
+
*
|
|
710
|
+
* The scheme is matched case-insensitively because that is what it is: a URL
|
|
711
|
+
* schema accepts `HTTPS://…` and keeps the caller's spelling, so an exact
|
|
712
|
+
* `startsWith` reads a configured `HTTPS://` base URL as plain HTTP and
|
|
713
|
+
* withholds the relaxation the operator asked for.
|
|
714
|
+
*/
|
|
715
|
+
#tlsInit(url) {
|
|
716
|
+
if (this.#config.verifySsl || !/^https:/i.test(url))
|
|
717
|
+
return {};
|
|
718
|
+
return { tls: { rejectUnauthorized: false } };
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* One request to the Local REST API — the single attempt `#request` hands
|
|
722
|
+
* to `withRetry`, and `tryGetSize`'s HEAD — with the dispatcher, the scoped
|
|
723
|
+
* TLS relaxation, and the caller's signal attached.
|
|
724
|
+
*
|
|
725
|
+
* A rejected `fetch` is classified here, inside the attempt, because the
|
|
726
|
+
* attempt is where the retry decision is made: a certificate rejection comes
|
|
727
|
+
* back as a non-transient `ConfigurationError` and `withRetry` rethrows it
|
|
728
|
+
* on the spot, while an unreachable plugin comes back as a transient
|
|
729
|
+
* `ServiceUnavailable` and keeps its retries (issues #133 and #136). A
|
|
730
|
+
* rejection that is neither passes through untouched, and so does anything
|
|
731
|
+
* thrown after the caller aborted — a cancellation is not a network fault.
|
|
732
|
+
*/
|
|
733
|
+
async #send(ctx, url, init) {
|
|
734
|
+
try {
|
|
735
|
+
return await this.#fetch(url, {
|
|
736
|
+
...init,
|
|
737
|
+
dispatcher: this.#dispatcher,
|
|
738
|
+
...this.#tlsInit(url),
|
|
739
|
+
signal: ctx.signal,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
catch (err) {
|
|
743
|
+
if (ctx.signal?.aborted)
|
|
744
|
+
throw err;
|
|
745
|
+
throw classifyFetchRejection(err) ?? err;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
581
748
|
#request(ctx, pathAndQuery, init) {
|
|
582
749
|
const url = `${this.#config.baseUrl}${pathAndQuery}`;
|
|
583
750
|
const headers = {
|
|
@@ -585,15 +752,16 @@ export class ObsidianService {
|
|
|
585
752
|
Authorization: `Bearer ${this.#config.apiKey}`,
|
|
586
753
|
};
|
|
587
754
|
const exec = async () => {
|
|
588
|
-
const res = await this.#
|
|
755
|
+
const res = await this.#send(ctx, url, {
|
|
589
756
|
method: init.method,
|
|
590
757
|
headers,
|
|
591
758
|
...(init.body !== undefined ? { body: init.body } : {}),
|
|
592
|
-
dispatcher: this.#dispatcher,
|
|
593
|
-
signal: ctx.signal,
|
|
594
759
|
});
|
|
595
760
|
if (!res.ok) {
|
|
596
|
-
await this.#throwForStatus(res, pathAndQuery, ctx, {
|
|
761
|
+
await this.#throwForStatus(res, pathAndQuery, ctx, {
|
|
762
|
+
listRead: init.listRead,
|
|
763
|
+
requestedUrl: url,
|
|
764
|
+
});
|
|
597
765
|
}
|
|
598
766
|
if (init.noteRead) {
|
|
599
767
|
this.#assertNotDirectory(res, pathAndQuery, ctx);
|
|
@@ -697,10 +865,12 @@ export class ObsidianService {
|
|
|
697
865
|
async #probeCapabilities(ctx) {
|
|
698
866
|
const deadline = AbortSignal.timeout(CAPABILITY_PROBE_TIMEOUT_MS);
|
|
699
867
|
try {
|
|
700
|
-
const
|
|
868
|
+
const url = `${this.#config.baseUrl}/`;
|
|
869
|
+
const res = await this.#fetch(url, {
|
|
701
870
|
method: 'GET',
|
|
702
871
|
headers: { Authorization: `Bearer ${this.#config.apiKey}` },
|
|
703
872
|
dispatcher: this.#dispatcher,
|
|
873
|
+
...this.#tlsInit(url),
|
|
704
874
|
signal: ctx.signal ? AbortSignal.any([ctx.signal, deadline]) : deadline,
|
|
705
875
|
});
|
|
706
876
|
if (!res.ok)
|
|
@@ -715,10 +885,12 @@ export class ObsidianService {
|
|
|
715
885
|
* Classify an upstream error response and throw.
|
|
716
886
|
*
|
|
717
887
|
* **Containment invariant.** Four things leave this method on the wire and
|
|
718
|
-
* nothing else: a message authored in this file, the
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
888
|
+
* nothing else: a message authored in this file, the caller's own identifier
|
|
889
|
+
* (`data.path` on a note route, `data.commandId` on a command, neither on a
|
|
890
|
+
* route that carries no caller input — see `callerIdentifier`), the HTTP
|
|
891
|
+
* status (`data.status`, default branch), and the calling tool's own
|
|
892
|
+
* contract `reason` + `recovery`. The upstream's response body never
|
|
893
|
+
* crosses, in any branch, under any key.
|
|
722
894
|
*
|
|
723
895
|
* That is stricter than redaction, deliberately. The Local REST API
|
|
724
896
|
* interleaves genuine diagnostics with vault data in one string: a rejected
|
|
@@ -742,17 +914,25 @@ export class ObsidianService {
|
|
|
742
914
|
* envelope is built from `code`/`message`/`data` alone, and so is the
|
|
743
915
|
* JSON-RPC error object the SDK emits for resources.
|
|
744
916
|
*
|
|
745
|
-
*
|
|
917
|
+
* `opts.requestedUrl` — the absolute URL actually fetched — rides the same
|
|
918
|
+
* channel for the same reason. `path` stays the vault route both call sites
|
|
919
|
+
* supply: the branches below classify on its prefix and `displayPath()`
|
|
920
|
+
* renders it as the caller wrote it, while the base URL is deployment detail
|
|
921
|
+
* that has no place on the wire. It does belong in the log, though, since a
|
|
922
|
+
* malformed `OBSIDIAN_BASE_URL` is otherwise invisible behind a clean-looking
|
|
923
|
+
* 404 (issue #122).
|
|
924
|
+
*
|
|
925
|
+
* Issues #104, #116, and #122.
|
|
746
926
|
*/
|
|
747
|
-
async #throwForStatus(res, path, ctx, opts
|
|
927
|
+
async #throwForStatus(res, path, ctx, opts) {
|
|
748
928
|
const text = await this.#readBodySafe(res);
|
|
749
929
|
const body = parseJsonObject(text);
|
|
750
930
|
const display = displayPath(path);
|
|
751
931
|
/** Classification input and log payload only — never a wire value. */
|
|
752
932
|
const upstreamMsg = typeof body?.message === 'string' ? body.message : text.trim();
|
|
753
|
-
const cause = new UpstreamErrorText(upstreamMsg);
|
|
933
|
+
const cause = new UpstreamErrorText(upstreamMsg, opts.requestedUrl);
|
|
754
934
|
const data = (reason) => ({
|
|
755
|
-
path
|
|
935
|
+
...callerIdentifier(path),
|
|
756
936
|
...(reason !== undefined ? { reason, ...ctx.recoveryFor(reason) } : {}),
|
|
757
937
|
});
|
|
758
938
|
switch (res.status) {
|
|
@@ -915,54 +1095,199 @@ export function encodeVaultPath(path) {
|
|
|
915
1095
|
return segments.map((seg) => encodeURIComponent(seg)).join('/');
|
|
916
1096
|
}
|
|
917
1097
|
/**
|
|
918
|
-
*
|
|
1098
|
+
* Every heading in a markdown-patch 2.0 heading tree as its `::`-joined full
|
|
1099
|
+
* path, in tree order, one entry per occurrence. A top-level untitled heading
|
|
1100
|
+
* is `""`, and its children read `::Child`, as the 1.x map writes them.
|
|
1101
|
+
*/
|
|
1102
|
+
function flattenHeadingTree(tree, parent) {
|
|
1103
|
+
return Object.entries(tree).flatMap(([key, children]) => {
|
|
1104
|
+
const text = key.replace(DUPLICATE_SUFFIX, '');
|
|
1105
|
+
const path = parent === undefined ? text : `${parent}${HEADING_DELIMITER}${text}`;
|
|
1106
|
+
return [path, ...flattenHeadingTree(children, path)];
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* The query as Obsidian's `prepareSimpleSearch` reads it: split on whitespace,
|
|
1111
|
+
* case-folded, each distinct token required. Quotes are ordinary characters.
|
|
1112
|
+
*/
|
|
1113
|
+
function queryTokens(query) {
|
|
1114
|
+
return [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))];
|
|
1115
|
+
}
|
|
1116
|
+
const isHighSurrogate = (cu) => cu >= 0xd800 && cu <= 0xdbff;
|
|
1117
|
+
const isLowSurrogate = (cu) => cu >= 0xdc00 && cu <= 0xdfff;
|
|
1118
|
+
/**
|
|
1119
|
+
* Locate the match span inside the `context` window the upstream ships with
|
|
1120
|
+
* it, and name the subject its `start`/`end` index.
|
|
919
1121
|
*
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
* window is `body.slice(max(0, start - contextLength), min(len, end + contextLength))`,
|
|
1122
|
+
* There are two subjects. For a **body match** the subject is the note text
|
|
1123
|
+
* and the window is `body.slice(max(0, start - contextLength), end + contextLength)`,
|
|
923
1124
|
* so the span sits `min(start, contextLength)` characters into `context`. For a
|
|
924
1125
|
* **filename match** the subject is the note's basename and the plugin returns
|
|
925
1126
|
* that basename whole as `context`, so the span sits at `start` — which runs
|
|
926
1127
|
* past `min(start, contextLength)` as soon as the name is longer than the
|
|
927
|
-
* window. A single hit's `matches[]` can carry both kinds.
|
|
928
|
-
* Local REST API v5.0.3.
|
|
1128
|
+
* window. A single hit's `matches[]` can carry both kinds.
|
|
929
1129
|
*
|
|
930
|
-
* `
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
* the
|
|
936
|
-
*
|
|
937
|
-
*
|
|
1130
|
+
* `match.source` (`"filename"` / `"content"`, plugin 5.0.3+) names the subject
|
|
1131
|
+
* outright. Without it, `context === basename` is the cheap separator, but it
|
|
1132
|
+
* is not decisive: a body window whose left edge is trimmed can coincide with
|
|
1133
|
+
* the basename (a note whose own name is quoted in its body), and then the two
|
|
1134
|
+
* readings disagree. Where they do, the text settles it — the correct reading
|
|
1135
|
+
* reproduces the match — and an inconclusive check keeps the filename reading.
|
|
1136
|
+
*
|
|
1137
|
+
* Plugin 5.1.0+ widens a body window by one code unit rather than split a
|
|
1138
|
+
* surrogate pair (`widenToCodePointBoundaries`), so the window can start one
|
|
1139
|
+
* character before `start - contextLength` — see `bodyWindowOffset`.
|
|
938
1140
|
*/
|
|
939
|
-
function contextRelativeSpan(m, filename, contextLength,
|
|
940
|
-
const
|
|
941
|
-
const
|
|
1141
|
+
function contextRelativeSpan(m, filename, contextLength, tokens) {
|
|
1142
|
+
const { start, end, source } = m.match;
|
|
1143
|
+
const span = end - start;
|
|
1144
|
+
const at = (subject, i) => ({
|
|
1145
|
+
context: m.context,
|
|
1146
|
+
match: { start, end, contextStart: i, contextEnd: i + span },
|
|
1147
|
+
subject,
|
|
1148
|
+
});
|
|
1149
|
+
/**
|
|
1150
|
+
* The matched text is a run of coalesced token occurrences, so it opens with
|
|
1151
|
+
* one token and closes with one. A reading shifted by a character fails
|
|
1152
|
+
* that: upstream reports every occurrence, so a token beginning one
|
|
1153
|
+
* character early, or ending one character late, would have been coalesced
|
|
1154
|
+
* into this span already.
|
|
1155
|
+
*/
|
|
1156
|
+
const reproduces = (i) => {
|
|
1157
|
+
const slice = m.context.slice(i, i + span).toLowerCase();
|
|
1158
|
+
return (slice.length === span &&
|
|
1159
|
+
tokens.some((t) => slice.startsWith(t)) &&
|
|
1160
|
+
tokens.some((t) => slice.endsWith(t)));
|
|
1161
|
+
};
|
|
1162
|
+
if (source === 'filename')
|
|
1163
|
+
return at('filename', start);
|
|
1164
|
+
const bodyStart = bodyWindowOffset(m, contextLength, reproduces);
|
|
1165
|
+
if (source === 'content')
|
|
1166
|
+
return at('body', bodyStart);
|
|
942
1167
|
const basename = (filename.split('/').pop() ?? filename).replace(/\.[^./]+$/, '');
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1168
|
+
if (m.context !== basename)
|
|
1169
|
+
return at('body', bodyStart);
|
|
1170
|
+
if (start === bodyStart)
|
|
1171
|
+
return at('filename', start);
|
|
1172
|
+
return reproduces(start) || !reproduces(bodyStart)
|
|
1173
|
+
? at('filename', start)
|
|
1174
|
+
: at('body', bodyStart);
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Where a body match sits in its window: `min(start, contextLength)`, or one
|
|
1178
|
+
* further when upstream widened the left edge to keep a surrogate pair whole.
|
|
1179
|
+
*
|
|
1180
|
+
* The widening happens only when the window is not clipped at the note start
|
|
1181
|
+
* and its unwidened edge fell on a low surrogate, and it leaves a window that
|
|
1182
|
+
* opens on a whole pair. A window that simply begins on a pair looks the same
|
|
1183
|
+
* from `context` alone, so between the two candidate offsets the one that
|
|
1184
|
+
* reproduces the match wins; a tie keeps the unwidened reading, which is also
|
|
1185
|
+
* correct for plugins that predate the widening.
|
|
1186
|
+
*/
|
|
1187
|
+
function bodyWindowOffset(m, contextLength, reproduces) {
|
|
1188
|
+
const base = Math.min(m.match.start, contextLength);
|
|
1189
|
+
if (m.match.start <= contextLength)
|
|
1190
|
+
return base;
|
|
1191
|
+
if (!isHighSurrogate(m.context.charCodeAt(0)) || !isLowSurrogate(m.context.charCodeAt(1))) {
|
|
1192
|
+
return base;
|
|
1193
|
+
}
|
|
1194
|
+
return !reproduces(base) && reproduces(base + 1) ? base + 1 : base;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Merge a hit's spans into match locations. `prepareSimpleSearch` reports one
|
|
1198
|
+
* span per token occurrence and coalesces only spans that touch, so each word
|
|
1199
|
+
* of a phrase arrives as its own span with a near-identical window.
|
|
1200
|
+
*
|
|
1201
|
+
* Walking the spans in upstream order, a location takes the next span when all
|
|
1202
|
+
* of these hold:
|
|
1203
|
+
*
|
|
1204
|
+
* - Same subject. Body and basename offsets have different origins.
|
|
1205
|
+
* - `next.start - location.end ≤ 2 × contextLength`, which is when the two
|
|
1206
|
+
* windows overlap or abut. The union window is then contiguous and no longer
|
|
1207
|
+
* than the two it replaces, so a merge never grows the response.
|
|
1208
|
+
* - The location does not already hold that span's case-folded text. Without
|
|
1209
|
+
* this bound a common token chains transitively across a whole note into a
|
|
1210
|
+
* single unbounded window that counts once against `maxMatchesPerHit`.
|
|
1211
|
+
* - The windows agree on the text they share. The stitch is computed from
|
|
1212
|
+
* each window's real extent, so it follows edge clipping and surrogate
|
|
1213
|
+
* widening; a disagreement means the offsets are wrong, and refusing beats
|
|
1214
|
+
* fabricating note text.
|
|
1215
|
+
*
|
|
1216
|
+
* A query with a single distinct token is left alone, so single-word results
|
|
1217
|
+
* keep one match per occurrence.
|
|
1218
|
+
*/
|
|
1219
|
+
function mergeIntoLocations(spans, distinctTokens, contextLength) {
|
|
1220
|
+
if (distinctTokens < 2)
|
|
1221
|
+
return spans;
|
|
1222
|
+
const locations = [];
|
|
1223
|
+
let current;
|
|
1224
|
+
for (const span of spans) {
|
|
1225
|
+
const text = matchedText(span);
|
|
1226
|
+
if (current && !current.texts.has(text)) {
|
|
1227
|
+
const joined = joinLocation(current.location, span, contextLength);
|
|
1228
|
+
if (joined) {
|
|
1229
|
+
current.location = joined;
|
|
1230
|
+
current.texts.add(text);
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if (current)
|
|
1235
|
+
locations.push(current.location);
|
|
1236
|
+
current = { location: span, texts: new Set([text]) };
|
|
1237
|
+
}
|
|
1238
|
+
if (current)
|
|
1239
|
+
locations.push(current.location);
|
|
1240
|
+
return locations;
|
|
1241
|
+
}
|
|
1242
|
+
function matchedText(span) {
|
|
1243
|
+
return span.context.slice(span.match.contextStart, span.match.contextEnd).toLowerCase();
|
|
1244
|
+
}
|
|
1245
|
+
/** `location` extended through `next`, or `undefined` when the two may not merge. */
|
|
1246
|
+
function joinLocation(location, next, contextLength) {
|
|
1247
|
+
if (next.subject !== location.subject)
|
|
1248
|
+
return;
|
|
1249
|
+
const gap = next.match.start - location.match.end;
|
|
1250
|
+
if (gap < 0 || gap > 2 * contextLength)
|
|
1251
|
+
return;
|
|
1252
|
+
/** Subject offsets of each window's first character. */
|
|
1253
|
+
const left = location.match.start - location.match.contextStart;
|
|
1254
|
+
const nextLeft = next.match.start - next.match.contextStart;
|
|
1255
|
+
const offset = nextLeft - left;
|
|
1256
|
+
const shared = location.context.length - offset;
|
|
1257
|
+
if (offset < 0 || shared < 0)
|
|
1258
|
+
return;
|
|
1259
|
+
const head = next.context.slice(0, shared);
|
|
1260
|
+
if (location.context.slice(offset, offset + head.length) !== head)
|
|
1261
|
+
return;
|
|
1262
|
+
return {
|
|
1263
|
+
context: location.context + next.context.slice(shared),
|
|
1264
|
+
match: {
|
|
1265
|
+
start: location.match.start,
|
|
1266
|
+
end: next.match.end,
|
|
1267
|
+
contextStart: location.match.contextStart,
|
|
1268
|
+
contextEnd: next.match.end - left,
|
|
1269
|
+
},
|
|
1270
|
+
subject: location.subject,
|
|
949
1271
|
};
|
|
950
|
-
return reproducesToken(m.match.start) || !reproducesToken(bodyStart)
|
|
951
|
-
? at(m.match.start)
|
|
952
|
-
: at(bodyStart);
|
|
953
1272
|
}
|
|
954
1273
|
/**
|
|
955
|
-
* The upstream error body, carried as the `cause` of
|
|
956
|
-
* `#throwForStatus` throws.
|
|
1274
|
+
* The upstream error body and the URL it answered, carried as the `cause` of
|
|
1275
|
+
* everything `#throwForStatus` throws.
|
|
957
1276
|
*
|
|
958
1277
|
* `cause` is the one channel that reaches the server's logs without reaching
|
|
959
1278
|
* the client, which is what lets the containment invariant hold while the
|
|
960
|
-
* text stays available for debugging and for the classifiers below.
|
|
961
|
-
*
|
|
1279
|
+
* text stays available for debugging and for the classifiers below. Both facts
|
|
1280
|
+
* go in the `Error` message so the framework's cause-chain extractor records
|
|
1281
|
+
* them. Each is also kept as its own field: the classifiers match on `text`
|
|
1282
|
+
* alone, so a request URL can never satisfy a pattern meant for the body.
|
|
962
1283
|
*/
|
|
963
1284
|
class UpstreamErrorText extends Error {
|
|
964
|
-
|
|
965
|
-
|
|
1285
|
+
text;
|
|
1286
|
+
requestedUrl;
|
|
1287
|
+
constructor(text, requestedUrl) {
|
|
1288
|
+
super([text, `[requested: ${requestedUrl}]`].filter(Boolean).join(' '));
|
|
1289
|
+
this.text = text;
|
|
1290
|
+
this.requestedUrl = requestedUrl;
|
|
966
1291
|
this.name = 'UpstreamErrorText';
|
|
967
1292
|
}
|
|
968
1293
|
}
|
|
@@ -976,7 +1301,7 @@ function upstreamTextOf(err) {
|
|
|
976
1301
|
let current = err;
|
|
977
1302
|
for (let depth = 0; depth < 4; depth++) {
|
|
978
1303
|
if (current instanceof UpstreamErrorText)
|
|
979
|
-
return current.
|
|
1304
|
+
return current.text;
|
|
980
1305
|
if (!(current instanceof Error))
|
|
981
1306
|
return;
|
|
982
1307
|
current = current.cause;
|
|
@@ -1059,6 +1384,65 @@ function periodOf(urlPath) {
|
|
|
1059
1384
|
function routeOf(urlPath) {
|
|
1060
1385
|
return urlPath.split('?')[0] ?? urlPath;
|
|
1061
1386
|
}
|
|
1387
|
+
/**
|
|
1388
|
+
* The caller's identifier for an error on `urlPath`, keyed by the input it
|
|
1389
|
+
* echoes — the same convention `nameRegex` and `contextLength` follow. A note
|
|
1390
|
+
* route carries the note as `path`; `/commands/<id>/` carries the ID as
|
|
1391
|
+
* `commandId`, which is what `obsidian_execute_command` calls it. Every other
|
|
1392
|
+
* route (`/`, `/tags/`, `/commands/`, `/search/`, `/search/simple/`) carries
|
|
1393
|
+
* no caller identifier, so it gets no key — `path` there used to hold the
|
|
1394
|
+
* route string itself, which names no note (issue #130).
|
|
1395
|
+
*/
|
|
1396
|
+
function callerIdentifier(urlPath) {
|
|
1397
|
+
if (NOTE_ROUTE_PREFIXES.some((prefix) => urlPath.startsWith(prefix))) {
|
|
1398
|
+
return { path: displayPath(urlPath) };
|
|
1399
|
+
}
|
|
1400
|
+
if (/^\/commands\/[^/]+\/?$/.test(routeOf(urlPath))) {
|
|
1401
|
+
return { commandId: displayPath(urlPath) };
|
|
1402
|
+
}
|
|
1403
|
+
return {};
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Classify a rejected `fetch` — one that produced no response at all — or
|
|
1407
|
+
* return `undefined` to leave it as thrown.
|
|
1408
|
+
*
|
|
1409
|
+
* The two runtimes put the code in different places: Bun on the thrown error
|
|
1410
|
+
* itself, Node's undici one level down on `cause` under a bare `TypeError:
|
|
1411
|
+
* fetch failed`. Both levels are read, and the result is built from the code
|
|
1412
|
+
* alone, so a Bun and a Node rejection produce the same message and `data`.
|
|
1413
|
+
* Neither the runtime's own text nor the URL it names reaches the wire; the
|
|
1414
|
+
* rejection rides as `cause`, the channel `#throwForStatus` uses for the same
|
|
1415
|
+
* reason.
|
|
1416
|
+
*
|
|
1417
|
+
* The recovery hint is written inline, as `encodeVaultPath` writes
|
|
1418
|
+
* `path_traversal`'s: both failures are the operator's to fix and reachable
|
|
1419
|
+
* from every tool and resource — including the two resources that declare no
|
|
1420
|
+
* `errors[]` for `ctx.recoveryFor` to resolve against.
|
|
1421
|
+
*/
|
|
1422
|
+
function classifyFetchRejection(err) {
|
|
1423
|
+
const codes = [err, err instanceof Error ? err.cause : undefined].flatMap((e) => {
|
|
1424
|
+
const code = e?.code;
|
|
1425
|
+
return typeof code === 'string' ? [code] : [];
|
|
1426
|
+
});
|
|
1427
|
+
const certificate = codes.find((code) => CERTIFICATE_REJECTION_CODES.has(code));
|
|
1428
|
+
if (certificate !== undefined) {
|
|
1429
|
+
return configurationError(`The Obsidian Local REST API's TLS certificate failed verification (${certificate}).`, {
|
|
1430
|
+
reason: 'certificate_rejected',
|
|
1431
|
+
recovery: {
|
|
1432
|
+
hint: "OBSIDIAN_VERIFY_SSL=true accepts only a certificate this runtime trusts, and the Local REST API's HTTPS port serves a self-signed one by default. Set OBSIDIAN_VERIFY_SSL=false, or point OBSIDIAN_BASE_URL at the plugin's plain-HTTP port (27123 by default).",
|
|
1433
|
+
},
|
|
1434
|
+
}, { cause: err });
|
|
1435
|
+
}
|
|
1436
|
+
if (codes.some((code) => CONNECTION_FAILURE_CODES.has(code))) {
|
|
1437
|
+
return serviceUnavailable('Could not connect to the Obsidian Local REST API.', {
|
|
1438
|
+
reason: 'obsidian_unreachable',
|
|
1439
|
+
recovery: {
|
|
1440
|
+
hint: 'Confirm Obsidian is running with the Local REST API plugin enabled, and that OBSIDIAN_BASE_URL names the host and port the plugin listens on.',
|
|
1441
|
+
},
|
|
1442
|
+
}, { cause: err });
|
|
1443
|
+
}
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1062
1446
|
/**
|
|
1063
1447
|
* Convert an internal URL path (e.g. `/vault/Projects/My%20Note.md`) to the
|
|
1064
1448
|
* vault-relative form a caller would recognize. Used in error messages so the
|