@batalabs/virlow-mcp-core 3.18.0 → 3.68.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/LICENSE +8 -18
- package/README.md +1 -1
- package/dist/api.d.ts +32 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +38 -5
- package/dist/api.js.map +1 -1
- package/dist/attrs-extract.d.ts +48 -0
- package/dist/attrs-extract.d.ts.map +1 -0
- package/dist/attrs-extract.js +153 -0
- package/dist/attrs-extract.js.map +1 -0
- package/dist/attrs.d.ts +445 -0
- package/dist/attrs.d.ts.map +1 -0
- package/dist/attrs.js +604 -0
- package/dist/attrs.js.map +1 -0
- package/dist/cjs/api.d.ts +32 -0
- package/dist/cjs/api.js +38 -5
- package/dist/cjs/attrs-extract.d.ts +47 -0
- package/dist/cjs/attrs-extract.js +159 -0
- package/dist/cjs/attrs.d.ts +444 -0
- package/dist/cjs/attrs.js +635 -0
- package/dist/cjs/index.d.ts +5 -1
- package/dist/cjs/index.js +22 -1
- package/dist/cjs/memories.d.ts +79 -1
- package/dist/cjs/memories.js +249 -15
- package/dist/cjs/memory-graph.d.ts +105 -0
- package/dist/cjs/memory-graph.js +270 -0
- package/dist/cjs/memory-note.d.ts +57 -0
- package/dist/cjs/memory-note.js +174 -10
- package/dist/cjs/memory-secrets.d.ts +19 -0
- package/dist/cjs/memory-secrets.js +108 -0
- package/dist/cjs/notes.d.ts +208 -2
- package/dist/cjs/notes.js +721 -12
- package/dist/http/index.d.ts +4 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +3 -0
- package/dist/http/index.js.map +1 -0
- package/dist/http/local-origin.d.ts +61 -0
- package/dist/http/local-origin.d.ts.map +1 -0
- package/dist/http/local-origin.js +92 -0
- package/dist/http/local-origin.js.map +1 -0
- package/dist/http/server.d.ts +39 -0
- package/dist/http/server.d.ts.map +1 -0
- package/dist/http/server.js +131 -0
- package/dist/http/server.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/memories.d.ts +79 -1
- package/dist/memories.d.ts.map +1 -1
- package/dist/memories.js +249 -15
- package/dist/memories.js.map +1 -1
- package/dist/memory-graph.d.ts +106 -0
- package/dist/memory-graph.d.ts.map +1 -0
- package/dist/memory-graph.js +258 -0
- package/dist/memory-graph.js.map +1 -0
- package/dist/memory-note.d.ts +57 -0
- package/dist/memory-note.d.ts.map +1 -1
- package/dist/memory-note.js +170 -10
- package/dist/memory-note.js.map +1 -1
- package/dist/memory-secrets.d.ts +20 -0
- package/dist/memory-secrets.d.ts.map +1 -0
- package/dist/memory-secrets.js +103 -0
- package/dist/memory-secrets.js.map +1 -0
- package/dist/notes.d.ts +208 -2
- package/dist/notes.d.ts.map +1 -1
- package/dist/notes.js +722 -13
- package/dist/notes.js.map +1 -1
- package/dist/tools.d.ts +8 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +465 -10
- package/dist/tools.js.map +1 -1
- package/package.json +21 -5
package/dist/notes.js
CHANGED
|
@@ -1,13 +1,178 @@
|
|
|
1
|
-
import { bytesToBase64, decryptNoteFields, encryptNoteFields } from '@batalabs/virlow-crypto';
|
|
1
|
+
import { bytesToBase64, decryptNoteFields, decryptTitleField, encryptNoteFields } from '@batalabs/virlow-crypto';
|
|
2
2
|
import { computeExposure } from './exposure.js';
|
|
3
|
+
import { ATTRS_MAX_BYTES, SCHEMA_MAX_BYTES, decodeAttrs, decodeSchema, defineProp, emptyAttrs, emptySchema, encodeAttrs, encodeSchema, newPropId, formatProp, sortedProps, updateProp, homeWidgets, } from './attrs.js';
|
|
4
|
+
import { buildTitleMatcher, extractLinkIds, htmlToText } from './attrs-extract.js';
|
|
5
|
+
import { LockedError } from './vault.js';
|
|
3
6
|
/** Hard ceiling on how many notes searchNotes() will decrypt+scan in a
|
|
4
7
|
* single call. Keeps a single search bounded even for very large accounts;
|
|
5
|
-
* `truncated` tells the caller when the account holds more than this.
|
|
8
|
+
* `truncated` tells the caller when the account holds more than this. Also
|
|
9
|
+
* bounds listVisibleRows(), which backs mention-matching and backlinks(). */
|
|
6
10
|
export const SEARCH_SCAN_CAP = 500;
|
|
7
11
|
const SEARCH_PAGE_LIMIT = 50;
|
|
8
12
|
/** Window radius (chars) either side of the match start when building a
|
|
9
13
|
* content snippet; total window is up to 2*SNIPPET_RADIUS. */
|
|
10
14
|
const SNIPPET_RADIUS = 80;
|
|
15
|
+
/** The one wording every "the vault locked while this was in flight" guard
|
|
16
|
+
* uses, so the sentence the caller reads cannot drift between them. */
|
|
17
|
+
function lockedError() {
|
|
18
|
+
return new LockedError('Vault is locked. Ask the user to run the unlock tool to sign in and unlock their Virlow vault.');
|
|
19
|
+
}
|
|
20
|
+
const TRUE_WORDS = new Set(['true', 'yes', 'y', '1', 'on', 'done', 'checked']);
|
|
21
|
+
const FALSE_WORDS = new Set(['false', 'no', 'n', '0', 'off']);
|
|
22
|
+
/** A date value has to start with a calendar day; anything after it (a time,
|
|
23
|
+
* a zone) is dropped, since a property date is a day. */
|
|
24
|
+
const DATE_PREFIX = /^\d{4}-\d{2}-\d{2}/;
|
|
25
|
+
/** The offending value, echoed back so the caller can see what it sent.
|
|
26
|
+
* Errors travel to the caller and are never logged, so this is not a
|
|
27
|
+
* disclosure path for note content. */
|
|
28
|
+
function describeValue(raw) {
|
|
29
|
+
const text = JSON.stringify(raw);
|
|
30
|
+
return text === undefined ? String(raw) : text;
|
|
31
|
+
}
|
|
32
|
+
/** The definition a `values` key names, by id or by name (case-insensitive).
|
|
33
|
+
* Never guesses between two properties sharing a name: a schema written by an
|
|
34
|
+
* older client can contain duplicates, and picking one silently would write
|
|
35
|
+
* the value onto whichever happened to sort first. */
|
|
36
|
+
function findDefinition(defs, nameOrId) {
|
|
37
|
+
const byId = defs.find((d) => d.id === nameOrId);
|
|
38
|
+
if (byId)
|
|
39
|
+
return byId;
|
|
40
|
+
const wanted = nameOrId.trim().toLowerCase();
|
|
41
|
+
const byName = defs.filter((d) => d.name.trim().toLowerCase() === wanted);
|
|
42
|
+
if (byName.length > 1) {
|
|
43
|
+
throw new Error(`"${nameOrId}" names ${byName.length} properties in this folder. Use the id instead: ` +
|
|
44
|
+
`${byName.map((d) => d.id).join(', ')}.`);
|
|
45
|
+
}
|
|
46
|
+
const [only] = byName;
|
|
47
|
+
if (only)
|
|
48
|
+
return only;
|
|
49
|
+
const known = defs.map((d) => d.name).join(', ');
|
|
50
|
+
throw new Error(`This note's folder has no property "${nameOrId}". Defined properties: ${known || '(none)'}. ` +
|
|
51
|
+
'Run list_properties to see them, or define_property to add one.');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Turns the plain JSON a model sends (`{ Stage: "Shipped", Count: 3 }`) into
|
|
55
|
+
* typed PropValues keyed by property id, plus the options that had to be
|
|
56
|
+
* invented along the way.
|
|
57
|
+
*
|
|
58
|
+
* Nothing is written here: the caller decides the write order, which matters,
|
|
59
|
+
* because a value naming an option id the definition does not carry is
|
|
60
|
+
* unreadable in the app and cannot be repaired through this tool.
|
|
61
|
+
*/
|
|
62
|
+
function resolveValues(defs, values, createOption) {
|
|
63
|
+
const props = {};
|
|
64
|
+
const applied = [];
|
|
65
|
+
const newOptions = new Map();
|
|
66
|
+
/** Every option this call may resolve against: the definition's own, plus
|
|
67
|
+
* the ones invented for it earlier in this same call, so `["New", "New"]`
|
|
68
|
+
* resolves to one option rather than two with the same name. */
|
|
69
|
+
const pool = (def) => [
|
|
70
|
+
...(def.options ?? []),
|
|
71
|
+
...(newOptions.get(def.id) ?? []),
|
|
72
|
+
];
|
|
73
|
+
const optionId = (def, given) => {
|
|
74
|
+
const wanted = given.trim();
|
|
75
|
+
if (wanted === '') {
|
|
76
|
+
throw new Error(`Property "${def.name}" was given an empty option name.`);
|
|
77
|
+
}
|
|
78
|
+
const options = pool(def);
|
|
79
|
+
const match = options.find((o) => o.id === wanted) ??
|
|
80
|
+
options.find((o) => o.name.trim().toLowerCase() === wanted.toLowerCase());
|
|
81
|
+
if (match)
|
|
82
|
+
return match.id;
|
|
83
|
+
if (!createOption) {
|
|
84
|
+
const names = (def.options ?? []).map((o) => o.name).join(', ');
|
|
85
|
+
throw new Error(`Property "${def.name}" has no option "${wanted}". Valid options: ${names || '(none defined)'}. ` +
|
|
86
|
+
'Pass createOption: true to add it to the folder\'s property definition.');
|
|
87
|
+
}
|
|
88
|
+
const option = { id: newPropId(), name: wanted };
|
|
89
|
+
newOptions.set(def.id, [...(newOptions.get(def.id) ?? []), option]);
|
|
90
|
+
return option.id;
|
|
91
|
+
};
|
|
92
|
+
const optionName = (def, id) => pool(def).find((o) => o.id === id)?.name ?? id;
|
|
93
|
+
const asString = (def, raw) => {
|
|
94
|
+
if (typeof raw === 'string')
|
|
95
|
+
return raw;
|
|
96
|
+
if (typeof raw === 'number' || typeof raw === 'boolean')
|
|
97
|
+
return String(raw);
|
|
98
|
+
throw new Error(`Property "${def.name}" expects a string, got ${describeValue(raw)}.`);
|
|
99
|
+
};
|
|
100
|
+
const asStrings = (def, raw) => Array.isArray(raw) ? raw.map((item) => asString(def, item)) : [asString(def, raw)];
|
|
101
|
+
const coerce = (def, raw) => {
|
|
102
|
+
switch (def.type) {
|
|
103
|
+
case 'text': {
|
|
104
|
+
const v = asString(def, raw);
|
|
105
|
+
return { value: { t: 'text', v }, display: v };
|
|
106
|
+
}
|
|
107
|
+
case 'url': {
|
|
108
|
+
const v = asString(def, raw);
|
|
109
|
+
return { value: { t: 'url', v }, display: v };
|
|
110
|
+
}
|
|
111
|
+
case 'number': {
|
|
112
|
+
const n = typeof raw === 'number'
|
|
113
|
+
? raw
|
|
114
|
+
: typeof raw === 'string' && raw.trim() !== ''
|
|
115
|
+
? Number(raw)
|
|
116
|
+
: Number.NaN;
|
|
117
|
+
if (!Number.isFinite(n)) {
|
|
118
|
+
throw new Error(`Property "${def.name}" expects a number, got ${describeValue(raw)}.`);
|
|
119
|
+
}
|
|
120
|
+
return { value: { t: 'number', v: n }, display: String(n) };
|
|
121
|
+
}
|
|
122
|
+
case 'checkbox': {
|
|
123
|
+
const word = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
|
124
|
+
const b = typeof raw === 'boolean'
|
|
125
|
+
? raw
|
|
126
|
+
: TRUE_WORDS.has(word)
|
|
127
|
+
? true
|
|
128
|
+
: FALSE_WORDS.has(word)
|
|
129
|
+
? false
|
|
130
|
+
: null;
|
|
131
|
+
if (b === null) {
|
|
132
|
+
throw new Error(`Property "${def.name}" expects true or false, got ${describeValue(raw)}.`);
|
|
133
|
+
}
|
|
134
|
+
return { value: { t: 'checkbox', v: b }, display: b ? 'Yes' : 'No' };
|
|
135
|
+
}
|
|
136
|
+
case 'date': {
|
|
137
|
+
const given = asString(def, raw).trim();
|
|
138
|
+
if (!DATE_PREFIX.test(given)) {
|
|
139
|
+
throw new Error(`Property "${def.name}" expects a date like 2026-09-07, got ${describeValue(raw)}.`);
|
|
140
|
+
}
|
|
141
|
+
const v = given.slice(0, 10);
|
|
142
|
+
return { value: { t: 'date', v }, display: v };
|
|
143
|
+
}
|
|
144
|
+
case 'select': {
|
|
145
|
+
const id = optionId(def, asString(def, raw));
|
|
146
|
+
return { value: { t: 'select', v: id }, display: optionName(def, id) };
|
|
147
|
+
}
|
|
148
|
+
case 'multiSelect': {
|
|
149
|
+
const ids = asStrings(def, raw).map((name) => optionId(def, name));
|
|
150
|
+
return {
|
|
151
|
+
value: { t: 'multiSelect', v: ids },
|
|
152
|
+
display: ids.map((id) => optionName(def, id)).join(', '),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
case 'relation': {
|
|
156
|
+
const ids = asStrings(def, raw)
|
|
157
|
+
.map((id) => id.trim())
|
|
158
|
+
.filter((id) => id !== '');
|
|
159
|
+
return { value: { t: 'relation', v: ids }, display: ids.join(', ') };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
for (const [nameOrId, raw] of Object.entries(values)) {
|
|
164
|
+
const def = findDefinition(defs, nameOrId);
|
|
165
|
+
if (raw === null) {
|
|
166
|
+
props[def.id] = null;
|
|
167
|
+
applied.push({ name: def.name, value: 'removed' });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const { value, display } = coerce(def, raw);
|
|
171
|
+
props[def.id] = value;
|
|
172
|
+
applied.push({ name: def.name, value: display });
|
|
173
|
+
}
|
|
174
|
+
return { props, applied, newOptions };
|
|
175
|
+
}
|
|
11
176
|
export class NotesService {
|
|
12
177
|
api;
|
|
13
178
|
vault;
|
|
@@ -15,6 +180,33 @@ export class NotesService {
|
|
|
15
180
|
* so re-reading/re-scanning the same note twice in one unlock doesn't
|
|
16
181
|
* re-run AES-GCM decryption. Cleared whenever the vault locks. */
|
|
17
182
|
decryptCache = new Map();
|
|
183
|
+
/** Per-unlock cache of decoded attrs, keyed the same way as decryptCache.
|
|
184
|
+
* Stores `null` too (a note with no attrs), so `.has()` rather than a
|
|
185
|
+
* truthiness check distinguishes "decoded, no attrs" from "not cached
|
|
186
|
+
* yet". Cleared on lock alongside decryptCache. */
|
|
187
|
+
attrsCache = new Map();
|
|
188
|
+
/** Per-unlock cache of decrypted titles only (no content), used by
|
|
189
|
+
* mention-matching and backlinks() so neither has to pay for a full body
|
|
190
|
+
* decrypt just to learn a note's title. Cleared on lock alongside the
|
|
191
|
+
* other caches. */
|
|
192
|
+
titleCache = new Map();
|
|
193
|
+
/** Per-unlock cache of decoded folder schemas, keyed on the folder id and
|
|
194
|
+
* the ciphertext itself so a schema this service just rewrote is never
|
|
195
|
+
* served from a stale entry. Cleared on lock alongside the other caches. */
|
|
196
|
+
schemaCache = new Map();
|
|
197
|
+
/**
|
|
198
|
+
* The rows listVisibleRows() last paged, with the epoch and folder-exposure
|
|
199
|
+
* they were computed under. Without this, update_note paged the entire note
|
|
200
|
+
* list on every call just to build mention titles, so one edit cost as many
|
|
201
|
+
* round trips as the account has pages of notes.
|
|
202
|
+
*
|
|
203
|
+
* Keyed on the exposure as well as the epoch, so flipping a folder's
|
|
204
|
+
* "Expose to MCP" switch takes effect on the very next call rather than
|
|
205
|
+
* leaving a closed folder's titles in the mention set; dropped outright on
|
|
206
|
+
* every write, so a note this service just created, moved, trashed or
|
|
207
|
+
* retitled is never missing from (or stale in) the next scan.
|
|
208
|
+
*/
|
|
209
|
+
visibleRowsCache = null;
|
|
18
210
|
/** Bumped every time the vault locks (see handleLock()). decryptRow()
|
|
19
211
|
* captures this before its await on decryptNoteFields() and re-checks it
|
|
20
212
|
* afterward, so a decrypt that was still in flight when the vault locked
|
|
@@ -33,8 +225,18 @@ export class NotesService {
|
|
|
33
225
|
}
|
|
34
226
|
handleLock() {
|
|
35
227
|
this.decryptCache.clear();
|
|
228
|
+
this.attrsCache.clear();
|
|
229
|
+
this.titleCache.clear();
|
|
230
|
+
this.schemaCache.clear();
|
|
231
|
+
this.visibleRowsCache = null;
|
|
36
232
|
this.epoch++;
|
|
37
233
|
}
|
|
234
|
+
/** Drops the paged row list after anything that could change which rows
|
|
235
|
+
* exist, where they live, or what they are called. Called after the write
|
|
236
|
+
* lands, so a failed write leaves the cache alone. */
|
|
237
|
+
invalidateVisibleRows() {
|
|
238
|
+
this.visibleRowsCache = null;
|
|
239
|
+
}
|
|
38
240
|
async listNotes(params) {
|
|
39
241
|
const key = this.requireSession();
|
|
40
242
|
const { notes, pagination } = await this.api.listNotes({ ...params, deleted: false });
|
|
@@ -63,6 +265,76 @@ export class NotesService {
|
|
|
63
265
|
}));
|
|
64
266
|
return { notes: mapped, pagination, withheld, exposure };
|
|
65
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* A folder the way a view shows it: every note in it, with the value it
|
|
270
|
+
* carries for each of the folder's property definitions.
|
|
271
|
+
*
|
|
272
|
+
* The program's AI-parity principle is that every capability the UI gains,
|
|
273
|
+
* the tools gain in the same epic. The app can now read a folder as a table,
|
|
274
|
+
* a board or a calendar; without this an assistant could read and write
|
|
275
|
+
* individual properties but never see the shape the user is looking at, so
|
|
276
|
+
* "which of these is still in review" meant reading every note in turn.
|
|
277
|
+
*
|
|
278
|
+
* Values come from the attrs blob already on each list row, so a folder of
|
|
279
|
+
* two thousand notes answers without fetching or decrypting a single body.
|
|
280
|
+
* That is the same constraint the views are built to, and it is why this
|
|
281
|
+
* reads rows rather than calling readNote per note.
|
|
282
|
+
*
|
|
283
|
+
* Hidden notes and unexposed folders are filtered exactly as listNotes does,
|
|
284
|
+
* and for the same reasons; the counts it withholds are reported here too so
|
|
285
|
+
* a filtered folder is never mistaken for a small one.
|
|
286
|
+
*/
|
|
287
|
+
async listFolderView(params) {
|
|
288
|
+
const key = this.requireSession();
|
|
289
|
+
const folder = await this.requireFolder(params.folderId);
|
|
290
|
+
const defs = sortedProps(await this.decodeSchemaFor(key, folder));
|
|
291
|
+
const { notes, pagination } = await this.api.listNotes({
|
|
292
|
+
folderId: params.folderId,
|
|
293
|
+
page: params.page,
|
|
294
|
+
limit: params.limit,
|
|
295
|
+
deleted: false,
|
|
296
|
+
});
|
|
297
|
+
const exposure = await this.exposure();
|
|
298
|
+
const notHidden = notes.filter((row) => row.hidden !== true);
|
|
299
|
+
const visible = notHidden.filter((row) => exposure.allows(row.folderId));
|
|
300
|
+
const withheld = {
|
|
301
|
+
inClosedFolders: notHidden.length - visible.length,
|
|
302
|
+
};
|
|
303
|
+
const rows = await Promise.all(visible.map(async (row) => {
|
|
304
|
+
const attrs = await this.decodeAttrsFor(key, row);
|
|
305
|
+
const values = {};
|
|
306
|
+
for (const def of defs) {
|
|
307
|
+
values[def.id] = formatProp(def, attrs?.props?.[def.id]);
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
id: row.id,
|
|
311
|
+
title: await this.decryptTitleFor(key, row),
|
|
312
|
+
values,
|
|
313
|
+
};
|
|
314
|
+
}));
|
|
315
|
+
// Sorting on the formatted value, as the table does, so a select orders by
|
|
316
|
+
// its option name rather than by an option id nobody chose. An empty value
|
|
317
|
+
// sorts last, so asking for an order never buries the rows that have data.
|
|
318
|
+
const sortedBy = params.sortBy
|
|
319
|
+
? (defs.find((d) => d.id === params.sortBy) ??
|
|
320
|
+
defs.find((d) => d.name.toLowerCase() === params.sortBy.toLowerCase()) ??
|
|
321
|
+
null)
|
|
322
|
+
: null;
|
|
323
|
+
if (sortedBy) {
|
|
324
|
+
rows.sort((a, b) => {
|
|
325
|
+
const ka = a.values[sortedBy.id] ?? '';
|
|
326
|
+
const kb = b.values[sortedBy.id] ?? '';
|
|
327
|
+
if (ka === kb)
|
|
328
|
+
return 0;
|
|
329
|
+
if (ka === '')
|
|
330
|
+
return 1;
|
|
331
|
+
if (kb === '')
|
|
332
|
+
return -1;
|
|
333
|
+
return ka.localeCompare(kb);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return { defs, rows, pagination, withheld, sortedBy };
|
|
337
|
+
}
|
|
66
338
|
async readNote(id) {
|
|
67
339
|
const key = this.requireSession();
|
|
68
340
|
const row = await this.api.getNote(id);
|
|
@@ -71,12 +343,19 @@ export class NotesService {
|
|
|
71
343
|
const exposure = await this.exposure();
|
|
72
344
|
this.requireExposed(exposure, row.folderId, id);
|
|
73
345
|
const { title, content } = await this.decryptRow(key, row);
|
|
74
|
-
|
|
346
|
+
const attrs = await this.decodeAttrsFor(key, row);
|
|
347
|
+
return { id: row.id, title, content, folderId: row.folderId, attrs };
|
|
75
348
|
}
|
|
76
349
|
async createNote(title, content, folderId, type) {
|
|
77
350
|
const key = this.requireSession();
|
|
351
|
+
// Same guard as updateNote(): captured before the encrypt await so a
|
|
352
|
+
// lock landing mid-create is caught before the write, not after.
|
|
353
|
+
const startEpoch = this.epoch;
|
|
78
354
|
const salt = bytesToBase64(crypto.getRandomValues(new Uint8Array(32)));
|
|
79
355
|
const { encryptedTitle, encryptedContent, iv } = await encryptNoteFields(key, title, content);
|
|
356
|
+
if (this.epoch !== startEpoch) {
|
|
357
|
+
throw lockedError();
|
|
358
|
+
}
|
|
80
359
|
const body = {
|
|
81
360
|
title: '[ENCRYPTED]',
|
|
82
361
|
content: '[ENCRYPTED]',
|
|
@@ -90,27 +369,278 @@ export class NotesService {
|
|
|
90
369
|
if (type !== undefined)
|
|
91
370
|
body.type = type;
|
|
92
371
|
const row = await this.api.createNote(body);
|
|
372
|
+
this.invalidateVisibleRows();
|
|
93
373
|
return { id: row.id };
|
|
94
374
|
}
|
|
95
375
|
updateNote(id, changes) {
|
|
96
376
|
return this.enqueue(async () => {
|
|
97
377
|
const key = this.requireSession();
|
|
378
|
+
// Captured before any await so a lock that lands mid-update is caught
|
|
379
|
+
// before the write, not after: this note's ciphertext must never be
|
|
380
|
+
// sent encrypted under a key the vault has since revoked.
|
|
381
|
+
const startEpoch = this.epoch;
|
|
98
382
|
const existingRow = await this.api.getNote(id);
|
|
99
383
|
const exposure = await this.exposure();
|
|
100
384
|
this.requireExposed(exposure, existingRow.folderId, id);
|
|
101
385
|
const current = await this.decryptRow(key, existingRow);
|
|
102
386
|
const title = changes.title ?? current.title;
|
|
103
387
|
const content = changes.content ?? current.content;
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
388
|
+
const prevAttrs = (await this.decodeAttrsFor(key, existingRow)) ?? emptyAttrs();
|
|
389
|
+
const props = { ...prevAttrs.props };
|
|
390
|
+
for (const [propKey, value] of Object.entries(changes.props ?? {})) {
|
|
391
|
+
if (value === null) {
|
|
392
|
+
delete props[propKey];
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
props[propKey] = value;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const links = extractLinkIds(content).filter((linkId) => linkId !== id);
|
|
399
|
+
// Reuses the exposure already computed above for requireExposed(),
|
|
400
|
+
// rather than making visibleTitles() fetch the folder list again.
|
|
401
|
+
const titles = await this.visibleTitles(key, id, exposure);
|
|
402
|
+
const mentions = buildTitleMatcher(titles).find(htmlToText(content), new Set([id, ...links]));
|
|
403
|
+
const attrs = {
|
|
404
|
+
// `v` comes from prevAttrs (1 via emptyAttrs() when there was no
|
|
405
|
+
// previous blob); it is never overwritten with a literal 1 here, so
|
|
406
|
+
// a blob written by a newer client that bumped `v` keeps its number
|
|
407
|
+
// through this round trip instead of being silently downgraded.
|
|
408
|
+
...prevAttrs,
|
|
409
|
+
props,
|
|
410
|
+
links,
|
|
411
|
+
mentions,
|
|
412
|
+
updated: new Date().toISOString(),
|
|
413
|
+
...(changes.aliases !== undefined ? { aliases: changes.aliases } : {}),
|
|
414
|
+
};
|
|
415
|
+
const encryptedAttrs = await this.encodeAttrsChecked(key, attrs, id);
|
|
416
|
+
// A props-or-aliases-only edit leaves the body exactly as it was, so it
|
|
417
|
+
// sends only the attrs blob. Re-encrypting the unchanged title and
|
|
418
|
+
// content would rewrite the row's ciphertext (and its IV and salt) for
|
|
419
|
+
// no reason: pure work, a bigger request, a pointless revision, and one
|
|
420
|
+
// more chance for an unrelated failure to corrupt a body nobody asked
|
|
421
|
+
// to change.
|
|
422
|
+
const body = { encryptedAttrs };
|
|
423
|
+
if (changes.title !== undefined || changes.content !== undefined) {
|
|
424
|
+
const salt = bytesToBase64(crypto.getRandomValues(new Uint8Array(32)));
|
|
425
|
+
const { encryptedTitle, encryptedContent, iv } = await encryptNoteFields(key, title, content);
|
|
426
|
+
body.title = '[ENCRYPTED]';
|
|
427
|
+
body.content = '[ENCRYPTED]';
|
|
428
|
+
body.encryptedTitle = encryptedTitle;
|
|
429
|
+
body.encryptedContent = encryptedContent;
|
|
430
|
+
body.iv = iv;
|
|
431
|
+
body.salt = salt;
|
|
432
|
+
}
|
|
433
|
+
if (this.epoch !== startEpoch) {
|
|
434
|
+
throw lockedError();
|
|
435
|
+
}
|
|
436
|
+
await this.api.updateNote(id, body);
|
|
437
|
+
this.invalidateVisibleRows();
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Notes that link to `id`, built entirely from attrs, never a body
|
|
442
|
+
* decrypt. Scans the same folder-visible, non-hidden rows as listNotes(),
|
|
443
|
+
* bounded by SEARCH_SCAN_CAP like searchNotes().
|
|
444
|
+
*
|
|
445
|
+
* Unlike readNote()/decryptRow(), which let an in-flight decrypt finish and
|
|
446
|
+
* simply skip the cache write if the vault locked mid-flight, this
|
|
447
|
+
* aggregates across every visible note in one call: a lock partway through
|
|
448
|
+
* means the result mixes attrs decoded under a session that's since been
|
|
449
|
+
* revoked, which is not safe to hand back. So the whole call rejects with
|
|
450
|
+
* LockedError instead.
|
|
451
|
+
*/
|
|
452
|
+
async backlinks(id) {
|
|
453
|
+
const key = this.requireSession();
|
|
454
|
+
const startEpoch = this.epoch;
|
|
455
|
+
const rows = await this.listVisibleRows();
|
|
456
|
+
const result = [];
|
|
457
|
+
for (const row of rows) {
|
|
458
|
+
if (row.id === id)
|
|
459
|
+
continue;
|
|
460
|
+
const attrs = await this.decodeAttrsFor(key, row);
|
|
461
|
+
if (attrs?.links.includes(id)) {
|
|
462
|
+
result.push({ id: row.id, title: await this.decryptTitleFor(key, row) });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (this.epoch !== startEpoch) {
|
|
466
|
+
throw lockedError();
|
|
467
|
+
}
|
|
468
|
+
return result;
|
|
469
|
+
}
|
|
470
|
+
/** A folder's property definitions, in display order. Read-only, so no
|
|
471
|
+
* mutation queue and no epoch guard past the read itself. */
|
|
472
|
+
async listProperties(folderId) {
|
|
473
|
+
const key = this.requireSession();
|
|
474
|
+
const folder = await this.requireFolder(folderId);
|
|
475
|
+
return sortedProps(await this.decodeSchemaFor(key, folder));
|
|
476
|
+
}
|
|
477
|
+
/** A folder's dashboard as it stands, with the blocks it will draw. */
|
|
478
|
+
async readDashboard(folderId) {
|
|
479
|
+
const key = this.requireSession();
|
|
480
|
+
const folder = await this.requireFolder(folderId);
|
|
481
|
+
const schema = await this.decodeSchemaFor(key, folder);
|
|
482
|
+
return {
|
|
483
|
+
widgets: homeWidgets(schema, sortedProps(schema)),
|
|
484
|
+
defs: sortedProps(schema),
|
|
485
|
+
arranged: (schema.home?.widgets?.length ?? 0) > 0,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Replaces a folder's dashboard arrangement.
|
|
490
|
+
*
|
|
491
|
+
* The previous arrangement is kept alongside the new one, so a person can
|
|
492
|
+
* put back what an assistant changed. That matters more here than for a
|
|
493
|
+
* note: a note edit is visible in the note, while a dashboard edit can
|
|
494
|
+
* remove a block and leave nothing behind to say what was there. One level,
|
|
495
|
+
* deliberately: this is an undo, not a history, and the schema travels
|
|
496
|
+
* inside every folder read.
|
|
497
|
+
*/
|
|
498
|
+
setDashboard(folderId, widgets) {
|
|
499
|
+
return this.enqueue(async () => {
|
|
500
|
+
const key = this.requireSession();
|
|
501
|
+
// Captured before the first await, re-checked immediately before the
|
|
502
|
+
// write, exactly as updateNote() does.
|
|
503
|
+
const startEpoch = this.epoch;
|
|
504
|
+
const folder = await this.requireFolder(folderId);
|
|
505
|
+
const schema = await this.decodeSchemaFor(key, folder);
|
|
506
|
+
const kept = homeWidgets(schema, sortedProps(schema));
|
|
507
|
+
// The date on a summary is stamped here rather than taken from the
|
|
508
|
+
// caller. It records when the text was actually written, which is a
|
|
509
|
+
// fact about this write and not a claim for an assistant to make; one
|
|
510
|
+
// that forgot to send it would otherwise leave a snapshot with nothing
|
|
511
|
+
// to say how old it is, which is the whole reason the field exists.
|
|
512
|
+
const now = new Date().toISOString();
|
|
513
|
+
const stamped = widgets.map((w) => w.kind === 'summary' ? { ...w, at: now } : w);
|
|
514
|
+
const next = {
|
|
515
|
+
...schema,
|
|
516
|
+
home: { ...schema.home, widgets: stamped, previous: kept },
|
|
517
|
+
};
|
|
518
|
+
const encryptedSchema = await this.encodeSchemaChecked(key, next, folderId);
|
|
519
|
+
if (this.epoch !== startEpoch)
|
|
520
|
+
throw lockedError();
|
|
521
|
+
await this.api.updateFolder(folderId, { encryptedSchema });
|
|
522
|
+
return stamped;
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Appends a property definition to a folder's schema.
|
|
527
|
+
*
|
|
528
|
+
* Refuses a name the folder already uses, rather than creating a second
|
|
529
|
+
* definition with it: setProperties() resolves a value's key by name, and a
|
|
530
|
+
* duplicate would make every later call ambiguous. The message names the
|
|
531
|
+
* existing id so the caller can just use it.
|
|
532
|
+
*/
|
|
533
|
+
defineProperty(folderId, def) {
|
|
534
|
+
return this.enqueue(async () => {
|
|
535
|
+
const key = this.requireSession();
|
|
536
|
+
// Captured before the first await, re-checked immediately before the
|
|
537
|
+
// write, exactly as updateNote() does.
|
|
538
|
+
const startEpoch = this.epoch;
|
|
539
|
+
const folder = await this.requireFolder(folderId);
|
|
540
|
+
const schema = await this.decodeSchemaFor(key, folder);
|
|
541
|
+
const name = def.name.trim();
|
|
542
|
+
const clash = sortedProps(schema).find((p) => p.name.trim().toLowerCase() === name.toLowerCase());
|
|
543
|
+
if (clash) {
|
|
544
|
+
throw new Error(`Folder ${folderId} already has a property named "${clash.name}" (id ${clash.id}, ` +
|
|
545
|
+
`type ${clash.type}). Use that id, or choose a different name.`);
|
|
546
|
+
}
|
|
547
|
+
const id = newPropId();
|
|
548
|
+
const options = (def.options ?? []).map((o) => ({ id: newPropId(), name: o.name.trim() }));
|
|
549
|
+
const next = defineProp(schema, {
|
|
550
|
+
id,
|
|
551
|
+
name,
|
|
552
|
+
type: def.type,
|
|
553
|
+
...(options.length > 0 ? { options } : {}),
|
|
554
|
+
...(def.showInList !== undefined ? { showInList: def.showInList } : {}),
|
|
113
555
|
});
|
|
556
|
+
// Narrowing, not a guess: defineProp() appends exactly what it was
|
|
557
|
+
// given, so this finds it. Looked up rather than reconstructed so the
|
|
558
|
+
// definition reported back is the one actually written.
|
|
559
|
+
const created = sortedProps(next).find((p) => p.id === id);
|
|
560
|
+
if (created === undefined) {
|
|
561
|
+
throw new Error(`Could not define property "${name}" in folder ${folderId}.`);
|
|
562
|
+
}
|
|
563
|
+
const encryptedSchema = await this.encodeSchemaChecked(key, next, folderId);
|
|
564
|
+
if (this.epoch !== startEpoch)
|
|
565
|
+
throw lockedError();
|
|
566
|
+
await this.api.updateFolder(folderId, { encryptedSchema });
|
|
567
|
+
return created;
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Sets typed property values on one note, keyed by property name or id.
|
|
572
|
+
*
|
|
573
|
+
* Only `encryptedAttrs` is sent: the title and body keep the ciphertext,
|
|
574
|
+
* IV and salt they already had, so a property change can never damage a
|
|
575
|
+
* body nobody asked to change.
|
|
576
|
+
*
|
|
577
|
+
* When `createOption` invents an option for a select or multiSelect, the
|
|
578
|
+
* definition carrying it is written to the folder **before** the value that
|
|
579
|
+
* names it. Get that order wrong and a failure in between leaves a value
|
|
580
|
+
* pointing at an option id no definition carries, which reads as blank in
|
|
581
|
+
* the app and cannot be repaired from here. In this order the worst a
|
|
582
|
+
* failure between the two writes leaves behind is an option nobody uses
|
|
583
|
+
* yet, and repeating the same call finds that option by name, adds no
|
|
584
|
+
* duplicate, and writes the value.
|
|
585
|
+
*/
|
|
586
|
+
setProperties(noteId, values, opts = {}) {
|
|
587
|
+
// Shares updateNote()'s queue: a property write and a body write on the
|
|
588
|
+
// same note must not interleave their read-merge-encrypt-write cycles.
|
|
589
|
+
return this.enqueue(async () => {
|
|
590
|
+
const key = this.requireSession();
|
|
591
|
+
const startEpoch = this.epoch;
|
|
592
|
+
if (Object.keys(values).length === 0) {
|
|
593
|
+
throw new Error(`No values given for note ${noteId}. Run list_properties on its folder to see what ` +
|
|
594
|
+
'can be set, and pass at least one value.');
|
|
595
|
+
}
|
|
596
|
+
const row = await this.api.getNote(noteId);
|
|
597
|
+
const { folders } = await this.api.listFolders();
|
|
598
|
+
const exposure = this.exposureOf(folders);
|
|
599
|
+
this.requireExposed(exposure, row.folderId, noteId);
|
|
600
|
+
const folder = folders.find((f) => f.id === row.folderId);
|
|
601
|
+
if (folder === undefined) {
|
|
602
|
+
// Narrowing: requireExposed() above only passes for a folder that is
|
|
603
|
+
// in this same list, so there is always one to find.
|
|
604
|
+
throw new Error(`Note ${noteId}'s folder was not found.`);
|
|
605
|
+
}
|
|
606
|
+
const schema = await this.decodeSchemaFor(key, folder);
|
|
607
|
+
const defs = sortedProps(schema);
|
|
608
|
+
const { props: patch, applied, newOptions } = resolveValues(defs, values, opts.createOption === true);
|
|
609
|
+
// The attrs blob is built and size-checked before anything is written,
|
|
610
|
+
// so a note whose properties would not fit never costs the folder an
|
|
611
|
+
// orphan option.
|
|
612
|
+
const prevAttrs = (await this.decodeAttrsFor(key, row)) ?? emptyAttrs();
|
|
613
|
+
const props = { ...prevAttrs.props };
|
|
614
|
+
for (const [propId, value] of Object.entries(patch)) {
|
|
615
|
+
if (value === null) {
|
|
616
|
+
delete props[propId];
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
props[propId] = value;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// `v` and every unknown field come from prevAttrs, so a blob written by
|
|
623
|
+
// a newer client survives this round trip (see updateNote()).
|
|
624
|
+
const attrs = { ...prevAttrs, props, updated: new Date().toISOString() };
|
|
625
|
+
const encryptedAttrs = await this.encodeAttrsChecked(key, attrs, noteId);
|
|
626
|
+
if (newOptions.size > 0) {
|
|
627
|
+
let nextSchema = schema;
|
|
628
|
+
for (const [defId, added] of newOptions) {
|
|
629
|
+
const def = defs.find((d) => d.id === defId);
|
|
630
|
+
nextSchema = updateProp(nextSchema, defId, {
|
|
631
|
+
options: [...(def?.options ?? []), ...added],
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
const encryptedSchema = await this.encodeSchemaChecked(key, nextSchema, folder.id);
|
|
635
|
+
if (this.epoch !== startEpoch)
|
|
636
|
+
throw lockedError();
|
|
637
|
+
await this.api.updateFolder(folder.id, { encryptedSchema });
|
|
638
|
+
}
|
|
639
|
+
if (this.epoch !== startEpoch)
|
|
640
|
+
throw lockedError();
|
|
641
|
+
await this.api.updateNote(noteId, { encryptedAttrs });
|
|
642
|
+
this.invalidateVisibleRows();
|
|
643
|
+
return { attrs, applied };
|
|
114
644
|
});
|
|
115
645
|
}
|
|
116
646
|
async searchNotes(query, limit = 20) {
|
|
@@ -168,6 +698,7 @@ export class NotesService {
|
|
|
168
698
|
this.requireExposed(exposure, folderId, id);
|
|
169
699
|
}
|
|
170
700
|
await this.api.updateNote(id, { folderId });
|
|
701
|
+
this.invalidateVisibleRows();
|
|
171
702
|
}
|
|
172
703
|
async setStar(id, starred) {
|
|
173
704
|
this.requireSession();
|
|
@@ -175,6 +706,7 @@ export class NotesService {
|
|
|
175
706
|
const exposure = await this.exposure();
|
|
176
707
|
this.requireExposed(exposure, existingRow.folderId, id);
|
|
177
708
|
await this.api.updateNote(id, { starred });
|
|
709
|
+
this.invalidateVisibleRows();
|
|
178
710
|
}
|
|
179
711
|
async archiveNote(id, archived) {
|
|
180
712
|
this.requireSession();
|
|
@@ -182,6 +714,7 @@ export class NotesService {
|
|
|
182
714
|
const exposure = await this.exposure();
|
|
183
715
|
this.requireExposed(exposure, existingRow.folderId, id);
|
|
184
716
|
await this.api.updateNote(id, { archived });
|
|
717
|
+
this.invalidateVisibleRows();
|
|
185
718
|
}
|
|
186
719
|
async trashNote(id) {
|
|
187
720
|
this.requireSession();
|
|
@@ -189,6 +722,7 @@ export class NotesService {
|
|
|
189
722
|
const exposure = await this.exposure();
|
|
190
723
|
this.requireExposed(exposure, existingRow.folderId, id);
|
|
191
724
|
await this.api.updateNote(id, { deleted: true });
|
|
725
|
+
this.invalidateVisibleRows();
|
|
192
726
|
}
|
|
193
727
|
async listFolders() {
|
|
194
728
|
this.requireSession();
|
|
@@ -207,7 +741,22 @@ export class NotesService {
|
|
|
207
741
|
* respect it. */
|
|
208
742
|
async exposure() {
|
|
209
743
|
const { folders } = await this.api.listFolders();
|
|
210
|
-
return
|
|
744
|
+
return this.exposureOf(folders);
|
|
745
|
+
}
|
|
746
|
+
/** The same computation for a caller that already has the folder list and
|
|
747
|
+
* needs a folder out of it as well as the exposure (the property tools do:
|
|
748
|
+
* a folder's schema and its exposure come from the same row). */
|
|
749
|
+
exposureOf(folders) {
|
|
750
|
+
// Everything computeExposure() actually reads, in list order. Any change
|
|
751
|
+
// to it changes which notes are visible, so it invalidates the paged row
|
|
752
|
+
// cache keyed on this string. The key travels on the exposure object
|
|
753
|
+
// rather than in an instance field: two calls can be in flight at once,
|
|
754
|
+
// and a field would let the second one's key be stamped onto rows the
|
|
755
|
+
// first one filtered under a different exposure.
|
|
756
|
+
const key = folders
|
|
757
|
+
.map((f) => `${f.id}/${f.parentId ?? ''}/${f.mcpEnabled ? 1 : 0}/${f.kind ?? ''}`)
|
|
758
|
+
.join('|');
|
|
759
|
+
return { ...computeExposure(folders), key };
|
|
211
760
|
}
|
|
212
761
|
/** Throws unless `folderId` is exposed to MCP. Every method that reads or
|
|
213
762
|
* mutates a specific note (by id) must call this — knowing a note's id is
|
|
@@ -243,6 +792,166 @@ export class NotesService {
|
|
|
243
792
|
}
|
|
244
793
|
return fields;
|
|
245
794
|
}
|
|
795
|
+
/** Decode (with per-unlock caching) a note's attrs blob. Mirrors
|
|
796
|
+
* decryptRow()'s epoch guard: a decode still in flight when the vault
|
|
797
|
+
* locks resolves for its own caller but never lands in the cache. */
|
|
798
|
+
async decodeAttrsFor(key, row) {
|
|
799
|
+
// Keyed on the encrypted blob itself, not just id:updatedAt like
|
|
800
|
+
// decryptCache: title/content changes always bump updatedAt together with
|
|
801
|
+
// their ciphertext, but an attrs-only write (or a caller handing in a row
|
|
802
|
+
// it patched in memory) can leave updatedAt behind while encryptedAttrs
|
|
803
|
+
// changed, and a stale hit here would silently serve the wrong props.
|
|
804
|
+
const cacheKey = `${row.id}:${row.updatedAt}:${row.encryptedAttrs ?? ''}`;
|
|
805
|
+
if (this.attrsCache.has(cacheKey)) {
|
|
806
|
+
return this.attrsCache.get(cacheKey) ?? null;
|
|
807
|
+
}
|
|
808
|
+
const startEpoch = this.epoch;
|
|
809
|
+
const attrs = await decodeAttrs(key, row.encryptedAttrs);
|
|
810
|
+
if (this.epoch === startEpoch) {
|
|
811
|
+
this.attrsCache.set(cacheKey, attrs);
|
|
812
|
+
}
|
|
813
|
+
return attrs;
|
|
814
|
+
}
|
|
815
|
+
/** Decrypt (with per-unlock caching) just a note's title, without touching
|
|
816
|
+
* its body, the cheaper primitive backlinks() and mention-matching need. */
|
|
817
|
+
async decryptTitleFor(key, row) {
|
|
818
|
+
if (!row.encryptedTitle) {
|
|
819
|
+
return row.title;
|
|
820
|
+
}
|
|
821
|
+
const cacheKey = `${row.id}:${row.updatedAt}`;
|
|
822
|
+
const cached = this.titleCache.get(cacheKey);
|
|
823
|
+
if (cached !== undefined)
|
|
824
|
+
return cached;
|
|
825
|
+
const startEpoch = this.epoch;
|
|
826
|
+
const title = await decryptTitleField(key, row.encryptedTitle, row.iv ?? '');
|
|
827
|
+
if (this.epoch === startEpoch) {
|
|
828
|
+
this.titleCache.set(cacheKey, title);
|
|
829
|
+
}
|
|
830
|
+
return title;
|
|
831
|
+
}
|
|
832
|
+
/** The folder-visible, non-hidden rows a mention/backlink scan is allowed to
|
|
833
|
+
* see, same exposure rules as listNotes()/searchNotes(), bounded by
|
|
834
|
+
* SEARCH_SCAN_CAP for the same reason searchNotes() is. Accepts an
|
|
835
|
+
* already-computed `exposure` so a caller that has just checked it (as
|
|
836
|
+
* updateNote() has, for requireExposed()) does not pay for a second
|
|
837
|
+
* listFolders() round trip. */
|
|
838
|
+
async listVisibleRows(exposure) {
|
|
839
|
+
const exp = exposure ?? (await this.exposure());
|
|
840
|
+
const startEpoch = this.epoch;
|
|
841
|
+
const key = `${startEpoch}:${exp.key}`;
|
|
842
|
+
if (this.visibleRowsCache?.key === key)
|
|
843
|
+
return this.visibleRowsCache.rows;
|
|
844
|
+
const rows = [];
|
|
845
|
+
let page = 1;
|
|
846
|
+
for (;;) {
|
|
847
|
+
const { notes, pagination } = await this.api.listNotes({ page, limit: SEARCH_PAGE_LIMIT, deleted: false });
|
|
848
|
+
for (const row of notes) {
|
|
849
|
+
if (row.hidden === true)
|
|
850
|
+
continue;
|
|
851
|
+
// Archived notes are out of the way by the user's own choice, so an
|
|
852
|
+
// archived note is not a backlink source and not a mention target.
|
|
853
|
+
// The web index excludes them for the same reason; without this,
|
|
854
|
+
// get_backlinks and the web disagreed about the same vault.
|
|
855
|
+
if (row.archived === true)
|
|
856
|
+
continue;
|
|
857
|
+
if (!exp.allows(row.folderId))
|
|
858
|
+
continue;
|
|
859
|
+
rows.push(row);
|
|
860
|
+
if (rows.length >= SEARCH_SCAN_CAP) {
|
|
861
|
+
this.cacheVisibleRows(key, startEpoch, rows);
|
|
862
|
+
return rows;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (notes.length === 0 || page >= pagination.pages)
|
|
866
|
+
break;
|
|
867
|
+
page++;
|
|
868
|
+
}
|
|
869
|
+
this.cacheVisibleRows(key, startEpoch, rows);
|
|
870
|
+
return rows;
|
|
871
|
+
}
|
|
872
|
+
/** Same guard as decryptRow(): a paging run that was still in flight when
|
|
873
|
+
* the vault locked must not seed the cache for the session that follows. */
|
|
874
|
+
cacheVisibleRows(key, startEpoch, rows) {
|
|
875
|
+
if (this.epoch !== startEpoch)
|
|
876
|
+
return;
|
|
877
|
+
this.visibleRowsCache = { key, rows };
|
|
878
|
+
}
|
|
879
|
+
/** Title entries for mention-matching against a note being saved: one entry
|
|
880
|
+
* per other folder-visible note's title, plus one more per alias in its
|
|
881
|
+
* attrs (an alias is another name mentions should recognize, same as the
|
|
882
|
+
* title). Titles come from decryptTitleFor() and aliases from
|
|
883
|
+
* decodeAttrsFor() (the same per-epoch caches backlinks() uses), so this
|
|
884
|
+
* costs no body decryption. */
|
|
885
|
+
async visibleTitles(key, excludeId, exposure) {
|
|
886
|
+
const rows = await this.listVisibleRows(exposure);
|
|
887
|
+
const entries = [];
|
|
888
|
+
for (const row of rows) {
|
|
889
|
+
if (row.id === excludeId)
|
|
890
|
+
continue;
|
|
891
|
+
entries.push({ id: row.id, title: await this.decryptTitleFor(key, row) });
|
|
892
|
+
const attrs = await this.decodeAttrsFor(key, row);
|
|
893
|
+
for (const alias of attrs?.aliases ?? []) {
|
|
894
|
+
entries.push({ id: row.id, title: alias });
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return entries;
|
|
898
|
+
}
|
|
899
|
+
/** The folder a property tool was pointed at, refused unless it exists and
|
|
900
|
+
* the user has opened it to MCP. Knowing a folder id is no more consent
|
|
901
|
+
* than knowing a note id is (see requireExposed). */
|
|
902
|
+
async requireFolder(folderId) {
|
|
903
|
+
const { folders } = await this.api.listFolders();
|
|
904
|
+
const folder = folders.find((f) => f.id === folderId);
|
|
905
|
+
if (folder === undefined) {
|
|
906
|
+
throw new Error(`There is no folder ${folderId}. Run list_folders to see the folders exposed to MCP.`);
|
|
907
|
+
}
|
|
908
|
+
if (!this.exposureOf(folders).allows(folder.id)) {
|
|
909
|
+
throw new Error(`Folder ${folderId} is not exposed to MCP. Turn on "Expose to MCP" in that folder's ` +
|
|
910
|
+
'settings in the Virlow app to access it here.');
|
|
911
|
+
}
|
|
912
|
+
return folder;
|
|
913
|
+
}
|
|
914
|
+
/** Decode (with per-unlock caching) a folder's property definitions. A
|
|
915
|
+
* folder that has never had any decodes to an empty schema rather than
|
|
916
|
+
* null, so every caller can treat the two the same way. */
|
|
917
|
+
async decodeSchemaFor(key, folder) {
|
|
918
|
+
if (!folder.encryptedSchema)
|
|
919
|
+
return emptySchema();
|
|
920
|
+
const cacheKey = `${folder.id}:${folder.encryptedSchema}`;
|
|
921
|
+
const cached = this.schemaCache.get(cacheKey);
|
|
922
|
+
if (cached)
|
|
923
|
+
return cached;
|
|
924
|
+
const startEpoch = this.epoch;
|
|
925
|
+
const schema = (await decodeSchema(key, folder.encryptedSchema)) ?? emptySchema();
|
|
926
|
+
// Same guard as decryptRow(): a decode still in flight when the vault
|
|
927
|
+
// locks resolves for its own caller but never seeds the next session.
|
|
928
|
+
if (this.epoch === startEpoch) {
|
|
929
|
+
this.schemaCache.set(cacheKey, schema);
|
|
930
|
+
}
|
|
931
|
+
return schema;
|
|
932
|
+
}
|
|
933
|
+
/** Encrypts a folder schema and enforces the server's size cap
|
|
934
|
+
* client-side, the way encodeAttrsChecked() does for a note's attrs. */
|
|
935
|
+
async encodeSchemaChecked(key, schema, folderId) {
|
|
936
|
+
const encryptedSchema = await encodeSchema(key, schema);
|
|
937
|
+
const byteLength = new TextEncoder().encode(encryptedSchema).length;
|
|
938
|
+
if (byteLength > SCHEMA_MAX_BYTES) {
|
|
939
|
+
throw new Error(`Folder ${folderId}'s property definitions are too large to save (${byteLength} bytes, ` +
|
|
940
|
+
`limit ${SCHEMA_MAX_BYTES}). Remove some properties or options and try again.`);
|
|
941
|
+
}
|
|
942
|
+
return encryptedSchema;
|
|
943
|
+
}
|
|
944
|
+
/** Encrypts attrs and enforces the server's size cap client-side: an
|
|
945
|
+
* oversized blob is refused with a clear message rather than sent and
|
|
946
|
+
* bounced back as an HTTP 413 `ATTRS_TOO_LARGE`. */
|
|
947
|
+
async encodeAttrsChecked(key, attrs, id) {
|
|
948
|
+
const encryptedAttrs = await encodeAttrs(key, attrs);
|
|
949
|
+
const byteLength = new TextEncoder().encode(encryptedAttrs).length;
|
|
950
|
+
if (byteLength > ATTRS_MAX_BYTES) {
|
|
951
|
+
throw new Error(`Note ${id}'s properties and aliases are too large to save (${byteLength} bytes, limit ${ATTRS_MAX_BYTES}). Remove some properties, tags, or aliases and try again.`);
|
|
952
|
+
}
|
|
953
|
+
return encryptedAttrs;
|
|
954
|
+
}
|
|
246
955
|
buildSnippet(title, content, contentMatchIndex) {
|
|
247
956
|
if (contentMatchIndex === -1) {
|
|
248
957
|
return title;
|