obsidian-mcp-server 3.6.1 → 3.7.1
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 +40 -22
- package/CLAUDE.md +40 -22
- package/Dockerfile +3 -3
- package/README.md +29 -17
- package/changelog/3.7.x/3.7.0.md +34 -0
- package/changelog/3.7.x/3.7.1.md +40 -0
- package/dist/config/server-config.d.ts +1 -0
- package/dist/config/server-config.d.ts.map +1 -1
- package/dist/config/server-config.js +5 -1
- package/dist/config/server-config.js.map +1 -1
- package/dist/index.js +35 -35
- package/dist/index.js.map +1 -1
- package/dist/instructions.d.ts +20 -0
- package/dist/instructions.d.ts.map +1 -0
- package/dist/instructions.js +40 -0
- package/dist/instructions.js.map +1 -0
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts +7 -5
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts.map +1 -1
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js +12 -8
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +69 -9
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +88 -7
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +256 -309
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/index.js +9 -3
- package/dist/mcp-server/tools/definitions/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +6 -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 +12 -4
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +107 -10
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +237 -162
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +70 -24
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +6 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +8 -10
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts +7 -5
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +13 -7
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +6 -4
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +4 -2
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +4 -1
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +15 -4
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +2 -2
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +4 -2
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +26 -2
- 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 +47 -15
- 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 +5 -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 +21 -4
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
- package/dist/services/obsidian/frontmatter-ops.d.ts +15 -1
- package/dist/services/obsidian/frontmatter-ops.d.ts.map +1 -1
- package/dist/services/obsidian/frontmatter-ops.js +55 -10
- package/dist/services/obsidian/frontmatter-ops.js.map +1 -1
- package/dist/services/obsidian/jsonlogic-evaluator.d.ts +83 -0
- package/dist/services/obsidian/jsonlogic-evaluator.d.ts.map +1 -0
- package/dist/services/obsidian/jsonlogic-evaluator.js +192 -0
- package/dist/services/obsidian/jsonlogic-evaluator.js.map +1 -0
- package/dist/services/obsidian/jsonlogic-worker.d.ts +53 -0
- package/dist/services/obsidian/jsonlogic-worker.d.ts.map +1 -0
- package/dist/services/obsidian/jsonlogic-worker.js +237 -0
- package/dist/services/obsidian/jsonlogic-worker.js.map +1 -0
- package/dist/services/obsidian/obsidian-service.d.ts +61 -6
- package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
- package/dist/services/obsidian/obsidian-service.js +301 -53
- package/dist/services/obsidian/obsidian-service.js.map +1 -1
- package/dist/services/obsidian/patch-instruction.d.ts +20 -1
- package/dist/services/obsidian/patch-instruction.d.ts.map +1 -1
- package/dist/services/obsidian/patch-instruction.js +30 -0
- package/dist/services/obsidian/patch-instruction.js.map +1 -1
- package/dist/services/obsidian/path-policy.d.ts +55 -5
- package/dist/services/obsidian/path-policy.d.ts.map +1 -1
- package/dist/services/obsidian/path-policy.js +143 -19
- package/dist/services/obsidian/path-policy.js.map +1 -1
- package/dist/services/obsidian/section-extractor.d.ts +24 -0
- package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
- package/dist/services/obsidian/section-extractor.js +37 -0
- package/dist/services/obsidian/section-extractor.js.map +1 -1
- package/dist/services/obsidian/types.d.ts +12 -3
- package/dist/services/obsidian/types.d.ts.map +1 -1
- package/manifest.json +10 -2
- package/package.json +18 -14
- package/server.json +19 -5
|
@@ -8,9 +8,54 @@ 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 {
|
|
11
|
+
import { assertTreeDepth, createJsonLogicEvaluation, readsContent } from './jsonlogic-evaluator.js';
|
|
12
|
+
import { canonicalContent, flattenDocumentMap, flattenHeadingTree, hasIntegerKey, inNoteOrder, isIntegerKey, patchFormatFor, relativeHeadingLevels, v1HeadingBody, v1PatchHeaders, v2Instruction, } from './patch-instruction.js';
|
|
12
13
|
import { PathPolicy } from './path-policy.js';
|
|
13
14
|
import { atxHeadingMarkers, blockKinds, isIsolatedBlockId, listHeadingPaths, sectionBody, sectionLevel, } from './section-extractor.js';
|
|
15
|
+
/**
|
|
16
|
+
* Most note-body bytes (by `stat.size`) a scoped JSONLogic search fetches and
|
|
17
|
+
* evaluates at once. Each batch costs one upstream search, during which the
|
|
18
|
+
* plugin reads every vault note's body, since the body-fetch tree names
|
|
19
|
+
* `content`; so this trades those vault-wide reads against how much of the
|
|
20
|
+
* readable vault sits in memory.
|
|
21
|
+
*/
|
|
22
|
+
const CONTENT_BATCH_BYTES = 32 * 1024 * 1024;
|
|
23
|
+
/** Split `notes`, in order, into runs whose bodies total at most {@link CONTENT_BATCH_BYTES}; a larger note gets a run of its own. */
|
|
24
|
+
function contentBatches(notes) {
|
|
25
|
+
const batches = [];
|
|
26
|
+
let current = [];
|
|
27
|
+
let bytes = 0;
|
|
28
|
+
for (const note of notes) {
|
|
29
|
+
if (current.length > 0 && bytes + note.stat.size > CONTENT_BATCH_BYTES) {
|
|
30
|
+
batches.push(current);
|
|
31
|
+
current = [];
|
|
32
|
+
bytes = 0;
|
|
33
|
+
}
|
|
34
|
+
current.push(note);
|
|
35
|
+
bytes += note.stat.size;
|
|
36
|
+
}
|
|
37
|
+
if (current.length > 0)
|
|
38
|
+
batches.push(current);
|
|
39
|
+
return batches;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* How long a stored scoped JSONLogic walk outlives its last read. A scoped
|
|
43
|
+
* evaluation reads every readable note, so a walk's later pages are served
|
|
44
|
+
* from the hit list its first page stored rather than evaluated again.
|
|
45
|
+
*/
|
|
46
|
+
const WALK_TTL_MS = 5 * 60_000;
|
|
47
|
+
/** Most characters all stored walks hold together, each its tree plus its serialized hits; the least recently read goes first. */
|
|
48
|
+
const WALK_STORE_CHARS = 32 * 1024 * 1024;
|
|
49
|
+
/** Size of a stored walk: its serialized tree plus its serialized hits, counted until it passes `limit`. */
|
|
50
|
+
function walkChars(tree, hits, limit) {
|
|
51
|
+
let total = tree.length;
|
|
52
|
+
for (const hit of hits) {
|
|
53
|
+
if (total > limit)
|
|
54
|
+
break;
|
|
55
|
+
total += hit.filename.length + JSON.stringify(hit.result).length;
|
|
56
|
+
}
|
|
57
|
+
return total;
|
|
58
|
+
}
|
|
14
59
|
/** Per-call timeout for the startup probe — covers the 4-tuple TCP handshake + a tiny GET. */
|
|
15
60
|
const OMNISEARCH_PROBE_TIMEOUT_MS = 500;
|
|
16
61
|
/**
|
|
@@ -104,6 +149,9 @@ export class ObsidianService {
|
|
|
104
149
|
* one read; a failed read is evicted so the next write tries again.
|
|
105
150
|
*/
|
|
106
151
|
#patchFormat;
|
|
152
|
+
/** Stored scoped JSONLogic walks by id, least recently read first (`pageJsonLogic`). */
|
|
153
|
+
#walks = new Map();
|
|
154
|
+
#walkChars = 0;
|
|
107
155
|
/**
|
|
108
156
|
* @param config - Validated server config (api key, base URL, TLS, timeouts).
|
|
109
157
|
* @param fetchImpl - Optional fetch override for tests. Defaults to undici's
|
|
@@ -125,7 +173,7 @@ export class ObsidianService {
|
|
|
125
173
|
});
|
|
126
174
|
this.#fetch = fetchImpl ?? undiciFetch;
|
|
127
175
|
}
|
|
128
|
-
/** Path-policy accessor
|
|
176
|
+
/** Path-policy accessor for tools that filter results or check scope themselves. */
|
|
129
177
|
get policy() {
|
|
130
178
|
return this.#policy;
|
|
131
179
|
}
|
|
@@ -165,7 +213,7 @@ export class ObsidianService {
|
|
|
165
213
|
// ── Notes ────────────────────────────────────────────────────────────────
|
|
166
214
|
async getNoteContent(ctx, target) {
|
|
167
215
|
if (target.type === 'path') {
|
|
168
|
-
this.#policy.assertReadable(target.path);
|
|
216
|
+
this.#policy.assertReadable(target.path, target.resolvedFrom);
|
|
169
217
|
const url = this.#targetToPath(target);
|
|
170
218
|
const res = await this.#request(ctx, url, {
|
|
171
219
|
method: 'GET',
|
|
@@ -193,43 +241,51 @@ export class ObsidianService {
|
|
|
193
241
|
}
|
|
194
242
|
async getNoteJson(ctx, target) {
|
|
195
243
|
if (target.type === 'path') {
|
|
196
|
-
this.#policy.assertReadable(target.path);
|
|
244
|
+
this.#policy.assertReadable(target.path, target.resolvedFrom);
|
|
197
245
|
}
|
|
198
246
|
const note = await this.#rawGetNoteJson(ctx, target);
|
|
199
247
|
if (target.type !== 'path') {
|
|
200
|
-
this
|
|
248
|
+
const pinned = this.pinTarget(target, note.path);
|
|
249
|
+
this.#policy.assertReadable(pinned.path, pinned.resolvedFrom);
|
|
201
250
|
}
|
|
202
251
|
return note;
|
|
203
252
|
}
|
|
204
253
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
254
|
+
* Pin `target` to the note it names right now. A path target comes back as
|
|
255
|
+
* sent; an `active` or `periodic` target is resolved upstream (read-gated)
|
|
256
|
+
* and comes back as a path target for that note, so every later call acts on
|
|
257
|
+
* the same file however the target moves in between.
|
|
208
258
|
*/
|
|
209
|
-
async
|
|
259
|
+
async resolveTarget(ctx, target) {
|
|
210
260
|
if (target.type === 'path')
|
|
211
|
-
return target
|
|
212
|
-
return (await this.getNoteJson(ctx, target)).path;
|
|
261
|
+
return target;
|
|
262
|
+
return this.pinTarget(target, (await this.getNoteJson(ctx, target)).path);
|
|
213
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* `target` as a path target for `path`, the note a read of `target` returned.
|
|
266
|
+
* A path target already names its note and comes back as sent. A pinned
|
|
267
|
+
* `active` / `periodic` target carries the target's label as `resolvedFrom`,
|
|
268
|
+
* the identifier `no_active_file` and `periodic_not_found` use, so a
|
|
269
|
+
* `path_forbidden` denial on it names the target rather than the note.
|
|
270
|
+
*/
|
|
271
|
+
pinTarget(target, path) {
|
|
272
|
+
if (target.type === 'path')
|
|
273
|
+
return target;
|
|
274
|
+
return { type: 'path', path, resolvedFrom: displayPath(this.#targetToPath(target)) };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The note's headings, block ids, and frontmatter field names. An `active` /
|
|
278
|
+
* `periodic` target is resolved once (read-gated, a denial named by the
|
|
279
|
+
* target's label) and the map read from the resolved `/vault/` path, which
|
|
280
|
+
* the plugin's `/active/` and `/periodic/…` routes delegate to anyway, so the
|
|
281
|
+
* map describes the note the resolution returned even if the target moves.
|
|
282
|
+
*/
|
|
214
283
|
async getDocumentMap(ctx, target) {
|
|
215
|
-
if (target.type
|
|
216
|
-
this.#
|
|
217
|
-
return this.#rawGetDocumentMap(ctx, target);
|
|
218
|
-
}
|
|
219
|
-
if (this.#policy.isUnrestricted) {
|
|
220
|
-
return this.#rawGetDocumentMap(ctx, target);
|
|
284
|
+
if (target.type !== 'path') {
|
|
285
|
+
return this.#rawGetDocumentMap(ctx, await this.resolveTarget(ctx, target));
|
|
221
286
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
* path so we can gate. If the gate denies, the parallel fetch result is
|
|
225
|
-
* discarded — acceptable cost given the rarity of this configuration.
|
|
226
|
-
*/
|
|
227
|
-
const [path, map] = await Promise.all([
|
|
228
|
-
this.resolvePath(ctx, target),
|
|
229
|
-
this.#rawGetDocumentMap(ctx, target),
|
|
230
|
-
]);
|
|
231
|
-
this.#policy.assertReadable(path);
|
|
232
|
-
return map;
|
|
287
|
+
this.#policy.assertReadable(target.path, target.resolvedFrom);
|
|
288
|
+
return this.#rawGetDocumentMap(ctx, target);
|
|
233
289
|
}
|
|
234
290
|
async writeNote(ctx, target, content, contentType = 'markdown') {
|
|
235
291
|
const safe = await this.#gateAsWrite(ctx, target);
|
|
@@ -262,13 +318,15 @@ export class ObsidianService {
|
|
|
262
318
|
const safe = await this.#gateAsWrite(ctx, target);
|
|
263
319
|
const url = this.#targetToPath(safe);
|
|
264
320
|
const format = await this.#wireFormat(ctx, safe, instruction);
|
|
265
|
-
const resolvedTarget = await this.#resolveHeadingTarget(ctx, safe, instruction, format);
|
|
321
|
+
const { target: resolvedTarget, note } = await this.#resolveHeadingTarget(ctx, safe, instruction, format);
|
|
266
322
|
const resolved = { ...instruction, target: resolvedTarget };
|
|
267
323
|
if (format === '1') {
|
|
268
324
|
await this.#request(ctx, url, {
|
|
269
325
|
method: 'PATCH',
|
|
270
326
|
headers: v1PatchHeaders(resolved),
|
|
271
|
-
body:
|
|
327
|
+
body: note === undefined || resolved.contentType === 'json'
|
|
328
|
+
? content
|
|
329
|
+
: v1HeadingBody(resolved, content, note),
|
|
272
330
|
});
|
|
273
331
|
return resolvedTarget;
|
|
274
332
|
}
|
|
@@ -306,7 +364,7 @@ export class ObsidianService {
|
|
|
306
364
|
*/
|
|
307
365
|
async tryGetSize(ctx, target) {
|
|
308
366
|
if (target.type === 'path') {
|
|
309
|
-
this.#policy.assertReadable(target.path);
|
|
367
|
+
this.#policy.assertReadable(target.path, target.resolvedFrom);
|
|
310
368
|
}
|
|
311
369
|
const url = this.#targetToPath(target);
|
|
312
370
|
const requestUrl = `${this.#config.baseUrl}${url}`;
|
|
@@ -316,8 +374,9 @@ export class ObsidianService {
|
|
|
316
374
|
});
|
|
317
375
|
if (res.status === 404)
|
|
318
376
|
return null;
|
|
319
|
-
if (!res.ok)
|
|
320
|
-
await this.#throwForStatus(res, url, ctx, { requestedUrl: requestUrl });
|
|
377
|
+
if (!res.ok) {
|
|
378
|
+
await this.#throwForStatus(res, url, ctx, { method: 'HEAD', requestedUrl: requestUrl });
|
|
379
|
+
}
|
|
321
380
|
this.#assertNotDirectory(res, url);
|
|
322
381
|
return parseContentLength(res, url);
|
|
323
382
|
}
|
|
@@ -346,18 +405,32 @@ export class ObsidianService {
|
|
|
346
405
|
url = `/vault/${encodeVaultPath(normalized)}/`;
|
|
347
406
|
}
|
|
348
407
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
408
|
+
* The root and folders on the way to the read scope list so a nested scope
|
|
409
|
+
* can be browsed to; any other out-of-scope folder is refused before the
|
|
410
|
+
* upstream call. Entries are filtered here rather than in callers, so every
|
|
411
|
+
* consumer of a listing — the `obsidian_list_notes` walk and the
|
|
412
|
+
* case-fallback probe alike — sees only readable entries and ancestors.
|
|
353
413
|
*/
|
|
354
414
|
if (normalized) {
|
|
355
|
-
this.#policy.
|
|
415
|
+
this.#policy.assertListable(normalized);
|
|
356
416
|
}
|
|
357
417
|
const res = await this.#request(ctx, url, { method: 'GET', listRead: true });
|
|
358
|
-
|
|
418
|
+
const listing = (await res.json());
|
|
419
|
+
if (!this.#policy.restrictsReads)
|
|
420
|
+
return listing;
|
|
421
|
+
return { files: this.#policy.filterListing(normalized, listing.files) };
|
|
359
422
|
}
|
|
423
|
+
/**
|
|
424
|
+
* Vault tags with usage counts, hierarchical parents included. Unscoped, the
|
|
425
|
+
* plugin's `/tags/` with its occurrence counts. That payload carries no
|
|
426
|
+
* per-note attribution, so under OBSIDIAN_READ_PATHS the tags are read from
|
|
427
|
+
* the readable notes' contexts instead (one upstream search, no bodies), and
|
|
428
|
+
* each one counts the readable notes carrying it or a tag nested under it.
|
|
429
|
+
*/
|
|
360
430
|
async listTags(ctx) {
|
|
431
|
+
if (this.#policy.restrictsReads) {
|
|
432
|
+
return countTagsPerNote(await this.#readableNoteContexts(ctx));
|
|
433
|
+
}
|
|
361
434
|
const res = await this.#request(ctx, '/tags/', { method: 'GET' });
|
|
362
435
|
const body = (await res.json());
|
|
363
436
|
return body.tags ?? [];
|
|
@@ -409,13 +482,140 @@ export class ObsidianService {
|
|
|
409
482
|
matches: mergeIntoLocations(h.matches.map((m) => contextRelativeSpan(m, h.filename, contextLength, tokens)), tokens.length, contextLength).map(({ context, match }) => ({ context, match })),
|
|
410
483
|
}));
|
|
411
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* JSONLogic search. Unscoped, the tree goes to the plugin unchanged. Under
|
|
487
|
+
* OBSIDIAN_READ_PATHS the plugin would evaluate it against every note in
|
|
488
|
+
* the vault, so the tree stays here instead: the readable notes' contexts
|
|
489
|
+
* are fetched and the tree is evaluated over those alone
|
|
490
|
+
* (`createJsonLogicEvaluation`), the way the plugin evaluates it. No hit,
|
|
491
|
+
* result, or error then reads a note outside the read scope; which note a
|
|
492
|
+
* link resolves to is still Obsidian's vault-wide resolution, so `links`,
|
|
493
|
+
* `backlinks`, and `unresolvedLinks` can change when a note outside the
|
|
494
|
+
* scope is created or removed.
|
|
495
|
+
*
|
|
496
|
+
* The plugin hands a tree note bodies only when its JSON holds the string
|
|
497
|
+
* `"content"` (`readsContent`); such a tree gets them here too, fetched
|
|
498
|
+
* and evaluated in batches of at most `CONTENT_BATCH_BYTES` so a broad
|
|
499
|
+
* scope never holds every readable body at once.
|
|
500
|
+
*/
|
|
412
501
|
async searchJsonLogic(ctx, logic) {
|
|
413
|
-
|
|
502
|
+
if (this.#policy.restrictsReads) {
|
|
503
|
+
assertTreeDepth(logic);
|
|
504
|
+
const evaluation = createJsonLogicEvaluation(logic, { signal: ctx.signal });
|
|
505
|
+
const notes = await this.#readableNoteContexts(ctx);
|
|
506
|
+
if (!readsContent(logic))
|
|
507
|
+
return evaluation.evaluate(notes);
|
|
508
|
+
const hits = [];
|
|
509
|
+
for (const batch of contentBatches(notes)) {
|
|
510
|
+
const bodies = await this.#noteBodies(ctx, batch);
|
|
511
|
+
const withBodies = batch.map((note) => ({ ...note, content: bodies.get(note.path) ?? '' }));
|
|
512
|
+
hits.push(...(await evaluation.evaluate(withBodies)));
|
|
513
|
+
}
|
|
514
|
+
return hits;
|
|
515
|
+
}
|
|
516
|
+
const res = await this.#postJsonLogic(ctx, logic);
|
|
517
|
+
return (await res.json());
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* A JSONLogic search for a caller paging through its hits `pageSize` at a
|
|
521
|
+
* time. Unscoped, it is `searchJsonLogic`. Under OBSIDIAN_READ_PATHS a call
|
|
522
|
+
* without `walk` evaluates fresh and, when the hits run past one page,
|
|
523
|
+
* stores the full ordered hit list under a new walk id, returned as `walk`;
|
|
524
|
+
* a call naming that id with the same tree is served from the stored list
|
|
525
|
+
* without evaluating. The store belongs to this service, whose read scope
|
|
526
|
+
* is fixed at construction, so a list is only read under the scope that
|
|
527
|
+
* produced it. Anything else — an expired, evicted, or unknown id, or a
|
|
528
|
+
* different tree — evaluates fresh, and a call cancelled before it answers
|
|
529
|
+
* stores nothing.
|
|
530
|
+
*/
|
|
531
|
+
async pageJsonLogic(ctx, logic, walk, pageSize) {
|
|
532
|
+
if (!this.#policy.restrictsReads)
|
|
533
|
+
return { hits: await this.searchJsonLogic(ctx, logic) };
|
|
534
|
+
// Before serializing it: a tree deep enough overflows `JSON.stringify`'s stack under Bun.
|
|
535
|
+
assertTreeDepth(logic);
|
|
536
|
+
const tree = JSON.stringify(logic);
|
|
537
|
+
const stored = walk === undefined ? undefined : this.#walks.get(walk);
|
|
538
|
+
if (walk !== undefined && stored?.tree === tree && stored.expiresAt > Date.now()) {
|
|
539
|
+
// Re-inserted so the map stays in last-read order.
|
|
540
|
+
this.#walks.delete(walk);
|
|
541
|
+
this.#walks.set(walk, { ...stored, expiresAt: Date.now() + WALK_TTL_MS });
|
|
542
|
+
return { hits: stored.hits, walk };
|
|
543
|
+
}
|
|
544
|
+
const hits = await this.searchJsonLogic(ctx, logic);
|
|
545
|
+
const id = hits.length > pageSize && !ctx.signal.aborted ? this.#storeWalk(tree, hits) : undefined;
|
|
546
|
+
return id === undefined ? { hits } : { hits, walk: id };
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Store a walk's hits under a fresh id, first dropping expired walks and,
|
|
550
|
+
* while the store would pass {@link WALK_STORE_CHARS}, the least recently
|
|
551
|
+
* read. A walk larger than the whole store is not kept.
|
|
552
|
+
*/
|
|
553
|
+
#storeWalk(tree, hits) {
|
|
554
|
+
const chars = walkChars(tree, hits, WALK_STORE_CHARS);
|
|
555
|
+
if (chars > WALK_STORE_CHARS)
|
|
556
|
+
return;
|
|
557
|
+
const now = Date.now();
|
|
558
|
+
// Every walk lives WALK_TTL_MS past its last read, so expired walks lead the last-read order.
|
|
559
|
+
for (const [id, old] of this.#walks) {
|
|
560
|
+
if (old.expiresAt > now && this.#walkChars + chars <= WALK_STORE_CHARS)
|
|
561
|
+
break;
|
|
562
|
+
this.#walks.delete(id);
|
|
563
|
+
this.#walkChars -= old.chars;
|
|
564
|
+
}
|
|
565
|
+
const id = crypto.randomUUID();
|
|
566
|
+
this.#walks.set(id, { tree, hits, chars, expiresAt: now + WALK_TTL_MS });
|
|
567
|
+
this.#walkChars += chars;
|
|
568
|
+
return id;
|
|
569
|
+
}
|
|
570
|
+
/** One upstream JSONLogic search: `tree` posted to `/search/`. */
|
|
571
|
+
#postJsonLogic(ctx, tree) {
|
|
572
|
+
return this.#request(ctx, '/search/', {
|
|
414
573
|
method: 'POST',
|
|
415
574
|
headers: { 'Content-Type': JSONLOGIC_CT },
|
|
416
|
-
body: JSON.stringify(
|
|
575
|
+
body: JSON.stringify(tree),
|
|
417
576
|
});
|
|
418
|
-
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* The readable notes' JSONLogic contexts, bodies left empty as the plugin
|
|
580
|
+
* leaves them, in vault order, from one upstream search whose tree is the
|
|
581
|
+
* server's own: a `regexp` guard on the path (`PathPolicy.readScopePattern`)
|
|
582
|
+
* narrows what the plugin returns, and `filterReadable` decides what is
|
|
583
|
+
* kept. Each context carries only the fields the plugin documents, its link
|
|
584
|
+
* fields reduced to readable entries.
|
|
585
|
+
*/
|
|
586
|
+
async #readableNoteContexts(ctx) {
|
|
587
|
+
const guard = {
|
|
588
|
+
if: [{ regexp: [this.#policy.readScopePattern(), { var: 'path' }] }, { var: '' }, null],
|
|
589
|
+
};
|
|
590
|
+
const res = await this.#postJsonLogic(ctx, guard);
|
|
591
|
+
const hits = (await res.json());
|
|
592
|
+
return this.#policy.filterReadable(hits).map(({ filename, result }) => ({
|
|
593
|
+
tags: result.tags,
|
|
594
|
+
frontmatter: result.frontmatter,
|
|
595
|
+
stat: result.stat,
|
|
596
|
+
path: filename,
|
|
597
|
+
content: result.content,
|
|
598
|
+
links: result.links && this.#policy.keepReadable(result.links),
|
|
599
|
+
backlinks: result.backlinks && this.#policy.keepReadable(result.backlinks),
|
|
600
|
+
unresolvedLinks: result.unresolvedLinks && this.#policy.keepReadable(result.unresolvedLinks),
|
|
601
|
+
}));
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Bodies of `notes`, already-gated contexts, by path, from one upstream
|
|
605
|
+
* search for exactly those paths. A body for any other path is dropped; an
|
|
606
|
+
* empty body is absent, as the plugin's search drops a falsy result.
|
|
607
|
+
*/
|
|
608
|
+
async #noteBodies(ctx, notes) {
|
|
609
|
+
const paths = notes.map((note) => note.path);
|
|
610
|
+
const guard = { if: [{ in: [{ var: 'path' }, paths] }, { var: 'content' }, null] };
|
|
611
|
+
const res = await this.#postJsonLogic(ctx, guard);
|
|
612
|
+
const wanted = new Set(paths);
|
|
613
|
+
const bodies = new Map();
|
|
614
|
+
for (const hit of (await res.json())) {
|
|
615
|
+
if (wanted.has(hit.filename))
|
|
616
|
+
bodies.set(hit.filename, hit.result);
|
|
617
|
+
}
|
|
618
|
+
return bodies;
|
|
419
619
|
}
|
|
420
620
|
/**
|
|
421
621
|
* One-shot startup probe for the Omnisearch plugin's HTTP endpoint. Returns
|
|
@@ -524,15 +724,15 @@ export class ObsidianService {
|
|
|
524
724
|
*/
|
|
525
725
|
async #gateAsWrite(ctx, target) {
|
|
526
726
|
if (target.type === 'path') {
|
|
527
|
-
this.#policy.assertWritable(target.path);
|
|
727
|
+
this.#policy.assertWritable(target.path, target.resolvedFrom);
|
|
528
728
|
return target;
|
|
529
729
|
}
|
|
530
730
|
if (this.#policy.isUnrestricted) {
|
|
531
731
|
return target;
|
|
532
732
|
}
|
|
533
|
-
const
|
|
534
|
-
this.#policy.assertWritable(
|
|
535
|
-
return
|
|
733
|
+
const pinned = this.pinTarget(target, (await this.#rawGetNoteJson(ctx, target)).path);
|
|
734
|
+
this.#policy.assertWritable(pinned.path, pinned.resolvedFrom);
|
|
735
|
+
return pinned;
|
|
536
736
|
}
|
|
537
737
|
/** Raw NoteJson fetch — bypasses path-policy. Used by gate helpers to learn the resolved path. */
|
|
538
738
|
async #rawGetNoteJson(ctx, target) {
|
|
@@ -735,18 +935,19 @@ export class ObsidianService {
|
|
|
735
935
|
* on the last repeat while a section read returns the first. A write to a
|
|
736
936
|
* repeated path is rejected as ambiguous. The count comes from the plugin's
|
|
737
937
|
* own parse (`#headingIndex`) wherever the plugin serves one, so the map the
|
|
738
|
-
* PATCH resolves against is also the one that counts its repeats.
|
|
938
|
+
* PATCH resolves against is also the one that counts its repeats. On 1.x the
|
|
939
|
+
* note read for that count comes back as `note`, for `v1HeadingBody`.
|
|
739
940
|
*/
|
|
740
941
|
async #resolveHeadingTarget(ctx, target, instruction, format) {
|
|
741
942
|
if (instruction.targetType !== 'heading')
|
|
742
|
-
return instruction.target;
|
|
743
|
-
const { headings, occurrences } = await this.#headingIndex(ctx, target, format);
|
|
943
|
+
return { target: instruction.target, note: undefined };
|
|
944
|
+
const { headings, occurrences, note } = await this.#headingIndex(ctx, target, format);
|
|
744
945
|
const resolved = instruction.target.includes(HEADING_DELIMITER)
|
|
745
946
|
? instruction.target
|
|
746
947
|
: this.#expandHeadingLeaf(target, instruction.target, headings);
|
|
747
948
|
const repeats = occurrences.filter((p) => p === resolved);
|
|
748
949
|
if (repeats.length <= 1)
|
|
749
|
-
return resolved;
|
|
950
|
+
return { target: resolved, note };
|
|
750
951
|
const display = displayPath(this.#targetToPath(target));
|
|
751
952
|
const named = resolved === instruction.target ? `'${resolved}'` : `'${instruction.target}' (${resolved})`;
|
|
752
953
|
throw conflict(`Heading ${named} occurs ${repeats.length} times in ${display}, so a section write cannot tell which one to edit.`, {
|
|
@@ -770,7 +971,7 @@ export class ObsidianService {
|
|
|
770
971
|
* top-level heading tokens. Plugin v4.x serves only the 1.x array, which
|
|
771
972
|
* cannot count repeats, so there the note body is fetched and scanned
|
|
772
973
|
* locally (`listHeadingPaths`), which finds headings with the same `marked`
|
|
773
|
-
* lexing the map does.
|
|
974
|
+
* lexing the map does. That note text comes back as `note`.
|
|
774
975
|
*
|
|
775
976
|
* Both lists run in note order, as `#rawGetDocumentMap` explains, so the
|
|
776
977
|
* `candidates` an ambiguity error names, and the first one its hint offers,
|
|
@@ -782,7 +983,7 @@ export class ObsidianService {
|
|
|
782
983
|
const map = await this.#fetchDocumentMap(ctx, url, format);
|
|
783
984
|
const { content } = await this.#rawGetNoteJson(ctx, target);
|
|
784
985
|
const occurrences = listHeadingPaths(content);
|
|
785
|
-
return { headings: inNoteOrder(map.headings, occurrences), occurrences };
|
|
986
|
+
return { headings: inNoteOrder(map.headings, occurrences), note: content, occurrences };
|
|
786
987
|
}
|
|
787
988
|
const map = await this.#fetchDocumentMap(ctx, url, format);
|
|
788
989
|
const flat = flattenHeadingTree(map.headings);
|
|
@@ -905,6 +1106,7 @@ export class ObsidianService {
|
|
|
905
1106
|
});
|
|
906
1107
|
if (!res.ok) {
|
|
907
1108
|
await this.#throwForStatus(res, pathAndQuery, ctx, {
|
|
1109
|
+
method: init.method,
|
|
908
1110
|
listRead: init.listRead,
|
|
909
1111
|
requestedUrl: url,
|
|
910
1112
|
});
|
|
@@ -1069,7 +1271,10 @@ export class ObsidianService {
|
|
|
1069
1271
|
* malformed `OBSIDIAN_BASE_URL` is otherwise invisible behind a clean-looking
|
|
1070
1272
|
* 404 (issue #122).
|
|
1071
1273
|
*
|
|
1072
|
-
*
|
|
1274
|
+
* `opts.method` is classification input only: the same upstream status and
|
|
1275
|
+
* wording mean different things on a whole-file write than elsewhere.
|
|
1276
|
+
*
|
|
1277
|
+
* Issues #104, #116, #122, and #159.
|
|
1073
1278
|
*/
|
|
1074
1279
|
async #throwForStatus(res, path, ctx, opts) {
|
|
1075
1280
|
const text = await this.#readBodySafe(res);
|
|
@@ -1193,6 +1398,25 @@ export class ObsidianService {
|
|
|
1193
1398
|
if (res.status === 409 && /\balready contains the content\b/i.test(upstreamMsg)) {
|
|
1194
1399
|
throw contentPreexists();
|
|
1195
1400
|
}
|
|
1401
|
+
/**
|
|
1402
|
+
* A whole-file `PUT` or `POST` creates the note through Obsidian's
|
|
1403
|
+
* `Vault.create` whenever the vault index, which is case-sensitive,
|
|
1404
|
+
* holds no file under that exact name — and `Vault.create` throws
|
|
1405
|
+
* "File already exists." when the disk does. On a case-insensitive
|
|
1406
|
+
* filesystem that is a note whose name differs only in letter case;
|
|
1407
|
+
* a file Obsidian does not index (a dot-file) refuses the same way.
|
|
1408
|
+
* The plugin relays it as a 500 whose message ends in that sentence
|
|
1409
|
+
* on a line of its own; the phrase inside other text (a filesystem
|
|
1410
|
+
* `EEXIST` naming another path) is a different failure. A `Conflict`
|
|
1411
|
+
* keeps it out of the retry loop: the next attempt meets the same
|
|
1412
|
+
* file. The tools name the existing note from the folder listing.
|
|
1413
|
+
*/
|
|
1414
|
+
if (res.status === 500 &&
|
|
1415
|
+
(opts.method === 'PUT' || opts.method === 'POST') &&
|
|
1416
|
+
path.startsWith('/vault/') &&
|
|
1417
|
+
/(?:^|\n)[ \t]*file already exists\.?\s*$/i.test(upstreamMsg)) {
|
|
1418
|
+
throw conflict(`Obsidian refused to create ${display}: a file already exists on disk at that path under different letter case, or one Obsidian does not index.`, data('case_variant_exists'), { cause });
|
|
1419
|
+
}
|
|
1196
1420
|
/**
|
|
1197
1421
|
* Unhandled 4xx and all 5xx. `httpStatusToErrorCode` supplies the same
|
|
1198
1422
|
* canonical mapping `httpErrorFromResponse` would (the whole 5xx range
|
|
@@ -1230,6 +1454,30 @@ export class ObsidianService {
|
|
|
1230
1454
|
}
|
|
1231
1455
|
}
|
|
1232
1456
|
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Count, per tag, the notes carrying it or a tag nested under it. Each note's
|
|
1459
|
+
* `tags` is the plugin's per-note tag list — strings, deduplicated, `#`
|
|
1460
|
+
* stripped, not parent-expanded — so one note carrying `work/a` and `work/b`
|
|
1461
|
+
* adds 1 to `work`, and an untagged note adds nothing. Tags come back in
|
|
1462
|
+
* order of first appearance.
|
|
1463
|
+
*/
|
|
1464
|
+
function countTagsPerNote(notes) {
|
|
1465
|
+
const counts = new Map();
|
|
1466
|
+
for (const { tags } of notes) {
|
|
1467
|
+
const noteTags = new Set();
|
|
1468
|
+
for (const tag of tags) {
|
|
1469
|
+
const parts = tag.split('/');
|
|
1470
|
+
for (let depth = 1; depth <= parts.length; depth++) {
|
|
1471
|
+
noteTags.add(parts.slice(0, depth).join('/'));
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
for (const name of noteTags) {
|
|
1475
|
+
if (name)
|
|
1476
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return Array.from(counts, ([name, count]) => ({ name, count }));
|
|
1480
|
+
}
|
|
1233
1481
|
/**
|
|
1234
1482
|
* Encode a vault-relative path for the URL. Splits on `/` and `\` (so
|
|
1235
1483
|
* Windows-style separators are honored), URL-encodes each segment, and
|