@openparachute/vault 0.6.0-rc.1 → 0.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/.parachute/module.json +14 -3
- package/README.md +7 -7
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +97 -2
- package/core/src/mcp.ts +201 -33
- package/core/src/notes.ts +44 -8
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/schema.ts +58 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +463 -1
- package/src/mirror-routes.ts +474 -4
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +696 -121
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +113 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-CGL256oe.js +60 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/src/routes.ts
CHANGED
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
* and the Request, and returns a Response.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type { Store, Note } from "../core/src/types.ts";
|
|
14
|
+
import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
15
|
+
import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
15
16
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
16
|
-
import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
17
|
+
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
17
18
|
import { attachValidationStatus } from "../core/src/mcp.ts";
|
|
18
19
|
import * as linkOps from "../core/src/links.ts";
|
|
19
20
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
20
21
|
import {
|
|
22
|
+
buildExpandVisibility,
|
|
23
|
+
filterHydratedLinksByTagScope,
|
|
21
24
|
filterNotesByTagScope,
|
|
22
25
|
noteWithinTagScope,
|
|
23
26
|
tagScopeForbidden,
|
|
@@ -44,8 +47,16 @@ import {
|
|
|
44
47
|
} from "../core/src/expand.ts";
|
|
45
48
|
import { join, extname, normalize } from "path";
|
|
46
49
|
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
47
|
-
import {
|
|
50
|
+
import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
|
|
48
51
|
import { shouldAutoTranscribe } from "./auto-transcribe.ts";
|
|
52
|
+
// usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
|
|
53
|
+
// of invalidateUsageCache does NOT form a cycle — routes.ts → usage.ts only.
|
|
54
|
+
import { invalidateUsageCache } from "./usage.ts";
|
|
55
|
+
|
|
56
|
+
// Re-export `assetsDir` (now defined in config.ts) so the existing callers
|
|
57
|
+
// that import it from this module — mirror-deps, mirror-routes, server,
|
|
58
|
+
// triggers, cli — keep working unchanged.
|
|
59
|
+
export { assetsDir };
|
|
49
60
|
|
|
50
61
|
// ---------------------------------------------------------------------------
|
|
51
62
|
// Helpers
|
|
@@ -71,6 +82,22 @@ function parseQuery(url: URL, key: string): string | null {
|
|
|
71
82
|
return url.searchParams.get(key);
|
|
72
83
|
}
|
|
73
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Parse `link_count_direction` (vault feedback #4). Defaults to "both";
|
|
87
|
+
* anything other than the three known values falls back to "both" so a
|
|
88
|
+
* typo silently degrades to the documented default rather than erroring.
|
|
89
|
+
*
|
|
90
|
+
* Tag-scope note (symmetric with the MCP param description): `linkCount`
|
|
91
|
+
* is a raw degree that MAY include edges to notes a tag-scoped token can't
|
|
92
|
+
* see — the tag-scope filter runs post-query, over the result notes, not
|
|
93
|
+
* their neighbors. Only the number leaks, not the neighbor.
|
|
94
|
+
*/
|
|
95
|
+
function parseLinkCountDirection(url: URL): "both" | "outbound" | "inbound" {
|
|
96
|
+
const v = url.searchParams.get("link_count_direction");
|
|
97
|
+
if (v === "outbound" || v === "inbound") return v;
|
|
98
|
+
return "both";
|
|
99
|
+
}
|
|
100
|
+
|
|
74
101
|
function parseQueryList(url: URL, key: string): string[] | undefined {
|
|
75
102
|
const val = url.searchParams.get(key);
|
|
76
103
|
return val ? val.split(",") : undefined;
|
|
@@ -353,6 +380,180 @@ function parseMetaBrackets(url: URL): {
|
|
|
353
380
|
return result;
|
|
354
381
|
}
|
|
355
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Parse the `?metadata=<json>` alias on GET /api/notes — the JSON-object form
|
|
385
|
+
* of the metadata filter, symmetric with the nested `metadata` object MCP
|
|
386
|
+
* forwards verbatim (`core/src/mcp.ts`). The value is a JSON object of the form
|
|
387
|
+
* `{"field":{"op":value}}` (operator query) or `{"field":value}` (shorthand
|
|
388
|
+
* equality via the engine's json_extract fallback).
|
|
389
|
+
*
|
|
390
|
+
* This exists because the bracket grammar (`?meta[field][op]=value`) couldn't
|
|
391
|
+
* see a `metadata=` param at all — it was silently dropped, and the query
|
|
392
|
+
* returned ALL tag-matching notes (a silent wrong result, not an error).
|
|
393
|
+
*
|
|
394
|
+
* We do NOT validate operators here — the parsed object lowers straight into
|
|
395
|
+
* `queryNotes`, where `validateOperatorObject` raises a loud 400 on unknown
|
|
396
|
+
* operators (caught by the QueryError handler in handleNotes). We only enforce
|
|
397
|
+
* that the param parses and is a non-null, non-array plain object; anything
|
|
398
|
+
* else is a malformed filter the engine can't consume.
|
|
399
|
+
*
|
|
400
|
+
* An empty object (`metadata={}`) carries no filter intent, so it's treated as
|
|
401
|
+
* absent: it sets no metadata filter AND doesn't trip the both-forms guard, so
|
|
402
|
+
* it composes harmlessly with bracket params.
|
|
403
|
+
*
|
|
404
|
+
* Returns `{ metadata?, error? }`. When `error` is set the caller returns it
|
|
405
|
+
* directly (already shaped as a 400 with `error` + `code`).
|
|
406
|
+
*/
|
|
407
|
+
function parseMetadataJsonAlias(url: URL): {
|
|
408
|
+
metadata?: Record<string, unknown>;
|
|
409
|
+
error?: Response;
|
|
410
|
+
} {
|
|
411
|
+
const raw = parseQuery(url, "metadata");
|
|
412
|
+
if (raw === null) return {};
|
|
413
|
+
|
|
414
|
+
const malformed = (detail: string): Response =>
|
|
415
|
+
json(
|
|
416
|
+
{
|
|
417
|
+
error: `metadata query param must be a JSON object of the form {"field":{"op":value}} — ${detail}`,
|
|
418
|
+
code: "INVALID_QUERY",
|
|
419
|
+
},
|
|
420
|
+
400,
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
let parsed: unknown;
|
|
424
|
+
try {
|
|
425
|
+
parsed = JSON.parse(raw);
|
|
426
|
+
} catch (e) {
|
|
427
|
+
return { error: malformed(`failed to parse: ${e instanceof Error ? e.message : String(e)}`) };
|
|
428
|
+
}
|
|
429
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
430
|
+
const got = Array.isArray(parsed) ? "array" : parsed === null ? "null" : typeof parsed;
|
|
431
|
+
return { error: malformed(`got ${got}`) };
|
|
432
|
+
}
|
|
433
|
+
// Empty object → no filter intent. Return absent so it neither sets a
|
|
434
|
+
// metadata filter nor trips the both-forms guard in the handler.
|
|
435
|
+
if (Object.keys(parsed).length === 0) return {};
|
|
436
|
+
return { metadata: parsed as Record<string, unknown> };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Parse + validate the `?expand=` tag-expansion axis (vault tag `expand` axis).
|
|
441
|
+
* Shared by `parseNotesQueryOpts` (structured + subscribe) AND the full-text
|
|
442
|
+
* search branch of `handleNotes` (which bypasses `parseNotesQueryOpts`), so the
|
|
443
|
+
* enum lives in exactly one place and `GET /notes?search=...&expand=bogus` is
|
|
444
|
+
* validated identically to the structured path.
|
|
445
|
+
*
|
|
446
|
+
* Returns `{ expand }` (undefined when absent/empty → store defaults to
|
|
447
|
+
* "subtypes") or `{ error }` (a 400 Response) on an unknown value.
|
|
448
|
+
*/
|
|
449
|
+
export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Response } {
|
|
450
|
+
const expandParam = parseQuery(url, "expand");
|
|
451
|
+
if (expandParam === null || expandParam === "") return {};
|
|
452
|
+
if (!(TAG_EXPAND_MODES as readonly string[]).includes(expandParam)) {
|
|
453
|
+
return {
|
|
454
|
+
error: json(
|
|
455
|
+
{
|
|
456
|
+
error: `invalid \`expand\` value "${expandParam}" — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes": parent_names descendants).`,
|
|
457
|
+
code: "INVALID_QUERY",
|
|
458
|
+
},
|
|
459
|
+
400,
|
|
460
|
+
),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
return { expand: expandParam as TagExpandMode };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Parse the shared notes-query parameters (tags / excludeTags / path /
|
|
468
|
+
* pathPrefix / extension / metadata filters / date filters / sort / paging /
|
|
469
|
+
* cursor) into a `QueryOpts`, plus flags for the query shapes that the live
|
|
470
|
+
* subscription endpoint must reject (`search`, `near`, `cursor`).
|
|
471
|
+
*
|
|
472
|
+
* Factored out of `handleNotesInner`'s structured-query branch so the
|
|
473
|
+
* `/subscribe` route evaluates the SAME predicate the snapshot query does —
|
|
474
|
+
* predicate parity by construction, not copy-paste. `handleNotesInner` keeps
|
|
475
|
+
* its own inline parsing for the single-note (`id`) and full-text (`search`)
|
|
476
|
+
* branches; this helper covers the structured-query shape both endpoints share.
|
|
477
|
+
*
|
|
478
|
+
* Returns `{ error }` (a 400 Response) on a malformed metadata filter, exactly
|
|
479
|
+
* as the inline code did. `hasSearch` is surfaced from the raw `search` param
|
|
480
|
+
* (this helper does not itself build a search query — the caller routes that).
|
|
481
|
+
*/
|
|
482
|
+
export function parseNotesQueryOpts(url: URL): {
|
|
483
|
+
queryOpts?: QueryOpts;
|
|
484
|
+
hasSearch: boolean;
|
|
485
|
+
hasNear: boolean;
|
|
486
|
+
hasCursor: boolean;
|
|
487
|
+
error?: Response;
|
|
488
|
+
} {
|
|
489
|
+
const hasSearch = parseQuery(url, "search") !== null && parseQuery(url, "search") !== "";
|
|
490
|
+
const hasNear = parseQuery(url, "near[note_id]") !== null;
|
|
491
|
+
const cursorParam = parseQuery(url, "cursor");
|
|
492
|
+
const hasCursor = cursorParam !== null && cursorParam !== "";
|
|
493
|
+
|
|
494
|
+
const tags = parseQueryList(url, "tag");
|
|
495
|
+
const bracket = parseMetaBrackets(url);
|
|
496
|
+
if (bracket.error) return { hasSearch, hasNear, hasCursor, error: bracket.error };
|
|
497
|
+
const metadataAlias = parseMetadataJsonAlias(url);
|
|
498
|
+
if (metadataAlias.error) return { hasSearch, hasNear, hasCursor, error: metadataAlias.error };
|
|
499
|
+
if (metadataAlias.metadata && bracket.metadata) {
|
|
500
|
+
return {
|
|
501
|
+
hasSearch,
|
|
502
|
+
hasNear,
|
|
503
|
+
hasCursor,
|
|
504
|
+
error: json(
|
|
505
|
+
{
|
|
506
|
+
error: "pass metadata filters as either the JSON `metadata=` param or bracket `meta[field][op]=` form, not both.",
|
|
507
|
+
code: "INVALID_QUERY",
|
|
508
|
+
},
|
|
509
|
+
400,
|
|
510
|
+
),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Tag-expansion axis (vault tag `expand` axis). Optional; absent →
|
|
515
|
+
// "subtypes" (resolved at the store, kept undefined here so it stays
|
|
516
|
+
// byte-identical to pre-axis behavior). Unknown value → 400 via the shared
|
|
517
|
+
// helper (same shape the search branch uses).
|
|
518
|
+
const expandParsed = parseExpandParam(url);
|
|
519
|
+
if (expandParsed.error) return { hasSearch, hasNear, hasCursor, error: expandParsed.error };
|
|
520
|
+
const expand = expandParsed.expand;
|
|
521
|
+
|
|
522
|
+
const queryOpts: QueryOpts = {
|
|
523
|
+
tags,
|
|
524
|
+
tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
525
|
+
expand,
|
|
526
|
+
excludeTags: parseQueryList(url, "exclude_tag"),
|
|
527
|
+
hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
|
|
528
|
+
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
529
|
+
path: parseQuery(url, "path") ?? undefined,
|
|
530
|
+
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
531
|
+
extension: parseExtensionFilter(url),
|
|
532
|
+
metadata: bracket.metadata ?? metadataAlias.metadata,
|
|
533
|
+
...(bracket.dateFilter
|
|
534
|
+
? { dateFilter: bracket.dateFilter }
|
|
535
|
+
: parseQuery(url, "date_field")
|
|
536
|
+
? {
|
|
537
|
+
dateFilter: {
|
|
538
|
+
field: parseQuery(url, "date_field")!,
|
|
539
|
+
from: parseQuery(url, "date_from") ?? undefined,
|
|
540
|
+
to: parseQuery(url, "date_to") ?? undefined,
|
|
541
|
+
},
|
|
542
|
+
}
|
|
543
|
+
: {
|
|
544
|
+
dateFrom: parseQuery(url, "date_from") ?? undefined,
|
|
545
|
+
dateTo: parseQuery(url, "date_to") ?? undefined,
|
|
546
|
+
}),
|
|
547
|
+
sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
|
|
548
|
+
orderBy: parseQuery(url, "order_by") ?? undefined,
|
|
549
|
+
limit: parseInt10(parseQuery(url, "limit")) ?? 50,
|
|
550
|
+
offset: parseInt10(parseQuery(url, "offset")),
|
|
551
|
+
cursor: cursorParam ?? undefined,
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
return { queryOpts, hasSearch, hasNear, hasCursor };
|
|
555
|
+
}
|
|
556
|
+
|
|
356
557
|
/**
|
|
357
558
|
* Parse include_metadata query param.
|
|
358
559
|
* - absent/null → undefined (all metadata, default)
|
|
@@ -373,10 +574,20 @@ function parseIncludeMetadata(url: URL): boolean | string[] | undefined {
|
|
|
373
574
|
/**
|
|
374
575
|
* Parse expand_links/expand_depth/expand_mode from query params, returning
|
|
375
576
|
* an (ExpandContext, depth) pair if expansion is requested, else null.
|
|
577
|
+
*
|
|
578
|
+
* `tagScope` (security review): when the caller is tag-scoped, an `isVisible`
|
|
579
|
+
* predicate is built from the SAME tag-scope allowlist the rest of the
|
|
580
|
+
* handler uses and injected into the expand context. The expander then
|
|
581
|
+
* leaves any `[[wikilink]]` whose target is out of scope UNRESOLVED — so
|
|
582
|
+
* `expand_links=true` can never inline out-of-scope note content (and the
|
|
583
|
+
* out-of-scope case is byte-indistinguishable from a missing target). For
|
|
584
|
+
* an unscoped token the predicate is `undefined` and expansion behaves
|
|
585
|
+
* exactly as before.
|
|
376
586
|
*/
|
|
377
587
|
function parseExpandParams(
|
|
378
588
|
url: URL,
|
|
379
589
|
db: any,
|
|
590
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
380
591
|
): { ctx: ExpandContext; depth: number } | null {
|
|
381
592
|
if (!parseBool(parseQuery(url, "expand_links"), false)) return null;
|
|
382
593
|
const modeRaw = parseQuery(url, "expand_mode");
|
|
@@ -388,7 +599,8 @@ function parseExpandParams(
|
|
|
388
599
|
MAX_EXPAND_DEPTH,
|
|
389
600
|
),
|
|
390
601
|
);
|
|
391
|
-
|
|
602
|
+
const isVisible = buildExpandVisibility(tagScope.allowed, tagScope.raw);
|
|
603
|
+
return { ctx: { db, mode, expanded: new Set(), ...(isVisible ? { isVisible } : {}) }, depth };
|
|
392
604
|
}
|
|
393
605
|
|
|
394
606
|
|
|
@@ -474,7 +686,7 @@ async function handleNotesInner(
|
|
|
474
686
|
): Promise<Response> {
|
|
475
687
|
const url = new URL(req.url);
|
|
476
688
|
const method = req.method;
|
|
477
|
-
const db =
|
|
689
|
+
const db = store.db;
|
|
478
690
|
|
|
479
691
|
// ---- Collection routes (no ID in path) ----
|
|
480
692
|
if (subpath === "") {
|
|
@@ -495,18 +707,30 @@ async function handleNotesInner(
|
|
|
495
707
|
}
|
|
496
708
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
497
709
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
498
|
-
const expand = parseExpandParams(url, db);
|
|
710
|
+
const expand = parseExpandParams(url, db, tagScope);
|
|
499
711
|
if (expand && includeContent && typeof result.content === "string") {
|
|
500
712
|
expand.ctx.expanded.add(note.id);
|
|
501
713
|
result.content = expandContent(result.content, expand.ctx, expand.depth);
|
|
502
714
|
}
|
|
503
715
|
result = filterMetadata(result, parseIncludeMetadata(url));
|
|
504
716
|
if (parseBool(parseQuery(url, "include_links"), false)) {
|
|
505
|
-
|
|
717
|
+
// Tag-scope: drop links whose neighbor is out of scope so the
|
|
718
|
+
// hydrated sourceNote/targetNote summaries can't leak out-of-scope
|
|
719
|
+
// ids/paths/tags. No-op for unscoped tokens.
|
|
720
|
+
result.links = filterHydratedLinksByTagScope(
|
|
721
|
+
linkOps.getLinksHydrated(db, note.id),
|
|
722
|
+
tagScope.allowed,
|
|
723
|
+
tagScope.raw,
|
|
724
|
+
);
|
|
506
725
|
}
|
|
507
726
|
if (parseBool(parseQuery(url, "include_attachments"), false)) {
|
|
508
727
|
result.attachments = await store.getAttachments(note.id);
|
|
509
728
|
}
|
|
729
|
+
// linkCount injected after filterMetadata on purpose — same as
|
|
730
|
+
// links/attachments above; filterMetadata only touches `metadata`.
|
|
731
|
+
if (parseBool(parseQuery(url, "include_link_count"), false)) {
|
|
732
|
+
result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
|
|
733
|
+
}
|
|
510
734
|
return json(result);
|
|
511
735
|
}
|
|
512
736
|
|
|
@@ -529,7 +753,17 @@ async function handleNotesInner(
|
|
|
529
753
|
if (search) {
|
|
530
754
|
const searchTags = parseQueryList(url, "tag");
|
|
531
755
|
const limit = parseInt10(parseQuery(url, "limit")) ?? 50;
|
|
532
|
-
|
|
756
|
+
// Tag-expansion axis (vault tag `expand` axis). This branch bypasses
|
|
757
|
+
// `parseNotesQueryOpts`, so validate `?expand=` here too — otherwise
|
|
758
|
+
// `GET /notes?search=x&expand=bogus` would silently ignore the bad
|
|
759
|
+
// value. The validated mode is threaded into the search tag-narrowing.
|
|
760
|
+
const tagExpand = parseExpandParam(url);
|
|
761
|
+
if (tagExpand.error) return tagExpand.error;
|
|
762
|
+
const rawResults = await store.searchNotes(search, {
|
|
763
|
+
tags: searchTags,
|
|
764
|
+
limit,
|
|
765
|
+
expand: tagExpand.expand,
|
|
766
|
+
});
|
|
533
767
|
// Tag-scope: drop any result the token isn't permitted to see. Filter
|
|
534
768
|
// happens after the store query so an empty post-filter list still
|
|
535
769
|
// returns 200 [] (consistent with "no matches"), not 403.
|
|
@@ -537,7 +771,7 @@ async function handleNotesInner(
|
|
|
537
771
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
538
772
|
const inclMeta = parseIncludeMetadata(url);
|
|
539
773
|
let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
|
|
540
|
-
const expand = parseExpandParams(url, db);
|
|
774
|
+
const expand = parseExpandParams(url, db, tagScope);
|
|
541
775
|
if (expand && includeContent) {
|
|
542
776
|
for (const n of output) expand.ctx.expanded.add(n.id);
|
|
543
777
|
for (const n of output) {
|
|
@@ -549,6 +783,18 @@ async function handleNotesInner(
|
|
|
549
783
|
if (inclMeta !== undefined && inclMeta !== true) {
|
|
550
784
|
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
551
785
|
}
|
|
786
|
+
// Opt-in link degree (vault feedback #4) — one batch count, same as
|
|
787
|
+
// the structured-query path. Runs AFTER filterMetadata on purpose
|
|
788
|
+
// (filterMetadata only touches `metadata`, so linkCount survives —
|
|
789
|
+
// don't casually swap the order).
|
|
790
|
+
if (parseBool(parseQuery(url, "include_link_count"), false)) {
|
|
791
|
+
const counts = linkOps.getLinkCounts(
|
|
792
|
+
db,
|
|
793
|
+
output.map((n: any) => n.id),
|
|
794
|
+
parseLinkCountDirection(url),
|
|
795
|
+
);
|
|
796
|
+
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
797
|
+
}
|
|
552
798
|
return json(output);
|
|
553
799
|
}
|
|
554
800
|
|
|
@@ -564,7 +810,7 @@ async function handleNotesInner(
|
|
|
564
810
|
//
|
|
565
811
|
// - **Flat date params** (DEPRECATED): `?date_field=created_at&
|
|
566
812
|
// date_from=…&date_to=…` and the legacy `?date_from=…&date_to=…`.
|
|
567
|
-
// Still functional through 0.5.x; planned removal in 0.
|
|
813
|
+
// Still functional through 0.5.x; planned removal in a later 0.x
|
|
568
814
|
// (vault#288). New consumers should use bracket-style.
|
|
569
815
|
//
|
|
570
816
|
// Precedence on overlap: bracket-style wins. If a caller passes both
|
|
@@ -577,15 +823,16 @@ async function handleNotesInner(
|
|
|
577
823
|
// Surface asymmetry: REST flattens to a query string; MCP takes a
|
|
578
824
|
// nested `date_filter: { field, from, to }` object directly. Both
|
|
579
825
|
// lower to the same store-level `dateFilter` shape.
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
// exclusive with the `near` graph-neighborhood scope (rebuilding the
|
|
587
|
-
// neighborhood per page isn't stable) — rejected below.
|
|
826
|
+
// Structured-query parsing is shared with the live `/subscribe` route
|
|
827
|
+
// (see `parseNotesQueryOpts`) so both endpoints lower an identical query
|
|
828
|
+
// string to the same `QueryOpts` — predicate parity by construction.
|
|
829
|
+
const parsed = parseNotesQueryOpts(url);
|
|
830
|
+
if (parsed.error) return parsed.error;
|
|
831
|
+
const queryOpts = parsed.queryOpts!;
|
|
588
832
|
const cursorParam = parseQuery(url, "cursor");
|
|
833
|
+
// Opaque cursor for "since last checked" agent loops (vault#313) is
|
|
834
|
+
// mutually exclusive with the `near` graph-neighborhood scope (rebuilding
|
|
835
|
+
// the neighborhood per page isn't stable).
|
|
589
836
|
const nearNoteIdEarly = parseQuery(url, "near[note_id]");
|
|
590
837
|
if (cursorParam && nearNoteIdEarly) {
|
|
591
838
|
return json(
|
|
@@ -598,48 +845,6 @@ async function handleNotesInner(
|
|
|
598
845
|
}
|
|
599
846
|
let results: Note[];
|
|
600
847
|
let nextCursor: string | null = null;
|
|
601
|
-
const queryOpts = {
|
|
602
|
-
tags,
|
|
603
|
-
tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
604
|
-
excludeTags: parseQueryList(url, "exclude_tag"),
|
|
605
|
-
hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
|
|
606
|
-
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
607
|
-
path: parseQuery(url, "path") ?? undefined,
|
|
608
|
-
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
609
|
-
// Extension filter (vault#328). Accepts repeated `extension=`
|
|
610
|
-
// params for the array form: `?extension=csv&extension=yaml`.
|
|
611
|
-
// `parseQueryList` already returns undefined when no params
|
|
612
|
-
// are present, so the filter is silently skipped on a plain
|
|
613
|
-
// GET without the extension query.
|
|
614
|
-
extension: parseExtensionFilter(url),
|
|
615
|
-
metadata: bracket.metadata,
|
|
616
|
-
// Date-range precedence chain (highest to lowest):
|
|
617
|
-
// 1. Bracket-style `meta[created_at][gte]=…` (canonical).
|
|
618
|
-
// 2. Flat `date_field=…&date_from=…&date_to=…` (deprecated).
|
|
619
|
-
// 3. Legacy `date_from=…&date_to=…` (no date_field, deprecated)
|
|
620
|
-
// — filters on `n.created_at` by definition.
|
|
621
|
-
// The engine rejects combinations of `dateFilter` with the legacy
|
|
622
|
-
// `dateFrom`/`dateTo`, so we never set both shapes simultaneously.
|
|
623
|
-
...(bracket.dateFilter
|
|
624
|
-
? { dateFilter: bracket.dateFilter }
|
|
625
|
-
: parseQuery(url, "date_field")
|
|
626
|
-
? {
|
|
627
|
-
dateFilter: {
|
|
628
|
-
field: parseQuery(url, "date_field")!,
|
|
629
|
-
from: parseQuery(url, "date_from") ?? undefined,
|
|
630
|
-
to: parseQuery(url, "date_to") ?? undefined,
|
|
631
|
-
},
|
|
632
|
-
}
|
|
633
|
-
: {
|
|
634
|
-
dateFrom: parseQuery(url, "date_from") ?? undefined,
|
|
635
|
-
dateTo: parseQuery(url, "date_to") ?? undefined,
|
|
636
|
-
}),
|
|
637
|
-
sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
|
|
638
|
-
orderBy: parseQuery(url, "order_by") ?? undefined,
|
|
639
|
-
limit: parseInt10(parseQuery(url, "limit")) ?? 50,
|
|
640
|
-
offset: parseInt10(parseQuery(url, "offset")),
|
|
641
|
-
cursor: cursorParam ?? undefined,
|
|
642
|
-
};
|
|
643
848
|
try {
|
|
644
849
|
if (cursorParam) {
|
|
645
850
|
const page = await store.queryNotesPaged(queryOpts);
|
|
@@ -677,7 +882,24 @@ async function handleNotesInner(
|
|
|
677
882
|
}
|
|
678
883
|
const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
|
|
679
884
|
const relationship = parseQuery(url, "near[relationship]") ?? undefined;
|
|
680
|
-
|
|
885
|
+
// Tag-scope policy for `near[]` (vault#439 — hop-guard, symmetric with
|
|
886
|
+
// find-path): for a tag-scoped token the BFS refuses to traverse
|
|
887
|
+
// THROUGH out-of-scope notes — scope is a wall, not a sieve. So a token
|
|
888
|
+
// scoped to ["work"] can't reach an in-scope note at depth 2 via a
|
|
889
|
+
// #personal intermediary at depth 1; that note is simply unreachable.
|
|
890
|
+
// The `filterNotesByTagScope` pass below still runs (defense in depth),
|
|
891
|
+
// but the wall makes it redundant for the `near[]` result set.
|
|
892
|
+
// Unscoped tokens (`tagScope.raw === null`) install no predicate → the
|
|
893
|
+
// FULL graph is walked exactly as before, behavior unchanged.
|
|
894
|
+
const isTraversable = tagScope.raw
|
|
895
|
+
? (id: string) =>
|
|
896
|
+
noteWithinTagScope(
|
|
897
|
+
{ id, tags: getNoteTags(db, id) } as Note,
|
|
898
|
+
tagScope.allowed,
|
|
899
|
+
tagScope.raw,
|
|
900
|
+
)
|
|
901
|
+
: undefined;
|
|
902
|
+
const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship, isTraversable });
|
|
681
903
|
const nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
|
|
682
904
|
results = results.filter((n) => nearScope.has(n.id));
|
|
683
905
|
}
|
|
@@ -690,9 +912,10 @@ async function handleNotesInner(
|
|
|
690
912
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
691
913
|
const includeLinks = parseBool(parseQuery(url, "include_links"), false);
|
|
692
914
|
const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
|
|
915
|
+
const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
|
|
693
916
|
const inclMeta = parseIncludeMetadata(url);
|
|
694
917
|
let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
|
|
695
|
-
const expand = parseExpandParams(url, db);
|
|
918
|
+
const expand = parseExpandParams(url, db, tagScope);
|
|
696
919
|
if (expand && includeContent) {
|
|
697
920
|
for (const n of output) expand.ctx.expanded.add(n.id);
|
|
698
921
|
for (const n of output) {
|
|
@@ -704,6 +927,24 @@ async function handleNotesInner(
|
|
|
704
927
|
if (inclMeta !== undefined && inclMeta !== true) {
|
|
705
928
|
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
706
929
|
}
|
|
930
|
+
// Opt-in link degree (vault feedback #4). ONE batch count over all
|
|
931
|
+
// result ids — NOT a per-note query — so the field stays O(2 index
|
|
932
|
+
// scans) per request regardless of page size. Mutates `output` in
|
|
933
|
+
// place; injected on the same objects the enrichment loop below
|
|
934
|
+
// touches, the same way `links`/`attachments` are surfaced.
|
|
935
|
+
// Ordering: this runs AFTER the filterMetadata pass above on purpose —
|
|
936
|
+
// filterMetadata only touches the `metadata` key, so a linkCount
|
|
937
|
+
// injected here survives. Don't casually swap the order; injecting
|
|
938
|
+
// before filterMetadata would still survive today but couples the two
|
|
939
|
+
// to filterMetadata's current narrow behavior.
|
|
940
|
+
if (includeLinkCount) {
|
|
941
|
+
const counts = linkOps.getLinkCounts(
|
|
942
|
+
db,
|
|
943
|
+
output.map((n: any) => n.id),
|
|
944
|
+
parseLinkCountDirection(url),
|
|
945
|
+
);
|
|
946
|
+
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
947
|
+
}
|
|
707
948
|
|
|
708
949
|
// Graph format — reshape into { nodes, edges }
|
|
709
950
|
if (parseQuery(url, "format") === "graph") {
|
|
@@ -727,7 +968,14 @@ async function handleNotesInner(
|
|
|
727
968
|
const enrichedOut: any[] = [];
|
|
728
969
|
for (const n of output) {
|
|
729
970
|
const enriched: any = { ...n };
|
|
730
|
-
if (includeLinks)
|
|
971
|
+
if (includeLinks) {
|
|
972
|
+
// Tag-scope: strip out-of-scope-neighbor links (no-op unscoped).
|
|
973
|
+
enriched.links = filterHydratedLinksByTagScope(
|
|
974
|
+
linkOps.getLinksHydrated(db, n.id),
|
|
975
|
+
tagScope.allowed,
|
|
976
|
+
tagScope.raw,
|
|
977
|
+
);
|
|
978
|
+
}
|
|
731
979
|
if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
|
|
732
980
|
enrichedOut.push(enriched);
|
|
733
981
|
}
|
|
@@ -830,19 +1078,33 @@ async function handleNotesInner(
|
|
|
830
1078
|
throw e;
|
|
831
1079
|
}
|
|
832
1080
|
|
|
833
|
-
// Apply tag schema defaults
|
|
1081
|
+
// Apply tag schema defaults, then re-read the notes whose metadata was
|
|
1082
|
+
// actually default-filled so the response reflects the final on-disk
|
|
1083
|
+
// state (the `created` entries were read before `applySchemaDefaults`
|
|
1084
|
+
// ran). Mirrors the MCP create-note path (vault#316). Batched re-read
|
|
1085
|
+
// (`getNotes` = one `WHERE id IN (...)`), skipped when no defaults
|
|
1086
|
+
// applied so the common no-defaults path adds zero extra reads.
|
|
1087
|
+
const mutatedIds = new Set<string>();
|
|
834
1088
|
for (const note of created) {
|
|
835
1089
|
if (note.tags?.length) {
|
|
836
|
-
await applySchemaDefaults(store, db, [note.id], note.tags)
|
|
1090
|
+
for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
|
|
1091
|
+
mutatedIds.add(id);
|
|
1092
|
+
}
|
|
837
1093
|
}
|
|
838
1094
|
}
|
|
1095
|
+
const refreshed =
|
|
1096
|
+
mutatedIds.size === 0
|
|
1097
|
+
? created
|
|
1098
|
+
: (() => {
|
|
1099
|
+
const byId = new Map(getNotes(db, [...mutatedIds]).map((n) => [n.id, n]));
|
|
1100
|
+
return created.map((n) => byId.get(n.id) ?? n);
|
|
1101
|
+
})();
|
|
839
1102
|
|
|
840
1103
|
// Attach `validation_status` so HTTP create-note matches the MCP
|
|
841
|
-
// surface (vault#287).
|
|
842
|
-
// `core/src/mcp.ts:451`. `attachValidationStatus` returns the note
|
|
1104
|
+
// surface (vault#287). `attachValidationStatus` returns the note
|
|
843
1105
|
// unchanged when no tag declares fields, so vaults without any tag
|
|
844
1106
|
// schemas see no behavior change.
|
|
845
|
-
const final =
|
|
1107
|
+
const final = refreshed.map((n) => attachValidationStatus(store, db, n));
|
|
846
1108
|
return json(body.notes ? final : final[0], 201);
|
|
847
1109
|
}
|
|
848
1110
|
|
|
@@ -882,7 +1144,14 @@ async function handleNotesInner(
|
|
|
882
1144
|
// Explicit `transcribe: true` wins — if the caller asked, we honor that
|
|
883
1145
|
// regardless of the auto-transcribe toggle (back-compat).
|
|
884
1146
|
const explicitOptIn = body.transcribe === true;
|
|
885
|
-
|
|
1147
|
+
// Per-vault auto-transcribe: read THIS vault's `auto_transcribe.enabled`
|
|
1148
|
+
// (vault.yaml) and pass it as the precedence-winning toggle. A vault that
|
|
1149
|
+
// set its own value uses it; one that left it unset falls through to the
|
|
1150
|
+
// global toggle inside `shouldAutoTranscribe` (per-vault → global → true).
|
|
1151
|
+
const perVaultEnabled = vault
|
|
1152
|
+
? readVaultConfig(vault)?.auto_transcribe?.enabled
|
|
1153
|
+
: undefined;
|
|
1154
|
+
const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType, { perVaultEnabled });
|
|
886
1155
|
const attMeta = (explicitOptIn || autoOptIn)
|
|
887
1156
|
? {
|
|
888
1157
|
transcribe_status: "pending" as const,
|
|
@@ -942,22 +1211,26 @@ async function handleNotesInner(
|
|
|
942
1211
|
return json({ error: "Method not allowed" }, 405);
|
|
943
1212
|
}
|
|
944
1213
|
|
|
945
|
-
// POST /notes/:idOrPath/retry-transcription — vault#353 design Q5.
|
|
1214
|
+
// POST /notes/:idOrPath/retry-transcription — vault#353 design Q5 + finding F.
|
|
946
1215
|
//
|
|
947
|
-
// Re-runs
|
|
948
|
-
//
|
|
949
|
-
// frontmatter
|
|
950
|
-
//
|
|
951
|
-
//
|
|
1216
|
+
// Re-runs transcription against a failed audio attachment. Two target
|
|
1217
|
+
// shapes, dispatched in handleRetryTranscription by whether the note carries
|
|
1218
|
+
// `transcript_status` frontmatter:
|
|
1219
|
+
// - Auto-flow (vault#353): target is a `<audio>.transcript.md` note with
|
|
1220
|
+
// `transcript_status: "failed"`; audio located via
|
|
1221
|
+
// `transcript_attachment_id`, origin preserved as "auto".
|
|
1222
|
+
// - Legacy in-body memo (finding F): target is the memo note itself (no
|
|
1223
|
+
// `transcript_status`); finds its own failed attachment, resets it
|
|
1224
|
+
// preserving `transcribe_origin: "legacy"`, and re-arms `transcribe_stub`.
|
|
952
1225
|
//
|
|
953
1226
|
// Wire shape:
|
|
954
1227
|
// POST .../notes/<idOrPath>/retry-transcription
|
|
955
|
-
// → 202 { attachment_id,
|
|
956
|
-
// 400
|
|
957
|
-
// 400
|
|
958
|
-
//
|
|
1228
|
+
// → 202 { attachment_id, attachment_path, transcript_note_id, worker }
|
|
1229
|
+
// 400 not_failed (auto-flow: transcript already succeeded)
|
|
1230
|
+
// 400 missing_attachment_id (auto-flow: transcript_attachment_id absent)
|
|
1231
|
+
// 400 no_failed_attachment (legacy: no failed audio attachment to retry)
|
|
1232
|
+
// 404 attachment_missing (auto-flow: transcript_attachment_id row deleted)
|
|
959
1233
|
// 404 audio_missing (audio file unlinked from disk)
|
|
960
|
-
// 503 scribe_unavailable (no worker configured this boot)
|
|
961
1234
|
if (sub === "/retry-transcription") {
|
|
962
1235
|
if (method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
963
1236
|
if (!vault) return json({ error: "Vault context required" }, 400);
|
|
@@ -980,18 +1253,26 @@ async function handleNotesInner(
|
|
|
980
1253
|
}
|
|
981
1254
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
982
1255
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
983
|
-
const expand = parseExpandParams(url, db);
|
|
1256
|
+
const expand = parseExpandParams(url, db, tagScope);
|
|
984
1257
|
if (expand && includeContent && typeof result.content === "string") {
|
|
985
1258
|
expand.ctx.expanded.add(note.id);
|
|
986
1259
|
result.content = expandContent(result.content, expand.ctx, expand.depth);
|
|
987
1260
|
}
|
|
988
1261
|
result = filterMetadata(result, parseIncludeMetadata(url));
|
|
989
1262
|
if (parseBool(parseQuery(url, "include_links"), false)) {
|
|
990
|
-
|
|
1263
|
+
// Tag-scope: drop out-of-scope-neighbor links (no-op unscoped).
|
|
1264
|
+
result.links = filterHydratedLinksByTagScope(
|
|
1265
|
+
linkOps.getLinksHydrated(db, note.id),
|
|
1266
|
+
tagScope.allowed,
|
|
1267
|
+
tagScope.raw,
|
|
1268
|
+
);
|
|
991
1269
|
}
|
|
992
1270
|
if (parseBool(parseQuery(url, "include_attachments"), false)) {
|
|
993
1271
|
result.attachments = await store.getAttachments(note.id);
|
|
994
1272
|
}
|
|
1273
|
+
if (parseBool(parseQuery(url, "include_link_count"), false)) {
|
|
1274
|
+
result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
|
|
1275
|
+
}
|
|
995
1276
|
return json(result);
|
|
996
1277
|
}
|
|
997
1278
|
|
|
@@ -1256,7 +1537,27 @@ async function handleNotesInner(
|
|
|
1256
1537
|
// `toNoteIndex` drops unknown fields).
|
|
1257
1538
|
const updatedNote = await store.getNote(note.id);
|
|
1258
1539
|
if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
|
|
1259
|
-
const validated = attachValidationStatus(store, db, updatedNote);
|
|
1540
|
+
const validated: any = attachValidationStatus(store, db, updatedNote);
|
|
1541
|
+
// Echo hydrated links when a link mutation was part of this request,
|
|
1542
|
+
// OR the caller explicitly asked for them via `?include_links=true`
|
|
1543
|
+
// (vault feedback #8). Previously the update response omitted links
|
|
1544
|
+
// entirely (`getNote` populates tags but not links), forcing callers
|
|
1545
|
+
// to re-GET with `?include_links=true` just to confirm a link they
|
|
1546
|
+
// had just added/removed. Additive field, scoped to UPDATE: present
|
|
1547
|
+
// only when mutated or requested. Mirrors the GET / query-notes
|
|
1548
|
+
// hydration call form exactly (`linkOps.getLinksHydrated`).
|
|
1549
|
+
const linkMutated = body.links?.add !== undefined || body.links?.remove !== undefined;
|
|
1550
|
+
const includeLinksResp = linkMutated || parseBool(parseQuery(url, "include_links"), false);
|
|
1551
|
+
if (includeLinksResp) {
|
|
1552
|
+
// Tag-scope: strip out-of-scope-neighbor links from the echoed set
|
|
1553
|
+
// (no-op unscoped). A write token tag-scoped to #work mustn't learn
|
|
1554
|
+
// about a #personal note it happened to link to.
|
|
1555
|
+
validated.links = filterHydratedLinksByTagScope(
|
|
1556
|
+
linkOps.getLinksHydrated(db, updatedNote.id),
|
|
1557
|
+
tagScope.allowed,
|
|
1558
|
+
tagScope.raw,
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1260
1561
|
const includeContentResp = body.include_content !== false;
|
|
1261
1562
|
// `created: false` is appended to every update-path response so
|
|
1262
1563
|
// sync-loop callers using `if_missing: "create"` can distinguish
|
|
@@ -1266,6 +1567,9 @@ async function handleNotesInner(
|
|
|
1266
1567
|
const lean: any = toNoteIndex(validated);
|
|
1267
1568
|
const vs = (validated as any).validation_status;
|
|
1268
1569
|
if (vs !== undefined) lean.validation_status = vs;
|
|
1570
|
+
// Carry the link echo across the lean conversion — `toNoteIndex`
|
|
1571
|
+
// drops unknown fields, same as the `validation_status` recipe above.
|
|
1572
|
+
if (validated.links !== undefined) lean.links = validated.links;
|
|
1269
1573
|
lean.created = false;
|
|
1270
1574
|
return json(lean);
|
|
1271
1575
|
} catch (e: any) {
|
|
@@ -1418,7 +1722,7 @@ export async function handleTags(
|
|
|
1418
1722
|
// that token's allowlist. Aggregate matches across sources for a single
|
|
1419
1723
|
// 409 envelope.
|
|
1420
1724
|
const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
|
|
1421
|
-
const db =
|
|
1725
|
+
const db = store.db;
|
|
1422
1726
|
for (const src of sources) {
|
|
1423
1727
|
const tokens = findTokensReferencingTag(db, src as string);
|
|
1424
1728
|
if (tokens.length > 0) referenced.push({ source: src as string, tokens });
|
|
@@ -1513,10 +1817,12 @@ export async function handleTags(
|
|
|
1513
1817
|
parent_names?: unknown;
|
|
1514
1818
|
};
|
|
1515
1819
|
|
|
1516
|
-
// Validate relationships
|
|
1517
|
-
//
|
|
1820
|
+
// Validate the relationships payload up front so a bad payload returns
|
|
1821
|
+
// 400, not a thrown 500. `relationships` is an opaque vocabulary map
|
|
1822
|
+
// (relationship-name → arbitrary JSON the app interprets); we only check
|
|
1823
|
+
// that it's a JSON object (a map), then persist verbatim.
|
|
1518
1824
|
let relationshipsPatch:
|
|
1519
|
-
|
|
|
1825
|
+
| tagSchemaOps.TagRelationshipMap
|
|
1520
1826
|
| null
|
|
1521
1827
|
| undefined;
|
|
1522
1828
|
if (body.relationships === null) {
|
|
@@ -1580,7 +1886,7 @@ export async function handleTags(
|
|
|
1580
1886
|
// tag would silently orphan the token's allowlist. Fail closed (409)
|
|
1581
1887
|
// and name the offending tokens so the operator can revoke or re-mint
|
|
1582
1888
|
// before retrying. patterns/tag-scoped-tokens.md §Dependencies.
|
|
1583
|
-
const referenced_by = findTokensReferencingTag(
|
|
1889
|
+
const referenced_by = findTokensReferencingTag(store.db, tagName);
|
|
1584
1890
|
if (referenced_by.length > 0) {
|
|
1585
1891
|
return json(
|
|
1586
1892
|
{
|
|
@@ -1615,7 +1921,7 @@ export async function handleFindPath(
|
|
|
1615
1921
|
const target = parseQuery(url, "target");
|
|
1616
1922
|
if (!source || !target) return json({ error: "source and target parameters are required" }, 400);
|
|
1617
1923
|
|
|
1618
|
-
const db =
|
|
1924
|
+
const db = store.db;
|
|
1619
1925
|
try {
|
|
1620
1926
|
const sourceNote = await resolveNote(store, source);
|
|
1621
1927
|
if (!sourceNote) return json({ error: `Note not found: "${source}"` }, 404);
|
|
@@ -1661,16 +1967,36 @@ type VaultConfigLike = {
|
|
|
1661
1967
|
name: string;
|
|
1662
1968
|
description?: string;
|
|
1663
1969
|
audio_retention?: "keep" | "until_transcribed" | "never";
|
|
1970
|
+
auto_transcribe?: { enabled?: boolean };
|
|
1664
1971
|
};
|
|
1665
1972
|
|
|
1666
1973
|
const VALID_AUDIO_RETENTION = ["keep", "until_transcribed", "never"] as const;
|
|
1667
1974
|
|
|
1975
|
+
/**
|
|
1976
|
+
* Resolve the effective auto-transcribe toggle for a vault's GET response, the
|
|
1977
|
+
* SAME resolution `shouldAutoTranscribe` uses at the decision point:
|
|
1978
|
+
* **per-vault → global → true**. A vault that set its own `auto_transcribe`
|
|
1979
|
+
* shows that; one that left it unset shows the server-wide default (itself
|
|
1980
|
+
* default-ON). This keeps the GET in lock-step with what the worker actually
|
|
1981
|
+
* does, with no field-name drift.
|
|
1982
|
+
*/
|
|
1983
|
+
function resolveAutoTranscribeEnabled(vaultConfig: VaultConfigLike): boolean {
|
|
1984
|
+
return (
|
|
1985
|
+
vaultConfig.auto_transcribe?.enabled
|
|
1986
|
+
?? readGlobalConfig().auto_transcribe?.enabled
|
|
1987
|
+
?? true
|
|
1988
|
+
);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1668
1991
|
function vaultResponse(vaultConfig: VaultConfigLike): Record<string, unknown> {
|
|
1669
1992
|
return {
|
|
1670
1993
|
name: vaultConfig.name,
|
|
1671
1994
|
description: vaultConfig.description ?? null,
|
|
1672
1995
|
config: {
|
|
1673
1996
|
audio_retention: vaultConfig.audio_retention ?? "keep",
|
|
1997
|
+
auto_transcribe: {
|
|
1998
|
+
enabled: resolveAutoTranscribeEnabled(vaultConfig),
|
|
1999
|
+
},
|
|
1674
2000
|
},
|
|
1675
2001
|
};
|
|
1676
2002
|
}
|
|
@@ -1694,7 +2020,7 @@ export async function handleVault(
|
|
|
1694
2020
|
if (req.method === "PATCH") {
|
|
1695
2021
|
const body = await req.json() as {
|
|
1696
2022
|
description?: string;
|
|
1697
|
-
config?: { audio_retention?: string };
|
|
2023
|
+
config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
|
|
1698
2024
|
};
|
|
1699
2025
|
let dirty = false;
|
|
1700
2026
|
|
|
@@ -1718,6 +2044,28 @@ export async function handleVault(
|
|
|
1718
2044
|
dirty = true;
|
|
1719
2045
|
}
|
|
1720
2046
|
|
|
2047
|
+
// auto_transcribe.enabled — PER-VAULT toggle persisted to THIS vault's
|
|
2048
|
+
// vault.yaml (via `persist`, the same writeVaultConfig path as
|
|
2049
|
+
// description/audio_retention). Flipping it for vault X affects only X;
|
|
2050
|
+
// scribe's "link to vault X" PATCHes this and never touches other vaults.
|
|
2051
|
+
// The worker reads the same per-vault field (per-vault → global → true).
|
|
2052
|
+
// Validate the shape: when `auto_transcribe` is present it must carry a
|
|
2053
|
+
// boolean `enabled`.
|
|
2054
|
+
if (body.config?.auto_transcribe !== undefined) {
|
|
2055
|
+
const enabled = body.config.auto_transcribe?.enabled;
|
|
2056
|
+
if (typeof enabled !== "boolean") {
|
|
2057
|
+
return json(
|
|
2058
|
+
{
|
|
2059
|
+
error: "invalid_auto_transcribe",
|
|
2060
|
+
message: "auto_transcribe.enabled must be a boolean",
|
|
2061
|
+
},
|
|
2062
|
+
400,
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
vaultConfig.auto_transcribe = { ...vaultConfig.auto_transcribe, enabled };
|
|
2066
|
+
dirty = true;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
1721
2069
|
if (dirty && persist) persist();
|
|
1722
2070
|
return json(vaultResponse(vaultConfig));
|
|
1723
2071
|
}
|
|
@@ -1729,12 +2077,32 @@ export async function handleVault(
|
|
|
1729
2077
|
// Unresolved wikilinks — REST-only (admin/maintenance)
|
|
1730
2078
|
// ---------------------------------------------------------------------------
|
|
1731
2079
|
|
|
1732
|
-
export function handleUnresolvedWikilinks(
|
|
2080
|
+
export function handleUnresolvedWikilinks(
|
|
2081
|
+
req: Request,
|
|
2082
|
+
store: Store,
|
|
2083
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
2084
|
+
): Response {
|
|
1733
2085
|
const url = new URL(req.url);
|
|
1734
2086
|
const limitStr = url.searchParams.get("limit");
|
|
1735
2087
|
const limit = limitStr ? parseInt(limitStr, 10) : 50;
|
|
1736
|
-
const db =
|
|
1737
|
-
|
|
2088
|
+
const db = store.db;
|
|
2089
|
+
const result = listUnresolvedWikilinks(db, limit);
|
|
2090
|
+
|
|
2091
|
+
// Unscoped token → return as-is (unchanged behavior).
|
|
2092
|
+
if (tagScope.raw === null) return Response.json(result);
|
|
2093
|
+
|
|
2094
|
+
// Tag-scope confidentiality (security review): each unresolved row carries
|
|
2095
|
+
// a `source_id` (+ `source_path`) plus the raw `target_path` wikilink
|
|
2096
|
+
// string. For a tag-scoped token, surface ONLY rows whose SOURCE note is
|
|
2097
|
+
// within the token's tag scope — otherwise we'd leak out-of-scope note IDs
|
|
2098
|
+
// and the wikilink target strings those notes contain. Filter the page and
|
|
2099
|
+
// recompute `count` from the filtered set so the aggregate total of
|
|
2100
|
+
// out-of-scope rows doesn't leak either.
|
|
2101
|
+
const filtered = result.unresolved.filter((row) => {
|
|
2102
|
+
const note = getNote(db, row.source_id);
|
|
2103
|
+
return note !== null && noteWithinTagScope(note, tagScope.allowed, tagScope.raw);
|
|
2104
|
+
});
|
|
2105
|
+
return Response.json({ unresolved: filtered, count: filtered.length });
|
|
1738
2106
|
}
|
|
1739
2107
|
|
|
1740
2108
|
// ---------------------------------------------------------------------------
|
|
@@ -1923,21 +2291,35 @@ ${rendered}
|
|
|
1923
2291
|
// ---------------------------------------------------------------------------
|
|
1924
2292
|
|
|
1925
2293
|
/**
|
|
1926
|
-
* Re-enqueue the original audio attachment for a
|
|
1927
|
-
* transcript note. Steps:
|
|
2294
|
+
* Re-enqueue the original audio attachment for a failed transcription.
|
|
1928
2295
|
*
|
|
1929
|
-
*
|
|
1930
|
-
*
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1935
|
-
*
|
|
1936
|
-
*
|
|
1937
|
-
*
|
|
1938
|
-
*
|
|
1939
|
-
*
|
|
1940
|
-
*
|
|
2296
|
+
* Two target shapes are accepted:
|
|
2297
|
+
*
|
|
2298
|
+
* - **Auto-flow (vault#353):** the target is a `<audio>.transcript.md` note
|
|
2299
|
+
* carrying `transcript_status` frontmatter. Requires that status be
|
|
2300
|
+
* `failed`; locates the audio via `transcript_attachment_id`; preserves
|
|
2301
|
+
* `transcribe_origin: "auto"` so a retried success overwrites this
|
|
2302
|
+
* transcript note in place. Behavior is byte-identical to the original
|
|
2303
|
+
* vault#353 contract.
|
|
2304
|
+
*
|
|
2305
|
+
* - **Legacy in-body memo (finding F):** the target is the memo note
|
|
2306
|
+
* itself — no `transcript_status` frontmatter. The original capture body
|
|
2307
|
+
* holds `![[<audio>]]` + a `_Transcription unavailable._` marker (written
|
|
2308
|
+
* by the worker on terminal failure). We find the note's own failed audio
|
|
2309
|
+
* attachment, reset it to pending **preserving `transcribe_origin:
|
|
2310
|
+
* "legacy"`** (forcing "auto" would switch to the sibling-transcript-note
|
|
2311
|
+
* shape and orphan the in-body embed), and **re-stamp `transcribe_stub:
|
|
2312
|
+
* true`** on the note. The stub re-arm is load-bearing: the legacy success
|
|
2313
|
+
* path early-returns unless `transcribe_stub === true`, so without it the
|
|
2314
|
+
* retried success would never write the transcript back into the body.
|
|
2315
|
+
* On success the worker replaces the `_Transcription unavailable._` marker
|
|
2316
|
+
* with the transcript in place, yielding the same final shape a first-try
|
|
2317
|
+
* success would.
|
|
2318
|
+
*
|
|
2319
|
+
* Common steps for both: validate the audio attachment row exists (404 if
|
|
2320
|
+
* gone) and its file is still on disk (404 if unlinked — e.g. retention=never
|
|
2321
|
+
* already dropped it), reset the transcribe_status fields, then kick the
|
|
2322
|
+
* worker if registered (otherwise the sweep picks it up).
|
|
1941
2323
|
*/
|
|
1942
2324
|
async function handleRetryTranscription(
|
|
1943
2325
|
store: Store,
|
|
@@ -1945,15 +2327,13 @@ async function handleRetryTranscription(
|
|
|
1945
2327
|
vault: string,
|
|
1946
2328
|
): Promise<Response> {
|
|
1947
2329
|
const meta = (note.metadata as Record<string, unknown> | undefined) ?? {};
|
|
2330
|
+
|
|
2331
|
+
// Legacy in-body memo: no `transcript_status` frontmatter. The note owns
|
|
2332
|
+
// the failed audio attachment directly; there's no sibling transcript note.
|
|
1948
2333
|
if (typeof meta.transcript_status !== "string") {
|
|
1949
|
-
return
|
|
1950
|
-
{
|
|
1951
|
-
error: "invalid_target",
|
|
1952
|
-
message: "Target note is not a transcript note (no transcript_status frontmatter).",
|
|
1953
|
-
},
|
|
1954
|
-
400,
|
|
1955
|
-
);
|
|
2334
|
+
return handleRetryLegacyInBody(store, note, vault);
|
|
1956
2335
|
}
|
|
2336
|
+
|
|
1957
2337
|
if (meta.transcript_status !== "failed") {
|
|
1958
2338
|
return json(
|
|
1959
2339
|
{
|
|
@@ -2040,13 +2420,171 @@ async function handleRetryTranscription(
|
|
|
2040
2420
|
);
|
|
2041
2421
|
}
|
|
2042
2422
|
|
|
2423
|
+
/**
|
|
2424
|
+
* Retry path for a legacy in-body voice memo (finding F). The target note is
|
|
2425
|
+
* the memo itself (no `transcript_status` frontmatter); it directly owns the
|
|
2426
|
+
* audio attachment whose transcription failed.
|
|
2427
|
+
*
|
|
2428
|
+
* Steps:
|
|
2429
|
+
* 1. Find the note's own audio attachment with `transcribe_status ===
|
|
2430
|
+
* "failed"`. 400 `no_failed_attachment` if none — there's nothing to
|
|
2431
|
+
* retry.
|
|
2432
|
+
* 2. Validate the audio file is still on disk (404 `audio_missing`).
|
|
2433
|
+
* 3. Reset the attachment to pending, **preserving `transcribe_origin:
|
|
2434
|
+
* "legacy"`** (never force "auto" — that switches to the sibling-
|
|
2435
|
+
* transcript-note shape and orphans the in-body `![[memo]]` embed).
|
|
2436
|
+
* 4. **Re-stamp `transcribe_stub: true`** on the note. The legacy worker
|
|
2437
|
+
* success path early-returns unless the note carries this flag (it was
|
|
2438
|
+
* cleared when the failure marker was written), so re-arming it is what
|
|
2439
|
+
* lets the retried success replace the `_Transcription unavailable._`
|
|
2440
|
+
* marker with the transcript.
|
|
2441
|
+
* 5. Kick the worker if registered; otherwise the sweep picks it up.
|
|
2442
|
+
*/
|
|
2443
|
+
async function handleRetryLegacyInBody(
|
|
2444
|
+
store: Store,
|
|
2445
|
+
note: Note,
|
|
2446
|
+
vault: string,
|
|
2447
|
+
): Promise<Response> {
|
|
2448
|
+
const attachments = await store.getAttachments(note.id);
|
|
2449
|
+
const failed = attachments.find((a) => {
|
|
2450
|
+
const m = (a.metadata as Record<string, unknown> | undefined) ?? {};
|
|
2451
|
+
return m.transcribe_status === "failed";
|
|
2452
|
+
});
|
|
2453
|
+
if (!failed) {
|
|
2454
|
+
return json(
|
|
2455
|
+
{
|
|
2456
|
+
error: "no_failed_attachment",
|
|
2457
|
+
message:
|
|
2458
|
+
"Target note is not a transcript note and has no audio attachment with a failed transcription to retry.",
|
|
2459
|
+
},
|
|
2460
|
+
400,
|
|
2461
|
+
);
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
// Audio file existence + safety: defense-in-depth against a bad attachment
|
|
2465
|
+
// row pointing outside the vault assets dir. Same guard as the worker.
|
|
2466
|
+
const assetsRoot = assetsDir(vault);
|
|
2467
|
+
const audioFilePath = normalize(join(assetsRoot, failed.path));
|
|
2468
|
+
if (!audioFilePath.startsWith(normalize(assetsRoot)) || !existsSync(audioFilePath)) {
|
|
2469
|
+
return json(
|
|
2470
|
+
{
|
|
2471
|
+
error: "audio_missing",
|
|
2472
|
+
message: `Original audio file at "${failed.path}" no longer exists on disk.`,
|
|
2473
|
+
},
|
|
2474
|
+
404,
|
|
2475
|
+
);
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
// Reset the attachment back to pending. Preserve `transcribe_origin:
|
|
2479
|
+
// "legacy"` (a default of `undefined` is also legacy, but make it explicit
|
|
2480
|
+
// so a retried row reads unambiguously) — forcing "auto" here would make
|
|
2481
|
+
// the worker materialize a sibling transcript note instead of patching the
|
|
2482
|
+
// in-body embed, orphaning the memo.
|
|
2483
|
+
const attMeta = { ...(failed.metadata ?? {}) } as Record<string, unknown>;
|
|
2484
|
+
attMeta.transcribe_status = "pending";
|
|
2485
|
+
attMeta.transcribe_requested_at = new Date().toISOString();
|
|
2486
|
+
attMeta.transcribe_origin = "legacy";
|
|
2487
|
+
delete attMeta.transcribe_backoff_until;
|
|
2488
|
+
delete attMeta.transcribe_error;
|
|
2489
|
+
delete attMeta.transcribe_error_code;
|
|
2490
|
+
delete attMeta.transcribe_attempts;
|
|
2491
|
+
await store.setAttachmentMetadata(failed.id, attMeta);
|
|
2492
|
+
|
|
2493
|
+
// Re-arm the stub on the note. The worker's legacy success path gates on
|
|
2494
|
+
// `transcribe_stub === true` and CLEARED it when it wrote the failure
|
|
2495
|
+
// marker; without re-stamping it the retried success early-returns and
|
|
2496
|
+
// never writes the transcript back into the body. Use skipUpdatedAt so the
|
|
2497
|
+
// note's modification time still reflects user intent, matching the
|
|
2498
|
+
// worker's own note writes.
|
|
2499
|
+
//
|
|
2500
|
+
// OC-guarded (vault#435): this read-transform-write would otherwise clobber
|
|
2501
|
+
// a user edit landing between `resolveNote` (above) and this write. Thread
|
|
2502
|
+
// `if_updated_at` and retry once on conflict — re-read, re-apply the
|
|
2503
|
+
// metadata-only re-stamp against the fresh note, write with the fresh
|
|
2504
|
+
// precondition. A second conflict surfaces as 409 so the user can retry.
|
|
2505
|
+
// Only the `transcribe_stub` flag is stamped (never content), so re-applying
|
|
2506
|
+
// against the fresh note is always the correct surgical transform.
|
|
2507
|
+
const restampStub = (current: Note): Record<string, unknown> => ({
|
|
2508
|
+
...((current.metadata as Record<string, unknown> | undefined) ?? {}),
|
|
2509
|
+
transcribe_stub: true,
|
|
2510
|
+
});
|
|
2511
|
+
try {
|
|
2512
|
+
try {
|
|
2513
|
+
await store.updateNote(note.id, {
|
|
2514
|
+
metadata: restampStub(note),
|
|
2515
|
+
skipUpdatedAt: true,
|
|
2516
|
+
if_updated_at: note.updatedAt,
|
|
2517
|
+
});
|
|
2518
|
+
} catch (err: any) {
|
|
2519
|
+
if (!err || err.code !== "CONFLICT") throw err;
|
|
2520
|
+
// Conflict — re-read, re-apply the stub re-stamp, retry once.
|
|
2521
|
+
const fresh = await store.getNote(note.id);
|
|
2522
|
+
if (!fresh) {
|
|
2523
|
+
return json(
|
|
2524
|
+
{ error: "note_missing", message: "Target note disappeared during retry." },
|
|
2525
|
+
404,
|
|
2526
|
+
);
|
|
2527
|
+
}
|
|
2528
|
+
await store.updateNote(fresh.id, {
|
|
2529
|
+
metadata: restampStub(fresh),
|
|
2530
|
+
skipUpdatedAt: true,
|
|
2531
|
+
if_updated_at: fresh.updatedAt,
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
} catch (err: any) {
|
|
2535
|
+
if (err && err.code === "CONFLICT") {
|
|
2536
|
+
// Double conflict — the note kept changing under us. It's a user-facing
|
|
2537
|
+
// request; return 409 so the caller can retry against fresh state. The
|
|
2538
|
+
// attachment was already reset to pending above; a successful re-stamp
|
|
2539
|
+
// on the user's next retry (or the next sweep, if they re-arm the stub)
|
|
2540
|
+
// will let the worker patch the transcript in.
|
|
2541
|
+
return json(
|
|
2542
|
+
{
|
|
2543
|
+
error_type: "conflict",
|
|
2544
|
+
error: "conflict",
|
|
2545
|
+
note_id: note.id,
|
|
2546
|
+
current_updated_at: err.current_updated_at ?? null,
|
|
2547
|
+
your_updated_at: err.expected_updated_at,
|
|
2548
|
+
// Mirror the standard PATCH 409 shape (see the notes-update handler
|
|
2549
|
+
// above) — both `your_updated_at` and `expected_updated_at` carry the
|
|
2550
|
+
// same value, kept for shape-congruence with existing callers.
|
|
2551
|
+
expected_updated_at: err.expected_updated_at,
|
|
2552
|
+
message:
|
|
2553
|
+
"Note was modified concurrently while arming the retry; re-fetch and try again.",
|
|
2554
|
+
},
|
|
2555
|
+
409,
|
|
2556
|
+
);
|
|
2557
|
+
}
|
|
2558
|
+
throw err;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
const { getTranscriptionWorker } = await import("./transcription-registry.ts");
|
|
2562
|
+
const worker = getTranscriptionWorker();
|
|
2563
|
+
if (worker) {
|
|
2564
|
+
const fresh = await store.getAttachment(failed.id) ?? failed;
|
|
2565
|
+
void worker.kick(vault, fresh);
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
return json(
|
|
2569
|
+
{
|
|
2570
|
+
status: "queued",
|
|
2571
|
+
attachment_id: failed.id,
|
|
2572
|
+
attachment_path: failed.path,
|
|
2573
|
+
transcript_note_id: note.id,
|
|
2574
|
+
worker: worker ? "kicked" : "sweep-only",
|
|
2575
|
+
},
|
|
2576
|
+
202,
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2043
2580
|
// ---------------------------------------------------------------------------
|
|
2044
2581
|
// Storage (file upload/serve) — kept as-is, Daily needs it
|
|
2582
|
+
//
|
|
2583
|
+
// `assetsDir` moved to config.ts (next to the other path helpers) to break the
|
|
2584
|
+
// usage.ts↔routes.ts import cycle; it's re-exported from this module's top so
|
|
2585
|
+
// existing importers are unaffected.
|
|
2045
2586
|
// ---------------------------------------------------------------------------
|
|
2046
2587
|
|
|
2047
|
-
export function assetsDir(vault: string): string {
|
|
2048
|
-
return process.env.ASSETS_DIR ?? join(vaultDir(vault), "assets");
|
|
2049
|
-
}
|
|
2050
2588
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
|
2051
2589
|
|
|
2052
2590
|
// Storage allowlist policy:
|
|
@@ -2112,10 +2650,41 @@ export async function handleStorage(
|
|
|
2112
2650
|
const relativePath = `${date}/${filename}`;
|
|
2113
2651
|
const mimeType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
2114
2652
|
|
|
2653
|
+
// Invalidate the usage dir-walk cache for this vault — the new attachment
|
|
2654
|
+
// changed the assets-directory footprint, so the next /.parachute/usage
|
|
2655
|
+
// read must re-walk rather than report a stale (smaller) number. Without
|
|
2656
|
+
// this hook the cache's 60s TTL would briefly under-report after an
|
|
2657
|
+
// upload. (usage.ts:invalidateUsageCache is a no-op-cheap map delete.)
|
|
2658
|
+
invalidateUsageCache(vault);
|
|
2659
|
+
|
|
2115
2660
|
return json({ path: relativePath, size: buffer.length, mimeType }, 201);
|
|
2116
2661
|
}
|
|
2117
2662
|
|
|
2118
|
-
|
|
2663
|
+
// Decode percent-encoding BEFORE matching. `path` arrives from
|
|
2664
|
+
// `url.pathname`, which (per WHATWG) keeps an encoded `%2F` slash literal —
|
|
2665
|
+
// so a caller requesting `/api/storage/<date>%2F<file>` would never satisfy
|
|
2666
|
+
// the literal-slash match below and would fall through to the 404. Decoding
|
|
2667
|
+
// first accepts both the literal-slash and `%2F`-encoded forms, and yields
|
|
2668
|
+
// the literal-slash path that the DB stores (`${date}/${filename}`) so the
|
|
2669
|
+
// tag-scope reverse-lookup matches. This intentionally diverges from the
|
|
2670
|
+
// single-note routes, which decode their *first* segment only and therefore
|
|
2671
|
+
// REQUIRE `%2F` for slashes-in-an-id — a trap-grade asymmetry we accept here
|
|
2672
|
+
// because storage paths are always multi-segment date/file pairs.
|
|
2673
|
+
//
|
|
2674
|
+
// Guard-safety (verified): the traversal guard below operates on the
|
|
2675
|
+
// post-`normalize(join())` filesystem path, so a decoded `..` is still
|
|
2676
|
+
// caught → 403. Decode is idempotent for today's unencoded callers
|
|
2677
|
+
// (filenames are `<Date.now()>-<uuid>.<ext>` — no stray `%`). A malformed
|
|
2678
|
+
// `%` (e.g. `2026%2`) throws → 404, consistent with the no-existence-oracle
|
|
2679
|
+
// stance (and an improvement over the prior catch-all 500).
|
|
2680
|
+
let decodedPath: string;
|
|
2681
|
+
try {
|
|
2682
|
+
decodedPath = decodeURIComponent(path);
|
|
2683
|
+
} catch {
|
|
2684
|
+
return json({ error: "Not found" }, 404);
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
const fileMatch = decodedPath.match(/^\/([^/]+)\/(.+)$/);
|
|
2119
2688
|
if (req.method === "GET" && fileMatch) {
|
|
2120
2689
|
const reqPath = `${fileMatch[1]}/${fileMatch[2]}`;
|
|
2121
2690
|
const filePath = normalize(join(assets, reqPath));
|
|
@@ -2178,9 +2747,12 @@ export async function handleStorage(
|
|
|
2178
2747
|
// Tag schema defaults — same logic as core/src/mcp.ts applySchemaDefaults
|
|
2179
2748
|
// ---------------------------------------------------------------------------
|
|
2180
2749
|
|
|
2181
|
-
|
|
2750
|
+
// Returns the IDs of notes whose metadata was actually default-filled, so
|
|
2751
|
+
// the caller can re-read ONLY the mutated notes (and skip the re-read when
|
|
2752
|
+
// nothing changed). Mirrors the core/src/mcp.ts contract.
|
|
2753
|
+
async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
2182
2754
|
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
2183
|
-
if (Object.keys(schemas).length === 0) return;
|
|
2755
|
+
if (Object.keys(schemas).length === 0) return [];
|
|
2184
2756
|
|
|
2185
2757
|
const defaults: Record<string, unknown> = {};
|
|
2186
2758
|
for (const tag of tags) {
|
|
@@ -2192,8 +2764,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
|
|
|
2192
2764
|
}
|
|
2193
2765
|
}
|
|
2194
2766
|
}
|
|
2195
|
-
if (Object.keys(defaults).length === 0) return;
|
|
2767
|
+
if (Object.keys(defaults).length === 0) return [];
|
|
2196
2768
|
|
|
2769
|
+
const mutated: string[] = [];
|
|
2197
2770
|
for (const noteId of noteIds) {
|
|
2198
2771
|
const note = await store.getNote(noteId);
|
|
2199
2772
|
if (!note) continue;
|
|
@@ -2207,7 +2780,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
|
|
|
2207
2780
|
metadata: { ...existing, ...missing },
|
|
2208
2781
|
skipUpdatedAt: true,
|
|
2209
2782
|
});
|
|
2783
|
+
mutated.push(noteId);
|
|
2210
2784
|
}
|
|
2785
|
+
return mutated;
|
|
2211
2786
|
}
|
|
2212
2787
|
|
|
2213
2788
|
function defaultForField(field: { type: string; enum?: string[] }): unknown {
|