obsidian-mcp-server 3.7.0 → 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 +15 -7
- package/CLAUDE.md +15 -7
- package/Dockerfile +3 -3
- package/README.md +9 -6
- package/changelog/3.7.x/3.7.1.md +40 -0
- package/dist/config/server-config.js +1 -1
- package/dist/config/server-config.js.map +1 -1
- package/dist/index.js +13 -32
- 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/tools/definitions/_shared/suggest-paths.d.ts +39 -0
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +60 -1
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +127 -116
- 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 +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.js +3 -3
- 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.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +20 -16
- package/dist/mcp-server/tools/definitions/obsidian-get-note.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-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 +46 -14
- 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 +56 -8
- package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
- package/dist/services/obsidian/obsidian-service.js +254 -48
- package/dist/services/obsidian/obsidian-service.js.map +1 -1
- package/dist/services/obsidian/path-policy.d.ts +33 -5
- package/dist/services/obsidian/path-policy.d.ts.map +1 -1
- package/dist/services/obsidian/path-policy.js +99 -17
- package/dist/services/obsidian/path-policy.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 +2 -2
- package/package.json +18 -14
- package/server.json +5 -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 { assertTreeDepth, createJsonLogicEvaluation, readsContent } from './jsonlogic-evaluator.js';
|
|
11
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);
|
|
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)) };
|
|
213
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);
|
|
@@ -308,7 +364,7 @@ export class ObsidianService {
|
|
|
308
364
|
*/
|
|
309
365
|
async tryGetSize(ctx, target) {
|
|
310
366
|
if (target.type === 'path') {
|
|
311
|
-
this.#policy.assertReadable(target.path);
|
|
367
|
+
this.#policy.assertReadable(target.path, target.resolvedFrom);
|
|
312
368
|
}
|
|
313
369
|
const url = this.#targetToPath(target);
|
|
314
370
|
const requestUrl = `${this.#config.baseUrl}${url}`;
|
|
@@ -318,8 +374,9 @@ export class ObsidianService {
|
|
|
318
374
|
});
|
|
319
375
|
if (res.status === 404)
|
|
320
376
|
return null;
|
|
321
|
-
if (!res.ok)
|
|
322
|
-
await this.#throwForStatus(res, url, ctx, { requestedUrl: requestUrl });
|
|
377
|
+
if (!res.ok) {
|
|
378
|
+
await this.#throwForStatus(res, url, ctx, { method: 'HEAD', requestedUrl: requestUrl });
|
|
379
|
+
}
|
|
323
380
|
this.#assertNotDirectory(res, url);
|
|
324
381
|
return parseContentLength(res, url);
|
|
325
382
|
}
|
|
@@ -366,14 +423,13 @@ export class ObsidianService {
|
|
|
366
423
|
/**
|
|
367
424
|
* Vault tags with usage counts, hierarchical parents included. Unscoped, the
|
|
368
425
|
* plugin's `/tags/` with its occurrence counts. That payload carries no
|
|
369
|
-
* per-note attribution, so under OBSIDIAN_READ_PATHS the tags are
|
|
370
|
-
*
|
|
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
|
|
371
428
|
* each one counts the readable notes carrying it or a tag nested under it.
|
|
372
429
|
*/
|
|
373
430
|
async listTags(ctx) {
|
|
374
431
|
if (this.#policy.restrictsReads) {
|
|
375
|
-
|
|
376
|
-
return countTagsPerNote(this.#policy.filterReadable(notes));
|
|
432
|
+
return countTagsPerNote(await this.#readableNoteContexts(ctx));
|
|
377
433
|
}
|
|
378
434
|
const res = await this.#request(ctx, '/tags/', { method: 'GET' });
|
|
379
435
|
const body = (await res.json());
|
|
@@ -426,13 +482,140 @@ export class ObsidianService {
|
|
|
426
482
|
matches: mergeIntoLocations(h.matches.map((m) => contextRelativeSpan(m, h.filename, contextLength, tokens)), tokens.length, contextLength).map(({ context, match }) => ({ context, match })),
|
|
427
483
|
}));
|
|
428
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
|
+
*/
|
|
429
501
|
async searchJsonLogic(ctx, logic) {
|
|
430
|
-
|
|
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/', {
|
|
431
573
|
method: 'POST',
|
|
432
574
|
headers: { 'Content-Type': JSONLOGIC_CT },
|
|
433
|
-
body: JSON.stringify(
|
|
575
|
+
body: JSON.stringify(tree),
|
|
434
576
|
});
|
|
435
|
-
|
|
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;
|
|
436
619
|
}
|
|
437
620
|
/**
|
|
438
621
|
* One-shot startup probe for the Omnisearch plugin's HTTP endpoint. Returns
|
|
@@ -541,15 +724,15 @@ export class ObsidianService {
|
|
|
541
724
|
*/
|
|
542
725
|
async #gateAsWrite(ctx, target) {
|
|
543
726
|
if (target.type === 'path') {
|
|
544
|
-
this.#policy.assertWritable(target.path);
|
|
727
|
+
this.#policy.assertWritable(target.path, target.resolvedFrom);
|
|
545
728
|
return target;
|
|
546
729
|
}
|
|
547
730
|
if (this.#policy.isUnrestricted) {
|
|
548
731
|
return target;
|
|
549
732
|
}
|
|
550
|
-
const
|
|
551
|
-
this.#policy.assertWritable(
|
|
552
|
-
return
|
|
733
|
+
const pinned = this.pinTarget(target, (await this.#rawGetNoteJson(ctx, target)).path);
|
|
734
|
+
this.#policy.assertWritable(pinned.path, pinned.resolvedFrom);
|
|
735
|
+
return pinned;
|
|
553
736
|
}
|
|
554
737
|
/** Raw NoteJson fetch — bypasses path-policy. Used by gate helpers to learn the resolved path. */
|
|
555
738
|
async #rawGetNoteJson(ctx, target) {
|
|
@@ -923,6 +1106,7 @@ export class ObsidianService {
|
|
|
923
1106
|
});
|
|
924
1107
|
if (!res.ok) {
|
|
925
1108
|
await this.#throwForStatus(res, pathAndQuery, ctx, {
|
|
1109
|
+
method: init.method,
|
|
926
1110
|
listRead: init.listRead,
|
|
927
1111
|
requestedUrl: url,
|
|
928
1112
|
});
|
|
@@ -1087,7 +1271,10 @@ export class ObsidianService {
|
|
|
1087
1271
|
* malformed `OBSIDIAN_BASE_URL` is otherwise invisible behind a clean-looking
|
|
1088
1272
|
* 404 (issue #122).
|
|
1089
1273
|
*
|
|
1090
|
-
*
|
|
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.
|
|
1091
1278
|
*/
|
|
1092
1279
|
async #throwForStatus(res, path, ctx, opts) {
|
|
1093
1280
|
const text = await this.#readBodySafe(res);
|
|
@@ -1211,6 +1398,25 @@ export class ObsidianService {
|
|
|
1211
1398
|
if (res.status === 409 && /\balready contains the content\b/i.test(upstreamMsg)) {
|
|
1212
1399
|
throw contentPreexists();
|
|
1213
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
|
+
}
|
|
1214
1420
|
/**
|
|
1215
1421
|
* Unhandled 4xx and all 5xx. `httpStatusToErrorCode` supplies the same
|
|
1216
1422
|
* canonical mapping `httpErrorFromResponse` would (the whole 5xx range
|
|
@@ -1249,17 +1455,17 @@ export class ObsidianService {
|
|
|
1249
1455
|
}
|
|
1250
1456
|
}
|
|
1251
1457
|
/**
|
|
1252
|
-
* Count, per tag, the notes carrying it or a tag nested under it. Each
|
|
1253
|
-
* `
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
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
|
|
1256
1462
|
* order of first appearance.
|
|
1257
1463
|
*/
|
|
1258
1464
|
function countTagsPerNote(notes) {
|
|
1259
1465
|
const counts = new Map();
|
|
1260
|
-
for (const {
|
|
1466
|
+
for (const { tags } of notes) {
|
|
1261
1467
|
const noteTags = new Set();
|
|
1262
|
-
for (const tag of
|
|
1468
|
+
for (const tag of tags) {
|
|
1263
1469
|
const parts = tag.split('/');
|
|
1264
1470
|
for (let depth = 1; depth <= parts.length; depth++) {
|
|
1265
1471
|
noteTags.add(parts.slice(0, depth).join('/'));
|