@openparachute/vault 0.6.0-rc.1 → 0.6.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/.parachute/module.json +14 -3
- package/README.md +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- 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 +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- 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/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -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/content-range-routes.test.ts +178 -0
- 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/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- 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-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- 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 +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -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-BPgyIjR7.js +61 -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,22 @@
|
|
|
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";
|
|
18
|
+
import {
|
|
19
|
+
parseContentRange,
|
|
20
|
+
applyContentRange,
|
|
21
|
+
contentRangeRequiresContent,
|
|
22
|
+
type ContentRange,
|
|
23
|
+
} from "../core/src/content-range.ts";
|
|
17
24
|
import { attachValidationStatus } from "../core/src/mcp.ts";
|
|
18
25
|
import * as linkOps from "../core/src/links.ts";
|
|
19
26
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
20
27
|
import {
|
|
28
|
+
buildExpandVisibility,
|
|
29
|
+
filterHydratedLinksByTagScope,
|
|
21
30
|
filterNotesByTagScope,
|
|
22
31
|
noteWithinTagScope,
|
|
23
32
|
tagScopeForbidden,
|
|
@@ -44,8 +53,16 @@ import {
|
|
|
44
53
|
} from "../core/src/expand.ts";
|
|
45
54
|
import { join, extname, normalize } from "path";
|
|
46
55
|
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
47
|
-
import {
|
|
56
|
+
import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
|
|
48
57
|
import { shouldAutoTranscribe } from "./auto-transcribe.ts";
|
|
58
|
+
// usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
|
|
59
|
+
// of invalidateUsageCache does NOT form a cycle — routes.ts → usage.ts only.
|
|
60
|
+
import { invalidateUsageCache } from "./usage.ts";
|
|
61
|
+
|
|
62
|
+
// Re-export `assetsDir` (now defined in config.ts) so the existing callers
|
|
63
|
+
// that import it from this module — mirror-deps, mirror-routes, server,
|
|
64
|
+
// triggers, cli — keep working unchanged.
|
|
65
|
+
export { assetsDir };
|
|
49
66
|
|
|
50
67
|
// ---------------------------------------------------------------------------
|
|
51
68
|
// Helpers
|
|
@@ -71,11 +88,58 @@ function parseQuery(url: URL, key: string): string | null {
|
|
|
71
88
|
return url.searchParams.get(key);
|
|
72
89
|
}
|
|
73
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Parse `link_count_direction` (vault feedback #4). Defaults to "both";
|
|
93
|
+
* anything other than the three known values falls back to "both" so a
|
|
94
|
+
* typo silently degrades to the documented default rather than erroring.
|
|
95
|
+
*
|
|
96
|
+
* Tag-scope note (symmetric with the MCP param description): `linkCount`
|
|
97
|
+
* is a raw degree that MAY include edges to notes a tag-scoped token can't
|
|
98
|
+
* see — the tag-scope filter runs post-query, over the result notes, not
|
|
99
|
+
* their neighbors. Only the number leaks, not the neighbor.
|
|
100
|
+
*/
|
|
101
|
+
function parseLinkCountDirection(url: URL): "both" | "outbound" | "inbound" {
|
|
102
|
+
const v = url.searchParams.get("link_count_direction");
|
|
103
|
+
if (v === "outbound" || v === "inbound") return v;
|
|
104
|
+
return "both";
|
|
105
|
+
}
|
|
106
|
+
|
|
74
107
|
function parseQueryList(url: URL, key: string): string[] | undefined {
|
|
75
108
|
const val = url.searchParams.get(key);
|
|
76
109
|
return val ? val.split(",") : undefined;
|
|
77
110
|
}
|
|
78
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Parse `?content_offset=` / `?content_length=` (content range — bounded
|
|
114
|
+
* reads for large notes; unit is UTF-8 bytes, see core/src/content-range.ts
|
|
115
|
+
* for the codepoint-boundary slicing rules). Returns `{ range: null }` when
|
|
116
|
+
* neither param is present (response byte-identical to the no-pagination
|
|
117
|
+
* shape), or `{ error }` (400 INVALID_QUERY) on invalid values or when the
|
|
118
|
+
* response shape excludes content — range params on a content-less shape
|
|
119
|
+
* error loudly rather than silently no-op, same policy as `?expand=`.
|
|
120
|
+
*/
|
|
121
|
+
function parseContentRangeQuery(
|
|
122
|
+
url: URL,
|
|
123
|
+
includeContent: boolean,
|
|
124
|
+
): { range: ContentRange | null; error?: Response } {
|
|
125
|
+
try {
|
|
126
|
+
const range = parseContentRange(
|
|
127
|
+
parseQuery(url, "content_offset") ?? undefined,
|
|
128
|
+
parseQuery(url, "content_length") ?? undefined,
|
|
129
|
+
);
|
|
130
|
+
if (range && !includeContent) throw contentRangeRequiresContent();
|
|
131
|
+
return { range };
|
|
132
|
+
} catch (e: any) {
|
|
133
|
+
// Duck-type on `name` — core is a separate module, so `instanceof`
|
|
134
|
+
// is fragile across bundling boundaries (same note as the QueryError
|
|
135
|
+
// handling in the structured-query path below).
|
|
136
|
+
if (e && e.name === "QueryError") {
|
|
137
|
+
return { range: null, error: json({ error: e.message, code: e.code ?? "INVALID_QUERY" }, 400) };
|
|
138
|
+
}
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
79
143
|
/**
|
|
80
144
|
* Parse the extension query parameter (vault#328). Two accepted shapes:
|
|
81
145
|
* - `?extension=csv` (single value → string)
|
|
@@ -353,6 +417,180 @@ function parseMetaBrackets(url: URL): {
|
|
|
353
417
|
return result;
|
|
354
418
|
}
|
|
355
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Parse the `?metadata=<json>` alias on GET /api/notes — the JSON-object form
|
|
422
|
+
* of the metadata filter, symmetric with the nested `metadata` object MCP
|
|
423
|
+
* forwards verbatim (`core/src/mcp.ts`). The value is a JSON object of the form
|
|
424
|
+
* `{"field":{"op":value}}` (operator query) or `{"field":value}` (shorthand
|
|
425
|
+
* equality via the engine's json_extract fallback).
|
|
426
|
+
*
|
|
427
|
+
* This exists because the bracket grammar (`?meta[field][op]=value`) couldn't
|
|
428
|
+
* see a `metadata=` param at all — it was silently dropped, and the query
|
|
429
|
+
* returned ALL tag-matching notes (a silent wrong result, not an error).
|
|
430
|
+
*
|
|
431
|
+
* We do NOT validate operators here — the parsed object lowers straight into
|
|
432
|
+
* `queryNotes`, where `validateOperatorObject` raises a loud 400 on unknown
|
|
433
|
+
* operators (caught by the QueryError handler in handleNotes). We only enforce
|
|
434
|
+
* that the param parses and is a non-null, non-array plain object; anything
|
|
435
|
+
* else is a malformed filter the engine can't consume.
|
|
436
|
+
*
|
|
437
|
+
* An empty object (`metadata={}`) carries no filter intent, so it's treated as
|
|
438
|
+
* absent: it sets no metadata filter AND doesn't trip the both-forms guard, so
|
|
439
|
+
* it composes harmlessly with bracket params.
|
|
440
|
+
*
|
|
441
|
+
* Returns `{ metadata?, error? }`. When `error` is set the caller returns it
|
|
442
|
+
* directly (already shaped as a 400 with `error` + `code`).
|
|
443
|
+
*/
|
|
444
|
+
function parseMetadataJsonAlias(url: URL): {
|
|
445
|
+
metadata?: Record<string, unknown>;
|
|
446
|
+
error?: Response;
|
|
447
|
+
} {
|
|
448
|
+
const raw = parseQuery(url, "metadata");
|
|
449
|
+
if (raw === null) return {};
|
|
450
|
+
|
|
451
|
+
const malformed = (detail: string): Response =>
|
|
452
|
+
json(
|
|
453
|
+
{
|
|
454
|
+
error: `metadata query param must be a JSON object of the form {"field":{"op":value}} — ${detail}`,
|
|
455
|
+
code: "INVALID_QUERY",
|
|
456
|
+
},
|
|
457
|
+
400,
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
let parsed: unknown;
|
|
461
|
+
try {
|
|
462
|
+
parsed = JSON.parse(raw);
|
|
463
|
+
} catch (e) {
|
|
464
|
+
return { error: malformed(`failed to parse: ${e instanceof Error ? e.message : String(e)}`) };
|
|
465
|
+
}
|
|
466
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
467
|
+
const got = Array.isArray(parsed) ? "array" : parsed === null ? "null" : typeof parsed;
|
|
468
|
+
return { error: malformed(`got ${got}`) };
|
|
469
|
+
}
|
|
470
|
+
// Empty object → no filter intent. Return absent so it neither sets a
|
|
471
|
+
// metadata filter nor trips the both-forms guard in the handler.
|
|
472
|
+
if (Object.keys(parsed).length === 0) return {};
|
|
473
|
+
return { metadata: parsed as Record<string, unknown> };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Parse + validate the `?expand=` tag-expansion axis (vault tag `expand` axis).
|
|
478
|
+
* Shared by `parseNotesQueryOpts` (structured + subscribe) AND the full-text
|
|
479
|
+
* search branch of `handleNotes` (which bypasses `parseNotesQueryOpts`), so the
|
|
480
|
+
* enum lives in exactly one place and `GET /notes?search=...&expand=bogus` is
|
|
481
|
+
* validated identically to the structured path.
|
|
482
|
+
*
|
|
483
|
+
* Returns `{ expand }` (undefined when absent/empty → store defaults to
|
|
484
|
+
* "subtypes") or `{ error }` (a 400 Response) on an unknown value.
|
|
485
|
+
*/
|
|
486
|
+
export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Response } {
|
|
487
|
+
const expandParam = parseQuery(url, "expand");
|
|
488
|
+
if (expandParam === null || expandParam === "") return {};
|
|
489
|
+
if (!(TAG_EXPAND_MODES as readonly string[]).includes(expandParam)) {
|
|
490
|
+
return {
|
|
491
|
+
error: json(
|
|
492
|
+
{
|
|
493
|
+
error: `invalid \`expand\` value "${expandParam}" — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes": parent_names descendants).`,
|
|
494
|
+
code: "INVALID_QUERY",
|
|
495
|
+
},
|
|
496
|
+
400,
|
|
497
|
+
),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
return { expand: expandParam as TagExpandMode };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Parse the shared notes-query parameters (tags / excludeTags / path /
|
|
505
|
+
* pathPrefix / extension / metadata filters / date filters / sort / paging /
|
|
506
|
+
* cursor) into a `QueryOpts`, plus flags for the query shapes that the live
|
|
507
|
+
* subscription endpoint must reject (`search`, `near`, `cursor`).
|
|
508
|
+
*
|
|
509
|
+
* Factored out of `handleNotesInner`'s structured-query branch so the
|
|
510
|
+
* `/subscribe` route evaluates the SAME predicate the snapshot query does —
|
|
511
|
+
* predicate parity by construction, not copy-paste. `handleNotesInner` keeps
|
|
512
|
+
* its own inline parsing for the single-note (`id`) and full-text (`search`)
|
|
513
|
+
* branches; this helper covers the structured-query shape both endpoints share.
|
|
514
|
+
*
|
|
515
|
+
* Returns `{ error }` (a 400 Response) on a malformed metadata filter, exactly
|
|
516
|
+
* as the inline code did. `hasSearch` is surfaced from the raw `search` param
|
|
517
|
+
* (this helper does not itself build a search query — the caller routes that).
|
|
518
|
+
*/
|
|
519
|
+
export function parseNotesQueryOpts(url: URL): {
|
|
520
|
+
queryOpts?: QueryOpts;
|
|
521
|
+
hasSearch: boolean;
|
|
522
|
+
hasNear: boolean;
|
|
523
|
+
hasCursor: boolean;
|
|
524
|
+
error?: Response;
|
|
525
|
+
} {
|
|
526
|
+
const hasSearch = parseQuery(url, "search") !== null && parseQuery(url, "search") !== "";
|
|
527
|
+
const hasNear = parseQuery(url, "near[note_id]") !== null;
|
|
528
|
+
const cursorParam = parseQuery(url, "cursor");
|
|
529
|
+
const hasCursor = cursorParam !== null && cursorParam !== "";
|
|
530
|
+
|
|
531
|
+
const tags = parseQueryList(url, "tag");
|
|
532
|
+
const bracket = parseMetaBrackets(url);
|
|
533
|
+
if (bracket.error) return { hasSearch, hasNear, hasCursor, error: bracket.error };
|
|
534
|
+
const metadataAlias = parseMetadataJsonAlias(url);
|
|
535
|
+
if (metadataAlias.error) return { hasSearch, hasNear, hasCursor, error: metadataAlias.error };
|
|
536
|
+
if (metadataAlias.metadata && bracket.metadata) {
|
|
537
|
+
return {
|
|
538
|
+
hasSearch,
|
|
539
|
+
hasNear,
|
|
540
|
+
hasCursor,
|
|
541
|
+
error: json(
|
|
542
|
+
{
|
|
543
|
+
error: "pass metadata filters as either the JSON `metadata=` param or bracket `meta[field][op]=` form, not both.",
|
|
544
|
+
code: "INVALID_QUERY",
|
|
545
|
+
},
|
|
546
|
+
400,
|
|
547
|
+
),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Tag-expansion axis (vault tag `expand` axis). Optional; absent →
|
|
552
|
+
// "subtypes" (resolved at the store, kept undefined here so it stays
|
|
553
|
+
// byte-identical to pre-axis behavior). Unknown value → 400 via the shared
|
|
554
|
+
// helper (same shape the search branch uses).
|
|
555
|
+
const expandParsed = parseExpandParam(url);
|
|
556
|
+
if (expandParsed.error) return { hasSearch, hasNear, hasCursor, error: expandParsed.error };
|
|
557
|
+
const expand = expandParsed.expand;
|
|
558
|
+
|
|
559
|
+
const queryOpts: QueryOpts = {
|
|
560
|
+
tags,
|
|
561
|
+
tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
562
|
+
expand,
|
|
563
|
+
excludeTags: parseQueryList(url, "exclude_tag"),
|
|
564
|
+
hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
|
|
565
|
+
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
566
|
+
path: parseQuery(url, "path") ?? undefined,
|
|
567
|
+
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
568
|
+
extension: parseExtensionFilter(url),
|
|
569
|
+
metadata: bracket.metadata ?? metadataAlias.metadata,
|
|
570
|
+
...(bracket.dateFilter
|
|
571
|
+
? { dateFilter: bracket.dateFilter }
|
|
572
|
+
: parseQuery(url, "date_field")
|
|
573
|
+
? {
|
|
574
|
+
dateFilter: {
|
|
575
|
+
field: parseQuery(url, "date_field")!,
|
|
576
|
+
from: parseQuery(url, "date_from") ?? undefined,
|
|
577
|
+
to: parseQuery(url, "date_to") ?? undefined,
|
|
578
|
+
},
|
|
579
|
+
}
|
|
580
|
+
: {
|
|
581
|
+
dateFrom: parseQuery(url, "date_from") ?? undefined,
|
|
582
|
+
dateTo: parseQuery(url, "date_to") ?? undefined,
|
|
583
|
+
}),
|
|
584
|
+
sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
|
|
585
|
+
orderBy: parseQuery(url, "order_by") ?? undefined,
|
|
586
|
+
limit: parseInt10(parseQuery(url, "limit")) ?? 50,
|
|
587
|
+
offset: parseInt10(parseQuery(url, "offset")),
|
|
588
|
+
cursor: cursorParam ?? undefined,
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
return { queryOpts, hasSearch, hasNear, hasCursor };
|
|
592
|
+
}
|
|
593
|
+
|
|
356
594
|
/**
|
|
357
595
|
* Parse include_metadata query param.
|
|
358
596
|
* - absent/null → undefined (all metadata, default)
|
|
@@ -373,10 +611,20 @@ function parseIncludeMetadata(url: URL): boolean | string[] | undefined {
|
|
|
373
611
|
/**
|
|
374
612
|
* Parse expand_links/expand_depth/expand_mode from query params, returning
|
|
375
613
|
* an (ExpandContext, depth) pair if expansion is requested, else null.
|
|
614
|
+
*
|
|
615
|
+
* `tagScope` (security review): when the caller is tag-scoped, an `isVisible`
|
|
616
|
+
* predicate is built from the SAME tag-scope allowlist the rest of the
|
|
617
|
+
* handler uses and injected into the expand context. The expander then
|
|
618
|
+
* leaves any `[[wikilink]]` whose target is out of scope UNRESOLVED — so
|
|
619
|
+
* `expand_links=true` can never inline out-of-scope note content (and the
|
|
620
|
+
* out-of-scope case is byte-indistinguishable from a missing target). For
|
|
621
|
+
* an unscoped token the predicate is `undefined` and expansion behaves
|
|
622
|
+
* exactly as before.
|
|
376
623
|
*/
|
|
377
624
|
function parseExpandParams(
|
|
378
625
|
url: URL,
|
|
379
626
|
db: any,
|
|
627
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
380
628
|
): { ctx: ExpandContext; depth: number } | null {
|
|
381
629
|
if (!parseBool(parseQuery(url, "expand_links"), false)) return null;
|
|
382
630
|
const modeRaw = parseQuery(url, "expand_mode");
|
|
@@ -388,7 +636,8 @@ function parseExpandParams(
|
|
|
388
636
|
MAX_EXPAND_DEPTH,
|
|
389
637
|
),
|
|
390
638
|
);
|
|
391
|
-
|
|
639
|
+
const isVisible = buildExpandVisibility(tagScope.allowed, tagScope.raw);
|
|
640
|
+
return { ctx: { db, mode, expanded: new Set(), ...(isVisible ? { isVisible } : {}) }, depth };
|
|
392
641
|
}
|
|
393
642
|
|
|
394
643
|
|
|
@@ -474,7 +723,7 @@ async function handleNotesInner(
|
|
|
474
723
|
): Promise<Response> {
|
|
475
724
|
const url = new URL(req.url);
|
|
476
725
|
const method = req.method;
|
|
477
|
-
const db =
|
|
726
|
+
const db = store.db;
|
|
478
727
|
|
|
479
728
|
// ---- Collection routes (no ID in path) ----
|
|
480
729
|
if (subpath === "") {
|
|
@@ -494,19 +743,37 @@ async function handleNotesInner(
|
|
|
494
743
|
return json({ error: "Note not found", id }, 404);
|
|
495
744
|
}
|
|
496
745
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
746
|
+
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
747
|
+
if (contentRange.error) return contentRange.error;
|
|
497
748
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
498
|
-
const expand = parseExpandParams(url, db);
|
|
749
|
+
const expand = parseExpandParams(url, db, tagScope);
|
|
499
750
|
if (expand && includeContent && typeof result.content === "string") {
|
|
500
751
|
expand.ctx.expanded.add(note.id);
|
|
501
752
|
result.content = expandContent(result.content, expand.ctx, expand.depth);
|
|
502
753
|
}
|
|
754
|
+
// Content range applies to the FINAL returned content (post-
|
|
755
|
+
// expansion) — the window the client pages through is the same
|
|
756
|
+
// document it would have received unpaged.
|
|
757
|
+
if (contentRange.range && includeContent) applyContentRange(result, contentRange.range);
|
|
503
758
|
result = filterMetadata(result, parseIncludeMetadata(url));
|
|
504
759
|
if (parseBool(parseQuery(url, "include_links"), false)) {
|
|
505
|
-
|
|
760
|
+
// Tag-scope: drop links whose neighbor is out of scope so the
|
|
761
|
+
// hydrated sourceNote/targetNote summaries can't leak out-of-scope
|
|
762
|
+
// ids/paths/tags. No-op for unscoped tokens.
|
|
763
|
+
result.links = filterHydratedLinksByTagScope(
|
|
764
|
+
linkOps.getLinksHydrated(db, note.id),
|
|
765
|
+
tagScope.allowed,
|
|
766
|
+
tagScope.raw,
|
|
767
|
+
);
|
|
506
768
|
}
|
|
507
769
|
if (parseBool(parseQuery(url, "include_attachments"), false)) {
|
|
508
770
|
result.attachments = await store.getAttachments(note.id);
|
|
509
771
|
}
|
|
772
|
+
// linkCount injected after filterMetadata on purpose — same as
|
|
773
|
+
// links/attachments above; filterMetadata only touches `metadata`.
|
|
774
|
+
if (parseBool(parseQuery(url, "include_link_count"), false)) {
|
|
775
|
+
result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
|
|
776
|
+
}
|
|
510
777
|
return json(result);
|
|
511
778
|
}
|
|
512
779
|
|
|
@@ -529,15 +796,27 @@ async function handleNotesInner(
|
|
|
529
796
|
if (search) {
|
|
530
797
|
const searchTags = parseQueryList(url, "tag");
|
|
531
798
|
const limit = parseInt10(parseQuery(url, "limit")) ?? 50;
|
|
532
|
-
|
|
799
|
+
// Tag-expansion axis (vault tag `expand` axis). This branch bypasses
|
|
800
|
+
// `parseNotesQueryOpts`, so validate `?expand=` here too — otherwise
|
|
801
|
+
// `GET /notes?search=x&expand=bogus` would silently ignore the bad
|
|
802
|
+
// value. The validated mode is threaded into the search tag-narrowing.
|
|
803
|
+
const tagExpand = parseExpandParam(url);
|
|
804
|
+
if (tagExpand.error) return tagExpand.error;
|
|
805
|
+
const rawResults = await store.searchNotes(search, {
|
|
806
|
+
tags: searchTags,
|
|
807
|
+
limit,
|
|
808
|
+
expand: tagExpand.expand,
|
|
809
|
+
});
|
|
533
810
|
// Tag-scope: drop any result the token isn't permitted to see. Filter
|
|
534
811
|
// happens after the store query so an empty post-filter list still
|
|
535
812
|
// returns 200 [] (consistent with "no matches"), not 403.
|
|
536
813
|
const results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
|
|
537
814
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
815
|
+
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
816
|
+
if (contentRange.error) return contentRange.error;
|
|
538
817
|
const inclMeta = parseIncludeMetadata(url);
|
|
539
818
|
let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
|
|
540
|
-
const expand = parseExpandParams(url, db);
|
|
819
|
+
const expand = parseExpandParams(url, db, tagScope);
|
|
541
820
|
if (expand && includeContent) {
|
|
542
821
|
for (const n of output) expand.ctx.expanded.add(n.id);
|
|
543
822
|
for (const n of output) {
|
|
@@ -546,9 +825,25 @@ async function handleNotesInner(
|
|
|
546
825
|
}
|
|
547
826
|
}
|
|
548
827
|
}
|
|
828
|
+
// Content range — per-note, post-expansion (see core/src/content-range.ts).
|
|
829
|
+
if (contentRange.range && includeContent) {
|
|
830
|
+
for (const n of output) applyContentRange(n, contentRange.range);
|
|
831
|
+
}
|
|
549
832
|
if (inclMeta !== undefined && inclMeta !== true) {
|
|
550
833
|
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
551
834
|
}
|
|
835
|
+
// Opt-in link degree (vault feedback #4) — one batch count, same as
|
|
836
|
+
// the structured-query path. Runs AFTER filterMetadata on purpose
|
|
837
|
+
// (filterMetadata only touches `metadata`, so linkCount survives —
|
|
838
|
+
// don't casually swap the order).
|
|
839
|
+
if (parseBool(parseQuery(url, "include_link_count"), false)) {
|
|
840
|
+
const counts = linkOps.getLinkCounts(
|
|
841
|
+
db,
|
|
842
|
+
output.map((n: any) => n.id),
|
|
843
|
+
parseLinkCountDirection(url),
|
|
844
|
+
);
|
|
845
|
+
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
846
|
+
}
|
|
552
847
|
return json(output);
|
|
553
848
|
}
|
|
554
849
|
|
|
@@ -564,7 +859,7 @@ async function handleNotesInner(
|
|
|
564
859
|
//
|
|
565
860
|
// - **Flat date params** (DEPRECATED): `?date_field=created_at&
|
|
566
861
|
// date_from=…&date_to=…` and the legacy `?date_from=…&date_to=…`.
|
|
567
|
-
// Still functional through 0.5.x; planned removal in 0.
|
|
862
|
+
// Still functional through 0.5.x; planned removal in a later 0.x
|
|
568
863
|
// (vault#288). New consumers should use bracket-style.
|
|
569
864
|
//
|
|
570
865
|
// Precedence on overlap: bracket-style wins. If a caller passes both
|
|
@@ -577,15 +872,16 @@ async function handleNotesInner(
|
|
|
577
872
|
// Surface asymmetry: REST flattens to a query string; MCP takes a
|
|
578
873
|
// nested `date_filter: { field, from, to }` object directly. Both
|
|
579
874
|
// 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.
|
|
875
|
+
// Structured-query parsing is shared with the live `/subscribe` route
|
|
876
|
+
// (see `parseNotesQueryOpts`) so both endpoints lower an identical query
|
|
877
|
+
// string to the same `QueryOpts` — predicate parity by construction.
|
|
878
|
+
const parsed = parseNotesQueryOpts(url);
|
|
879
|
+
if (parsed.error) return parsed.error;
|
|
880
|
+
const queryOpts = parsed.queryOpts!;
|
|
588
881
|
const cursorParam = parseQuery(url, "cursor");
|
|
882
|
+
// Opaque cursor for "since last checked" agent loops (vault#313) is
|
|
883
|
+
// mutually exclusive with the `near` graph-neighborhood scope (rebuilding
|
|
884
|
+
// the neighborhood per page isn't stable).
|
|
589
885
|
const nearNoteIdEarly = parseQuery(url, "near[note_id]");
|
|
590
886
|
if (cursorParam && nearNoteIdEarly) {
|
|
591
887
|
return json(
|
|
@@ -598,48 +894,6 @@ async function handleNotesInner(
|
|
|
598
894
|
}
|
|
599
895
|
let results: Note[];
|
|
600
896
|
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
897
|
try {
|
|
644
898
|
if (cursorParam) {
|
|
645
899
|
const page = await store.queryNotesPaged(queryOpts);
|
|
@@ -677,7 +931,24 @@ async function handleNotesInner(
|
|
|
677
931
|
}
|
|
678
932
|
const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
|
|
679
933
|
const relationship = parseQuery(url, "near[relationship]") ?? undefined;
|
|
680
|
-
|
|
934
|
+
// Tag-scope policy for `near[]` (vault#439 — hop-guard, symmetric with
|
|
935
|
+
// find-path): for a tag-scoped token the BFS refuses to traverse
|
|
936
|
+
// THROUGH out-of-scope notes — scope is a wall, not a sieve. So a token
|
|
937
|
+
// scoped to ["work"] can't reach an in-scope note at depth 2 via a
|
|
938
|
+
// #personal intermediary at depth 1; that note is simply unreachable.
|
|
939
|
+
// The `filterNotesByTagScope` pass below still runs (defense in depth),
|
|
940
|
+
// but the wall makes it redundant for the `near[]` result set.
|
|
941
|
+
// Unscoped tokens (`tagScope.raw === null`) install no predicate → the
|
|
942
|
+
// FULL graph is walked exactly as before, behavior unchanged.
|
|
943
|
+
const isTraversable = tagScope.raw
|
|
944
|
+
? (id: string) =>
|
|
945
|
+
noteWithinTagScope(
|
|
946
|
+
{ id, tags: getNoteTags(db, id) } as Note,
|
|
947
|
+
tagScope.allowed,
|
|
948
|
+
tagScope.raw,
|
|
949
|
+
)
|
|
950
|
+
: undefined;
|
|
951
|
+
const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship, isTraversable });
|
|
681
952
|
const nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
|
|
682
953
|
results = results.filter((n) => nearScope.has(n.id));
|
|
683
954
|
}
|
|
@@ -688,11 +959,14 @@ async function handleNotesInner(
|
|
|
688
959
|
results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
|
|
689
960
|
|
|
690
961
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
962
|
+
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
963
|
+
if (contentRange.error) return contentRange.error;
|
|
691
964
|
const includeLinks = parseBool(parseQuery(url, "include_links"), false);
|
|
692
965
|
const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
|
|
966
|
+
const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
|
|
693
967
|
const inclMeta = parseIncludeMetadata(url);
|
|
694
968
|
let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
|
|
695
|
-
const expand = parseExpandParams(url, db);
|
|
969
|
+
const expand = parseExpandParams(url, db, tagScope);
|
|
696
970
|
if (expand && includeContent) {
|
|
697
971
|
for (const n of output) expand.ctx.expanded.add(n.id);
|
|
698
972
|
for (const n of output) {
|
|
@@ -701,9 +975,31 @@ async function handleNotesInner(
|
|
|
701
975
|
}
|
|
702
976
|
}
|
|
703
977
|
}
|
|
978
|
+
// Content range — per-note, post-expansion (see core/src/content-range.ts).
|
|
979
|
+
if (contentRange.range && includeContent) {
|
|
980
|
+
for (const n of output) applyContentRange(n, contentRange.range);
|
|
981
|
+
}
|
|
704
982
|
if (inclMeta !== undefined && inclMeta !== true) {
|
|
705
983
|
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
706
984
|
}
|
|
985
|
+
// Opt-in link degree (vault feedback #4). ONE batch count over all
|
|
986
|
+
// result ids — NOT a per-note query — so the field stays O(2 index
|
|
987
|
+
// scans) per request regardless of page size. Mutates `output` in
|
|
988
|
+
// place; injected on the same objects the enrichment loop below
|
|
989
|
+
// touches, the same way `links`/`attachments` are surfaced.
|
|
990
|
+
// Ordering: this runs AFTER the filterMetadata pass above on purpose —
|
|
991
|
+
// filterMetadata only touches the `metadata` key, so a linkCount
|
|
992
|
+
// injected here survives. Don't casually swap the order; injecting
|
|
993
|
+
// before filterMetadata would still survive today but couples the two
|
|
994
|
+
// to filterMetadata's current narrow behavior.
|
|
995
|
+
if (includeLinkCount) {
|
|
996
|
+
const counts = linkOps.getLinkCounts(
|
|
997
|
+
db,
|
|
998
|
+
output.map((n: any) => n.id),
|
|
999
|
+
parseLinkCountDirection(url),
|
|
1000
|
+
);
|
|
1001
|
+
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
1002
|
+
}
|
|
707
1003
|
|
|
708
1004
|
// Graph format — reshape into { nodes, edges }
|
|
709
1005
|
if (parseQuery(url, "format") === "graph") {
|
|
@@ -711,8 +1007,9 @@ async function handleNotesInner(
|
|
|
711
1007
|
const nodes = output.map((n: any) => ({ id: n.id, path: n.path ?? null, tags: n.tags ?? [] }));
|
|
712
1008
|
const edges: { source: string; target: string; relationship: string }[] = [];
|
|
713
1009
|
if (includeLinks) {
|
|
1010
|
+
const linksByNote = linkOps.getLinksHydratedForNotes(db, results.map((n) => n.id));
|
|
714
1011
|
for (const n of results) {
|
|
715
|
-
for (const link of
|
|
1012
|
+
for (const link of linksByNote.get(n.id) ?? []) {
|
|
716
1013
|
// Only include edges where source is this note and target is in the result set
|
|
717
1014
|
if (link.sourceId === n.id && resultIds.has(link.targetId)) {
|
|
718
1015
|
edges.push({ source: link.sourceId, target: link.targetId, relationship: link.relationship });
|
|
@@ -724,10 +1021,23 @@ async function handleNotesInner(
|
|
|
724
1021
|
}
|
|
725
1022
|
|
|
726
1023
|
if (includeLinks || includeAttachments) {
|
|
1024
|
+
// Whole-page link hydration in a constant number of queries — the
|
|
1025
|
+
// per-note variant cost (1 link query + 1 summary query + N tag
|
|
1026
|
+
// queries) × page size. 2026-06-10 perf measurements.
|
|
1027
|
+
const linksByNote = includeLinks
|
|
1028
|
+
? linkOps.getLinksHydratedForNotes(db, output.map((n: any) => n.id))
|
|
1029
|
+
: null;
|
|
727
1030
|
const enrichedOut: any[] = [];
|
|
728
1031
|
for (const n of output) {
|
|
729
1032
|
const enriched: any = { ...n };
|
|
730
|
-
if (
|
|
1033
|
+
if (linksByNote) {
|
|
1034
|
+
// Tag-scope: strip out-of-scope-neighbor links (no-op unscoped).
|
|
1035
|
+
enriched.links = filterHydratedLinksByTagScope(
|
|
1036
|
+
linksByNote.get(n.id) ?? [],
|
|
1037
|
+
tagScope.allowed,
|
|
1038
|
+
tagScope.raw,
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
731
1041
|
if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
|
|
732
1042
|
enrichedOut.push(enriched);
|
|
733
1043
|
}
|
|
@@ -830,19 +1140,33 @@ async function handleNotesInner(
|
|
|
830
1140
|
throw e;
|
|
831
1141
|
}
|
|
832
1142
|
|
|
833
|
-
// Apply tag schema defaults
|
|
1143
|
+
// Apply tag schema defaults, then re-read the notes whose metadata was
|
|
1144
|
+
// actually default-filled so the response reflects the final on-disk
|
|
1145
|
+
// state (the `created` entries were read before `applySchemaDefaults`
|
|
1146
|
+
// ran). Mirrors the MCP create-note path (vault#316). Batched re-read
|
|
1147
|
+
// (`getNotes` = one `WHERE id IN (...)`), skipped when no defaults
|
|
1148
|
+
// applied so the common no-defaults path adds zero extra reads.
|
|
1149
|
+
const mutatedIds = new Set<string>();
|
|
834
1150
|
for (const note of created) {
|
|
835
1151
|
if (note.tags?.length) {
|
|
836
|
-
await applySchemaDefaults(store, db, [note.id], note.tags)
|
|
1152
|
+
for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
|
|
1153
|
+
mutatedIds.add(id);
|
|
1154
|
+
}
|
|
837
1155
|
}
|
|
838
1156
|
}
|
|
1157
|
+
const refreshed =
|
|
1158
|
+
mutatedIds.size === 0
|
|
1159
|
+
? created
|
|
1160
|
+
: (() => {
|
|
1161
|
+
const byId = new Map(getNotes(db, [...mutatedIds]).map((n) => [n.id, n]));
|
|
1162
|
+
return created.map((n) => byId.get(n.id) ?? n);
|
|
1163
|
+
})();
|
|
839
1164
|
|
|
840
1165
|
// Attach `validation_status` so HTTP create-note matches the MCP
|
|
841
|
-
// surface (vault#287).
|
|
842
|
-
// `core/src/mcp.ts:451`. `attachValidationStatus` returns the note
|
|
1166
|
+
// surface (vault#287). `attachValidationStatus` returns the note
|
|
843
1167
|
// unchanged when no tag declares fields, so vaults without any tag
|
|
844
1168
|
// schemas see no behavior change.
|
|
845
|
-
const final =
|
|
1169
|
+
const final = refreshed.map((n) => attachValidationStatus(store, db, n));
|
|
846
1170
|
return json(body.notes ? final : final[0], 201);
|
|
847
1171
|
}
|
|
848
1172
|
|
|
@@ -882,7 +1206,14 @@ async function handleNotesInner(
|
|
|
882
1206
|
// Explicit `transcribe: true` wins — if the caller asked, we honor that
|
|
883
1207
|
// regardless of the auto-transcribe toggle (back-compat).
|
|
884
1208
|
const explicitOptIn = body.transcribe === true;
|
|
885
|
-
|
|
1209
|
+
// Per-vault auto-transcribe: read THIS vault's `auto_transcribe.enabled`
|
|
1210
|
+
// (vault.yaml) and pass it as the precedence-winning toggle. A vault that
|
|
1211
|
+
// set its own value uses it; one that left it unset falls through to the
|
|
1212
|
+
// global toggle inside `shouldAutoTranscribe` (per-vault → global → true).
|
|
1213
|
+
const perVaultEnabled = vault
|
|
1214
|
+
? readVaultConfig(vault)?.auto_transcribe?.enabled
|
|
1215
|
+
: undefined;
|
|
1216
|
+
const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType, { perVaultEnabled });
|
|
886
1217
|
const attMeta = (explicitOptIn || autoOptIn)
|
|
887
1218
|
? {
|
|
888
1219
|
transcribe_status: "pending" as const,
|
|
@@ -942,22 +1273,26 @@ async function handleNotesInner(
|
|
|
942
1273
|
return json({ error: "Method not allowed" }, 405);
|
|
943
1274
|
}
|
|
944
1275
|
|
|
945
|
-
// POST /notes/:idOrPath/retry-transcription — vault#353 design Q5.
|
|
1276
|
+
// POST /notes/:idOrPath/retry-transcription — vault#353 design Q5 + finding F.
|
|
946
1277
|
//
|
|
947
|
-
// Re-runs
|
|
948
|
-
//
|
|
949
|
-
// frontmatter
|
|
950
|
-
//
|
|
951
|
-
//
|
|
1278
|
+
// Re-runs transcription against a failed audio attachment. Two target
|
|
1279
|
+
// shapes, dispatched in handleRetryTranscription by whether the note carries
|
|
1280
|
+
// `transcript_status` frontmatter:
|
|
1281
|
+
// - Auto-flow (vault#353): target is a `<audio>.transcript.md` note with
|
|
1282
|
+
// `transcript_status: "failed"`; audio located via
|
|
1283
|
+
// `transcript_attachment_id`, origin preserved as "auto".
|
|
1284
|
+
// - Legacy in-body memo (finding F): target is the memo note itself (no
|
|
1285
|
+
// `transcript_status`); finds its own failed attachment, resets it
|
|
1286
|
+
// preserving `transcribe_origin: "legacy"`, and re-arms `transcribe_stub`.
|
|
952
1287
|
//
|
|
953
1288
|
// Wire shape:
|
|
954
1289
|
// POST .../notes/<idOrPath>/retry-transcription
|
|
955
|
-
// → 202 { attachment_id,
|
|
956
|
-
// 400
|
|
957
|
-
// 400
|
|
958
|
-
//
|
|
1290
|
+
// → 202 { attachment_id, attachment_path, transcript_note_id, worker }
|
|
1291
|
+
// 400 not_failed (auto-flow: transcript already succeeded)
|
|
1292
|
+
// 400 missing_attachment_id (auto-flow: transcript_attachment_id absent)
|
|
1293
|
+
// 400 no_failed_attachment (legacy: no failed audio attachment to retry)
|
|
1294
|
+
// 404 attachment_missing (auto-flow: transcript_attachment_id row deleted)
|
|
959
1295
|
// 404 audio_missing (audio file unlinked from disk)
|
|
960
|
-
// 503 scribe_unavailable (no worker configured this boot)
|
|
961
1296
|
if (sub === "/retry-transcription") {
|
|
962
1297
|
if (method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
963
1298
|
if (!vault) return json({ error: "Vault context required" }, 400);
|
|
@@ -979,19 +1314,31 @@ async function handleNotesInner(
|
|
|
979
1314
|
return json({ error: "Not found" }, 404);
|
|
980
1315
|
}
|
|
981
1316
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
1317
|
+
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1318
|
+
if (contentRange.error) return contentRange.error;
|
|
982
1319
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
983
|
-
const expand = parseExpandParams(url, db);
|
|
1320
|
+
const expand = parseExpandParams(url, db, tagScope);
|
|
984
1321
|
if (expand && includeContent && typeof result.content === "string") {
|
|
985
1322
|
expand.ctx.expanded.add(note.id);
|
|
986
1323
|
result.content = expandContent(result.content, expand.ctx, expand.depth);
|
|
987
1324
|
}
|
|
1325
|
+
// Content range applies to the FINAL returned content (post-expansion).
|
|
1326
|
+
if (contentRange.range && includeContent) applyContentRange(result, contentRange.range);
|
|
988
1327
|
result = filterMetadata(result, parseIncludeMetadata(url));
|
|
989
1328
|
if (parseBool(parseQuery(url, "include_links"), false)) {
|
|
990
|
-
|
|
1329
|
+
// Tag-scope: drop out-of-scope-neighbor links (no-op unscoped).
|
|
1330
|
+
result.links = filterHydratedLinksByTagScope(
|
|
1331
|
+
linkOps.getLinksHydrated(db, note.id),
|
|
1332
|
+
tagScope.allowed,
|
|
1333
|
+
tagScope.raw,
|
|
1334
|
+
);
|
|
991
1335
|
}
|
|
992
1336
|
if (parseBool(parseQuery(url, "include_attachments"), false)) {
|
|
993
1337
|
result.attachments = await store.getAttachments(note.id);
|
|
994
1338
|
}
|
|
1339
|
+
if (parseBool(parseQuery(url, "include_link_count"), false)) {
|
|
1340
|
+
result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
|
|
1341
|
+
}
|
|
995
1342
|
return json(result);
|
|
996
1343
|
}
|
|
997
1344
|
|
|
@@ -1256,7 +1603,27 @@ async function handleNotesInner(
|
|
|
1256
1603
|
// `toNoteIndex` drops unknown fields).
|
|
1257
1604
|
const updatedNote = await store.getNote(note.id);
|
|
1258
1605
|
if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
|
|
1259
|
-
const validated = attachValidationStatus(store, db, updatedNote);
|
|
1606
|
+
const validated: any = attachValidationStatus(store, db, updatedNote);
|
|
1607
|
+
// Echo hydrated links when a link mutation was part of this request,
|
|
1608
|
+
// OR the caller explicitly asked for them via `?include_links=true`
|
|
1609
|
+
// (vault feedback #8). Previously the update response omitted links
|
|
1610
|
+
// entirely (`getNote` populates tags but not links), forcing callers
|
|
1611
|
+
// to re-GET with `?include_links=true` just to confirm a link they
|
|
1612
|
+
// had just added/removed. Additive field, scoped to UPDATE: present
|
|
1613
|
+
// only when mutated or requested. Mirrors the GET / query-notes
|
|
1614
|
+
// hydration call form exactly (`linkOps.getLinksHydrated`).
|
|
1615
|
+
const linkMutated = body.links?.add !== undefined || body.links?.remove !== undefined;
|
|
1616
|
+
const includeLinksResp = linkMutated || parseBool(parseQuery(url, "include_links"), false);
|
|
1617
|
+
if (includeLinksResp) {
|
|
1618
|
+
// Tag-scope: strip out-of-scope-neighbor links from the echoed set
|
|
1619
|
+
// (no-op unscoped). A write token tag-scoped to #work mustn't learn
|
|
1620
|
+
// about a #personal note it happened to link to.
|
|
1621
|
+
validated.links = filterHydratedLinksByTagScope(
|
|
1622
|
+
linkOps.getLinksHydrated(db, updatedNote.id),
|
|
1623
|
+
tagScope.allowed,
|
|
1624
|
+
tagScope.raw,
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1260
1627
|
const includeContentResp = body.include_content !== false;
|
|
1261
1628
|
// `created: false` is appended to every update-path response so
|
|
1262
1629
|
// sync-loop callers using `if_missing: "create"` can distinguish
|
|
@@ -1266,6 +1633,9 @@ async function handleNotesInner(
|
|
|
1266
1633
|
const lean: any = toNoteIndex(validated);
|
|
1267
1634
|
const vs = (validated as any).validation_status;
|
|
1268
1635
|
if (vs !== undefined) lean.validation_status = vs;
|
|
1636
|
+
// Carry the link echo across the lean conversion — `toNoteIndex`
|
|
1637
|
+
// drops unknown fields, same as the `validation_status` recipe above.
|
|
1638
|
+
if (validated.links !== undefined) lean.links = validated.links;
|
|
1269
1639
|
lean.created = false;
|
|
1270
1640
|
return json(lean);
|
|
1271
1641
|
} catch (e: any) {
|
|
@@ -1418,7 +1788,7 @@ export async function handleTags(
|
|
|
1418
1788
|
// that token's allowlist. Aggregate matches across sources for a single
|
|
1419
1789
|
// 409 envelope.
|
|
1420
1790
|
const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
|
|
1421
|
-
const db =
|
|
1791
|
+
const db = store.db;
|
|
1422
1792
|
for (const src of sources) {
|
|
1423
1793
|
const tokens = findTokensReferencingTag(db, src as string);
|
|
1424
1794
|
if (tokens.length > 0) referenced.push({ source: src as string, tokens });
|
|
@@ -1513,10 +1883,12 @@ export async function handleTags(
|
|
|
1513
1883
|
parent_names?: unknown;
|
|
1514
1884
|
};
|
|
1515
1885
|
|
|
1516
|
-
// Validate relationships
|
|
1517
|
-
//
|
|
1886
|
+
// Validate the relationships payload up front so a bad payload returns
|
|
1887
|
+
// 400, not a thrown 500. `relationships` is an opaque vocabulary map
|
|
1888
|
+
// (relationship-name → arbitrary JSON the app interprets); we only check
|
|
1889
|
+
// that it's a JSON object (a map), then persist verbatim.
|
|
1518
1890
|
let relationshipsPatch:
|
|
1519
|
-
|
|
|
1891
|
+
| tagSchemaOps.TagRelationshipMap
|
|
1520
1892
|
| null
|
|
1521
1893
|
| undefined;
|
|
1522
1894
|
if (body.relationships === null) {
|
|
@@ -1580,7 +1952,7 @@ export async function handleTags(
|
|
|
1580
1952
|
// tag would silently orphan the token's allowlist. Fail closed (409)
|
|
1581
1953
|
// and name the offending tokens so the operator can revoke or re-mint
|
|
1582
1954
|
// before retrying. patterns/tag-scoped-tokens.md §Dependencies.
|
|
1583
|
-
const referenced_by = findTokensReferencingTag(
|
|
1955
|
+
const referenced_by = findTokensReferencingTag(store.db, tagName);
|
|
1584
1956
|
if (referenced_by.length > 0) {
|
|
1585
1957
|
return json(
|
|
1586
1958
|
{
|
|
@@ -1615,7 +1987,7 @@ export async function handleFindPath(
|
|
|
1615
1987
|
const target = parseQuery(url, "target");
|
|
1616
1988
|
if (!source || !target) return json({ error: "source and target parameters are required" }, 400);
|
|
1617
1989
|
|
|
1618
|
-
const db =
|
|
1990
|
+
const db = store.db;
|
|
1619
1991
|
try {
|
|
1620
1992
|
const sourceNote = await resolveNote(store, source);
|
|
1621
1993
|
if (!sourceNote) return json({ error: `Note not found: "${source}"` }, 404);
|
|
@@ -1661,16 +2033,36 @@ type VaultConfigLike = {
|
|
|
1661
2033
|
name: string;
|
|
1662
2034
|
description?: string;
|
|
1663
2035
|
audio_retention?: "keep" | "until_transcribed" | "never";
|
|
2036
|
+
auto_transcribe?: { enabled?: boolean };
|
|
1664
2037
|
};
|
|
1665
2038
|
|
|
1666
2039
|
const VALID_AUDIO_RETENTION = ["keep", "until_transcribed", "never"] as const;
|
|
1667
2040
|
|
|
2041
|
+
/**
|
|
2042
|
+
* Resolve the effective auto-transcribe toggle for a vault's GET response, the
|
|
2043
|
+
* SAME resolution `shouldAutoTranscribe` uses at the decision point:
|
|
2044
|
+
* **per-vault → global → true**. A vault that set its own `auto_transcribe`
|
|
2045
|
+
* shows that; one that left it unset shows the server-wide default (itself
|
|
2046
|
+
* default-ON). This keeps the GET in lock-step with what the worker actually
|
|
2047
|
+
* does, with no field-name drift.
|
|
2048
|
+
*/
|
|
2049
|
+
function resolveAutoTranscribeEnabled(vaultConfig: VaultConfigLike): boolean {
|
|
2050
|
+
return (
|
|
2051
|
+
vaultConfig.auto_transcribe?.enabled
|
|
2052
|
+
?? readGlobalConfig().auto_transcribe?.enabled
|
|
2053
|
+
?? true
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
|
|
1668
2057
|
function vaultResponse(vaultConfig: VaultConfigLike): Record<string, unknown> {
|
|
1669
2058
|
return {
|
|
1670
2059
|
name: vaultConfig.name,
|
|
1671
2060
|
description: vaultConfig.description ?? null,
|
|
1672
2061
|
config: {
|
|
1673
2062
|
audio_retention: vaultConfig.audio_retention ?? "keep",
|
|
2063
|
+
auto_transcribe: {
|
|
2064
|
+
enabled: resolveAutoTranscribeEnabled(vaultConfig),
|
|
2065
|
+
},
|
|
1674
2066
|
},
|
|
1675
2067
|
};
|
|
1676
2068
|
}
|
|
@@ -1694,7 +2086,7 @@ export async function handleVault(
|
|
|
1694
2086
|
if (req.method === "PATCH") {
|
|
1695
2087
|
const body = await req.json() as {
|
|
1696
2088
|
description?: string;
|
|
1697
|
-
config?: { audio_retention?: string };
|
|
2089
|
+
config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
|
|
1698
2090
|
};
|
|
1699
2091
|
let dirty = false;
|
|
1700
2092
|
|
|
@@ -1718,6 +2110,28 @@ export async function handleVault(
|
|
|
1718
2110
|
dirty = true;
|
|
1719
2111
|
}
|
|
1720
2112
|
|
|
2113
|
+
// auto_transcribe.enabled — PER-VAULT toggle persisted to THIS vault's
|
|
2114
|
+
// vault.yaml (via `persist`, the same writeVaultConfig path as
|
|
2115
|
+
// description/audio_retention). Flipping it for vault X affects only X;
|
|
2116
|
+
// scribe's "link to vault X" PATCHes this and never touches other vaults.
|
|
2117
|
+
// The worker reads the same per-vault field (per-vault → global → true).
|
|
2118
|
+
// Validate the shape: when `auto_transcribe` is present it must carry a
|
|
2119
|
+
// boolean `enabled`.
|
|
2120
|
+
if (body.config?.auto_transcribe !== undefined) {
|
|
2121
|
+
const enabled = body.config.auto_transcribe?.enabled;
|
|
2122
|
+
if (typeof enabled !== "boolean") {
|
|
2123
|
+
return json(
|
|
2124
|
+
{
|
|
2125
|
+
error: "invalid_auto_transcribe",
|
|
2126
|
+
message: "auto_transcribe.enabled must be a boolean",
|
|
2127
|
+
},
|
|
2128
|
+
400,
|
|
2129
|
+
);
|
|
2130
|
+
}
|
|
2131
|
+
vaultConfig.auto_transcribe = { ...vaultConfig.auto_transcribe, enabled };
|
|
2132
|
+
dirty = true;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
1721
2135
|
if (dirty && persist) persist();
|
|
1722
2136
|
return json(vaultResponse(vaultConfig));
|
|
1723
2137
|
}
|
|
@@ -1729,12 +2143,32 @@ export async function handleVault(
|
|
|
1729
2143
|
// Unresolved wikilinks — REST-only (admin/maintenance)
|
|
1730
2144
|
// ---------------------------------------------------------------------------
|
|
1731
2145
|
|
|
1732
|
-
export function handleUnresolvedWikilinks(
|
|
2146
|
+
export function handleUnresolvedWikilinks(
|
|
2147
|
+
req: Request,
|
|
2148
|
+
store: Store,
|
|
2149
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
2150
|
+
): Response {
|
|
1733
2151
|
const url = new URL(req.url);
|
|
1734
2152
|
const limitStr = url.searchParams.get("limit");
|
|
1735
2153
|
const limit = limitStr ? parseInt(limitStr, 10) : 50;
|
|
1736
|
-
const db =
|
|
1737
|
-
|
|
2154
|
+
const db = store.db;
|
|
2155
|
+
const result = listUnresolvedWikilinks(db, limit);
|
|
2156
|
+
|
|
2157
|
+
// Unscoped token → return as-is (unchanged behavior).
|
|
2158
|
+
if (tagScope.raw === null) return Response.json(result);
|
|
2159
|
+
|
|
2160
|
+
// Tag-scope confidentiality (security review): each unresolved row carries
|
|
2161
|
+
// a `source_id` (+ `source_path`) plus the raw `target_path` wikilink
|
|
2162
|
+
// string. For a tag-scoped token, surface ONLY rows whose SOURCE note is
|
|
2163
|
+
// within the token's tag scope — otherwise we'd leak out-of-scope note IDs
|
|
2164
|
+
// and the wikilink target strings those notes contain. Filter the page and
|
|
2165
|
+
// recompute `count` from the filtered set so the aggregate total of
|
|
2166
|
+
// out-of-scope rows doesn't leak either.
|
|
2167
|
+
const filtered = result.unresolved.filter((row) => {
|
|
2168
|
+
const note = getNote(db, row.source_id);
|
|
2169
|
+
return note !== null && noteWithinTagScope(note, tagScope.allowed, tagScope.raw);
|
|
2170
|
+
});
|
|
2171
|
+
return Response.json({ unresolved: filtered, count: filtered.length });
|
|
1738
2172
|
}
|
|
1739
2173
|
|
|
1740
2174
|
// ---------------------------------------------------------------------------
|
|
@@ -1923,21 +2357,35 @@ ${rendered}
|
|
|
1923
2357
|
// ---------------------------------------------------------------------------
|
|
1924
2358
|
|
|
1925
2359
|
/**
|
|
1926
|
-
* Re-enqueue the original audio attachment for a
|
|
1927
|
-
* transcript note. Steps:
|
|
2360
|
+
* Re-enqueue the original audio attachment for a failed transcription.
|
|
1928
2361
|
*
|
|
1929
|
-
*
|
|
1930
|
-
*
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1935
|
-
*
|
|
1936
|
-
*
|
|
1937
|
-
*
|
|
1938
|
-
*
|
|
1939
|
-
*
|
|
1940
|
-
*
|
|
2362
|
+
* Two target shapes are accepted:
|
|
2363
|
+
*
|
|
2364
|
+
* - **Auto-flow (vault#353):** the target is a `<audio>.transcript.md` note
|
|
2365
|
+
* carrying `transcript_status` frontmatter. Requires that status be
|
|
2366
|
+
* `failed`; locates the audio via `transcript_attachment_id`; preserves
|
|
2367
|
+
* `transcribe_origin: "auto"` so a retried success overwrites this
|
|
2368
|
+
* transcript note in place. Behavior is byte-identical to the original
|
|
2369
|
+
* vault#353 contract.
|
|
2370
|
+
*
|
|
2371
|
+
* - **Legacy in-body memo (finding F):** the target is the memo note
|
|
2372
|
+
* itself — no `transcript_status` frontmatter. The original capture body
|
|
2373
|
+
* holds `![[<audio>]]` + a `_Transcription unavailable._` marker (written
|
|
2374
|
+
* by the worker on terminal failure). We find the note's own failed audio
|
|
2375
|
+
* attachment, reset it to pending **preserving `transcribe_origin:
|
|
2376
|
+
* "legacy"`** (forcing "auto" would switch to the sibling-transcript-note
|
|
2377
|
+
* shape and orphan the in-body embed), and **re-stamp `transcribe_stub:
|
|
2378
|
+
* true`** on the note. The stub re-arm is load-bearing: the legacy success
|
|
2379
|
+
* path early-returns unless `transcribe_stub === true`, so without it the
|
|
2380
|
+
* retried success would never write the transcript back into the body.
|
|
2381
|
+
* On success the worker replaces the `_Transcription unavailable._` marker
|
|
2382
|
+
* with the transcript in place, yielding the same final shape a first-try
|
|
2383
|
+
* success would.
|
|
2384
|
+
*
|
|
2385
|
+
* Common steps for both: validate the audio attachment row exists (404 if
|
|
2386
|
+
* gone) and its file is still on disk (404 if unlinked — e.g. retention=never
|
|
2387
|
+
* already dropped it), reset the transcribe_status fields, then kick the
|
|
2388
|
+
* worker if registered (otherwise the sweep picks it up).
|
|
1941
2389
|
*/
|
|
1942
2390
|
async function handleRetryTranscription(
|
|
1943
2391
|
store: Store,
|
|
@@ -1945,15 +2393,13 @@ async function handleRetryTranscription(
|
|
|
1945
2393
|
vault: string,
|
|
1946
2394
|
): Promise<Response> {
|
|
1947
2395
|
const meta = (note.metadata as Record<string, unknown> | undefined) ?? {};
|
|
2396
|
+
|
|
2397
|
+
// Legacy in-body memo: no `transcript_status` frontmatter. The note owns
|
|
2398
|
+
// the failed audio attachment directly; there's no sibling transcript note.
|
|
1948
2399
|
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
|
-
);
|
|
2400
|
+
return handleRetryLegacyInBody(store, note, vault);
|
|
1956
2401
|
}
|
|
2402
|
+
|
|
1957
2403
|
if (meta.transcript_status !== "failed") {
|
|
1958
2404
|
return json(
|
|
1959
2405
|
{
|
|
@@ -2040,13 +2486,171 @@ async function handleRetryTranscription(
|
|
|
2040
2486
|
);
|
|
2041
2487
|
}
|
|
2042
2488
|
|
|
2489
|
+
/**
|
|
2490
|
+
* Retry path for a legacy in-body voice memo (finding F). The target note is
|
|
2491
|
+
* the memo itself (no `transcript_status` frontmatter); it directly owns the
|
|
2492
|
+
* audio attachment whose transcription failed.
|
|
2493
|
+
*
|
|
2494
|
+
* Steps:
|
|
2495
|
+
* 1. Find the note's own audio attachment with `transcribe_status ===
|
|
2496
|
+
* "failed"`. 400 `no_failed_attachment` if none — there's nothing to
|
|
2497
|
+
* retry.
|
|
2498
|
+
* 2. Validate the audio file is still on disk (404 `audio_missing`).
|
|
2499
|
+
* 3. Reset the attachment to pending, **preserving `transcribe_origin:
|
|
2500
|
+
* "legacy"`** (never force "auto" — that switches to the sibling-
|
|
2501
|
+
* transcript-note shape and orphans the in-body `![[memo]]` embed).
|
|
2502
|
+
* 4. **Re-stamp `transcribe_stub: true`** on the note. The legacy worker
|
|
2503
|
+
* success path early-returns unless the note carries this flag (it was
|
|
2504
|
+
* cleared when the failure marker was written), so re-arming it is what
|
|
2505
|
+
* lets the retried success replace the `_Transcription unavailable._`
|
|
2506
|
+
* marker with the transcript.
|
|
2507
|
+
* 5. Kick the worker if registered; otherwise the sweep picks it up.
|
|
2508
|
+
*/
|
|
2509
|
+
async function handleRetryLegacyInBody(
|
|
2510
|
+
store: Store,
|
|
2511
|
+
note: Note,
|
|
2512
|
+
vault: string,
|
|
2513
|
+
): Promise<Response> {
|
|
2514
|
+
const attachments = await store.getAttachments(note.id);
|
|
2515
|
+
const failed = attachments.find((a) => {
|
|
2516
|
+
const m = (a.metadata as Record<string, unknown> | undefined) ?? {};
|
|
2517
|
+
return m.transcribe_status === "failed";
|
|
2518
|
+
});
|
|
2519
|
+
if (!failed) {
|
|
2520
|
+
return json(
|
|
2521
|
+
{
|
|
2522
|
+
error: "no_failed_attachment",
|
|
2523
|
+
message:
|
|
2524
|
+
"Target note is not a transcript note and has no audio attachment with a failed transcription to retry.",
|
|
2525
|
+
},
|
|
2526
|
+
400,
|
|
2527
|
+
);
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// Audio file existence + safety: defense-in-depth against a bad attachment
|
|
2531
|
+
// row pointing outside the vault assets dir. Same guard as the worker.
|
|
2532
|
+
const assetsRoot = assetsDir(vault);
|
|
2533
|
+
const audioFilePath = normalize(join(assetsRoot, failed.path));
|
|
2534
|
+
if (!audioFilePath.startsWith(normalize(assetsRoot)) || !existsSync(audioFilePath)) {
|
|
2535
|
+
return json(
|
|
2536
|
+
{
|
|
2537
|
+
error: "audio_missing",
|
|
2538
|
+
message: `Original audio file at "${failed.path}" no longer exists on disk.`,
|
|
2539
|
+
},
|
|
2540
|
+
404,
|
|
2541
|
+
);
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// Reset the attachment back to pending. Preserve `transcribe_origin:
|
|
2545
|
+
// "legacy"` (a default of `undefined` is also legacy, but make it explicit
|
|
2546
|
+
// so a retried row reads unambiguously) — forcing "auto" here would make
|
|
2547
|
+
// the worker materialize a sibling transcript note instead of patching the
|
|
2548
|
+
// in-body embed, orphaning the memo.
|
|
2549
|
+
const attMeta = { ...(failed.metadata ?? {}) } as Record<string, unknown>;
|
|
2550
|
+
attMeta.transcribe_status = "pending";
|
|
2551
|
+
attMeta.transcribe_requested_at = new Date().toISOString();
|
|
2552
|
+
attMeta.transcribe_origin = "legacy";
|
|
2553
|
+
delete attMeta.transcribe_backoff_until;
|
|
2554
|
+
delete attMeta.transcribe_error;
|
|
2555
|
+
delete attMeta.transcribe_error_code;
|
|
2556
|
+
delete attMeta.transcribe_attempts;
|
|
2557
|
+
await store.setAttachmentMetadata(failed.id, attMeta);
|
|
2558
|
+
|
|
2559
|
+
// Re-arm the stub on the note. The worker's legacy success path gates on
|
|
2560
|
+
// `transcribe_stub === true` and CLEARED it when it wrote the failure
|
|
2561
|
+
// marker; without re-stamping it the retried success early-returns and
|
|
2562
|
+
// never writes the transcript back into the body. Use skipUpdatedAt so the
|
|
2563
|
+
// note's modification time still reflects user intent, matching the
|
|
2564
|
+
// worker's own note writes.
|
|
2565
|
+
//
|
|
2566
|
+
// OC-guarded (vault#435): this read-transform-write would otherwise clobber
|
|
2567
|
+
// a user edit landing between `resolveNote` (above) and this write. Thread
|
|
2568
|
+
// `if_updated_at` and retry once on conflict — re-read, re-apply the
|
|
2569
|
+
// metadata-only re-stamp against the fresh note, write with the fresh
|
|
2570
|
+
// precondition. A second conflict surfaces as 409 so the user can retry.
|
|
2571
|
+
// Only the `transcribe_stub` flag is stamped (never content), so re-applying
|
|
2572
|
+
// against the fresh note is always the correct surgical transform.
|
|
2573
|
+
const restampStub = (current: Note): Record<string, unknown> => ({
|
|
2574
|
+
...((current.metadata as Record<string, unknown> | undefined) ?? {}),
|
|
2575
|
+
transcribe_stub: true,
|
|
2576
|
+
});
|
|
2577
|
+
try {
|
|
2578
|
+
try {
|
|
2579
|
+
await store.updateNote(note.id, {
|
|
2580
|
+
metadata: restampStub(note),
|
|
2581
|
+
skipUpdatedAt: true,
|
|
2582
|
+
if_updated_at: note.updatedAt,
|
|
2583
|
+
});
|
|
2584
|
+
} catch (err: any) {
|
|
2585
|
+
if (!err || err.code !== "CONFLICT") throw err;
|
|
2586
|
+
// Conflict — re-read, re-apply the stub re-stamp, retry once.
|
|
2587
|
+
const fresh = await store.getNote(note.id);
|
|
2588
|
+
if (!fresh) {
|
|
2589
|
+
return json(
|
|
2590
|
+
{ error: "note_missing", message: "Target note disappeared during retry." },
|
|
2591
|
+
404,
|
|
2592
|
+
);
|
|
2593
|
+
}
|
|
2594
|
+
await store.updateNote(fresh.id, {
|
|
2595
|
+
metadata: restampStub(fresh),
|
|
2596
|
+
skipUpdatedAt: true,
|
|
2597
|
+
if_updated_at: fresh.updatedAt,
|
|
2598
|
+
});
|
|
2599
|
+
}
|
|
2600
|
+
} catch (err: any) {
|
|
2601
|
+
if (err && err.code === "CONFLICT") {
|
|
2602
|
+
// Double conflict — the note kept changing under us. It's a user-facing
|
|
2603
|
+
// request; return 409 so the caller can retry against fresh state. The
|
|
2604
|
+
// attachment was already reset to pending above; a successful re-stamp
|
|
2605
|
+
// on the user's next retry (or the next sweep, if they re-arm the stub)
|
|
2606
|
+
// will let the worker patch the transcript in.
|
|
2607
|
+
return json(
|
|
2608
|
+
{
|
|
2609
|
+
error_type: "conflict",
|
|
2610
|
+
error: "conflict",
|
|
2611
|
+
note_id: note.id,
|
|
2612
|
+
current_updated_at: err.current_updated_at ?? null,
|
|
2613
|
+
your_updated_at: err.expected_updated_at,
|
|
2614
|
+
// Mirror the standard PATCH 409 shape (see the notes-update handler
|
|
2615
|
+
// above) — both `your_updated_at` and `expected_updated_at` carry the
|
|
2616
|
+
// same value, kept for shape-congruence with existing callers.
|
|
2617
|
+
expected_updated_at: err.expected_updated_at,
|
|
2618
|
+
message:
|
|
2619
|
+
"Note was modified concurrently while arming the retry; re-fetch and try again.",
|
|
2620
|
+
},
|
|
2621
|
+
409,
|
|
2622
|
+
);
|
|
2623
|
+
}
|
|
2624
|
+
throw err;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
const { getTranscriptionWorker } = await import("./transcription-registry.ts");
|
|
2628
|
+
const worker = getTranscriptionWorker();
|
|
2629
|
+
if (worker) {
|
|
2630
|
+
const fresh = await store.getAttachment(failed.id) ?? failed;
|
|
2631
|
+
void worker.kick(vault, fresh);
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
return json(
|
|
2635
|
+
{
|
|
2636
|
+
status: "queued",
|
|
2637
|
+
attachment_id: failed.id,
|
|
2638
|
+
attachment_path: failed.path,
|
|
2639
|
+
transcript_note_id: note.id,
|
|
2640
|
+
worker: worker ? "kicked" : "sweep-only",
|
|
2641
|
+
},
|
|
2642
|
+
202,
|
|
2643
|
+
);
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2043
2646
|
// ---------------------------------------------------------------------------
|
|
2044
2647
|
// Storage (file upload/serve) — kept as-is, Daily needs it
|
|
2648
|
+
//
|
|
2649
|
+
// `assetsDir` moved to config.ts (next to the other path helpers) to break the
|
|
2650
|
+
// usage.ts↔routes.ts import cycle; it's re-exported from this module's top so
|
|
2651
|
+
// existing importers are unaffected.
|
|
2045
2652
|
// ---------------------------------------------------------------------------
|
|
2046
2653
|
|
|
2047
|
-
export function assetsDir(vault: string): string {
|
|
2048
|
-
return process.env.ASSETS_DIR ?? join(vaultDir(vault), "assets");
|
|
2049
|
-
}
|
|
2050
2654
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
|
2051
2655
|
|
|
2052
2656
|
// Storage allowlist policy:
|
|
@@ -2112,10 +2716,41 @@ export async function handleStorage(
|
|
|
2112
2716
|
const relativePath = `${date}/${filename}`;
|
|
2113
2717
|
const mimeType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
2114
2718
|
|
|
2719
|
+
// Invalidate the usage dir-walk cache for this vault — the new attachment
|
|
2720
|
+
// changed the assets-directory footprint, so the next /.parachute/usage
|
|
2721
|
+
// read must re-walk rather than report a stale (smaller) number. Without
|
|
2722
|
+
// this hook the cache's 60s TTL would briefly under-report after an
|
|
2723
|
+
// upload. (usage.ts:invalidateUsageCache is a no-op-cheap map delete.)
|
|
2724
|
+
invalidateUsageCache(vault);
|
|
2725
|
+
|
|
2115
2726
|
return json({ path: relativePath, size: buffer.length, mimeType }, 201);
|
|
2116
2727
|
}
|
|
2117
2728
|
|
|
2118
|
-
|
|
2729
|
+
// Decode percent-encoding BEFORE matching. `path` arrives from
|
|
2730
|
+
// `url.pathname`, which (per WHATWG) keeps an encoded `%2F` slash literal —
|
|
2731
|
+
// so a caller requesting `/api/storage/<date>%2F<file>` would never satisfy
|
|
2732
|
+
// the literal-slash match below and would fall through to the 404. Decoding
|
|
2733
|
+
// first accepts both the literal-slash and `%2F`-encoded forms, and yields
|
|
2734
|
+
// the literal-slash path that the DB stores (`${date}/${filename}`) so the
|
|
2735
|
+
// tag-scope reverse-lookup matches. This intentionally diverges from the
|
|
2736
|
+
// single-note routes, which decode their *first* segment only and therefore
|
|
2737
|
+
// REQUIRE `%2F` for slashes-in-an-id — a trap-grade asymmetry we accept here
|
|
2738
|
+
// because storage paths are always multi-segment date/file pairs.
|
|
2739
|
+
//
|
|
2740
|
+
// Guard-safety (verified): the traversal guard below operates on the
|
|
2741
|
+
// post-`normalize(join())` filesystem path, so a decoded `..` is still
|
|
2742
|
+
// caught → 403. Decode is idempotent for today's unencoded callers
|
|
2743
|
+
// (filenames are `<Date.now()>-<uuid>.<ext>` — no stray `%`). A malformed
|
|
2744
|
+
// `%` (e.g. `2026%2`) throws → 404, consistent with the no-existence-oracle
|
|
2745
|
+
// stance (and an improvement over the prior catch-all 500).
|
|
2746
|
+
let decodedPath: string;
|
|
2747
|
+
try {
|
|
2748
|
+
decodedPath = decodeURIComponent(path);
|
|
2749
|
+
} catch {
|
|
2750
|
+
return json({ error: "Not found" }, 404);
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
const fileMatch = decodedPath.match(/^\/([^/]+)\/(.+)$/);
|
|
2119
2754
|
if (req.method === "GET" && fileMatch) {
|
|
2120
2755
|
const reqPath = `${fileMatch[1]}/${fileMatch[2]}`;
|
|
2121
2756
|
const filePath = normalize(join(assets, reqPath));
|
|
@@ -2178,9 +2813,12 @@ export async function handleStorage(
|
|
|
2178
2813
|
// Tag schema defaults — same logic as core/src/mcp.ts applySchemaDefaults
|
|
2179
2814
|
// ---------------------------------------------------------------------------
|
|
2180
2815
|
|
|
2181
|
-
|
|
2816
|
+
// Returns the IDs of notes whose metadata was actually default-filled, so
|
|
2817
|
+
// the caller can re-read ONLY the mutated notes (and skip the re-read when
|
|
2818
|
+
// nothing changed). Mirrors the core/src/mcp.ts contract.
|
|
2819
|
+
async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
2182
2820
|
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
2183
|
-
if (Object.keys(schemas).length === 0) return;
|
|
2821
|
+
if (Object.keys(schemas).length === 0) return [];
|
|
2184
2822
|
|
|
2185
2823
|
const defaults: Record<string, unknown> = {};
|
|
2186
2824
|
for (const tag of tags) {
|
|
@@ -2192,8 +2830,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
|
|
|
2192
2830
|
}
|
|
2193
2831
|
}
|
|
2194
2832
|
}
|
|
2195
|
-
if (Object.keys(defaults).length === 0) return;
|
|
2833
|
+
if (Object.keys(defaults).length === 0) return [];
|
|
2196
2834
|
|
|
2835
|
+
const mutated: string[] = [];
|
|
2197
2836
|
for (const noteId of noteIds) {
|
|
2198
2837
|
const note = await store.getNote(noteId);
|
|
2199
2838
|
if (!note) continue;
|
|
@@ -2207,7 +2846,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
|
|
|
2207
2846
|
metadata: { ...existing, ...missing },
|
|
2208
2847
|
skipUpdatedAt: true,
|
|
2209
2848
|
});
|
|
2849
|
+
mutated.push(noteId);
|
|
2210
2850
|
}
|
|
2851
|
+
return mutated;
|
|
2211
2852
|
}
|
|
2212
2853
|
|
|
2213
2854
|
function defaultForField(field: { type: string; enum?: string[] }): unknown {
|