obsidian-mcp-server 3.5.4 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -3
- package/CLAUDE.md +9 -3
- package/README.md +14 -13
- package/changelog/3.5.x/3.5.5.md +20 -0
- package/changelog/3.6.x/3.6.0.md +31 -0
- package/dist/mcp-server/tools/definitions/_shared/schemas.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared/schemas.js +2 -2
- package/dist/mcp-server/tools/definitions/_shared/schemas.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +97 -61
- 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 +14 -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 +17 -4
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
- 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 +4 -3
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +5 -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 +6 -3
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +17 -4
- 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 +20 -6
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +6 -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 +26 -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 +14 -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 +26 -13
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
- package/dist/services/obsidian/frontmatter-ops.d.ts +7 -4
- package/dist/services/obsidian/frontmatter-ops.d.ts.map +1 -1
- package/dist/services/obsidian/frontmatter-ops.js +502 -59
- package/dist/services/obsidian/frontmatter-ops.js.map +1 -1
- package/dist/services/obsidian/markdown-blocks.d.ts +39 -0
- package/dist/services/obsidian/markdown-blocks.d.ts.map +1 -0
- package/dist/services/obsidian/markdown-blocks.js +611 -0
- package/dist/services/obsidian/markdown-blocks.js.map +1 -0
- package/dist/services/obsidian/obsidian-service.d.ts +18 -12
- package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
- package/dist/services/obsidian/obsidian-service.js +653 -130
- package/dist/services/obsidian/obsidian-service.js.map +1 -1
- package/dist/services/obsidian/patch-instruction.d.ts +141 -0
- package/dist/services/obsidian/patch-instruction.d.ts.map +1 -0
- package/dist/services/obsidian/patch-instruction.js +217 -0
- package/dist/services/obsidian/patch-instruction.js.map +1 -0
- package/dist/services/obsidian/section-extractor.d.ts +109 -6
- package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
- package/dist/services/obsidian/section-extractor.js +364 -87
- package/dist/services/obsidian/section-extractor.js.map +1 -1
- package/dist/services/obsidian/types.d.ts +23 -9
- package/dist/services/obsidian/types.d.ts.map +1 -1
- package/manifest.json +1 -1
- package/package.json +3 -2
- package/server.json +3 -3
|
@@ -4,11 +4,13 @@
|
|
|
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
|
+
import { canonicalContent, flattenDocumentMap, flattenHeadingTree, hasIntegerKey, inNoteOrder, isIntegerKey, patchFormatFor, relativeHeadingLevels, v1PatchHeaders, v2Instruction, } from './patch-instruction.js';
|
|
11
12
|
import { PathPolicy } from './path-policy.js';
|
|
13
|
+
import { atxHeadingMarkers, blockKinds, isIsolatedBlockId, listHeadingPaths, sectionBody, sectionLevel, } from './section-extractor.js';
|
|
12
14
|
/** Per-call timeout for the startup probe — covers the 4-tuple TCP handshake + a tiny GET. */
|
|
13
15
|
const OMNISEARCH_PROBE_TIMEOUT_MS = 500;
|
|
14
16
|
/**
|
|
@@ -26,22 +28,16 @@ const NOTE_JSON_ACCEPT = 'application/vnd.olrapi.note+json';
|
|
|
26
28
|
const DOCUMENT_MAP_ACCEPT = 'application/vnd.olrapi.document-map+json';
|
|
27
29
|
const JSONLOGIC_CT = 'application/vnd.olrapi.jsonlogic+json';
|
|
28
30
|
/**
|
|
29
|
-
* The markdown-patch
|
|
30
|
-
* request whose shape depends on it:
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* map's `headings` as a nested tree instead of `::`-joined paths. Plugin v4.x
|
|
36
|
-
* predates the header and only ever reads named headers it knows, so the pin is
|
|
37
|
-
* inert there — one unconditional value covers the whole supported plugin
|
|
38
|
-
* range, and no future default flip can move the format underneath this client.
|
|
39
|
-
*
|
|
40
|
-
* Format 1.x carries `Deprecation: true; sunset-version="6.0"` upstream; the
|
|
41
|
-
* migration to 2.0 is tracked in #102.
|
|
31
|
+
* The header naming the markdown-patch format a request speaks, sent on every
|
|
32
|
+
* request whose shape depends on it: section-targeted PATCHes and the document
|
|
33
|
+
* map. Which value goes out is negotiated per plugin install
|
|
34
|
+
* (`#markdownPatchFormat`), and stating it rather than inheriting the plugin's
|
|
35
|
+
* default keeps a future default flip from moving the format underneath this
|
|
36
|
+
* client. Plugin v4.x predates the header and ignores it.
|
|
42
37
|
*/
|
|
43
38
|
const MARKDOWN_PATCH_VERSION_HEADER = 'Markdown-Patch-Version';
|
|
44
|
-
|
|
39
|
+
/** Content type of a markdown-patch 2.0 JSON instruction body. */
|
|
40
|
+
const PATCH_INSTRUCTION_CT = 'application/vnd.olrapi.patch-instruction+json';
|
|
45
41
|
/** Delimiter joining ancestor headings into a single PATCH heading target. */
|
|
46
42
|
const HEADING_DELIMITER = '::';
|
|
47
43
|
/**
|
|
@@ -50,6 +46,45 @@ const HEADING_DELIMITER = '::';
|
|
|
50
46
|
* (duplicate `append`, re-run Obsidian command).
|
|
51
47
|
*/
|
|
52
48
|
const RETRY_SAFE_METHODS = new Set(['GET', 'PUT', 'DELETE']);
|
|
49
|
+
/**
|
|
50
|
+
* Codes a TLS stack reports when it refuses the server's certificate. Both
|
|
51
|
+
* runtimes use OpenSSL's names. The first four were each produced on Bun 1.4.0
|
|
52
|
+
* and Node 26.5.0 (a self-signed leaf, an untrusted issuer, a SAN mismatch, an
|
|
53
|
+
* expired leaf); the rest are the same verifier's documented neighbours.
|
|
54
|
+
*/
|
|
55
|
+
const CERTIFICATE_REJECTION_CODES = new Set([
|
|
56
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
57
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
58
|
+
'ERR_TLS_CERT_ALTNAME_INVALID',
|
|
59
|
+
'CERT_HAS_EXPIRED',
|
|
60
|
+
'CERT_NOT_YET_VALID',
|
|
61
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
62
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
63
|
+
]);
|
|
64
|
+
/**
|
|
65
|
+
* Codes for a connection that failed before any response. Node reports errno
|
|
66
|
+
* names plus undici's own (`UND_ERR_SOCKET` is a socket closed with no bytes);
|
|
67
|
+
* Bun reports a refused connection as the non-errno `ConnectionRefused` and the
|
|
68
|
+
* others by errno name. Refused, host-not-found, and reset were produced on
|
|
69
|
+
* both runtimes; the unreachable-network and connect-timeout codes are the same
|
|
70
|
+
* failure reached over a route that drops packets instead of refusing them.
|
|
71
|
+
*/
|
|
72
|
+
const CONNECTION_FAILURE_CODES = new Set([
|
|
73
|
+
'ECONNREFUSED',
|
|
74
|
+
'ConnectionRefused',
|
|
75
|
+
'ENOTFOUND',
|
|
76
|
+
'EAI_AGAIN',
|
|
77
|
+
'ECONNRESET',
|
|
78
|
+
'UND_ERR_SOCKET',
|
|
79
|
+
'EHOSTUNREACH',
|
|
80
|
+
'ENETUNREACH',
|
|
81
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
82
|
+
]);
|
|
83
|
+
/**
|
|
84
|
+
* Route prefixes that address a note. An error on one of these echoes the
|
|
85
|
+
* caller's note under `data.path`; no other route carries a vault path.
|
|
86
|
+
*/
|
|
87
|
+
const NOTE_ROUTE_PREFIXES = ['/vault/', '/open/', '/active/', '/periodic/'];
|
|
53
88
|
export class ObsidianService {
|
|
54
89
|
#config;
|
|
55
90
|
#dispatcher;
|
|
@@ -63,6 +98,12 @@ export class ObsidianService {
|
|
|
63
98
|
* again rather than inheriting a transient outage forever.
|
|
64
99
|
*/
|
|
65
100
|
#capabilities;
|
|
101
|
+
/**
|
|
102
|
+
* The markdown-patch format the installed plugin speaks, read once from its
|
|
103
|
+
* capability report. Holds the in-flight promise so concurrent writes share
|
|
104
|
+
* one read; a failed read is evicted so the next write tries again.
|
|
105
|
+
*/
|
|
106
|
+
#patchFormat;
|
|
66
107
|
/**
|
|
67
108
|
* @param config - Validated server config (api key, base URL, TLS, timeouts).
|
|
68
109
|
* @param fetchImpl - Optional fetch override for tests. Defaults to undici's
|
|
@@ -209,20 +250,36 @@ export class ObsidianService {
|
|
|
209
250
|
});
|
|
210
251
|
}
|
|
211
252
|
/**
|
|
212
|
-
* Apply a
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* `#
|
|
216
|
-
*
|
|
253
|
+
* Apply a section-targeted PATCH in the markdown-patch format the plugin
|
|
254
|
+
* speaks: a 2.0 JSON instruction on Local REST API 5.x and later, 1.x
|
|
255
|
+
* request headers on 4.x and for the table-row writes 2.0 cannot express
|
|
256
|
+
* (`#wireFormat`). Returns the section locator the edit was actually
|
|
257
|
+
* applied to — identical to `instruction.target` unless a bare heading leaf
|
|
258
|
+
* was expanded to its full `Parent::Child` path (see `#resolveHeadingTarget`),
|
|
259
|
+
* which callers echo back so an agent can see where the write landed.
|
|
217
260
|
*/
|
|
218
|
-
async patchNote(ctx, target, content,
|
|
261
|
+
async patchNote(ctx, target, content, instruction) {
|
|
219
262
|
const safe = await this.#gateAsWrite(ctx, target);
|
|
220
|
-
const resolvedTarget = await this.#resolveHeadingTarget(ctx, safe, headers);
|
|
221
263
|
const url = this.#targetToPath(safe);
|
|
264
|
+
const format = await this.#wireFormat(ctx, safe, instruction);
|
|
265
|
+
const resolvedTarget = await this.#resolveHeadingTarget(ctx, safe, instruction, format);
|
|
266
|
+
const resolved = { ...instruction, target: resolvedTarget };
|
|
267
|
+
if (format === '1') {
|
|
268
|
+
await this.#request(ctx, url, {
|
|
269
|
+
method: 'PATCH',
|
|
270
|
+
headers: v1PatchHeaders(resolved),
|
|
271
|
+
body: content,
|
|
272
|
+
});
|
|
273
|
+
return resolvedTarget;
|
|
274
|
+
}
|
|
275
|
+
const continuation = await this.#listContinuation(ctx, safe, resolved, content);
|
|
276
|
+
const body = continuation
|
|
277
|
+
? v2Instruction(resolved, continuation.content, continuation.within)
|
|
278
|
+
: v2Instruction(resolved, await this.#sectionRelativeContent(ctx, safe, resolved, content));
|
|
222
279
|
await this.#request(ctx, url, {
|
|
223
280
|
method: 'PATCH',
|
|
224
|
-
headers:
|
|
225
|
-
body:
|
|
281
|
+
headers: { 'Content-Type': PATCH_INSTRUCTION_CT, [MARKDOWN_PATCH_VERSION_HEADER]: '2' },
|
|
282
|
+
body: JSON.stringify(body),
|
|
226
283
|
});
|
|
227
284
|
return resolvedTarget;
|
|
228
285
|
}
|
|
@@ -253,12 +310,9 @@ export class ObsidianService {
|
|
|
253
310
|
}
|
|
254
311
|
const url = this.#targetToPath(target);
|
|
255
312
|
const requestUrl = `${this.#config.baseUrl}${url}`;
|
|
256
|
-
const res = await this.#
|
|
313
|
+
const res = await this.#send(ctx, requestUrl, {
|
|
257
314
|
method: 'HEAD',
|
|
258
315
|
headers: { Authorization: `Bearer ${this.#config.apiKey}` },
|
|
259
|
-
dispatcher: this.#dispatcher,
|
|
260
|
-
...this.#tlsInit(requestUrl),
|
|
261
|
-
signal: ctx.signal,
|
|
262
316
|
});
|
|
263
317
|
if (res.status === 404)
|
|
264
318
|
return null;
|
|
@@ -316,11 +370,15 @@ export class ObsidianService {
|
|
|
316
370
|
}
|
|
317
371
|
// ── Search ───────────────────────────────────────────────────────────────
|
|
318
372
|
/**
|
|
319
|
-
* Text search.
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
373
|
+
* Text search. Each upstream span gets its `context` offsets resolved
|
|
374
|
+
* (`contextRelativeSpan`), then a multi-token query's spans are merged into
|
|
375
|
+
* match locations (`mergeIntoLocations`).
|
|
376
|
+
*
|
|
377
|
+
* The upstream materializes one context window per matching note before it
|
|
378
|
+
* responds, so an oversized `contextLength` on a broad query exhausts V8's
|
|
379
|
+
* string capacity and comes back as an opaque 500 — caught here and
|
|
380
|
+
* re-thrown typed, the way `searchOmnisearch` re-throws its own reachability
|
|
381
|
+
* failure.
|
|
324
382
|
*/
|
|
325
383
|
async searchText(ctx, query, contextLength = 100) {
|
|
326
384
|
const params = new URLSearchParams({ query, contextLength: String(contextLength) });
|
|
@@ -344,15 +402,13 @@ export class ObsidianService {
|
|
|
344
402
|
}, { cause: err });
|
|
345
403
|
}
|
|
346
404
|
const raw = (await res.json());
|
|
405
|
+
const tokens = queryTokens(query);
|
|
347
406
|
// Upstream returns a constant `score` that carries no ranking signal for
|
|
348
407
|
// text mode — drop it on the way out so consumers don't mistake it for
|
|
349
408
|
// relevance. Omnisearch is the source of real BM25 ranking.
|
|
350
409
|
return raw.map((h) => ({
|
|
351
410
|
filename: h.filename,
|
|
352
|
-
matches: h.matches.map((m) => ({
|
|
353
|
-
context: m.context,
|
|
354
|
-
match: { ...m.match, ...contextRelativeSpan(m, h.filename, contextLength, query) },
|
|
355
|
-
})),
|
|
411
|
+
matches: mergeIntoLocations(h.matches.map((m) => contextRelativeSpan(m, h.filename, contextLength, tokens)), tokens.length, contextLength).map(({ context, match }) => ({ context, match })),
|
|
356
412
|
}));
|
|
357
413
|
}
|
|
358
414
|
async searchJsonLogic(ctx, logic) {
|
|
@@ -492,49 +548,272 @@ export class ObsidianService {
|
|
|
492
548
|
});
|
|
493
549
|
return (await res.json());
|
|
494
550
|
}
|
|
495
|
-
/**
|
|
551
|
+
/**
|
|
552
|
+
* Raw document-map fetch — bypasses path-policy. Caller must gate. On 2.0 the
|
|
553
|
+
* nested map is flattened to the 1.x shape this server has always returned.
|
|
554
|
+
*
|
|
555
|
+
* Headings are listed in note order. Both formats build their headings from
|
|
556
|
+
* object keys, so JavaScript lists an integer-like name (`2025`) ahead of its
|
|
557
|
+
* siblings — the 2.0 tree at every depth, the 1.x map at the top level. Only
|
|
558
|
+
* then is the note read, and its heading scan (`listHeadingPaths`, the same
|
|
559
|
+
* parse the map uses) supplies the order.
|
|
560
|
+
*/
|
|
496
561
|
async #rawGetDocumentMap(ctx, target) {
|
|
497
562
|
const url = this.#targetToPath(target);
|
|
563
|
+
const format = await this.#markdownPatchFormat(ctx);
|
|
564
|
+
if (format === '1') {
|
|
565
|
+
const map = await this.#fetchDocumentMap(ctx, url, format);
|
|
566
|
+
if (!map.headings.some(isIntegerKey))
|
|
567
|
+
return map;
|
|
568
|
+
return { ...map, headings: await this.#inNoteOrder(ctx, target, map.headings) };
|
|
569
|
+
}
|
|
570
|
+
const raw = await this.#fetchDocumentMap(ctx, url, format);
|
|
571
|
+
const map = flattenDocumentMap(raw);
|
|
572
|
+
if (!hasIntegerKey(raw.headings))
|
|
573
|
+
return map;
|
|
574
|
+
return { ...map, headings: await this.#inNoteOrder(ctx, target, map.headings) };
|
|
575
|
+
}
|
|
576
|
+
/** `paths` in the order the note's own headings run, read from the note. */
|
|
577
|
+
async #inNoteOrder(ctx, target, paths) {
|
|
578
|
+
const { content } = await this.#rawGetNoteJson(ctx, target);
|
|
579
|
+
return inNoteOrder(paths, listHeadingPaths(content));
|
|
580
|
+
}
|
|
581
|
+
/** The document map in `format`'s own shape: flat `::`-joined paths for 1.x, the nested tree for 2.0. */
|
|
582
|
+
async #fetchDocumentMap(ctx, url, format) {
|
|
498
583
|
const res = await this.#request(ctx, url, {
|
|
499
584
|
method: 'GET',
|
|
500
|
-
headers: {
|
|
501
|
-
Accept: DOCUMENT_MAP_ACCEPT,
|
|
502
|
-
[MARKDOWN_PATCH_VERSION_HEADER]: MARKDOWN_PATCH_VERSION,
|
|
503
|
-
},
|
|
585
|
+
headers: { Accept: DOCUMENT_MAP_ACCEPT, [MARKDOWN_PATCH_VERSION_HEADER]: format },
|
|
504
586
|
noteRead: true,
|
|
505
587
|
});
|
|
506
588
|
return (await res.json());
|
|
507
589
|
}
|
|
590
|
+
/**
|
|
591
|
+
* The markdown-patch format the installed plugin speaks, from `versions.self`
|
|
592
|
+
* in its `GET /` capability report: 2.0 from Local REST API 5.0 on, 1.x
|
|
593
|
+
* before. Read once per service. A report that cannot be read fails the
|
|
594
|
+
* calling write with its classified error — the format is never guessed.
|
|
595
|
+
*
|
|
596
|
+
* The report is shared with `#vaultCapabilities`: whichever reads it first
|
|
597
|
+
* serves the other.
|
|
598
|
+
*/
|
|
599
|
+
async #markdownPatchFormat(ctx) {
|
|
600
|
+
this.#patchFormat ??= this.#negotiatePatchFormat(ctx);
|
|
601
|
+
const pending = this.#patchFormat;
|
|
602
|
+
try {
|
|
603
|
+
return await pending;
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
if (this.#patchFormat === pending)
|
|
607
|
+
this.#patchFormat = undefined;
|
|
608
|
+
throw err;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* The format one PATCH goes out in: the plugin's (`#markdownPatchFormat`),
|
|
613
|
+
* except for two table-row writes that 2.0 cannot express, which go out as
|
|
614
|
+
* 1.x — plugin 5.x still accepts it.
|
|
615
|
+
*
|
|
616
|
+
* - Rows under a heading. 2.0 takes table rows only through a block target
|
|
617
|
+
* (a heading write carries its payload in `content`, never `value`); the
|
|
618
|
+
* 1.x engine appends them to the table that ends the section.
|
|
619
|
+
* - Rows through an isolated block id — `^id` on its own line after a blank
|
|
620
|
+
* line, the way Obsidian places a table's id. 2.0 resolves the id to that
|
|
621
|
+
* marker line's own paragraph rather than the table above it, so the write
|
|
622
|
+
* fails upstream with "block … is not a table" (markdown-patch 2.0.0,
|
|
623
|
+
* `patchTableRows`); the 1.x engine resolves it to the table. Telling the
|
|
624
|
+
* shapes apart needs the note, so a block row write on 2.0 reads it once.
|
|
625
|
+
*/
|
|
626
|
+
async #wireFormat(ctx, target, instruction) {
|
|
627
|
+
const format = await this.#markdownPatchFormat(ctx);
|
|
628
|
+
if (format === '1' ||
|
|
629
|
+
instruction.targetType === 'frontmatter' ||
|
|
630
|
+
instruction.contentType !== 'json') {
|
|
631
|
+
return format;
|
|
632
|
+
}
|
|
633
|
+
if (instruction.targetType === 'heading')
|
|
634
|
+
return '1';
|
|
635
|
+
const { content } = await this.#rawGetNoteJson(ctx, target);
|
|
636
|
+
return isIsolatedBlockId(content, instruction.target) ? '1' : format;
|
|
637
|
+
}
|
|
638
|
+
async #negotiatePatchFormat(ctx) {
|
|
639
|
+
const status = (await this.#capabilities) ?? (await this.getStatus(ctx));
|
|
640
|
+
this.#capabilities ??= Promise.resolve(status);
|
|
641
|
+
return patchFormatFor(status.versions?.self);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* The body block a 2.0 heading append or prepend continues, with the content
|
|
645
|
+
* reduced for a literal splice — or `undefined` for every other write, which
|
|
646
|
+
* goes out as a plain content write.
|
|
647
|
+
*
|
|
648
|
+
* 2.0 separates a plain heading append from the section's last block with a
|
|
649
|
+
* blank line (a prepend from its first), which turns a tight list loose when
|
|
650
|
+
* the content is one more item (issue #145). When the content opens with a
|
|
651
|
+
* list item and the section's own body ends (prepend: opens) with a list,
|
|
652
|
+
* the write addresses that list `within` the section, and 2.0 splices the
|
|
653
|
+
* item flush against it, as 1.x placed it. The write stays plain when:
|
|
654
|
+
*
|
|
655
|
+
* - a prepend's content ends in anything but a list — its last block meets
|
|
656
|
+
* the section's list, and flush against it a paragraph or HTML block would
|
|
657
|
+
* take that list in;
|
|
658
|
+
* - the content carries an ATX heading, which a literal splice would neither
|
|
659
|
+
* re-level nor check against the section (`#sectionRelativeContent`);
|
|
660
|
+
* - an append's section has sub-headings, below which a plain append lands;
|
|
661
|
+
* - the section already holds the content and duplicates are refused — a
|
|
662
|
+
* `within` write searches only the list, the plain one the whole section.
|
|
663
|
+
*
|
|
664
|
+
* The note is read only once the content qualifies.
|
|
665
|
+
*/
|
|
666
|
+
async #listContinuation(ctx, target, instruction, content) {
|
|
667
|
+
const { operation } = instruction;
|
|
668
|
+
if (instruction.targetType !== 'heading' || instruction.contentType === 'json')
|
|
669
|
+
return;
|
|
670
|
+
if (operation === 'replace')
|
|
671
|
+
return;
|
|
672
|
+
const reduced = canonicalContent(content);
|
|
673
|
+
const blocks = blockKinds(reduced);
|
|
674
|
+
if (blocks[0] !== 'list' || (operation === 'prepend' && blocks.at(-1) !== 'list'))
|
|
675
|
+
return;
|
|
676
|
+
if (atxHeadingMarkers(reduced).markers.length > 0)
|
|
677
|
+
return;
|
|
678
|
+
const { content: note } = await this.#rawGetNoteJson(ctx, target);
|
|
679
|
+
const body = sectionBody(note, instruction.target);
|
|
680
|
+
if (!body || (operation === 'append' && body.subsections))
|
|
681
|
+
return;
|
|
682
|
+
if ((operation === 'append' ? body.blocks.at(-1) : body.blocks[0]) !== 'list')
|
|
683
|
+
return;
|
|
684
|
+
if (!instruction.applyIfContentPreexists && body.content.includes(reduced.trim()))
|
|
685
|
+
return;
|
|
686
|
+
return { content: reduced, within: operation === 'append' ? -1 : 0 };
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Heading content with its `#` levels rewritten to the section-relative ones
|
|
690
|
+
* markdown-patch 2.0 reads (see `relativeHeadingLevels`), so the note gets
|
|
691
|
+
* the levels the caller wrote, as it did under 1.x. The section's level comes
|
|
692
|
+
* from the note itself, read only when the content carries an ATX heading.
|
|
693
|
+
* Every other payload passes through as written, and so does content for a
|
|
694
|
+
* section the note lacks and the write will not create, which the plugin
|
|
695
|
+
* then reports missing.
|
|
696
|
+
*/
|
|
697
|
+
async #sectionRelativeContent(ctx, target, instruction, content) {
|
|
698
|
+
if (instruction.targetType !== 'heading' || instruction.contentType === 'json')
|
|
699
|
+
return content;
|
|
700
|
+
const fragment = atxHeadingMarkers(content);
|
|
701
|
+
if (fragment.markers.length === 0)
|
|
702
|
+
return content;
|
|
703
|
+
const { content: note } = await this.#rawGetNoteJson(ctx, target);
|
|
704
|
+
const level = sectionLevel(note, instruction.target, instruction.createTargetIfMissing === true);
|
|
705
|
+
if (level === undefined)
|
|
706
|
+
return content;
|
|
707
|
+
const relative = relativeHeadingLevels(fragment, level);
|
|
708
|
+
if (relative.ok)
|
|
709
|
+
return relative.content;
|
|
710
|
+
/**
|
|
711
|
+
* The recovery names the calls that do work, so it is built here: the
|
|
712
|
+
* parent section and the target's level are only known from this note.
|
|
713
|
+
*/
|
|
714
|
+
const display = displayPath(this.#targetToPath(target));
|
|
715
|
+
const parent = instruction.target.split(HEADING_DELIMITER).slice(0, -1).join(HEADING_DELIMITER);
|
|
716
|
+
const elsewhere = parent
|
|
717
|
+
? `Append it to the parent section with obsidian_append_to_note with \`section: '${parent}'\`, or to the end of the note with obsidian_append_to_note without \`section\``
|
|
718
|
+
: 'Append it to the end of the note with obsidian_append_to_note without `section`';
|
|
719
|
+
throw validationError(`Heading '${relative.heading}' in the content is level ${relative.level}, so it cannot sit inside section '${instruction.target}' (level ${level}) of ${display}: a section write keeps its content inside the section.`, {
|
|
720
|
+
path: display,
|
|
721
|
+
reason: 'heading_outside_section',
|
|
722
|
+
recovery: {
|
|
723
|
+
hint: `${elsewhere}; to keep it inside '${instruction.target}', write it at level ${level + 1} or deeper.`,
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
}
|
|
508
727
|
/**
|
|
509
728
|
* Resolve a heading PATCH target against the note's document map so a bare
|
|
510
729
|
* leaf name reaches the same section on writes that it already reaches on
|
|
511
730
|
* reads. `extractSection` matches a heading at any depth, so an agent that
|
|
512
731
|
* read `## Sibling` by its bare name carries that name to a write, where
|
|
513
|
-
* upstream targeting wants the whole `Parent::Child` chain.
|
|
732
|
+
* upstream targeting wants the whole `Parent::Child` chain. Locators that
|
|
733
|
+
* already carry the delimiter pass through unexpanded; non-heading targets
|
|
734
|
+
* skip resolution entirely.
|
|
735
|
+
*
|
|
736
|
+
* The resolved path must then name exactly one heading. The 1.x map the
|
|
737
|
+
* PATCH resolves against keys headings by full path, so a path that repeats
|
|
738
|
+
* in the note is listed once, at its last occurrence — the PATCH would land
|
|
739
|
+
* on the last repeat while a section read returns the first. A write to a
|
|
740
|
+
* repeated path is rejected as ambiguous. The count comes from the plugin's
|
|
741
|
+
* own parse (`#headingIndex`) wherever the plugin serves one, so the map the
|
|
742
|
+
* PATCH resolves against is also the one that counts its repeats.
|
|
743
|
+
*/
|
|
744
|
+
async #resolveHeadingTarget(ctx, target, instruction, format) {
|
|
745
|
+
if (instruction.targetType !== 'heading')
|
|
746
|
+
return instruction.target;
|
|
747
|
+
const { headings, occurrences } = await this.#headingIndex(ctx, target, format);
|
|
748
|
+
const resolved = instruction.target.includes(HEADING_DELIMITER)
|
|
749
|
+
? instruction.target
|
|
750
|
+
: this.#expandHeadingLeaf(target, instruction.target, headings);
|
|
751
|
+
const repeats = occurrences.filter((p) => p === resolved);
|
|
752
|
+
if (repeats.length <= 1)
|
|
753
|
+
return resolved;
|
|
754
|
+
const display = displayPath(this.#targetToPath(target));
|
|
755
|
+
const named = resolved === instruction.target ? `'${resolved}'` : `'${instruction.target}' (${resolved})`;
|
|
756
|
+
throw conflict(`Heading ${named} occurs ${repeats.length} times in ${display}, so a section write cannot tell which one to edit.`, {
|
|
757
|
+
path: display,
|
|
758
|
+
reason: 'ambiguous_section',
|
|
759
|
+
candidates: repeats,
|
|
760
|
+
recovery: {
|
|
761
|
+
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.`,
|
|
762
|
+
},
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* The note's heading paths as the plugin parses them, in one request:
|
|
767
|
+
* `headings` lists each distinct path the way the 1.x document map does
|
|
768
|
+
* (what a leaf expands against), and `occurrences` lists every heading,
|
|
769
|
+
* repeats included (what the repeat count reads).
|
|
514
770
|
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
771
|
+
* On 2.0 both come from the document map, whose tree keeps a key per repeat;
|
|
772
|
+
* flattened with the repeat suffixes stripped and collapsed, it is the 1.x
|
|
773
|
+
* `headings` array exactly, since both formats read the same parser's
|
|
774
|
+
* top-level heading tokens. Plugin v4.x serves only the 1.x array, which
|
|
775
|
+
* cannot count repeats, so there the note body is fetched and scanned
|
|
776
|
+
* locally (`listHeadingPaths`), which finds headings with the same `marked`
|
|
777
|
+
* lexing the map does.
|
|
778
|
+
*
|
|
779
|
+
* Both lists run in note order, as `#rawGetDocumentMap` explains, so the
|
|
780
|
+
* `candidates` an ambiguity error names, and the first one its hint offers,
|
|
781
|
+
* follow the note.
|
|
522
782
|
*/
|
|
523
|
-
async #
|
|
524
|
-
|
|
525
|
-
|
|
783
|
+
async #headingIndex(ctx, target, format) {
|
|
784
|
+
const url = this.#targetToPath(target);
|
|
785
|
+
if (format === '1') {
|
|
786
|
+
const map = await this.#fetchDocumentMap(ctx, url, format);
|
|
787
|
+
const { content } = await this.#rawGetNoteJson(ctx, target);
|
|
788
|
+
const occurrences = listHeadingPaths(content);
|
|
789
|
+
return { headings: inNoteOrder(map.headings, occurrences), occurrences };
|
|
526
790
|
}
|
|
527
|
-
const map = await this.#
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
791
|
+
const map = await this.#fetchDocumentMap(ctx, url, format);
|
|
792
|
+
const flat = flattenHeadingTree(map.headings);
|
|
793
|
+
const occurrences = hasIntegerKey(map.headings)
|
|
794
|
+
? await this.#inNoteOrder(ctx, target, flat)
|
|
795
|
+
: flat;
|
|
796
|
+
return { headings: [...new Set(occurrences)].filter(Boolean), occurrences };
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Expand a bare heading leaf against the note's heading paths. Resolution
|
|
800
|
+
* order: an exact entry wins (preserving upstream's own interpretation),
|
|
801
|
+
* then a unique leaf match is expanded, then several leaf matches are
|
|
802
|
+
* rejected as ambiguous rather than silently writing to the first. A leaf
|
|
803
|
+
* absent from the paths passes through untouched so `Create-Target-If-Missing`
|
|
804
|
+
* still creates it and the upstream's own target-miss error still surfaces.
|
|
805
|
+
*/
|
|
806
|
+
#expandHeadingLeaf(target, leaf, headings) {
|
|
807
|
+
if (headings.includes(leaf))
|
|
808
|
+
return leaf;
|
|
809
|
+
const matches = headings.filter((h) => h.split(HEADING_DELIMITER).pop() === leaf);
|
|
531
810
|
const [first] = matches;
|
|
532
811
|
if (first === undefined)
|
|
533
|
-
return
|
|
812
|
+
return leaf;
|
|
534
813
|
if (matches.length === 1)
|
|
535
814
|
return first;
|
|
536
815
|
const display = displayPath(this.#targetToPath(target));
|
|
537
|
-
throw conflict(`Heading '${
|
|
816
|
+
throw conflict(`Heading '${leaf}' is ambiguous in ${display} — ${matches.length} headings share that name: ${matches.join(', ')}.`, {
|
|
538
817
|
path: display,
|
|
539
818
|
reason: 'ambiguous_section',
|
|
540
819
|
candidates: matches,
|
|
@@ -562,34 +841,6 @@ export class ObsidianService {
|
|
|
562
841
|
}
|
|
563
842
|
}
|
|
564
843
|
}
|
|
565
|
-
#buildPatchHeaders(p) {
|
|
566
|
-
const headers = {
|
|
567
|
-
[MARKDOWN_PATCH_VERSION_HEADER]: MARKDOWN_PATCH_VERSION,
|
|
568
|
-
Operation: p.operation,
|
|
569
|
-
'Target-Type': p.targetType,
|
|
570
|
-
Target: encodeURIComponent(p.target),
|
|
571
|
-
'Content-Type': p.contentType === 'json' ? 'application/json' : 'text/markdown',
|
|
572
|
-
};
|
|
573
|
-
if (p.targetDelimiter)
|
|
574
|
-
headers['Target-Delimiter'] = p.targetDelimiter;
|
|
575
|
-
if (p.createTargetIfMissing)
|
|
576
|
-
headers['Create-Target-If-Missing'] = 'true';
|
|
577
|
-
/**
|
|
578
|
-
* Sense inversion: markdown-patch 1.0 (shipped with Local REST API v4.0.0)
|
|
579
|
-
* renamed `Apply-If-Content-Preexists` to `Reject-If-Content-Preexists`
|
|
580
|
-
* and flipped the default — patches now apply regardless of duplicates
|
|
581
|
-
* unless the caller opts into rejection. We keep `applyIfContentPreexists`
|
|
582
|
-
* on the public schema for caller stability and translate here: a falsy
|
|
583
|
-
* value (the public default) sends the new Reject header to preserve the
|
|
584
|
-
* historical idempotent-by-default behavior. Replace operations are
|
|
585
|
-
* exempt at the plugin layer regardless of this flag.
|
|
586
|
-
*/
|
|
587
|
-
if (!p.applyIfContentPreexists)
|
|
588
|
-
headers['Reject-If-Content-Preexists'] = 'true';
|
|
589
|
-
if (p.trimTargetWhitespace)
|
|
590
|
-
headers['Trim-Target-Whitespace'] = 'true';
|
|
591
|
-
return headers;
|
|
592
|
-
}
|
|
593
844
|
/**
|
|
594
845
|
* The TLS relaxation for one request, scoped to that request.
|
|
595
846
|
*
|
|
@@ -616,6 +867,34 @@ export class ObsidianService {
|
|
|
616
867
|
return {};
|
|
617
868
|
return { tls: { rejectUnauthorized: false } };
|
|
618
869
|
}
|
|
870
|
+
/**
|
|
871
|
+
* One request to the Local REST API — the single attempt `#request` hands
|
|
872
|
+
* to `withRetry`, and `tryGetSize`'s HEAD — with the dispatcher, the scoped
|
|
873
|
+
* TLS relaxation, and the caller's signal attached.
|
|
874
|
+
*
|
|
875
|
+
* A rejected `fetch` is classified here, inside the attempt, because the
|
|
876
|
+
* attempt is where the retry decision is made: a certificate rejection comes
|
|
877
|
+
* back as a non-transient `ConfigurationError` and `withRetry` rethrows it
|
|
878
|
+
* on the spot, while an unreachable plugin comes back as a transient
|
|
879
|
+
* `ServiceUnavailable` and keeps its retries (issues #133 and #136). A
|
|
880
|
+
* rejection that is neither passes through untouched, and so does anything
|
|
881
|
+
* thrown after the caller aborted — a cancellation is not a network fault.
|
|
882
|
+
*/
|
|
883
|
+
async #send(ctx, url, init) {
|
|
884
|
+
try {
|
|
885
|
+
return await this.#fetch(url, {
|
|
886
|
+
...init,
|
|
887
|
+
dispatcher: this.#dispatcher,
|
|
888
|
+
...this.#tlsInit(url),
|
|
889
|
+
signal: ctx.signal,
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
catch (err) {
|
|
893
|
+
if (ctx.signal?.aborted)
|
|
894
|
+
throw err;
|
|
895
|
+
throw classifyFetchRejection(err) ?? err;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
619
898
|
#request(ctx, pathAndQuery, init) {
|
|
620
899
|
const url = `${this.#config.baseUrl}${pathAndQuery}`;
|
|
621
900
|
const headers = {
|
|
@@ -623,13 +902,10 @@ export class ObsidianService {
|
|
|
623
902
|
Authorization: `Bearer ${this.#config.apiKey}`,
|
|
624
903
|
};
|
|
625
904
|
const exec = async () => {
|
|
626
|
-
const res = await this.#
|
|
905
|
+
const res = await this.#send(ctx, url, {
|
|
627
906
|
method: init.method,
|
|
628
907
|
headers,
|
|
629
908
|
...(init.body !== undefined ? { body: init.body } : {}),
|
|
630
|
-
dispatcher: this.#dispatcher,
|
|
631
|
-
...this.#tlsInit(url),
|
|
632
|
-
signal: ctx.signal,
|
|
633
909
|
});
|
|
634
910
|
if (!res.ok) {
|
|
635
911
|
await this.#throwForStatus(res, pathAndQuery, ctx, {
|
|
@@ -728,6 +1004,8 @@ export class ObsidianService {
|
|
|
728
1004
|
* The cache never expires, and does not need to: it is read only to word an
|
|
729
1005
|
* error on a call that already failed. Installing the missing extension
|
|
730
1006
|
* mid-session makes the route resolve, so no 404 arrives to be classified.
|
|
1007
|
+
* A report the markdown-patch negotiation already read (`#markdownPatchFormat`)
|
|
1008
|
+
* seeds it, so the two share one `GET /`.
|
|
731
1009
|
*/
|
|
732
1010
|
async #vaultCapabilities(ctx) {
|
|
733
1011
|
this.#capabilities ??= this.#probeCapabilities(ctx);
|
|
@@ -759,10 +1037,12 @@ export class ObsidianService {
|
|
|
759
1037
|
* Classify an upstream error response and throw.
|
|
760
1038
|
*
|
|
761
1039
|
* **Containment invariant.** Four things leave this method on the wire and
|
|
762
|
-
* nothing else: a message authored in this file, the
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
1040
|
+
* nothing else: a message authored in this file, the caller's own identifier
|
|
1041
|
+
* (`data.path` on a note route, `data.commandId` on a command, neither on a
|
|
1042
|
+
* route that carries no caller input — see `callerIdentifier`), the HTTP
|
|
1043
|
+
* status (`data.status`, default branch), and the calling tool's own
|
|
1044
|
+
* contract `reason` + `recovery`. The upstream's response body never
|
|
1045
|
+
* crosses, in any branch, under any key.
|
|
766
1046
|
*
|
|
767
1047
|
* That is stricter than redaction, deliberately. The Local REST API
|
|
768
1048
|
* interleaves genuine diagnostics with vault data in one string: a rejected
|
|
@@ -804,15 +1084,26 @@ export class ObsidianService {
|
|
|
804
1084
|
const upstreamMsg = typeof body?.message === 'string' ? body.message : text.trim();
|
|
805
1085
|
const cause = new UpstreamErrorText(upstreamMsg, opts.requestedUrl);
|
|
806
1086
|
const data = (reason) => ({
|
|
807
|
-
path
|
|
1087
|
+
...callerIdentifier(path),
|
|
808
1088
|
...(reason !== undefined ? { reason, ...ctx.recoveryFor(reason) } : {}),
|
|
809
1089
|
});
|
|
1090
|
+
const contentPreexists = () => validationError(`The supplied content already appears at the target in ${display}. Pass \`applyIfContentPreexists: true\` to force-apply, or change the content.`, data('content_preexists'), { cause });
|
|
1091
|
+
const sectionTargetMissing = () => validationError(`Section target not found in ${display}. Use \`obsidian_get_note\` with \`format: "document-map"\` to list available headings, blocks, and frontmatter fields, then retry with one of those locators.`, data('section_target_missing'), { cause });
|
|
810
1092
|
switch (res.status) {
|
|
811
1093
|
case 401:
|
|
812
1094
|
throw unauthorized('Obsidian Local REST API rejected the API key. Verify OBSIDIAN_API_KEY matches the value in Obsidian → Settings → Local REST API.', data(), { cause });
|
|
813
1095
|
case 403:
|
|
814
1096
|
throw forbidden('Obsidian Local REST API forbids this request. Check the plugin permissions.', data(), { cause });
|
|
815
1097
|
case 404: {
|
|
1098
|
+
/**
|
|
1099
|
+
* A markdown-patch 2.0 PATCH whose section is not in the note answers
|
|
1100
|
+
* 404 like a missing note, told apart only by the engine's wording —
|
|
1101
|
+
* as does a `within` write whose body block the note no longer has.
|
|
1102
|
+
* The 1.x format reports the same miss as a 400 (below).
|
|
1103
|
+
*/
|
|
1104
|
+
if (/\bcould not resolve (?:heading|block|frontmatter) target\b|`within` index -?\d+ is out of range\b/i.test(upstreamMsg)) {
|
|
1105
|
+
throw sectionTargetMissing();
|
|
1106
|
+
}
|
|
816
1107
|
if (path.startsWith('/active/')) {
|
|
817
1108
|
throw notFound('No file is currently active in Obsidian — open a file in the app first.', data('no_active_file'), { cause });
|
|
818
1109
|
}
|
|
@@ -874,16 +1165,24 @@ export class ObsidianService {
|
|
|
874
1165
|
// Content-preexists is a more specific case nested inside the broader
|
|
875
1166
|
// "could not be applied" family — branch on it first so retries with
|
|
876
1167
|
// identical content surface the right reason and recovery (toggle
|
|
877
|
-
// `applyIfContentPreexists`) instead of
|
|
1168
|
+
// `applyIfContentPreexists`) instead of the general patch-rejection copy.
|
|
878
1169
|
if (/content-already-preexists-in-target/i.test(upstreamMsg)) {
|
|
879
|
-
throw
|
|
1170
|
+
throw contentPreexists();
|
|
880
1171
|
}
|
|
881
|
-
// The
|
|
882
|
-
//
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1172
|
+
// The 1.x engine names a section the note lacks with the `invalid-target`
|
|
1173
|
+
// reason token; 2.0 answers the same miss with a 404 (above).
|
|
1174
|
+
if (/\binvalid-target\b/i.test(upstreamMsg)) {
|
|
1175
|
+
throw sectionTargetMissing();
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Every other patch refusal: the content does not fit a target that
|
|
1179
|
+
* exists. Each shape gets this file's own sentence, picked from the 1.x
|
|
1180
|
+
* reason token or the 2.0 engine's wording; the upstream text itself is
|
|
1181
|
+
* read only to choose, per the containment invariant above.
|
|
1182
|
+
*/
|
|
1183
|
+
const rejection = patchRejection(upstreamMsg, body?.errorCode);
|
|
1184
|
+
if (rejection !== undefined) {
|
|
1185
|
+
throw validationError(`The Local REST API could not apply the patch to ${display}${rejection ? `: ${rejection}` : ''}.`, data('patch_rejected'), { cause });
|
|
887
1186
|
}
|
|
888
1187
|
// Periodic Notes plugin returns 400 with "Specified period is not enabled"
|
|
889
1188
|
// when the requested period (daily/weekly/monthly/...) is disabled in the
|
|
@@ -895,6 +1194,10 @@ export class ObsidianService {
|
|
|
895
1194
|
throw validationError(`Obsidian Local REST API rejected the request to ${display} as malformed (HTTP 400).`, data(), { cause });
|
|
896
1195
|
}
|
|
897
1196
|
default: {
|
|
1197
|
+
// markdown-patch 2.0 refuses duplicate content with a 409; 1.x with the 400 above.
|
|
1198
|
+
if (res.status === 409 && /\balready contains the content\b/i.test(upstreamMsg)) {
|
|
1199
|
+
throw contentPreexists();
|
|
1200
|
+
}
|
|
898
1201
|
/**
|
|
899
1202
|
* Unhandled 4xx and all 5xx. `httpStatusToErrorCode` supplies the same
|
|
900
1203
|
* canonical mapping `httpErrorFromResponse` would (the whole 5xx range
|
|
@@ -967,41 +1270,168 @@ export function encodeVaultPath(path) {
|
|
|
967
1270
|
return segments.map((seg) => encodeURIComponent(seg)).join('/');
|
|
968
1271
|
}
|
|
969
1272
|
/**
|
|
970
|
-
*
|
|
1273
|
+
* The query as Obsidian's `prepareSimpleSearch` reads it: split on whitespace,
|
|
1274
|
+
* case-folded, each distinct token required. Quotes are ordinary characters.
|
|
1275
|
+
*/
|
|
1276
|
+
function queryTokens(query) {
|
|
1277
|
+
return [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))];
|
|
1278
|
+
}
|
|
1279
|
+
const isHighSurrogate = (cu) => cu >= 0xd800 && cu <= 0xdbff;
|
|
1280
|
+
const isLowSurrogate = (cu) => cu >= 0xdc00 && cu <= 0xdfff;
|
|
1281
|
+
/**
|
|
1282
|
+
* Locate the match span inside the `context` window the upstream ships with
|
|
1283
|
+
* it, and name the subject its `start`/`end` index.
|
|
971
1284
|
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
* window is `body.slice(max(0, start - contextLength), min(len, end + contextLength))`,
|
|
1285
|
+
* There are two subjects. For a **body match** the subject is the note text
|
|
1286
|
+
* and the window is `body.slice(max(0, start - contextLength), end + contextLength)`,
|
|
975
1287
|
* so the span sits `min(start, contextLength)` characters into `context`. For a
|
|
976
1288
|
* **filename match** the subject is the note's basename and the plugin returns
|
|
977
1289
|
* that basename whole as `context`, so the span sits at `start` — which runs
|
|
978
1290
|
* past `min(start, contextLength)` as soon as the name is longer than the
|
|
979
|
-
* window. A single hit's `matches[]` can carry both kinds.
|
|
980
|
-
*
|
|
1291
|
+
* window. A single hit's `matches[]` can carry both kinds.
|
|
1292
|
+
*
|
|
1293
|
+
* `match.source` (`"filename"` / `"content"`, plugin 5.0.3+) names the subject
|
|
1294
|
+
* outright. Without it, `context === basename` is the cheap separator, but it
|
|
1295
|
+
* is not decisive: a body window whose left edge is trimmed can coincide with
|
|
1296
|
+
* the basename (a note whose own name is quoted in its body), and then the two
|
|
1297
|
+
* readings disagree. Where they do, the text settles it — the correct reading
|
|
1298
|
+
* reproduces the match — and an inconclusive check keeps the filename reading.
|
|
981
1299
|
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
* and then the two readings disagree and the filename one slices the wrong text
|
|
986
|
-
* — often past the end of `context` entirely. Where they disagree, settle it on
|
|
987
|
-
* the text rather than the coincidence: the plugin matches whole query tokens,
|
|
988
|
-
* so the correct reading reproduces one. An inconclusive check keeps the
|
|
989
|
-
* filename reading, which is what the coincidence test alone would have picked.
|
|
1300
|
+
* Plugin 5.1.0+ widens a body window by one code unit rather than split a
|
|
1301
|
+
* surrogate pair (`widenToCodePointBoundaries`), so the window can start one
|
|
1302
|
+
* character before `start - contextLength` — see `bodyWindowOffset`.
|
|
990
1303
|
*/
|
|
991
|
-
function contextRelativeSpan(m, filename, contextLength,
|
|
992
|
-
const
|
|
993
|
-
const
|
|
1304
|
+
function contextRelativeSpan(m, filename, contextLength, tokens) {
|
|
1305
|
+
const { start, end, source } = m.match;
|
|
1306
|
+
const span = end - start;
|
|
1307
|
+
const at = (subject, i) => ({
|
|
1308
|
+
context: m.context,
|
|
1309
|
+
match: { start, end, contextStart: i, contextEnd: i + span },
|
|
1310
|
+
subject,
|
|
1311
|
+
});
|
|
1312
|
+
/**
|
|
1313
|
+
* The matched text is a run of coalesced token occurrences, so it opens with
|
|
1314
|
+
* one token and closes with one. A reading shifted by a character fails
|
|
1315
|
+
* that: upstream reports every occurrence, so a token beginning one
|
|
1316
|
+
* character early, or ending one character late, would have been coalesced
|
|
1317
|
+
* into this span already.
|
|
1318
|
+
*/
|
|
1319
|
+
const reproduces = (i) => {
|
|
1320
|
+
const slice = m.context.slice(i, i + span).toLowerCase();
|
|
1321
|
+
return (slice.length === span &&
|
|
1322
|
+
tokens.some((t) => slice.startsWith(t)) &&
|
|
1323
|
+
tokens.some((t) => slice.endsWith(t)));
|
|
1324
|
+
};
|
|
1325
|
+
if (source === 'filename')
|
|
1326
|
+
return at('filename', start);
|
|
1327
|
+
const bodyStart = bodyWindowOffset(m, contextLength, reproduces);
|
|
1328
|
+
if (source === 'content')
|
|
1329
|
+
return at('body', bodyStart);
|
|
994
1330
|
const basename = (filename.split('/').pop() ?? filename).replace(/\.[^./]+$/, '');
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1331
|
+
if (m.context !== basename)
|
|
1332
|
+
return at('body', bodyStart);
|
|
1333
|
+
if (start === bodyStart)
|
|
1334
|
+
return at('filename', start);
|
|
1335
|
+
return reproduces(start) || !reproduces(bodyStart)
|
|
1336
|
+
? at('filename', start)
|
|
1337
|
+
: at('body', bodyStart);
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Where a body match sits in its window: `min(start, contextLength)`, or one
|
|
1341
|
+
* further when upstream widened the left edge to keep a surrogate pair whole.
|
|
1342
|
+
*
|
|
1343
|
+
* The widening happens only when the window is not clipped at the note start
|
|
1344
|
+
* and its unwidened edge fell on a low surrogate, and it leaves a window that
|
|
1345
|
+
* opens on a whole pair. A window that simply begins on a pair looks the same
|
|
1346
|
+
* from `context` alone, so between the two candidate offsets the one that
|
|
1347
|
+
* reproduces the match wins; a tie keeps the unwidened reading, which is also
|
|
1348
|
+
* correct for plugins that predate the widening.
|
|
1349
|
+
*/
|
|
1350
|
+
function bodyWindowOffset(m, contextLength, reproduces) {
|
|
1351
|
+
const base = Math.min(m.match.start, contextLength);
|
|
1352
|
+
if (m.match.start <= contextLength)
|
|
1353
|
+
return base;
|
|
1354
|
+
if (!isHighSurrogate(m.context.charCodeAt(0)) || !isLowSurrogate(m.context.charCodeAt(1))) {
|
|
1355
|
+
return base;
|
|
1356
|
+
}
|
|
1357
|
+
return !reproduces(base) && reproduces(base + 1) ? base + 1 : base;
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Merge a hit's spans into match locations. `prepareSimpleSearch` reports one
|
|
1361
|
+
* span per token occurrence and coalesces only spans that touch, so each word
|
|
1362
|
+
* of a phrase arrives as its own span with a near-identical window.
|
|
1363
|
+
*
|
|
1364
|
+
* Walking the spans in upstream order, a location takes the next span when all
|
|
1365
|
+
* of these hold:
|
|
1366
|
+
*
|
|
1367
|
+
* - Same subject. Body and basename offsets have different origins.
|
|
1368
|
+
* - `next.start - location.end ≤ 2 × contextLength`, which is when the two
|
|
1369
|
+
* windows overlap or abut. The union window is then contiguous and no longer
|
|
1370
|
+
* than the two it replaces, so a merge never grows the response.
|
|
1371
|
+
* - The location does not already hold that span's case-folded text. Without
|
|
1372
|
+
* this bound a common token chains transitively across a whole note into a
|
|
1373
|
+
* single unbounded window that counts once against `maxMatchesPerHit`.
|
|
1374
|
+
* - The windows agree on the text they share. The stitch is computed from
|
|
1375
|
+
* each window's real extent, so it follows edge clipping and surrogate
|
|
1376
|
+
* widening; a disagreement means the offsets are wrong, and refusing beats
|
|
1377
|
+
* fabricating note text.
|
|
1378
|
+
*
|
|
1379
|
+
* A query with a single distinct token is left alone, so single-word results
|
|
1380
|
+
* keep one match per occurrence.
|
|
1381
|
+
*/
|
|
1382
|
+
function mergeIntoLocations(spans, distinctTokens, contextLength) {
|
|
1383
|
+
if (distinctTokens < 2)
|
|
1384
|
+
return spans;
|
|
1385
|
+
const locations = [];
|
|
1386
|
+
let current;
|
|
1387
|
+
for (const span of spans) {
|
|
1388
|
+
const text = matchedText(span);
|
|
1389
|
+
if (current && !current.texts.has(text)) {
|
|
1390
|
+
const joined = joinLocation(current.location, span, contextLength);
|
|
1391
|
+
if (joined) {
|
|
1392
|
+
current.location = joined;
|
|
1393
|
+
current.texts.add(text);
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
if (current)
|
|
1398
|
+
locations.push(current.location);
|
|
1399
|
+
current = { location: span, texts: new Set([text]) };
|
|
1400
|
+
}
|
|
1401
|
+
if (current)
|
|
1402
|
+
locations.push(current.location);
|
|
1403
|
+
return locations;
|
|
1404
|
+
}
|
|
1405
|
+
function matchedText(span) {
|
|
1406
|
+
return span.context.slice(span.match.contextStart, span.match.contextEnd).toLowerCase();
|
|
1407
|
+
}
|
|
1408
|
+
/** `location` extended through `next`, or `undefined` when the two may not merge. */
|
|
1409
|
+
function joinLocation(location, next, contextLength) {
|
|
1410
|
+
if (next.subject !== location.subject)
|
|
1411
|
+
return;
|
|
1412
|
+
const gap = next.match.start - location.match.end;
|
|
1413
|
+
if (gap < 0 || gap > 2 * contextLength)
|
|
1414
|
+
return;
|
|
1415
|
+
/** Subject offsets of each window's first character. */
|
|
1416
|
+
const left = location.match.start - location.match.contextStart;
|
|
1417
|
+
const nextLeft = next.match.start - next.match.contextStart;
|
|
1418
|
+
const offset = nextLeft - left;
|
|
1419
|
+
const shared = location.context.length - offset;
|
|
1420
|
+
if (offset < 0 || shared < 0)
|
|
1421
|
+
return;
|
|
1422
|
+
const head = next.context.slice(0, shared);
|
|
1423
|
+
if (location.context.slice(offset, offset + head.length) !== head)
|
|
1424
|
+
return;
|
|
1425
|
+
return {
|
|
1426
|
+
context: location.context + next.context.slice(shared),
|
|
1427
|
+
match: {
|
|
1428
|
+
start: location.match.start,
|
|
1429
|
+
end: next.match.end,
|
|
1430
|
+
contextStart: location.match.contextStart,
|
|
1431
|
+
contextEnd: next.match.end - left,
|
|
1432
|
+
},
|
|
1433
|
+
subject: location.subject,
|
|
1001
1434
|
};
|
|
1002
|
-
return reproducesToken(m.match.start) || !reproducesToken(bodyStart)
|
|
1003
|
-
? at(m.match.start)
|
|
1004
|
-
: at(bodyStart);
|
|
1005
1435
|
}
|
|
1006
1436
|
/**
|
|
1007
1437
|
* The upstream error body and the URL it answered, carried as the `cause` of
|
|
@@ -1113,10 +1543,103 @@ function atLeastVersion(version, floor) {
|
|
|
1113
1543
|
function periodOf(urlPath) {
|
|
1114
1544
|
return /^\/periodic\/(daily|weekly|monthly|quarterly|yearly)\//.exec(urlPath)?.[1] ?? 'periodic';
|
|
1115
1545
|
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Why the plugin refused a patch, in this file's words: `''` for a refusal it
|
|
1548
|
+
* cannot place more precisely, `undefined` when the response is not a patch
|
|
1549
|
+
* refusal at all. A refusal is the plugin's `PatchFailed` (40080, both
|
|
1550
|
+
* formats) or 2.0's `InvalidPatchInstruction` (40081), recognized by code or by
|
|
1551
|
+
* `PatchFailed`'s fixed wording. The detail is chosen from the 1.x engine's
|
|
1552
|
+
* reason token or the 2.0 engine's message; neither is ever returned.
|
|
1553
|
+
*/
|
|
1554
|
+
function patchRejection(upstreamMsg, errorCode) {
|
|
1555
|
+
const refused = errorCode === 40080 || errorCode === 40081 || /\bcould not be applied\b/i.test(upstreamMsg);
|
|
1556
|
+
if (!refused)
|
|
1557
|
+
return;
|
|
1558
|
+
if (/\bis not a table\b/i.test(upstreamMsg)) {
|
|
1559
|
+
return 'the target block is not a table, so it cannot take table rows';
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* The 1.x engine reports a row whose cell count does not match the table
|
|
1563
|
+
* under this same token — its table writer rethrows every failure as "not a
|
|
1564
|
+
* table" — so the sentence names both.
|
|
1565
|
+
*/
|
|
1566
|
+
if (/content-type-invalid-for-target/i.test(upstreamMsg)) {
|
|
1567
|
+
return "the target block is not a table, or a row's cell count does not match the table's column count";
|
|
1568
|
+
}
|
|
1569
|
+
if (/table-content-incorrect-column-count|\bcell\(s\);.*\bcolumn\(s\)/i.test(upstreamMsg)) {
|
|
1570
|
+
return "a row's cell count does not match the table's column count";
|
|
1571
|
+
}
|
|
1572
|
+
if (/content-not-mergeable|\bnot mergeable\b|\bcannot be merged\b/i.test(upstreamMsg)) {
|
|
1573
|
+
return "the value cannot be merged with the field's current value — append and prepend need two lists, two objects, or two strings";
|
|
1574
|
+
}
|
|
1575
|
+
if (errorCode === 40081 || /\bcontent-type-invalid\b/i.test(upstreamMsg)) {
|
|
1576
|
+
return 'the content does not have the shape this target takes';
|
|
1577
|
+
}
|
|
1578
|
+
return '';
|
|
1579
|
+
}
|
|
1116
1580
|
/** The request path with its query string dropped — the route alone. */
|
|
1117
1581
|
function routeOf(urlPath) {
|
|
1118
1582
|
return urlPath.split('?')[0] ?? urlPath;
|
|
1119
1583
|
}
|
|
1584
|
+
/**
|
|
1585
|
+
* The caller's identifier for an error on `urlPath`, keyed by the input it
|
|
1586
|
+
* echoes — the same convention `nameRegex` and `contextLength` follow. A note
|
|
1587
|
+
* route carries the note as `path`; `/commands/<id>/` carries the ID as
|
|
1588
|
+
* `commandId`, which is what `obsidian_execute_command` calls it. Every other
|
|
1589
|
+
* route (`/`, `/tags/`, `/commands/`, `/search/`, `/search/simple/`) carries
|
|
1590
|
+
* no caller identifier, so it gets no key — `path` there used to hold the
|
|
1591
|
+
* route string itself, which names no note (issue #130).
|
|
1592
|
+
*/
|
|
1593
|
+
function callerIdentifier(urlPath) {
|
|
1594
|
+
if (NOTE_ROUTE_PREFIXES.some((prefix) => urlPath.startsWith(prefix))) {
|
|
1595
|
+
return { path: displayPath(urlPath) };
|
|
1596
|
+
}
|
|
1597
|
+
if (/^\/commands\/[^/]+\/?$/.test(routeOf(urlPath))) {
|
|
1598
|
+
return { commandId: displayPath(urlPath) };
|
|
1599
|
+
}
|
|
1600
|
+
return {};
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Classify a rejected `fetch` — one that produced no response at all — or
|
|
1604
|
+
* return `undefined` to leave it as thrown.
|
|
1605
|
+
*
|
|
1606
|
+
* The two runtimes put the code in different places: Bun on the thrown error
|
|
1607
|
+
* itself, Node's undici one level down on `cause` under a bare `TypeError:
|
|
1608
|
+
* fetch failed`. Both levels are read, and the result is built from the code
|
|
1609
|
+
* alone, so a Bun and a Node rejection produce the same message and `data`.
|
|
1610
|
+
* Neither the runtime's own text nor the URL it names reaches the wire; the
|
|
1611
|
+
* rejection rides as `cause`, the channel `#throwForStatus` uses for the same
|
|
1612
|
+
* reason.
|
|
1613
|
+
*
|
|
1614
|
+
* The recovery hint is written inline, as `encodeVaultPath` writes
|
|
1615
|
+
* `path_traversal`'s: both failures are the operator's to fix and reachable
|
|
1616
|
+
* from every tool and resource — including the two resources that declare no
|
|
1617
|
+
* `errors[]` for `ctx.recoveryFor` to resolve against.
|
|
1618
|
+
*/
|
|
1619
|
+
function classifyFetchRejection(err) {
|
|
1620
|
+
const codes = [err, err instanceof Error ? err.cause : undefined].flatMap((e) => {
|
|
1621
|
+
const code = e?.code;
|
|
1622
|
+
return typeof code === 'string' ? [code] : [];
|
|
1623
|
+
});
|
|
1624
|
+
const certificate = codes.find((code) => CERTIFICATE_REJECTION_CODES.has(code));
|
|
1625
|
+
if (certificate !== undefined) {
|
|
1626
|
+
return configurationError(`The Obsidian Local REST API's TLS certificate failed verification (${certificate}).`, {
|
|
1627
|
+
reason: 'certificate_rejected',
|
|
1628
|
+
recovery: {
|
|
1629
|
+
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).",
|
|
1630
|
+
},
|
|
1631
|
+
}, { cause: err });
|
|
1632
|
+
}
|
|
1633
|
+
if (codes.some((code) => CONNECTION_FAILURE_CODES.has(code))) {
|
|
1634
|
+
return serviceUnavailable('Could not connect to the Obsidian Local REST API.', {
|
|
1635
|
+
reason: 'obsidian_unreachable',
|
|
1636
|
+
recovery: {
|
|
1637
|
+
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.',
|
|
1638
|
+
},
|
|
1639
|
+
}, { cause: err });
|
|
1640
|
+
}
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1120
1643
|
/**
|
|
1121
1644
|
* Convert an internal URL path (e.g. `/vault/Projects/My%20Note.md`) to the
|
|
1122
1645
|
* vault-relative form a caller would recognize. Used in error messages so the
|