@lapis-notes/search 0.1.3 → 0.1.4
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 +27 -0
- package/README.md +1 -1
- package/dist/native-search.d.ts +5 -0
- package/dist/native-search.js +24 -0
- package/dist/search-history-versions.svelte +197 -0
- package/dist/search-history-versions.svelte.d.ts +8 -0
- package/dist/search-manager.d.ts +3 -0
- package/dist/search-manager.js +72 -7
- package/dist/search-panel.css +103 -0
- package/dist/search-panel.svelte +343 -41
- package/dist/search-panel.svelte.d.ts +8 -1
- package/dist/search-plugin.d.ts +7 -1
- package/dist/search-plugin.js +97 -15
- package/dist/search-provider-controller.d.ts +25 -0
- package/dist/search-provider-controller.js +157 -0
- package/dist/search-provider-groups.d.ts +14 -0
- package/dist/search-provider-groups.js +60 -0
- package/dist/search-settings-observer.d.ts +4 -0
- package/dist/search-settings-observer.js +10 -0
- package/dist/search-settings-tab.js +2 -2
- package/dist/search-tool-result.css +2 -7
- package/dist/search-tool-result.svelte +9 -2
- package/dist/search-view.d.ts +5 -1
- package/dist/search-view.js +32 -4
- package/manifest.json +2 -2
- package/package.json +5 -5
package/dist/search-plugin.js
CHANGED
|
@@ -9,17 +9,25 @@ import { createNotesSearchTool } from "./notes-search-tool";
|
|
|
9
9
|
import SearchToolResult from "./search-tool-result.svelte";
|
|
10
10
|
import { reconcileSearchAfterMetadata } from "./search-startup";
|
|
11
11
|
import manifestSpec from "@lapis-notes/search/manifest.json";
|
|
12
|
+
import { observeRecoveredSearchSettings } from "./search-settings-observer";
|
|
12
13
|
export class SearchPlugin extends Plugin {
|
|
13
14
|
settings;
|
|
14
15
|
startupRefreshStarted = false;
|
|
15
16
|
startupRefreshPromise = null;
|
|
16
17
|
providerReconcileReason = null;
|
|
17
18
|
providerReconcilePromise = null;
|
|
18
|
-
|
|
19
|
+
managerStopped = false;
|
|
20
|
+
currentSearchManager;
|
|
19
21
|
constructor(app, pluginManifest = manifestSpec) {
|
|
20
22
|
super(app, pluginManifest);
|
|
21
23
|
this.settings = mergeSearchSettings(null);
|
|
22
|
-
this.
|
|
24
|
+
this.currentSearchManager = new SearchManager(app, () => this.settings);
|
|
25
|
+
}
|
|
26
|
+
showNotice(message) {
|
|
27
|
+
new Notice(message, undefined, this.app);
|
|
28
|
+
}
|
|
29
|
+
get searchManager() {
|
|
30
|
+
return this.currentSearchManager;
|
|
23
31
|
}
|
|
24
32
|
getSettings() {
|
|
25
33
|
return {
|
|
@@ -33,13 +41,23 @@ export class SearchPlugin extends Plugin {
|
|
|
33
41
|
};
|
|
34
42
|
}
|
|
35
43
|
async updateSettings(patch) {
|
|
36
|
-
const
|
|
44
|
+
const previous = this.settings;
|
|
37
45
|
this.settings = patchSearchSettings(this.settings, patch);
|
|
38
46
|
await this.saveData(this.settings);
|
|
47
|
+
await this.reconfigureSettings(previous);
|
|
48
|
+
}
|
|
49
|
+
async reconfigureSettings(previous) {
|
|
50
|
+
const manager = this.searchManager;
|
|
51
|
+
const previousProvider = resolveSearchEmbeddingProviderConfig(previous);
|
|
39
52
|
const nextProvider = resolveSearchEmbeddingProviderConfig(this.settings);
|
|
40
53
|
if (JSON.stringify(previousProvider) !== JSON.stringify(nextProvider)) {
|
|
41
54
|
await this.app.appDatabase.configureSearchEmbeddingProvider(nextProvider);
|
|
42
55
|
}
|
|
56
|
+
if (this.app.session?.backgroundIndexing &&
|
|
57
|
+
JSON.stringify(previous.chunking) !==
|
|
58
|
+
JSON.stringify(this.settings.chunking)) {
|
|
59
|
+
await manager.configureIndexing();
|
|
60
|
+
}
|
|
43
61
|
}
|
|
44
62
|
getSearchStatus() {
|
|
45
63
|
return this.searchManager.getStatus();
|
|
@@ -48,11 +66,35 @@ export class SearchPlugin extends Plugin {
|
|
|
48
66
|
return this.searchManager.refreshFromVault("manual-semantic-rebuild");
|
|
49
67
|
}
|
|
50
68
|
async onload() {
|
|
69
|
+
if (this.managerStopped) {
|
|
70
|
+
this.currentSearchManager = new SearchManager(this.app, () => this.settings);
|
|
71
|
+
this.managerStopped = false;
|
|
72
|
+
this.startupRefreshStarted = false;
|
|
73
|
+
this.startupRefreshPromise = null;
|
|
74
|
+
this.providerReconcileReason = null;
|
|
75
|
+
this.providerReconcilePromise = null;
|
|
76
|
+
}
|
|
51
77
|
this.settings = mergeSearchSettings(await this.loadData());
|
|
52
78
|
await this.app.appDatabase.configureSearchEmbeddingProvider(resolveSearchEmbeddingProviderConfig(this.settings));
|
|
79
|
+
this.register(observeRecoveredSearchSettings(this.app, this.manifest.id, (settings) => {
|
|
80
|
+
if (this.managerStopped)
|
|
81
|
+
return;
|
|
82
|
+
const previous = this.settings;
|
|
83
|
+
this.settings = settings;
|
|
84
|
+
void this.reconfigureSettings(previous).catch((error) => {
|
|
85
|
+
if (this.nativeObserverDetached())
|
|
86
|
+
return;
|
|
87
|
+
this.showNotice(`Search settings recovery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
+
});
|
|
89
|
+
}));
|
|
53
90
|
this.registerEvent(this.app.searchDocumentProviders.on("changed", ({ reason }) => {
|
|
54
91
|
if (this.state !== "enabled")
|
|
55
92
|
return;
|
|
93
|
+
if (this.app.session?.backgroundIndexing &&
|
|
94
|
+
this.app.metadataCache.initialized) {
|
|
95
|
+
this.scheduleProviderReconciliation(`provider-${reason}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
56
98
|
if (!this.app.metadataCache.initialized ||
|
|
57
99
|
!this.startupRefreshStarted) {
|
|
58
100
|
return;
|
|
@@ -74,7 +116,7 @@ export class SearchPlugin extends Plugin {
|
|
|
74
116
|
command: {
|
|
75
117
|
id: "open-search",
|
|
76
118
|
name: "Open Search",
|
|
77
|
-
callback: (query) =>
|
|
119
|
+
callback: (query, scope) => this.openSearchInLeftSidebar(query, scope),
|
|
78
120
|
},
|
|
79
121
|
});
|
|
80
122
|
this.registerEvent(this.app.metadataCache.on("loaded", () => {
|
|
@@ -88,7 +130,7 @@ export class SearchPlugin extends Plugin {
|
|
|
88
130
|
name: "Rebuild semantic search embeddings",
|
|
89
131
|
callback: async () => {
|
|
90
132
|
const status = await this.rebuildSemanticSearch();
|
|
91
|
-
|
|
133
|
+
this.showNotice(`Semantic search refreshed (${status.readyChunkCount}/${status.chunkCount} ready)`);
|
|
92
134
|
},
|
|
93
135
|
});
|
|
94
136
|
this.addCommand({
|
|
@@ -104,9 +146,10 @@ export class SearchPlugin extends Plugin {
|
|
|
104
146
|
id: "rebuild-search-index",
|
|
105
147
|
name: "Rebuild search index",
|
|
106
148
|
callback: async () => {
|
|
107
|
-
|
|
149
|
+
if (!this.app.session?.backgroundIndexing)
|
|
150
|
+
await this.app.appDatabase.rebuildSearchIndex();
|
|
108
151
|
await this.refreshIndex("rebuild");
|
|
109
|
-
|
|
152
|
+
this.showNotice("Search index rebuilt");
|
|
110
153
|
},
|
|
111
154
|
});
|
|
112
155
|
this.addCommand({
|
|
@@ -118,7 +161,15 @@ export class SearchPlugin extends Plugin {
|
|
|
118
161
|
refreshIndex(reason = "manual") {
|
|
119
162
|
return this.searchManager.refreshFromVault(reason);
|
|
120
163
|
}
|
|
164
|
+
async disable() {
|
|
165
|
+
if (this.app.session?.backgroundIndexing) {
|
|
166
|
+
this.managerStopped = true;
|
|
167
|
+
await this.searchManager.dispose();
|
|
168
|
+
}
|
|
169
|
+
await super.disable();
|
|
170
|
+
}
|
|
121
171
|
async onunload() {
|
|
172
|
+
this.managerStopped = true;
|
|
122
173
|
await this.searchManager.dispose();
|
|
123
174
|
}
|
|
124
175
|
startupRefresh() {
|
|
@@ -126,44 +177,68 @@ export class SearchPlugin extends Plugin {
|
|
|
126
177
|
return this.startupRefreshPromise;
|
|
127
178
|
if (this.startupRefreshStarted)
|
|
128
179
|
return Promise.resolve();
|
|
180
|
+
const manager = this.searchManager;
|
|
129
181
|
this.startupRefreshStarted = true;
|
|
130
|
-
this.startupRefreshPromise = reconcileSearchAfterMetadata(this.app,
|
|
182
|
+
this.startupRefreshPromise = reconcileSearchAfterMetadata(this.app, manager)
|
|
131
183
|
.then(() => undefined)
|
|
132
184
|
.catch((error) => {
|
|
185
|
+
if (this.searchManager !== manager)
|
|
186
|
+
return;
|
|
133
187
|
this.startupRefreshStarted = false;
|
|
134
|
-
|
|
188
|
+
if (this.nativeObserverDetached())
|
|
189
|
+
return;
|
|
190
|
+
this.showNotice(`Search index refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
135
191
|
})
|
|
136
192
|
.finally(() => {
|
|
137
|
-
if (!this.startupRefreshStarted)
|
|
193
|
+
if (this.searchManager === manager && !this.startupRefreshStarted)
|
|
138
194
|
this.startupRefreshPromise = null;
|
|
139
195
|
});
|
|
140
196
|
return this.startupRefreshPromise;
|
|
141
197
|
}
|
|
142
198
|
scheduleProviderReconciliation(reason) {
|
|
199
|
+
if (this.managerStopped || this.state !== "enabled")
|
|
200
|
+
return;
|
|
143
201
|
this.providerReconcileReason = reason;
|
|
144
202
|
if (this.providerReconcilePromise)
|
|
145
203
|
return;
|
|
204
|
+
const manager = this.searchManager;
|
|
146
205
|
this.providerReconcilePromise = (async () => {
|
|
147
206
|
await this.startupRefresh();
|
|
148
|
-
while (this.providerReconcileReason
|
|
207
|
+
while (this.providerReconcileReason &&
|
|
208
|
+
!this.managerStopped &&
|
|
209
|
+
this.searchManager === manager &&
|
|
210
|
+
this.state === "enabled") {
|
|
149
211
|
this.providerReconcileReason = null;
|
|
150
|
-
await
|
|
212
|
+
await manager.reconcileStartup();
|
|
151
213
|
}
|
|
152
214
|
})()
|
|
153
215
|
.catch((error) => {
|
|
154
|
-
|
|
216
|
+
if (this.searchManager !== manager)
|
|
217
|
+
return;
|
|
218
|
+
if (this.nativeObserverDetached())
|
|
219
|
+
return;
|
|
220
|
+
this.showNotice(`Search provider refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
155
221
|
})
|
|
156
222
|
.finally(() => {
|
|
223
|
+
if (this.searchManager !== manager)
|
|
224
|
+
return;
|
|
157
225
|
this.providerReconcilePromise = null;
|
|
226
|
+
if (this.managerStopped || this.state !== "enabled") {
|
|
227
|
+
this.providerReconcileReason = null;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
158
230
|
if (this.providerReconcileReason) {
|
|
159
231
|
this.scheduleProviderReconciliation(this.providerReconcileReason);
|
|
160
232
|
}
|
|
161
233
|
});
|
|
162
234
|
}
|
|
163
|
-
async openSearchInLeftSidebar(query = "") {
|
|
235
|
+
async openSearchInLeftSidebar(query = "", scope = "files") {
|
|
164
236
|
const existing = this.app.workspace.getLeavesOfType(SearchViewType)[0];
|
|
165
237
|
const target = existing ?? this.app.workspace.ensureSideLeaf(SearchViewType, "left");
|
|
166
|
-
await target.setViewState({
|
|
238
|
+
await target.setViewState({
|
|
239
|
+
type: SearchViewType,
|
|
240
|
+
state: { query, scope },
|
|
241
|
+
});
|
|
167
242
|
this.app.workspace.activateLeaf(target, {
|
|
168
243
|
focusRootHost: false,
|
|
169
244
|
source: "api",
|
|
@@ -171,4 +246,11 @@ export class SearchPlugin extends Plugin {
|
|
|
171
246
|
});
|
|
172
247
|
await this.app.workspace.revealLeaf(target);
|
|
173
248
|
}
|
|
249
|
+
nativeObserverDetached() {
|
|
250
|
+
return Boolean(this.app.session?.backgroundIndexing &&
|
|
251
|
+
(this.app.backgroundIndexing?.detached ||
|
|
252
|
+
this.managerStopped ||
|
|
253
|
+
this.state === "disabling" ||
|
|
254
|
+
this.state === "disabled"));
|
|
255
|
+
}
|
|
174
256
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type SearchQueryExpression, type SearchProviderHit, type SearchProviderStatus, type SearchResultProvider, type SearchResultProviderRegistry, type SearchResultRequest } from "@lapis-notes/api";
|
|
2
|
+
export type ProviderResults = {
|
|
3
|
+
provider: SearchResultProvider;
|
|
4
|
+
hits: SearchProviderHit[];
|
|
5
|
+
busy: boolean;
|
|
6
|
+
next?: string;
|
|
7
|
+
generation?: string;
|
|
8
|
+
status?: SearchProviderStatus;
|
|
9
|
+
unavailable?: string;
|
|
10
|
+
};
|
|
11
|
+
type Input = Omit<SearchResultRequest, "query" | "cursor" | "signal">;
|
|
12
|
+
export declare function searchProviderFields(node: SearchQueryExpression | null): string[];
|
|
13
|
+
/** Independent sources publish pages without clearing successful peer results. */
|
|
14
|
+
export declare class SearchProviderController {
|
|
15
|
+
#private;
|
|
16
|
+
readonly registry: SearchResultProviderRegistry;
|
|
17
|
+
readonly report: (error: unknown) => void;
|
|
18
|
+
constructor(registry: SearchResultProviderRegistry, report: (error: unknown) => void);
|
|
19
|
+
subscribe(listener: (rows: ProviderResults[]) => void): () => void;
|
|
20
|
+
search(input: Input, enabled: boolean, retain?: boolean): void;
|
|
21
|
+
more(id: string): void;
|
|
22
|
+
refresh(): void;
|
|
23
|
+
close(): void;
|
|
24
|
+
}
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { parseSearchQueryAst, } from "@lapis-notes/api";
|
|
2
|
+
export function searchProviderFields(node) {
|
|
3
|
+
if (!node)
|
|
4
|
+
return [];
|
|
5
|
+
switch (node.type) {
|
|
6
|
+
case "field":
|
|
7
|
+
return [node.name, ...searchProviderFields(node.value)];
|
|
8
|
+
case "property":
|
|
9
|
+
return ["property"];
|
|
10
|
+
case "group":
|
|
11
|
+
return searchProviderFields(node.expression);
|
|
12
|
+
case "not":
|
|
13
|
+
return searchProviderFields(node.operand);
|
|
14
|
+
case "and":
|
|
15
|
+
case "or":
|
|
16
|
+
return node.clauses.flatMap(searchProviderFields);
|
|
17
|
+
case "literal":
|
|
18
|
+
return node.kind === "regex" ? ["regex"] : [];
|
|
19
|
+
default:
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Independent sources publish pages without clearing successful peer results. */
|
|
24
|
+
export class SearchProviderController {
|
|
25
|
+
registry;
|
|
26
|
+
report;
|
|
27
|
+
#rows = new Map();
|
|
28
|
+
#listeners = new Set();
|
|
29
|
+
#abort = new AbortController();
|
|
30
|
+
#input;
|
|
31
|
+
#enabled = false;
|
|
32
|
+
#epoch = 0;
|
|
33
|
+
#timer;
|
|
34
|
+
#event;
|
|
35
|
+
constructor(registry, report) {
|
|
36
|
+
this.registry = registry;
|
|
37
|
+
this.report = report;
|
|
38
|
+
this.#event = registry.on("changed", () => {
|
|
39
|
+
clearTimeout(this.#timer);
|
|
40
|
+
this.#timer = setTimeout(() => {
|
|
41
|
+
if (this.#input)
|
|
42
|
+
this.search(this.#input, this.#enabled, true);
|
|
43
|
+
}, 75);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
subscribe(listener) {
|
|
47
|
+
this.#listeners.add(listener);
|
|
48
|
+
listener([...this.#rows.values()]);
|
|
49
|
+
return () => {
|
|
50
|
+
this.#listeners.delete(listener);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
#emit() {
|
|
54
|
+
for (const listener of this.#listeners)
|
|
55
|
+
listener([...this.#rows.values()]);
|
|
56
|
+
}
|
|
57
|
+
search(input, enabled, retain = false) {
|
|
58
|
+
this.#input = input;
|
|
59
|
+
this.#enabled = enabled;
|
|
60
|
+
this.#abort.abort();
|
|
61
|
+
this.#abort = new AbortController();
|
|
62
|
+
const epoch = ++this.#epoch;
|
|
63
|
+
const previous = this.#rows;
|
|
64
|
+
this.#rows = new Map();
|
|
65
|
+
if (enabled && input.text.trim()) {
|
|
66
|
+
const query = parseSearchQueryAst(input.text);
|
|
67
|
+
if (!query.diagnostics.length)
|
|
68
|
+
for (const provider of this.registry.getAll()) {
|
|
69
|
+
const unsupported = searchProviderFields(query.expression).filter((field) => !provider.fields.includes(field));
|
|
70
|
+
const mode = input.mode === "auto" ? "lexical" : input.mode;
|
|
71
|
+
const unavailable = unsupported.length
|
|
72
|
+
? `Unsupported filters: ${[...new Set(unsupported)].join(", ")}`
|
|
73
|
+
: !provider.modes.includes(mode)
|
|
74
|
+
? `${provider.label} supports text search only`
|
|
75
|
+
: undefined;
|
|
76
|
+
const row = {
|
|
77
|
+
provider,
|
|
78
|
+
hits: retain ? previous.get(provider.id)?.hits ?? [] : [],
|
|
79
|
+
busy: !unavailable,
|
|
80
|
+
unavailable,
|
|
81
|
+
};
|
|
82
|
+
this.#rows.set(provider.id, row);
|
|
83
|
+
if (!unavailable)
|
|
84
|
+
void this.#run(row, epoch, Math.max(input.limit, row.hits.length));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
this.#emit();
|
|
88
|
+
}
|
|
89
|
+
async #run(row, epoch, limit, append = false) {
|
|
90
|
+
if (!this.#input)
|
|
91
|
+
return;
|
|
92
|
+
const signal = this.#abort.signal;
|
|
93
|
+
const input = this.#input;
|
|
94
|
+
let hits = append ? [...row.hits] : [];
|
|
95
|
+
let cursor = append ? row.next : undefined;
|
|
96
|
+
const cursors = new Set();
|
|
97
|
+
row.busy = true;
|
|
98
|
+
this.#emit();
|
|
99
|
+
try {
|
|
100
|
+
// Bounded work even when a source returns pages without matches.
|
|
101
|
+
for (let n = 0; n < 32; n++) {
|
|
102
|
+
const page = await row.provider.search({
|
|
103
|
+
...input,
|
|
104
|
+
query: parseSearchQueryAst(input.text),
|
|
105
|
+
cursor,
|
|
106
|
+
signal,
|
|
107
|
+
});
|
|
108
|
+
if (signal.aborted || epoch !== this.#epoch)
|
|
109
|
+
return;
|
|
110
|
+
if (cursor && row.generation && row.generation !== page.generation)
|
|
111
|
+
hits = [];
|
|
112
|
+
row.generation = page.generation;
|
|
113
|
+
const merged = new Map(hits.map((hit) => [hit.id, hit]));
|
|
114
|
+
for (const hit of page.hits)
|
|
115
|
+
merged.set(hit.id, hit);
|
|
116
|
+
hits = [...merged.values()];
|
|
117
|
+
Object.assign(row, { hits, status: page.status, next: page.next });
|
|
118
|
+
this.#emit();
|
|
119
|
+
cursor = page.next;
|
|
120
|
+
if (!cursor || hits.length >= limit)
|
|
121
|
+
break;
|
|
122
|
+
if (cursors.has(cursor))
|
|
123
|
+
throw new Error("Search provider returned a repeated cursor");
|
|
124
|
+
cursors.add(cursor);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (!signal.aborted && epoch === this.#epoch) {
|
|
129
|
+
row.unavailable =
|
|
130
|
+
"Search is temporarily unavailable. Refresh to retry.";
|
|
131
|
+
this.report(error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
if (epoch === this.#epoch) {
|
|
136
|
+
row.busy = false;
|
|
137
|
+
this.#emit();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
more(id) {
|
|
142
|
+
const row = this.#rows.get(id);
|
|
143
|
+
if (row && row.next && !row.busy)
|
|
144
|
+
void this.#run(row, this.#epoch, row.hits.length + (this.#input?.limit ?? 100), true);
|
|
145
|
+
}
|
|
146
|
+
refresh() {
|
|
147
|
+
if (this.#input)
|
|
148
|
+
this.search(this.#input, this.#enabled, true);
|
|
149
|
+
}
|
|
150
|
+
close() {
|
|
151
|
+
++this.#epoch;
|
|
152
|
+
this.#abort.abort();
|
|
153
|
+
clearTimeout(this.#timer);
|
|
154
|
+
this.registry.offref(this.#event);
|
|
155
|
+
this.#listeners.clear();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SearchProviderHit, SearchResultProvider } from "@lapis-notes/api";
|
|
2
|
+
import type { ProviderResults } from "./search-provider-controller";
|
|
3
|
+
export type ProviderVersion = {
|
|
4
|
+
provider: SearchResultProvider;
|
|
5
|
+
hit: SearchProviderHit;
|
|
6
|
+
};
|
|
7
|
+
export type ProviderGroup = {
|
|
8
|
+
id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
path?: string;
|
|
11
|
+
extension?: string;
|
|
12
|
+
versions: ProviderVersion[];
|
|
13
|
+
};
|
|
14
|
+
export declare function groupProviderResults(rows: ProviderResults[]): ProviderGroup[];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function groupProviderResults(rows) {
|
|
2
|
+
const groups = new Map();
|
|
3
|
+
for (const row of rows)
|
|
4
|
+
for (const hit of row.hits) {
|
|
5
|
+
const id = hit.path ?? `${row.provider.id}:${hit.groupId}`;
|
|
6
|
+
let group = groups.get(id);
|
|
7
|
+
if (!group) {
|
|
8
|
+
group = {
|
|
9
|
+
id,
|
|
10
|
+
title: hit.title,
|
|
11
|
+
path: hit.path,
|
|
12
|
+
extension: hit.extension,
|
|
13
|
+
versions: [],
|
|
14
|
+
};
|
|
15
|
+
groups.set(id, group);
|
|
16
|
+
}
|
|
17
|
+
const content = hit.version?.contentId;
|
|
18
|
+
const duplicate = content
|
|
19
|
+
? group.versions.find((version) => version.provider.id === row.provider.id &&
|
|
20
|
+
version.hit.version?.contentId === content)
|
|
21
|
+
: undefined;
|
|
22
|
+
const reference = (item) => item.version
|
|
23
|
+
? {
|
|
24
|
+
id: item.version.id,
|
|
25
|
+
label: item.version.label,
|
|
26
|
+
author: item.version.author,
|
|
27
|
+
timestamp: item.version.timestamp,
|
|
28
|
+
target: item.target,
|
|
29
|
+
}
|
|
30
|
+
: undefined;
|
|
31
|
+
if (duplicate && duplicate.hit.version && hit.version) {
|
|
32
|
+
const refs = new Map((duplicate.hit.version.references ?? [reference(duplicate.hit)]).map((ref) => [ref.id, ref]));
|
|
33
|
+
refs.set(hit.version.id, reference(hit));
|
|
34
|
+
const primary = hit.version.timestamp > duplicate.hit.version.timestamp
|
|
35
|
+
? hit
|
|
36
|
+
: duplicate.hit;
|
|
37
|
+
duplicate.hit = {
|
|
38
|
+
...primary,
|
|
39
|
+
id: `${hit.groupId}/content/${content}`,
|
|
40
|
+
version: {
|
|
41
|
+
...primary.version,
|
|
42
|
+
occurrences: refs.size,
|
|
43
|
+
references: [...refs.values()].sort((a, b) => b.timestamp - a.timestamp),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
else
|
|
48
|
+
group.versions.push({
|
|
49
|
+
provider: row.provider,
|
|
50
|
+
hit: content
|
|
51
|
+
? { ...hit, id: `${hit.groupId}/content/${content}` }
|
|
52
|
+
: hit,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return [...groups.values()].map((group) => ({
|
|
56
|
+
...group,
|
|
57
|
+
versions: group.versions.sort((a, b) => (b.hit.timestamp ?? 0) - (a.hit.timestamp ?? 0) ||
|
|
58
|
+
a.hit.id.localeCompare(b.hit.id)),
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { App } from "@lapis-notes/api";
|
|
2
|
+
import { type SearchPluginSettings } from "./search-settings";
|
|
3
|
+
/** Observe owner-applied recovery without writing the recovered document again. */
|
|
4
|
+
export declare function observeRecoveredSearchSettings(app: Pick<App, "configuration">, pluginId: string, apply: (settings: SearchPluginSettings) => void): () => void;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { mergeSearchSettings, } from "./search-settings";
|
|
2
|
+
/** Observe owner-applied recovery without writing the recovered document again. */
|
|
3
|
+
export function observeRecoveredSearchSettings(app, pluginId, apply) {
|
|
4
|
+
const changed = app.configuration.on("plugin-data-updated", (event) => {
|
|
5
|
+
if (event.pluginId !== pluginId || event.origin === "plugin-save")
|
|
6
|
+
return;
|
|
7
|
+
apply(mergeSearchSettings(event.value));
|
|
8
|
+
});
|
|
9
|
+
return () => app.configuration.offref(changed);
|
|
10
|
+
}
|
|
@@ -126,10 +126,10 @@ export class SearchSettingsTab extends PluginSettingTab {
|
|
|
126
126
|
this.display();
|
|
127
127
|
try {
|
|
128
128
|
const status = await this.searchPlugin.rebuildSemanticSearch();
|
|
129
|
-
new Notice(`Semantic search refreshed (${status.readyChunkCount}/${status.chunkCount} ready)
|
|
129
|
+
new Notice(`Semantic search refreshed (${status.readyChunkCount}/${status.chunkCount} ready)`, undefined, this.app);
|
|
130
130
|
}
|
|
131
131
|
catch (error) {
|
|
132
|
-
new Notice(`Semantic search refresh failed: ${error instanceof Error ? error.message : String(error)}
|
|
132
|
+
new Notice(`Semantic search refresh failed: ${error instanceof Error ? error.message : String(error)}`, undefined, this.app);
|
|
133
133
|
}
|
|
134
134
|
finally {
|
|
135
135
|
this.rebuilding = false;
|
|
@@ -6,12 +6,6 @@
|
|
|
6
6
|
gap: 0.375rem;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
[data-ui-component="search-tool-result"] [data-ui-part="empty"] {
|
|
10
|
-
padding: 0.375rem;
|
|
11
|
-
color: var(--muted-foreground);
|
|
12
|
-
font-size: 0.8125rem;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
9
|
[data-ui-component="search-tool-result"] [data-ui-part="hit"] {
|
|
16
10
|
display: flex;
|
|
17
11
|
min-width: 0;
|
|
@@ -37,7 +31,8 @@
|
|
|
37
31
|
[data-ui-component="search-tool-result"] [data-ui-part="file"]:hover,
|
|
38
32
|
[data-ui-component="search-tool-result"] [data-ui-part="file"]:focus-visible,
|
|
39
33
|
[data-ui-component="search-tool-result"] [data-ui-part="snippet"]:hover,
|
|
40
|
-
[data-ui-component="search-tool-result"]
|
|
34
|
+
[data-ui-component="search-tool-result"]
|
|
35
|
+
[data-ui-part="snippet"]:focus-visible {
|
|
41
36
|
background: var(
|
|
42
37
|
--ui-workspace-explorer-row-hover-background,
|
|
43
38
|
var(--sidebar-accent)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { AgentResultViewProps } from "@lapis-notes/api";
|
|
3
|
+
import * as Empty from "@lapismd/design-core/shadcn/empty";
|
|
4
|
+
import SearchIcon from "@lucide/svelte/icons/search";
|
|
3
5
|
import type { App } from "@lapis-notes/api";
|
|
4
6
|
import {
|
|
5
7
|
fileNameFromPath,
|
|
@@ -27,14 +29,19 @@
|
|
|
27
29
|
|
|
28
30
|
<div data-ui-component="search-tool-result">
|
|
29
31
|
{#if hits.length === 0}
|
|
30
|
-
<
|
|
32
|
+
<Empty.Root data-ui-part="empty">
|
|
33
|
+
<Empty.Header>
|
|
34
|
+
<Empty.Media variant="icon"><SearchIcon aria-hidden="true" /></Empty.Media>
|
|
35
|
+
<Empty.Description>No matching notes.</Empty.Description>
|
|
36
|
+
</Empty.Header>
|
|
37
|
+
</Empty.Root>
|
|
31
38
|
{:else}
|
|
32
39
|
{#each hits as hit (hit.path)}
|
|
33
40
|
<div data-ui-part="hit">
|
|
34
41
|
<button
|
|
35
42
|
type="button"
|
|
36
43
|
data-ui-part="file"
|
|
37
|
-
aria-label={`Open ${fileNameFromPath(hit.path)}`}
|
|
44
|
+
aria-label={`Open ${fileNameFromPath(hit.path)} ${hit.path}`}
|
|
38
45
|
onclick={() => void openHit(hit)}
|
|
39
46
|
>
|
|
40
47
|
<span data-ui-part="file-label">{fileNameFromPath(hit.path)}</span>
|
package/dist/search-view.d.ts
CHANGED
|
@@ -2,13 +2,17 @@ import { View, type WorkspaceLeaf } from "@lapis-notes/api";
|
|
|
2
2
|
export { SearchViewType } from "./search-view-type";
|
|
3
3
|
export declare class SearchView extends View {
|
|
4
4
|
private component;
|
|
5
|
+
private snapshot?;
|
|
6
|
+
private closing?;
|
|
5
7
|
constructor(leaf: WorkspaceLeaf);
|
|
6
8
|
getViewType(): string;
|
|
7
9
|
getDisplayText(): string;
|
|
8
10
|
getIcon(): string;
|
|
11
|
+
getState(): Record<string, unknown>;
|
|
9
12
|
protected onOpen(): Promise<void>;
|
|
10
13
|
protected onClose(): Promise<void>;
|
|
11
14
|
onload(): void;
|
|
12
15
|
setState(state: Record<string, unknown>): Promise<void>;
|
|
13
|
-
onunload(): void
|
|
16
|
+
onunload(): Promise<void>;
|
|
17
|
+
private stopPanel;
|
|
14
18
|
}
|
package/dist/search-view.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { View } from "@lapis-notes/api";
|
|
1
|
+
import { View, } from "@lapis-notes/api";
|
|
2
2
|
import { mount, unmount } from "svelte";
|
|
3
3
|
import SearchPanel from "./search-panel.svelte";
|
|
4
4
|
import { SearchViewType } from "./search-view-type";
|
|
5
5
|
export { SearchViewType } from "./search-view-type";
|
|
6
6
|
export class SearchView extends View {
|
|
7
7
|
component = null;
|
|
8
|
+
snapshot;
|
|
9
|
+
closing;
|
|
8
10
|
constructor(leaf) {
|
|
9
11
|
super(leaf);
|
|
10
12
|
}
|
|
@@ -17,32 +19,58 @@ export class SearchView extends View {
|
|
|
17
19
|
getIcon() {
|
|
18
20
|
return "search";
|
|
19
21
|
}
|
|
22
|
+
getState() {
|
|
23
|
+
return {
|
|
24
|
+
...super.getState(),
|
|
25
|
+
...(this.component?.getSearchState?.() ?? this.snapshot),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
20
28
|
onOpen() {
|
|
21
29
|
return Promise.resolve();
|
|
22
30
|
}
|
|
23
31
|
onClose() {
|
|
24
|
-
return
|
|
32
|
+
return this.stopPanel();
|
|
25
33
|
}
|
|
26
34
|
onload() {
|
|
35
|
+
this.closing = undefined;
|
|
27
36
|
this.component = mount(SearchPanel, {
|
|
28
37
|
target: this.containerEl,
|
|
29
38
|
props: {
|
|
30
39
|
app: this.app,
|
|
40
|
+
initialScope: (["files", "history", "all"].includes(String(this.getState()["scope"]))
|
|
41
|
+
? this.getState()["scope"]
|
|
42
|
+
: "files"),
|
|
31
43
|
initialQuery: typeof this.getState()["query"] === "string"
|
|
32
44
|
? this.getState()["query"]
|
|
33
45
|
: "",
|
|
46
|
+
onStateChange: () => {
|
|
47
|
+
if (this.component)
|
|
48
|
+
this.app.workspace.requestSaveLayout({
|
|
49
|
+
source: "api",
|
|
50
|
+
operation: "search-state",
|
|
51
|
+
});
|
|
52
|
+
},
|
|
34
53
|
},
|
|
35
54
|
});
|
|
36
55
|
}
|
|
37
56
|
async setState(state) {
|
|
57
|
+
this.snapshot = undefined;
|
|
38
58
|
await super.setState(state);
|
|
59
|
+
if (["files", "history", "all"].includes(String(state["scope"])))
|
|
60
|
+
this.component?.setSearchScope?.(state["scope"]);
|
|
39
61
|
if (typeof state["query"] === "string") {
|
|
40
62
|
this.component?.setSearchQuery?.(state["query"]);
|
|
41
63
|
}
|
|
42
64
|
}
|
|
43
65
|
onunload() {
|
|
44
|
-
|
|
45
|
-
|
|
66
|
+
return this.stopPanel();
|
|
67
|
+
}
|
|
68
|
+
stopPanel() {
|
|
69
|
+
if (this.closing)
|
|
70
|
+
return this.closing;
|
|
71
|
+
this.snapshot = this.component?.getSearchState?.() ?? this.snapshot;
|
|
72
|
+
const current = this.component;
|
|
46
73
|
this.component = null;
|
|
74
|
+
return (this.closing = current ? unmount(current) : Promise.resolve());
|
|
47
75
|
}
|
|
48
76
|
}
|