@lapis-notes/search 0.1.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/CHANGELOG.md +6 -0
- package/LICENSE.md +8 -0
- package/README.md +20 -0
- package/dist/agent-tools.d.ts +1 -0
- package/dist/agent-tools.js +1 -0
- package/dist/built-in-search-document-providers.d.ts +5 -0
- package/dist/built-in-search-document-providers.js +30 -0
- package/dist/highlighted-text.svelte +41 -0
- package/dist/highlighted-text.svelte.d.ts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +9 -0
- package/dist/notes-search-command.d.ts +2 -0
- package/dist/notes-search-command.js +8 -0
- package/dist/notes-search-tool.d.ts +8 -0
- package/dist/notes-search-tool.js +84 -0
- package/dist/public-components.d.ts +2 -0
- package/dist/public-components.js +2 -0
- package/dist/search-manager.d.ts +92 -0
- package/dist/search-manager.js +771 -0
- package/dist/search-match-context.d.ts +17 -0
- package/dist/search-match-context.js +45 -0
- package/dist/search-panel.css +460 -0
- package/dist/search-panel.svelte +796 -0
- package/dist/search-panel.svelte.d.ts +11 -0
- package/dist/search-plugin.d.ts +22 -0
- package/dist/search-plugin.js +174 -0
- package/dist/search-result-model.d.ts +29 -0
- package/dist/search-result-model.js +50 -0
- package/dist/search-settings-tab.d.ts +12 -0
- package/dist/search-settings-tab.js +139 -0
- package/dist/search-settings.d.ts +54 -0
- package/dist/search-settings.js +107 -0
- package/dist/search-sort.d.ts +8 -0
- package/dist/search-sort.js +36 -0
- package/dist/search-startup.d.ts +3 -0
- package/dist/search-startup.js +4 -0
- package/dist/search-tool-result.css +70 -0
- package/dist/search-tool-result.d.ts +16 -0
- package/dist/search-tool-result.js +111 -0
- package/dist/search-tool-result.svelte +55 -0
- package/dist/search-tool-result.svelte.d.ts +6 -0
- package/dist/search-view-type.d.ts +1 -0
- package/dist/search-view-type.js +1 -0
- package/dist/search-view.d.ts +14 -0
- package/dist/search-view.js +48 -0
- package/dist/styles.css +2 -0
- package/manifest.json +28 -0
- package/package.json +80 -0
- package/styles.css +1 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
import { debounce, md5, } from "@lapis-notes/api";
|
|
2
|
+
import { DEFAULT_SEARCH_SETTINGS, } from "./search-settings";
|
|
3
|
+
const REACTIVE_INDEX_DELAY_MS = 75;
|
|
4
|
+
const SEARCH_RECONCILE_CHECKPOINT_KEY = "search.reconcile-checkpoint.v1";
|
|
5
|
+
const SEARCH_RECONCILE_CHECKPOINT_VERSION = 1;
|
|
6
|
+
const FINGERPRINT_MASK = (1n << 128n) - 1n;
|
|
7
|
+
class StreamingManifestFingerprint {
|
|
8
|
+
count = 0;
|
|
9
|
+
sum = 0n;
|
|
10
|
+
xor = 0n;
|
|
11
|
+
add(value) {
|
|
12
|
+
const digest = BigInt(`0x${md5(value)}`);
|
|
13
|
+
this.count += 1;
|
|
14
|
+
this.sum = (this.sum + digest) & FINGERPRINT_MASK;
|
|
15
|
+
this.xor ^= digest;
|
|
16
|
+
}
|
|
17
|
+
finish(scope) {
|
|
18
|
+
return `${scope}:${this.count}:${this.sum.toString(16).padStart(32, "0")}:${this.xor.toString(16).padStart(32, "0")}`;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function isSearchReconcileCheckpoint(value) {
|
|
22
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
23
|
+
return false;
|
|
24
|
+
const checkpoint = value;
|
|
25
|
+
return (checkpoint.version === SEARCH_RECONCILE_CHECKPOINT_VERSION &&
|
|
26
|
+
typeof checkpoint.fingerprint === "string" &&
|
|
27
|
+
typeof checkpoint.completedAt === "number" &&
|
|
28
|
+
Number.isFinite(checkpoint.completedAt));
|
|
29
|
+
}
|
|
30
|
+
function yieldToBackground() {
|
|
31
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
32
|
+
}
|
|
33
|
+
function telemetryRefreshReason(reason) {
|
|
34
|
+
if (reason.startsWith("startup"))
|
|
35
|
+
return "startup";
|
|
36
|
+
if (reason.includes("provider"))
|
|
37
|
+
return "provider";
|
|
38
|
+
if (reason.includes("settings"))
|
|
39
|
+
return "settings";
|
|
40
|
+
if (reason.includes("manual") || reason.includes("rebuild"))
|
|
41
|
+
return "manual";
|
|
42
|
+
return "other";
|
|
43
|
+
}
|
|
44
|
+
function isCancellationError(error) {
|
|
45
|
+
return ((error instanceof Error && error.name === "AbortError") ||
|
|
46
|
+
(error instanceof Error && /cancel/iu.test(error.message)));
|
|
47
|
+
}
|
|
48
|
+
function isFileLike(value) {
|
|
49
|
+
return (Boolean(value) &&
|
|
50
|
+
typeof value === "object" &&
|
|
51
|
+
typeof value.path === "string" &&
|
|
52
|
+
typeof value.extension === "string");
|
|
53
|
+
}
|
|
54
|
+
function sourceMetadata(cache, settings, source, manifest) {
|
|
55
|
+
return {
|
|
56
|
+
...manifest,
|
|
57
|
+
rawTags: source.tags
|
|
58
|
+
? [...source.tags]
|
|
59
|
+
: (cache.tags ?? []).map((tag) => tag.tag),
|
|
60
|
+
frontmatter: source.metadata ?? cache.frontmatter ?? {},
|
|
61
|
+
frontmatterEndOffset: cache.frontmatterPosition?.end.offset ?? 0,
|
|
62
|
+
headings: (cache.headings ?? []).map((heading) => ({
|
|
63
|
+
heading: heading.heading,
|
|
64
|
+
level: heading.level,
|
|
65
|
+
position: {
|
|
66
|
+
start: { offset: heading.position.start.offset },
|
|
67
|
+
end: { offset: heading.position.end.offset },
|
|
68
|
+
},
|
|
69
|
+
})),
|
|
70
|
+
sections: (cache.sections ?? []).map((section) => ({
|
|
71
|
+
type: section.type,
|
|
72
|
+
position: {
|
|
73
|
+
start: { offset: section.position.start.offset },
|
|
74
|
+
end: { offset: section.position.end.offset },
|
|
75
|
+
},
|
|
76
|
+
})),
|
|
77
|
+
chunking: { ...settings },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export class SearchManager {
|
|
81
|
+
app;
|
|
82
|
+
getSettings;
|
|
83
|
+
refreshPromise = null;
|
|
84
|
+
queuedRefreshReason = null;
|
|
85
|
+
refreshState = {
|
|
86
|
+
active: false,
|
|
87
|
+
processed: 0,
|
|
88
|
+
total: 0,
|
|
89
|
+
reason: null,
|
|
90
|
+
refreshedAt: null,
|
|
91
|
+
};
|
|
92
|
+
queuedChanges = new Map();
|
|
93
|
+
queuedDeletes = new Map();
|
|
94
|
+
flushQueuedChanges = debounce(() => {
|
|
95
|
+
void this.drainQueuedChanges();
|
|
96
|
+
}, REACTIVE_INDEX_DELAY_MS);
|
|
97
|
+
ownedSourceProviderIds = new Set();
|
|
98
|
+
changeTrackingReady = false;
|
|
99
|
+
sourceGeneration = 0;
|
|
100
|
+
reconcileCheckpointReady = false;
|
|
101
|
+
reconcileCheckpointDirty = false;
|
|
102
|
+
reconcileCheckpointGeneration = 0;
|
|
103
|
+
activeIncrementalOperations = 0;
|
|
104
|
+
reconcileCheckpointWrite = null;
|
|
105
|
+
queuedProcessing = Promise.resolve();
|
|
106
|
+
pendingIncrementalOperations = new Set();
|
|
107
|
+
disposed = false;
|
|
108
|
+
constructor(app, getSettings = () => DEFAULT_SEARCH_SETTINGS) {
|
|
109
|
+
this.app = app;
|
|
110
|
+
this.getSettings = getSettings;
|
|
111
|
+
for (const provider of app.searchDocumentProviders?.getAll?.() ?? []) {
|
|
112
|
+
this.ownedSourceProviderIds.add(provider.id);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async processChange(file, content, cache) {
|
|
116
|
+
const provider = this.app.searchDocumentProviders.resolve(file);
|
|
117
|
+
if (!provider) {
|
|
118
|
+
await this.processDelete(file);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.ownedSourceProviderIds.add(provider.id);
|
|
122
|
+
const source = await provider.extract({
|
|
123
|
+
app: this.app,
|
|
124
|
+
file,
|
|
125
|
+
content,
|
|
126
|
+
metadata: cache,
|
|
127
|
+
});
|
|
128
|
+
if (!source) {
|
|
129
|
+
await this.processDelete(file);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (typeof source.content !== "string") {
|
|
133
|
+
throw new Error(`Search document provider ${provider.id} returned invalid content for ${file.path}`);
|
|
134
|
+
}
|
|
135
|
+
const checksumSource = JSON.stringify({
|
|
136
|
+
content: source.content,
|
|
137
|
+
metadata: source.metadata ?? null,
|
|
138
|
+
tags: source.tags ?? [],
|
|
139
|
+
});
|
|
140
|
+
const providerVersion = provider.version ?? "1";
|
|
141
|
+
const projectionSignature = this.projectionSignature(provider.id, providerVersion);
|
|
142
|
+
await this.app.appDatabase.upsertSearchDocument({
|
|
143
|
+
path: file.path,
|
|
144
|
+
sourceProviderId: provider.id,
|
|
145
|
+
name: file.baseName,
|
|
146
|
+
extension: file.extension.toLowerCase(),
|
|
147
|
+
checksum: md5(checksumSource),
|
|
148
|
+
content: source.content,
|
|
149
|
+
tags: [],
|
|
150
|
+
tagParts: [],
|
|
151
|
+
tagHierarchy: [],
|
|
152
|
+
sourceMetadata: sourceMetadata(cache, this.getSettings().chunking, source, {
|
|
153
|
+
metadataHash: md5(content),
|
|
154
|
+
providerVersion,
|
|
155
|
+
projectionSignature,
|
|
156
|
+
sourceMtime: file.stat.mtime,
|
|
157
|
+
sourceSize: file.stat.size,
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async processDelete(file) {
|
|
162
|
+
await this.app.appDatabase.deleteSearchDocument(typeof file === "string" ? file : file.path);
|
|
163
|
+
}
|
|
164
|
+
async query(params) {
|
|
165
|
+
const execute = async () => {
|
|
166
|
+
const settings = this.getSettings();
|
|
167
|
+
const requestedMode = params.mode ?? settings.view.retrievalMode;
|
|
168
|
+
const limit = params.limit ?? settings.query.resultLimit;
|
|
169
|
+
const results = await this.app.appDatabase.searchDocuments(params.term, {
|
|
170
|
+
snippetLength: params.snippetLength ?? settings.query.snippetLength,
|
|
171
|
+
limit,
|
|
172
|
+
...(params.pathPrefix ? { pathPrefix: params.pathPrefix } : {}),
|
|
173
|
+
caseSensitive: params.caseSensitive ?? settings.view.matchCase,
|
|
174
|
+
mode: requestedMode,
|
|
175
|
+
includeDiagnostics: true,
|
|
176
|
+
...(params.sourceProviderIds?.length
|
|
177
|
+
? { sourceProviderIds: [...params.sourceProviderIds] }
|
|
178
|
+
: {}),
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
requestedMode,
|
|
182
|
+
limit,
|
|
183
|
+
result: {
|
|
184
|
+
count: results.length,
|
|
185
|
+
hits: results.map((result) => ({
|
|
186
|
+
id: result.document.path,
|
|
187
|
+
score: result.score,
|
|
188
|
+
document: result.document,
|
|
189
|
+
snippets: result.snippets.map((snippet) => ({
|
|
190
|
+
...snippet,
|
|
191
|
+
ranges: snippet.ranges.map((range) => ({ ...range })),
|
|
192
|
+
})),
|
|
193
|
+
retrievalMode: result.retrievalMode,
|
|
194
|
+
scoreBreakdown: { ...result.scoreBreakdown },
|
|
195
|
+
matchedChunkIds: [...result.matchedChunkIds],
|
|
196
|
+
diagnostics: result.diagnostics
|
|
197
|
+
? { ...result.diagnostics }
|
|
198
|
+
: undefined,
|
|
199
|
+
})),
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
if (!this.app.telemetry)
|
|
204
|
+
return (await execute()).result;
|
|
205
|
+
return this.app.telemetry.measureAsync("search.query", async (span) => {
|
|
206
|
+
const { requestedMode, limit, result } = await execute();
|
|
207
|
+
const appliedModes = new Set(result.hits.map((hit) => hit.retrievalMode));
|
|
208
|
+
span.setAttribute("search.retrieval.requested", requestedMode);
|
|
209
|
+
span.setAttribute("search.retrieval.applied", appliedModes.size === 1 ? [...appliedModes][0] : "mixed");
|
|
210
|
+
span.setAttribute("search.result.limit", limit);
|
|
211
|
+
span.setAttribute("search.result.count", result.count);
|
|
212
|
+
span.setAttribute("search.path_prefix", Boolean(params.pathPrefix));
|
|
213
|
+
span.setAttribute("search.source_provider.count", params.sourceProviderIds?.length ?? 0);
|
|
214
|
+
span.setAttribute("search.reconcile.active", this.refreshState.active);
|
|
215
|
+
return result;
|
|
216
|
+
}, {
|
|
217
|
+
attributes: {
|
|
218
|
+
"search.case_sensitive": params.caseSensitive ?? this.getSettings().view.matchCase,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
async getStatus() {
|
|
223
|
+
const [provider, runtime, stats] = await Promise.all([
|
|
224
|
+
this.app.appDatabase.getSearchEmbeddingProvider(),
|
|
225
|
+
this.app.appDatabase.getSearchEmbeddingRuntimeStatus(),
|
|
226
|
+
this.app.appDatabase.getSearchIndexStats(),
|
|
227
|
+
]);
|
|
228
|
+
return {
|
|
229
|
+
backendKind: this.app.appDatabase.kind,
|
|
230
|
+
provider,
|
|
231
|
+
runtime,
|
|
232
|
+
...stats,
|
|
233
|
+
isRefreshing: this.refreshState.active,
|
|
234
|
+
refreshReason: this.refreshState.reason,
|
|
235
|
+
refreshProgress: {
|
|
236
|
+
processed: this.refreshState.processed,
|
|
237
|
+
total: this.refreshState.total,
|
|
238
|
+
},
|
|
239
|
+
refreshedAt: this.refreshState.refreshedAt,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
refreshFromVault(reason = "manual-refresh") {
|
|
243
|
+
this.queuedRefreshReason = reason;
|
|
244
|
+
this.refreshPromise ??= this.runRefreshQueue().finally(() => {
|
|
245
|
+
this.refreshPromise = null;
|
|
246
|
+
});
|
|
247
|
+
return this.refreshPromise;
|
|
248
|
+
}
|
|
249
|
+
async reconcileStartup() {
|
|
250
|
+
const execute = async (setCheckpoint) => {
|
|
251
|
+
const initialGeneration = this.sourceGeneration;
|
|
252
|
+
let status;
|
|
253
|
+
try {
|
|
254
|
+
const fingerprint = this.currentReconcileFingerprint();
|
|
255
|
+
const checkpoint = await this.readReconcileCheckpoint();
|
|
256
|
+
if (isSearchReconcileCheckpoint(checkpoint) &&
|
|
257
|
+
checkpoint.fingerprint === fingerprint &&
|
|
258
|
+
initialGeneration === this.sourceGeneration) {
|
|
259
|
+
this.reconcileCheckpointReady = true;
|
|
260
|
+
this.reconcileCheckpointDirty = false;
|
|
261
|
+
setCheckpoint?.("hit");
|
|
262
|
+
status = await this.getStatus();
|
|
263
|
+
this.app.telemetry?.recordEvent("search.reconcile.complete", {
|
|
264
|
+
checkpoint: "hit",
|
|
265
|
+
"files.total": 0,
|
|
266
|
+
"files.processed": 0,
|
|
267
|
+
"results.count": status.documentCount,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
setCheckpoint?.("miss");
|
|
272
|
+
status = await this.refreshFromVault("startup");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
this.changeTrackingReady = true;
|
|
277
|
+
}
|
|
278
|
+
if (initialGeneration !== this.sourceGeneration) {
|
|
279
|
+
status = await this.refreshFromVault("startup-drift");
|
|
280
|
+
}
|
|
281
|
+
return status;
|
|
282
|
+
};
|
|
283
|
+
if (!this.app.telemetry)
|
|
284
|
+
return execute();
|
|
285
|
+
return this.app.telemetry.measureAsync("search.startup_reconcile", (span) => execute((checkpoint) => span.setAttribute("search.checkpoint", checkpoint)));
|
|
286
|
+
}
|
|
287
|
+
async runRefreshQueue() {
|
|
288
|
+
let status = null;
|
|
289
|
+
while (this.queuedRefreshReason) {
|
|
290
|
+
const reason = this.queuedRefreshReason;
|
|
291
|
+
this.queuedRefreshReason = null;
|
|
292
|
+
status = await this.runRefresh(reason);
|
|
293
|
+
}
|
|
294
|
+
return status ?? this.getStatus();
|
|
295
|
+
}
|
|
296
|
+
trackChanges() {
|
|
297
|
+
const changed = this.app.metadataCache.on("changed", (file, content, cache) => {
|
|
298
|
+
if (!this.app.searchDocumentProviders.resolve(file))
|
|
299
|
+
return;
|
|
300
|
+
this.recordSourceMutation();
|
|
301
|
+
if (!this.changeTrackingReady)
|
|
302
|
+
return;
|
|
303
|
+
this.queuedDeletes.delete(file.path);
|
|
304
|
+
this.queuedChanges.set(file.path, { file, content, cache });
|
|
305
|
+
this.flushQueuedChanges();
|
|
306
|
+
});
|
|
307
|
+
const deleted = this.app.metadataCache.on("deleted", (file) => {
|
|
308
|
+
if (!this.app.searchDocumentProviders.resolve(file))
|
|
309
|
+
return;
|
|
310
|
+
this.recordSourceMutation();
|
|
311
|
+
if (!this.changeTrackingReady)
|
|
312
|
+
return;
|
|
313
|
+
this.queuedChanges.delete(file.path);
|
|
314
|
+
this.queuedDeletes.set(file.path, file);
|
|
315
|
+
this.flushQueuedChanges();
|
|
316
|
+
});
|
|
317
|
+
const vaultChanged = this.app.vault.on("modify", (file) => {
|
|
318
|
+
if (isFileLike(file) &&
|
|
319
|
+
!this.usesMetadataPipeline(file) &&
|
|
320
|
+
this.app.searchDocumentProviders.resolve(file)) {
|
|
321
|
+
this.recordSourceMutation();
|
|
322
|
+
if (this.changeTrackingReady) {
|
|
323
|
+
this.trackIncrementalOperation(this.processFileSafely(file));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
const vaultCreated = this.app.vault.on("create", (file) => {
|
|
328
|
+
if (isFileLike(file) &&
|
|
329
|
+
!this.usesMetadataPipeline(file) &&
|
|
330
|
+
this.app.searchDocumentProviders.resolve(file)) {
|
|
331
|
+
this.recordSourceMutation();
|
|
332
|
+
if (this.changeTrackingReady) {
|
|
333
|
+
this.trackIncrementalOperation(this.processFileSafely(file));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
const vaultDeleted = this.app.vault.on("delete", (file) => {
|
|
338
|
+
if (isFileLike(file) &&
|
|
339
|
+
!this.usesMetadataPipeline(file) &&
|
|
340
|
+
this.app.searchDocumentProviders.resolve(file)) {
|
|
341
|
+
this.recordSourceMutation();
|
|
342
|
+
if (this.changeTrackingReady) {
|
|
343
|
+
this.trackIncrementalOperation(this.processDelete(file));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
const vaultRenamed = this.app.vault.on("rename", (file, oldPath) => {
|
|
348
|
+
const oldFile = isFileLike(file) ? this.fileAtPath(file, oldPath) : null;
|
|
349
|
+
const currentProvider = isFileLike(file)
|
|
350
|
+
? this.app.searchDocumentProviders.resolve(file)
|
|
351
|
+
: null;
|
|
352
|
+
const previousProvider = oldFile
|
|
353
|
+
? this.app.searchDocumentProviders.resolve(oldFile)
|
|
354
|
+
: null;
|
|
355
|
+
if (!currentProvider && !previousProvider)
|
|
356
|
+
return;
|
|
357
|
+
this.recordSourceMutation();
|
|
358
|
+
if (!this.changeTrackingReady)
|
|
359
|
+
return;
|
|
360
|
+
this.trackIncrementalOperation(Promise.all([
|
|
361
|
+
this.processDelete(oldPath),
|
|
362
|
+
currentProvider && isFileLike(file)
|
|
363
|
+
? this.processFileSafely(file)
|
|
364
|
+
: Promise.resolve(),
|
|
365
|
+
]).then(() => undefined));
|
|
366
|
+
});
|
|
367
|
+
return () => {
|
|
368
|
+
this.flushQueuedChanges.cancel();
|
|
369
|
+
this.app.metadataCache.offref(changed);
|
|
370
|
+
this.app.metadataCache.offref(deleted);
|
|
371
|
+
this.app.vault.offref(vaultChanged);
|
|
372
|
+
this.app.vault.offref(vaultCreated);
|
|
373
|
+
this.app.vault.offref(vaultDeleted);
|
|
374
|
+
this.app.vault.offref(vaultRenamed);
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
async dispose() {
|
|
378
|
+
if (this.disposed)
|
|
379
|
+
return;
|
|
380
|
+
this.flushQueuedChanges.cancel();
|
|
381
|
+
if (this.queuedChanges.size || this.queuedDeletes.size) {
|
|
382
|
+
await this.drainQueuedChanges();
|
|
383
|
+
}
|
|
384
|
+
await Promise.allSettled([
|
|
385
|
+
...this.pendingIncrementalOperations,
|
|
386
|
+
this.queuedProcessing,
|
|
387
|
+
...(this.refreshPromise ? [this.refreshPromise] : []),
|
|
388
|
+
]);
|
|
389
|
+
await this.persistReconcileCheckpoint();
|
|
390
|
+
this.disposed = true;
|
|
391
|
+
this.queuedChanges.clear();
|
|
392
|
+
this.queuedDeletes.clear();
|
|
393
|
+
}
|
|
394
|
+
async processFile(file) {
|
|
395
|
+
if (!this.app.searchDocumentProviders.resolve(file)) {
|
|
396
|
+
await this.processDelete(file);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const content = await this.app.vault.cachedRead(file);
|
|
400
|
+
const cache = (await this.app.metadataCache.getFileCacheAsync(file)) ?? {};
|
|
401
|
+
await this.processChange(file, content, cache);
|
|
402
|
+
}
|
|
403
|
+
projectionSignature(providerId, providerVersion) {
|
|
404
|
+
return md5(JSON.stringify({
|
|
405
|
+
providerId,
|
|
406
|
+
providerVersion,
|
|
407
|
+
chunking: this.getSettings().chunking,
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
currentReconcileFingerprint() {
|
|
411
|
+
const fingerprint = new StreamingManifestFingerprint();
|
|
412
|
+
const providers = this.app.searchDocumentProviders
|
|
413
|
+
.getAll()
|
|
414
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
415
|
+
for (const provider of providers) {
|
|
416
|
+
fingerprint.add([
|
|
417
|
+
"provider",
|
|
418
|
+
provider.id,
|
|
419
|
+
provider.version ?? "1",
|
|
420
|
+
provider.priority ?? 0,
|
|
421
|
+
this.projectionSignature(provider.id, provider.version ?? "1"),
|
|
422
|
+
].join("\u0000"));
|
|
423
|
+
}
|
|
424
|
+
const files = typeof this.app.vault.iterateFiles === "function"
|
|
425
|
+
? this.app.vault.iterateFiles()
|
|
426
|
+
: this.app.vault.getFiles();
|
|
427
|
+
for (const file of files) {
|
|
428
|
+
const provider = this.app.searchDocumentProviders.resolve(file);
|
|
429
|
+
if (!provider)
|
|
430
|
+
continue;
|
|
431
|
+
const providerVersion = provider.version ?? "1";
|
|
432
|
+
fingerprint.add([
|
|
433
|
+
"file",
|
|
434
|
+
file.path,
|
|
435
|
+
file.stat.mtime,
|
|
436
|
+
file.stat.size,
|
|
437
|
+
provider.id,
|
|
438
|
+
providerVersion,
|
|
439
|
+
this.projectionSignature(provider.id, providerVersion),
|
|
440
|
+
].join("\u0000"));
|
|
441
|
+
}
|
|
442
|
+
return fingerprint.finish("search-index-v1");
|
|
443
|
+
}
|
|
444
|
+
readReconcileCheckpoint() {
|
|
445
|
+
return typeof this.app.appDatabase.getMeta === "function"
|
|
446
|
+
? this.app.appDatabase.getMeta(SEARCH_RECONCILE_CHECKPOINT_KEY)
|
|
447
|
+
: Promise.resolve(undefined);
|
|
448
|
+
}
|
|
449
|
+
markReconcileCheckpointDirty() {
|
|
450
|
+
this.reconcileCheckpointDirty = true;
|
|
451
|
+
this.reconcileCheckpointGeneration += 1;
|
|
452
|
+
}
|
|
453
|
+
recordSourceMutation() {
|
|
454
|
+
this.sourceGeneration += 1;
|
|
455
|
+
this.markReconcileCheckpointDirty();
|
|
456
|
+
}
|
|
457
|
+
async invalidateReconcileCheckpoint() {
|
|
458
|
+
this.reconcileCheckpointReady = false;
|
|
459
|
+
this.markReconcileCheckpointDirty();
|
|
460
|
+
if (typeof this.app.appDatabase.setMeta !== "function")
|
|
461
|
+
return;
|
|
462
|
+
await this.app.appDatabase.setMeta(SEARCH_RECONCILE_CHECKPOINT_KEY, {
|
|
463
|
+
version: SEARCH_RECONCILE_CHECKPOINT_VERSION,
|
|
464
|
+
fingerprint: "",
|
|
465
|
+
completedAt: 0,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
async persistReconcileCheckpoint() {
|
|
469
|
+
if (this.reconcileCheckpointWrite) {
|
|
470
|
+
await this.reconcileCheckpointWrite;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (this.disposed ||
|
|
474
|
+
!this.reconcileCheckpointReady ||
|
|
475
|
+
!this.reconcileCheckpointDirty ||
|
|
476
|
+
this.activeIncrementalOperations > 0 ||
|
|
477
|
+
this.refreshState.active ||
|
|
478
|
+
typeof this.app.appDatabase.setMeta !== "function") {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
this.reconcileCheckpointWrite = (async () => {
|
|
482
|
+
while (!this.disposed &&
|
|
483
|
+
this.reconcileCheckpointReady &&
|
|
484
|
+
this.reconcileCheckpointDirty &&
|
|
485
|
+
this.activeIncrementalOperations === 0 &&
|
|
486
|
+
!this.refreshState.active) {
|
|
487
|
+
const generation = this.reconcileCheckpointGeneration;
|
|
488
|
+
const fingerprint = this.currentReconcileFingerprint();
|
|
489
|
+
try {
|
|
490
|
+
await this.app.appDatabase.setMeta(SEARCH_RECONCILE_CHECKPOINT_KEY, {
|
|
491
|
+
version: SEARCH_RECONCILE_CHECKPOINT_VERSION,
|
|
492
|
+
fingerprint,
|
|
493
|
+
completedAt: Date.now(),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
this.reconcileCheckpointReady = false;
|
|
498
|
+
this.app.logger.warn("Failed to persist Search reconciliation checkpoint", error instanceof Error ? error : new Error(String(error)));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (generation === this.reconcileCheckpointGeneration) {
|
|
502
|
+
this.reconcileCheckpointDirty = false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
})();
|
|
506
|
+
try {
|
|
507
|
+
await this.reconcileCheckpointWrite;
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
this.reconcileCheckpointWrite = null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
trackIncrementalOperation(operation) {
|
|
514
|
+
this.activeIncrementalOperations += 1;
|
|
515
|
+
const tracked = operation
|
|
516
|
+
.catch((error) => {
|
|
517
|
+
this.reconcileCheckpointReady = false;
|
|
518
|
+
this.app.logger.warn("Search incremental indexing failed", error instanceof Error ? error : new Error(String(error)));
|
|
519
|
+
})
|
|
520
|
+
.finally(() => {
|
|
521
|
+
this.activeIncrementalOperations = Math.max(0, this.activeIncrementalOperations - 1);
|
|
522
|
+
this.pendingIncrementalOperations.delete(tracked);
|
|
523
|
+
void this.persistReconcileCheckpoint();
|
|
524
|
+
});
|
|
525
|
+
this.pendingIncrementalOperations.add(tracked);
|
|
526
|
+
}
|
|
527
|
+
async *searchManifestRows() {
|
|
528
|
+
let cursor;
|
|
529
|
+
do {
|
|
530
|
+
const page = await this.app.appDatabase.listSearchDocumentManifest({
|
|
531
|
+
after: cursor,
|
|
532
|
+
limit: 500,
|
|
533
|
+
});
|
|
534
|
+
for (const row of page.rows)
|
|
535
|
+
yield row;
|
|
536
|
+
cursor = page.nextCursor;
|
|
537
|
+
} while (cursor);
|
|
538
|
+
}
|
|
539
|
+
async *metadataManifestRows() {
|
|
540
|
+
let cursor;
|
|
541
|
+
do {
|
|
542
|
+
const page = await this.app.appDatabase.listIndexedFileManifest({
|
|
543
|
+
after: cursor,
|
|
544
|
+
limit: 500,
|
|
545
|
+
});
|
|
546
|
+
for (const row of page.rows)
|
|
547
|
+
yield row;
|
|
548
|
+
cursor = page.nextCursor;
|
|
549
|
+
} while (cursor);
|
|
550
|
+
}
|
|
551
|
+
usesMetadataPipeline(file) {
|
|
552
|
+
return Boolean(this.app.metadataCache.processors.get(file.extension.toLowerCase())?.size);
|
|
553
|
+
}
|
|
554
|
+
fileAtPath(file, path) {
|
|
555
|
+
const name = path.split("/").at(-1) ?? path;
|
|
556
|
+
const extension = name.includes(".") ? (name.split(".").at(-1) ?? "") : "";
|
|
557
|
+
return {
|
|
558
|
+
...file,
|
|
559
|
+
path,
|
|
560
|
+
name,
|
|
561
|
+
baseName: extension ? name.slice(0, -(extension.length + 1)) : name,
|
|
562
|
+
extension,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
async processFileSafely(file) {
|
|
566
|
+
try {
|
|
567
|
+
await this.processFile(file);
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
await this.handleProviderFailure(file, error);
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
async handleProviderFailure(file, error) {
|
|
576
|
+
this.app.logger.warn(`Search provider failed for ${file.path}`, error instanceof Error ? error : new Error(String(error)));
|
|
577
|
+
await this.processDelete(file);
|
|
578
|
+
}
|
|
579
|
+
async processQueuedChanges() {
|
|
580
|
+
const deleted = [...this.queuedDeletes.values()];
|
|
581
|
+
const changed = [...this.queuedChanges.values()];
|
|
582
|
+
this.queuedDeletes.clear();
|
|
583
|
+
this.queuedChanges.clear();
|
|
584
|
+
for (const file of deleted)
|
|
585
|
+
await this.processDelete(file);
|
|
586
|
+
for (const item of changed) {
|
|
587
|
+
try {
|
|
588
|
+
await this.processChange(item.file, item.content, item.cache);
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
await this.handleProviderFailure(item.file, error);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
drainQueuedChanges() {
|
|
596
|
+
const operation = this.queuedProcessing.then(() => this.processQueuedChanges());
|
|
597
|
+
this.queuedProcessing = operation.catch(() => undefined);
|
|
598
|
+
this.trackIncrementalOperation(operation);
|
|
599
|
+
return operation;
|
|
600
|
+
}
|
|
601
|
+
async runRefresh(reason) {
|
|
602
|
+
let changed = 0;
|
|
603
|
+
let deleted = 0;
|
|
604
|
+
let providerFailures = 0;
|
|
605
|
+
let batches = 0;
|
|
606
|
+
const reasonCategory = telemetryRefreshReason(reason);
|
|
607
|
+
const execute = async () => {
|
|
608
|
+
await this.invalidateReconcileCheckpoint();
|
|
609
|
+
const refreshGeneration = this.sourceGeneration;
|
|
610
|
+
const status = await this.app.notifications.withProgress({
|
|
611
|
+
title: "Refreshing search index",
|
|
612
|
+
source: "Search",
|
|
613
|
+
location: "status",
|
|
614
|
+
persistOnError: true,
|
|
615
|
+
}, async (progress) => {
|
|
616
|
+
const files = this.app.vault
|
|
617
|
+
.getFiles()
|
|
618
|
+
.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
619
|
+
for (const provider of this.app.searchDocumentProviders.getAll()) {
|
|
620
|
+
this.ownedSourceProviderIds.add(provider.id);
|
|
621
|
+
}
|
|
622
|
+
const total = files.length;
|
|
623
|
+
this.refreshState = {
|
|
624
|
+
active: true,
|
|
625
|
+
processed: 0,
|
|
626
|
+
total,
|
|
627
|
+
reason,
|
|
628
|
+
refreshedAt: this.refreshState.refreshedAt,
|
|
629
|
+
};
|
|
630
|
+
await this.app.appDatabase.beginSearchIndexingBatch();
|
|
631
|
+
try {
|
|
632
|
+
const searchIterator = this.searchManifestRows();
|
|
633
|
+
const metadataIterator = this.metadataManifestRows();
|
|
634
|
+
let searchRow = (await searchIterator.next()).value;
|
|
635
|
+
let metadataRow = (await metadataIterator.next()).value;
|
|
636
|
+
const ownedSearchRow = (row) => !row.sourceProviderId ||
|
|
637
|
+
this.ownedSourceProviderIds.has(row.sourceProviderId);
|
|
638
|
+
for (const file of files) {
|
|
639
|
+
progress.throwIfCancellationRequested();
|
|
640
|
+
while (searchRow && searchRow.path < file.path) {
|
|
641
|
+
if (ownedSearchRow(searchRow)) {
|
|
642
|
+
await this.app.appDatabase.deleteSearchDocument(searchRow.path);
|
|
643
|
+
deleted += 1;
|
|
644
|
+
}
|
|
645
|
+
searchRow = (await searchIterator.next()).value;
|
|
646
|
+
}
|
|
647
|
+
while (metadataRow && metadataRow.path < file.path) {
|
|
648
|
+
metadataRow = (await metadataIterator.next()).value;
|
|
649
|
+
}
|
|
650
|
+
progress.report({
|
|
651
|
+
current: this.refreshState.processed,
|
|
652
|
+
total,
|
|
653
|
+
message: file.path,
|
|
654
|
+
});
|
|
655
|
+
const indexedSearch = searchRow?.path === file.path ? searchRow : undefined;
|
|
656
|
+
const indexedMetadata = metadataRow?.path === file.path ? metadataRow : undefined;
|
|
657
|
+
const provider = this.app.searchDocumentProviders.resolve(file);
|
|
658
|
+
if (!provider) {
|
|
659
|
+
// A persisted Search row whose path is still a vault file is a
|
|
660
|
+
// stale vault projection when no provider currently claims it.
|
|
661
|
+
if (indexedSearch) {
|
|
662
|
+
await this.app.appDatabase.deleteSearchDocument(file.path);
|
|
663
|
+
deleted += 1;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
const providerVersion = provider.version ?? "1";
|
|
668
|
+
const projectionSignature = this.projectionSignature(provider.id, providerVersion);
|
|
669
|
+
const usesMetadata = this.usesMetadataPipeline(file);
|
|
670
|
+
const unchanged = indexedSearch?.sourceProviderId === provider.id &&
|
|
671
|
+
indexedSearch.providerVersion === providerVersion &&
|
|
672
|
+
indexedSearch.projectionSignature === projectionSignature &&
|
|
673
|
+
indexedSearch.sourceMtime === file.stat.mtime &&
|
|
674
|
+
indexedSearch.sourceSize === file.stat.size &&
|
|
675
|
+
(!usesMetadata ||
|
|
676
|
+
(Boolean(indexedMetadata?.hash) &&
|
|
677
|
+
indexedSearch.metadataHash === indexedMetadata?.hash));
|
|
678
|
+
if (!unchanged && (!usesMetadata || indexedMetadata)) {
|
|
679
|
+
if (await this.processFileSafely(file))
|
|
680
|
+
changed += 1;
|
|
681
|
+
else
|
|
682
|
+
providerFailures += 1;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (searchRow?.path === file.path) {
|
|
686
|
+
searchRow = (await searchIterator.next()).value;
|
|
687
|
+
}
|
|
688
|
+
if (metadataRow?.path === file.path) {
|
|
689
|
+
metadataRow = (await metadataIterator.next()).value;
|
|
690
|
+
}
|
|
691
|
+
this.refreshState.processed += 1;
|
|
692
|
+
if (this.refreshState.processed % 25 === 0) {
|
|
693
|
+
batches += 1;
|
|
694
|
+
await yieldToBackground();
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
let trailingRows = 0;
|
|
698
|
+
while (searchRow) {
|
|
699
|
+
progress.throwIfCancellationRequested();
|
|
700
|
+
if (ownedSearchRow(searchRow)) {
|
|
701
|
+
await this.app.appDatabase.deleteSearchDocument(searchRow.path);
|
|
702
|
+
deleted += 1;
|
|
703
|
+
}
|
|
704
|
+
searchRow = (await searchIterator.next()).value;
|
|
705
|
+
trailingRows += 1;
|
|
706
|
+
if (trailingRows % 25 === 0) {
|
|
707
|
+
batches += 1;
|
|
708
|
+
await yieldToBackground();
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
this.refreshState.refreshedAt = Date.now();
|
|
712
|
+
progress.report({
|
|
713
|
+
current: total,
|
|
714
|
+
total,
|
|
715
|
+
message: reason === "startup" ? "Search ready" : "Search refreshed",
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
finally {
|
|
719
|
+
await this.app.appDatabase.endSearchIndexingBatch();
|
|
720
|
+
this.refreshState.active = false;
|
|
721
|
+
this.refreshState.reason = null;
|
|
722
|
+
}
|
|
723
|
+
this.reconcileCheckpointReady = true;
|
|
724
|
+
this.markReconcileCheckpointDirty();
|
|
725
|
+
if (refreshGeneration === this.sourceGeneration) {
|
|
726
|
+
await this.persistReconcileCheckpoint();
|
|
727
|
+
}
|
|
728
|
+
return this.getStatus();
|
|
729
|
+
});
|
|
730
|
+
this.app.telemetry?.recordEvent("search.reconcile.complete", {
|
|
731
|
+
checkpoint: "miss",
|
|
732
|
+
reason: reasonCategory,
|
|
733
|
+
"files.total": this.refreshState.total,
|
|
734
|
+
"files.processed": this.refreshState.processed,
|
|
735
|
+
"files.changed": changed,
|
|
736
|
+
"files.deleted": deleted,
|
|
737
|
+
"providers.failed": providerFailures,
|
|
738
|
+
"results.count": status.documentCount,
|
|
739
|
+
});
|
|
740
|
+
return status;
|
|
741
|
+
};
|
|
742
|
+
if (!this.app.telemetry)
|
|
743
|
+
return execute();
|
|
744
|
+
return this.app.telemetry.measureAsync("search.reconcile", async (span) => {
|
|
745
|
+
try {
|
|
746
|
+
return await execute();
|
|
747
|
+
}
|
|
748
|
+
catch (error) {
|
|
749
|
+
const cancelled = isCancellationError(error);
|
|
750
|
+
this.app.telemetry.recordEvent(cancelled
|
|
751
|
+
? "search.reconcile.cancelled"
|
|
752
|
+
: "search.reconcile.failed", {
|
|
753
|
+
status: cancelled ? "cancelled" : "failed",
|
|
754
|
+
reason: reasonCategory,
|
|
755
|
+
"files.total": this.refreshState.total,
|
|
756
|
+
"files.processed": this.refreshState.processed,
|
|
757
|
+
});
|
|
758
|
+
throw error;
|
|
759
|
+
}
|
|
760
|
+
finally {
|
|
761
|
+
span.setAttribute("search.reconcile.reason", reasonCategory);
|
|
762
|
+
span.setAttribute("search.files.total", this.refreshState.total);
|
|
763
|
+
span.setAttribute("search.files.processed", this.refreshState.processed);
|
|
764
|
+
span.setAttribute("search.files.changed", changed);
|
|
765
|
+
span.setAttribute("search.files.deleted", deleted);
|
|
766
|
+
span.setAttribute("search.providers.failed", providerFailures);
|
|
767
|
+
span.setAttribute("search.batch.count", batches);
|
|
768
|
+
}
|
|
769
|
+
}, { attributes: { "search.reconcile.reason": reasonCategory } });
|
|
770
|
+
}
|
|
771
|
+
}
|