obsidian-mcp-server 3.5.5 → 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 +4 -2
- package/CLAUDE.md +4 -2
- package/README.md +8 -8
- 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 +1 -1
- package/dist/mcp-server/tools/definitions/_shared/schemas.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +36 -0
- 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 +12 -0
- 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 +15 -2
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +3 -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 +4 -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 +15 -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 +18 -4
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +12 -0
- 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 +23 -10
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
- package/dist/services/obsidian/frontmatter-ops.d.ts +4 -3
- package/dist/services/obsidian/frontmatter-ops.d.ts.map +1 -1
- package/dist/services/obsidian/frontmatter-ops.js +379 -114
- 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 +9 -7
- package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
- package/dist/services/obsidian/obsidian-service.js +308 -111
- 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 +97 -24
- package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
- package/dist/services/obsidian/section-extractor.js +295 -58
- package/dist/services/obsidian/section-extractor.js.map +1 -1
- package/dist/services/obsidian/types.d.ts +12 -5
- package/dist/services/obsidian/types.d.ts.map +1 -1
- package/manifest.json +1 -1
- package/package.json +2 -1
- package/server.json +3 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"obsidian-service.d.ts","sourceRoot":"","sources":["../../../src/services/obsidian/obsidian-service.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AAatD,OAAO,EAAS,KAAK,UAAU,EAAE,KAAK,WAAW,EAAE,KAAK,IAAI,WAAW,EAAE,MAAM,QAAQ,CAAC;AACxF,OAAO,EAAmB,KAAK,YAAY,EAAE,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"obsidian-service.d.ts","sourceRoot":"","sources":["../../../src/services/obsidian/obsidian-service.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AAatD,OAAO,EAAS,KAAK,UAAU,EAAE,KAAK,WAAW,EAAE,KAAK,IAAI,WAAW,EAAE,MAAM,QAAQ,CAAC;AACxF,OAAO,EAAmB,KAAK,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAgB/E,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAS9C,OAAO,KAAK,EAEV,WAAW,EACX,WAAW,EACX,QAAQ,EACR,UAAU,EACV,eAAe,EACf,WAAW,EACX,aAAa,EACb,gBAAgB,EAChB,mBAAmB,EACnB,aAAa,EACb,WAAW,EACZ,MAAM,YAAY,CAAC;AAEpB,KAAK,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC,CAAC;AAE9D;;;;;;;;;GASG;AACH,MAAM,MAAM,aAAa,GAAG,CAC1B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,WAAW,GAAG;IAClB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,GAAG,CAAC,EAAE;QAAE,kBAAkB,EAAE,OAAO,CAAA;KAAE,CAAC;CACvC,KACE,OAAO,CAAC,cAAc,CAAC,CAAC;AAuK7B,qBAAa,eAAe;;IAsB1B;;;;OAIG;IACH,YAAY,MAAM,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,aAAa,EAe1D;IAED,6EAA6E;IAC7E,IAAI,MAAM,IAAI,UAAU,CAEvB;IAED,0FAA0F;IAC1F,IAAI,aAAa,IAAI,MAAM,CAE1B;IAED;;;;;;;;OAQG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAG3B;IAID;;;;;;;;;;OAUG;IACG,SAAS,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,CAAC,CAGlD;IAIK,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA2BtE;IAEK,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC,CASrE;IAED;;;;OAIG;IACG,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAGnE;IAEK,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,CAmB3E;IAEK,SAAS,CACb,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,MAAM,EACf,WAAW,GAAE,UAAU,GAAG,MAAmB,GAC5C,OAAO,CAAC,IAAI,CAAC,CAQf;IAEK,YAAY,CAChB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,MAAM,EACf,WAAW,GAAE,UAAU,GAAG,MAAmB,GAC5C,OAAO,CAAC,IAAI,CAAC,CAQf;IAED;;;;;;;;OAQG;IACG,SAAS,CACb,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,gBAAgB,GAC5B,OAAO,CAAC,MAAM,CAAC,CAwBjB;IAEK,UAAU,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhE;IAED;;;;;;;;;;;;;;;OAeG;IACG,UAAU,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAczE;IAED;;;OAGG;IACG,OAAO,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAW/D;IAIK,SAAS,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAkBpE;IAEK,QAAQ,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAInD;IAEK,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAI3D;IAID;;;;;;;;;;OAUG;IACG,UAAU,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,SAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAoC3F;IAEK,eAAe,CACnB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAOhC;IAED;;;;;;;;OAQG;IACG,eAAe,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,CAkB5D;IAED;;;;;;;OAOG;IACG,gBAAgB,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAmC5E;IAIK,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEnE;IAED;;;;;;;;;OASG;IACG,QAAQ,CACZ,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAC;QAAC,eAAe,CAAC,EAAE,OAAO,CAAA;KAAE,GACtD,OAAO,CAAC,IAAI,CAAC,CAYf;CAo1BF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAoBpD;AA0gBD,wBAAgB,mBAAmB,CACjC,MAAM,GAAE,YAAgC,EACxC,SAAS,CAAC,EAAE,aAAa,GACxB,IAAI,CAEN;AAED,kFAAkF;AAClF,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,eAAe,GAAG,SAAS,GAAG,IAAI,CAE7E;AAED,wBAAgB,kBAAkB,IAAI,eAAe,CAKpD"}
|
|
@@ -8,8 +8,9 @@ import { configurationError, conflict, forbidden, JsonRpcErrorCode, McpError, no
|
|
|
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';
|
|
12
|
-
import { listHeadingPaths } from './section-extractor.js';
|
|
13
|
+
import { atxHeadingMarkers, blockKinds, isIsolatedBlockId, listHeadingPaths, sectionBody, sectionLevel, } from './section-extractor.js';
|
|
13
14
|
/** Per-call timeout for the startup probe — covers the 4-tuple TCP handshake + a tiny GET. */
|
|
14
15
|
const OMNISEARCH_PROBE_TIMEOUT_MS = 500;
|
|
15
16
|
/**
|
|
@@ -27,31 +28,18 @@ const NOTE_JSON_ACCEPT = 'application/vnd.olrapi.note+json';
|
|
|
27
28
|
const DOCUMENT_MAP_ACCEPT = 'application/vnd.olrapi.document-map+json';
|
|
28
29
|
const JSONLOGIC_CT = 'application/vnd.olrapi.jsonlogic+json';
|
|
29
30
|
/**
|
|
30
|
-
* The markdown-patch
|
|
31
|
-
* request whose shape depends on it:
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* map's `headings` as a nested tree instead of `::`-joined paths. Plugin v4.x
|
|
37
|
-
* predates the header and only ever reads named headers it knows, so the pin is
|
|
38
|
-
* inert there — one unconditional value covers the whole supported plugin
|
|
39
|
-
* range, and no future default flip can move the format underneath this client.
|
|
40
|
-
*
|
|
41
|
-
* Format 1.x carries `Deprecation: true; sunset-version="6.0"` upstream; the
|
|
42
|
-
* 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.
|
|
43
37
|
*/
|
|
44
38
|
const MARKDOWN_PATCH_VERSION_HEADER = 'Markdown-Patch-Version';
|
|
45
|
-
|
|
39
|
+
/** Content type of a markdown-patch 2.0 JSON instruction body. */
|
|
40
|
+
const PATCH_INSTRUCTION_CT = 'application/vnd.olrapi.patch-instruction+json';
|
|
46
41
|
/** Delimiter joining ancestor headings into a single PATCH heading target. */
|
|
47
42
|
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;
|
|
55
43
|
/**
|
|
56
44
|
* Methods safe to retry on transient errors. POST/PATCH are excluded — a
|
|
57
45
|
* successful upstream write with a lost response would double-apply on retry
|
|
@@ -110,6 +98,12 @@ export class ObsidianService {
|
|
|
110
98
|
* again rather than inheriting a transient outage forever.
|
|
111
99
|
*/
|
|
112
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;
|
|
113
107
|
/**
|
|
114
108
|
* @param config - Validated server config (api key, base URL, TLS, timeouts).
|
|
115
109
|
* @param fetchImpl - Optional fetch override for tests. Defaults to undici's
|
|
@@ -256,20 +250,36 @@ export class ObsidianService {
|
|
|
256
250
|
});
|
|
257
251
|
}
|
|
258
252
|
/**
|
|
259
|
-
* Apply a
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* `#
|
|
263
|
-
*
|
|
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.
|
|
264
260
|
*/
|
|
265
|
-
async patchNote(ctx, target, content,
|
|
261
|
+
async patchNote(ctx, target, content, instruction) {
|
|
266
262
|
const safe = await this.#gateAsWrite(ctx, target);
|
|
267
|
-
const resolvedTarget = await this.#resolveHeadingTarget(ctx, safe, headers);
|
|
268
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));
|
|
269
279
|
await this.#request(ctx, url, {
|
|
270
280
|
method: 'PATCH',
|
|
271
|
-
headers:
|
|
272
|
-
body:
|
|
281
|
+
headers: { 'Content-Type': PATCH_INSTRUCTION_CT, [MARKDOWN_PATCH_VERSION_HEADER]: '2' },
|
|
282
|
+
body: JSON.stringify(body),
|
|
273
283
|
});
|
|
274
284
|
return resolvedTarget;
|
|
275
285
|
}
|
|
@@ -538,19 +548,182 @@ export class ObsidianService {
|
|
|
538
548
|
});
|
|
539
549
|
return (await res.json());
|
|
540
550
|
}
|
|
541
|
-
/**
|
|
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
|
+
*/
|
|
542
561
|
async #rawGetDocumentMap(ctx, target) {
|
|
543
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) {
|
|
544
583
|
const res = await this.#request(ctx, url, {
|
|
545
584
|
method: 'GET',
|
|
546
|
-
headers: {
|
|
547
|
-
Accept: DOCUMENT_MAP_ACCEPT,
|
|
548
|
-
[MARKDOWN_PATCH_VERSION_HEADER]: MARKDOWN_PATCH_VERSION,
|
|
549
|
-
},
|
|
585
|
+
headers: { Accept: DOCUMENT_MAP_ACCEPT, [MARKDOWN_PATCH_VERSION_HEADER]: format },
|
|
550
586
|
noteRead: true,
|
|
551
587
|
});
|
|
552
588
|
return (await res.json());
|
|
553
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
|
+
}
|
|
554
727
|
/**
|
|
555
728
|
* Resolve a heading PATCH target against the note's document map so a bare
|
|
556
729
|
* leaf name reaches the same section on writes that it already reaches on
|
|
@@ -565,22 +738,21 @@ export class ObsidianService {
|
|
|
565
738
|
* in the note is listed once, at its last occurrence — the PATCH would land
|
|
566
739
|
* on the last repeat while a section read returns the first. A write to a
|
|
567
740
|
* repeated path is rejected as ambiguous. The count comes from the plugin's
|
|
568
|
-
* own parse (`#headingIndex`)
|
|
569
|
-
*
|
|
570
|
-
* count there rejected writes the PATCH would have landed correctly.
|
|
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.
|
|
571
743
|
*/
|
|
572
|
-
async #resolveHeadingTarget(ctx, target,
|
|
573
|
-
if (
|
|
574
|
-
return
|
|
575
|
-
const { headings, occurrences } = await this.#headingIndex(ctx, target);
|
|
576
|
-
const resolved =
|
|
577
|
-
?
|
|
578
|
-
: this.#expandHeadingLeaf(target,
|
|
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);
|
|
579
751
|
const repeats = occurrences.filter((p) => p === resolved);
|
|
580
752
|
if (repeats.length <= 1)
|
|
581
753
|
return resolved;
|
|
582
754
|
const display = displayPath(this.#targetToPath(target));
|
|
583
|
-
const named = resolved ===
|
|
755
|
+
const named = resolved === instruction.target ? `'${resolved}'` : `'${instruction.target}' (${resolved})`;
|
|
584
756
|
throw conflict(`Heading ${named} occurs ${repeats.length} times in ${display}, so a section write cannot tell which one to edit.`, {
|
|
585
757
|
path: display,
|
|
586
758
|
reason: 'ambiguous_section',
|
|
@@ -596,25 +768,31 @@ export class ObsidianService {
|
|
|
596
768
|
* (what a leaf expands against), and `occurrences` lists every heading,
|
|
597
769
|
* repeats included (what the repeat count reads).
|
|
598
770
|
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
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.
|
|
605
782
|
*/
|
|
606
|
-
async #headingIndex(ctx, target) {
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
noteRead: true,
|
|
611
|
-
});
|
|
612
|
-
const map = (await res.json());
|
|
613
|
-
if (Array.isArray(map.headings)) {
|
|
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);
|
|
614
787
|
const { content } = await this.#rawGetNoteJson(ctx, target);
|
|
615
|
-
|
|
788
|
+
const occurrences = listHeadingPaths(content);
|
|
789
|
+
return { headings: inNoteOrder(map.headings, occurrences), occurrences };
|
|
616
790
|
}
|
|
617
|
-
const
|
|
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;
|
|
618
796
|
return { headings: [...new Set(occurrences)].filter(Boolean), occurrences };
|
|
619
797
|
}
|
|
620
798
|
/**
|
|
@@ -663,34 +841,6 @@ export class ObsidianService {
|
|
|
663
841
|
}
|
|
664
842
|
}
|
|
665
843
|
}
|
|
666
|
-
#buildPatchHeaders(p) {
|
|
667
|
-
const headers = {
|
|
668
|
-
[MARKDOWN_PATCH_VERSION_HEADER]: MARKDOWN_PATCH_VERSION,
|
|
669
|
-
Operation: p.operation,
|
|
670
|
-
'Target-Type': p.targetType,
|
|
671
|
-
Target: encodeURIComponent(p.target),
|
|
672
|
-
'Content-Type': p.contentType === 'json' ? 'application/json' : 'text/markdown',
|
|
673
|
-
};
|
|
674
|
-
if (p.targetDelimiter)
|
|
675
|
-
headers['Target-Delimiter'] = p.targetDelimiter;
|
|
676
|
-
if (p.createTargetIfMissing)
|
|
677
|
-
headers['Create-Target-If-Missing'] = 'true';
|
|
678
|
-
/**
|
|
679
|
-
* Sense inversion: markdown-patch 1.0 (shipped with Local REST API v4.0.0)
|
|
680
|
-
* renamed `Apply-If-Content-Preexists` to `Reject-If-Content-Preexists`
|
|
681
|
-
* and flipped the default — patches now apply regardless of duplicates
|
|
682
|
-
* unless the caller opts into rejection. We keep `applyIfContentPreexists`
|
|
683
|
-
* on the public schema for caller stability and translate here: a falsy
|
|
684
|
-
* value (the public default) sends the new Reject header to preserve the
|
|
685
|
-
* historical idempotent-by-default behavior. Replace operations are
|
|
686
|
-
* exempt at the plugin layer regardless of this flag.
|
|
687
|
-
*/
|
|
688
|
-
if (!p.applyIfContentPreexists)
|
|
689
|
-
headers['Reject-If-Content-Preexists'] = 'true';
|
|
690
|
-
if (p.trimTargetWhitespace)
|
|
691
|
-
headers['Trim-Target-Whitespace'] = 'true';
|
|
692
|
-
return headers;
|
|
693
|
-
}
|
|
694
844
|
/**
|
|
695
845
|
* The TLS relaxation for one request, scoped to that request.
|
|
696
846
|
*
|
|
@@ -854,6 +1004,8 @@ export class ObsidianService {
|
|
|
854
1004
|
* The cache never expires, and does not need to: it is read only to word an
|
|
855
1005
|
* error on a call that already failed. Installing the missing extension
|
|
856
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 /`.
|
|
857
1009
|
*/
|
|
858
1010
|
async #vaultCapabilities(ctx) {
|
|
859
1011
|
this.#capabilities ??= this.#probeCapabilities(ctx);
|
|
@@ -935,12 +1087,23 @@ export class ObsidianService {
|
|
|
935
1087
|
...callerIdentifier(path),
|
|
936
1088
|
...(reason !== undefined ? { reason, ...ctx.recoveryFor(reason) } : {}),
|
|
937
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 });
|
|
938
1092
|
switch (res.status) {
|
|
939
1093
|
case 401:
|
|
940
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 });
|
|
941
1095
|
case 403:
|
|
942
1096
|
throw forbidden('Obsidian Local REST API forbids this request. Check the plugin permissions.', data(), { cause });
|
|
943
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
|
+
}
|
|
944
1107
|
if (path.startsWith('/active/')) {
|
|
945
1108
|
throw notFound('No file is currently active in Obsidian — open a file in the app first.', data('no_active_file'), { cause });
|
|
946
1109
|
}
|
|
@@ -1002,16 +1165,24 @@ export class ObsidianService {
|
|
|
1002
1165
|
// Content-preexists is a more specific case nested inside the broader
|
|
1003
1166
|
// "could not be applied" family — branch on it first so retries with
|
|
1004
1167
|
// identical content surface the right reason and recovery (toggle
|
|
1005
|
-
// `applyIfContentPreexists`) instead of
|
|
1168
|
+
// `applyIfContentPreexists`) instead of the general patch-rejection copy.
|
|
1006
1169
|
if (/content-already-preexists-in-target/i.test(upstreamMsg)) {
|
|
1007
|
-
throw
|
|
1170
|
+
throw contentPreexists();
|
|
1171
|
+
}
|
|
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();
|
|
1008
1176
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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 });
|
|
1015
1186
|
}
|
|
1016
1187
|
// Periodic Notes plugin returns 400 with "Specified period is not enabled"
|
|
1017
1188
|
// when the requested period (daily/weekly/monthly/...) is disabled in the
|
|
@@ -1023,6 +1194,10 @@ export class ObsidianService {
|
|
|
1023
1194
|
throw validationError(`Obsidian Local REST API rejected the request to ${display} as malformed (HTTP 400).`, data(), { cause });
|
|
1024
1195
|
}
|
|
1025
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
|
+
}
|
|
1026
1201
|
/**
|
|
1027
1202
|
* Unhandled 4xx and all 5xx. `httpStatusToErrorCode` supplies the same
|
|
1028
1203
|
* canonical mapping `httpErrorFromResponse` would (the whole 5xx range
|
|
@@ -1094,18 +1269,6 @@ export function encodeVaultPath(path) {
|
|
|
1094
1269
|
}
|
|
1095
1270
|
return segments.map((seg) => encodeURIComponent(seg)).join('/');
|
|
1096
1271
|
}
|
|
1097
|
-
/**
|
|
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
1272
|
/**
|
|
1110
1273
|
* The query as Obsidian's `prepareSimpleSearch` reads it: split on whitespace,
|
|
1111
1274
|
* case-folded, each distinct token required. Quotes are ordinary characters.
|
|
@@ -1380,6 +1543,40 @@ function atLeastVersion(version, floor) {
|
|
|
1380
1543
|
function periodOf(urlPath) {
|
|
1381
1544
|
return /^\/periodic\/(daily|weekly|monthly|quarterly|yearly)\//.exec(urlPath)?.[1] ?? 'periodic';
|
|
1382
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
|
+
}
|
|
1383
1580
|
/** The request path with its query string dropped — the route alone. */
|
|
1384
1581
|
function routeOf(urlPath) {
|
|
1385
1582
|
return urlPath.split('?')[0] ?? urlPath;
|