@canopy-iiif/app 0.7.16 → 0.7.18
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/lib/AGENTS.md +2 -0
- package/lib/build/dev.js +205 -5
- package/lib/build/iiif.js +1 -8
- package/lib/build/mdx.js +5 -2
- package/lib/build/pages.js +1 -5
- package/lib/build/search.js +2 -5
- package/lib/common.js +16 -8
- package/lib/head.js +21 -0
- package/lib/index.js +5 -1
- package/lib/orchestrator.js +203 -0
- package/lib/search/command-runtime.js +5 -4
- package/lib/search/search-app.jsx +548 -111
- package/lib/search/search.js +8 -2
- package/package.json +10 -3
- package/types/orchestrator.d.ts +18 -0
- package/ui/dist/index.mjs +280 -47
- package/ui/dist/index.mjs.map +4 -4
- package/ui/dist/server.mjs +139 -43
- package/ui/dist/server.mjs.map +4 -4
- package/ui/styles/base/_common.scss +8 -0
- package/ui/styles/base/index.scss +1 -0
- package/ui/styles/components/_command.scss +85 -1
- package/ui/styles/components/_header.scss +0 -0
- package/ui/styles/components/_hero.scss +21 -0
- package/ui/styles/components/index.scss +2 -3
- package/ui/styles/index.css +105 -1
- package/ui/styles/index.scss +3 -2
|
@@ -1,23 +1,34 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import React, {useEffect, useMemo, useSyncExternalStore, useState} from "react";
|
|
2
|
+
import {createRoot} from "react-dom/client";
|
|
3
|
+
import {
|
|
4
|
+
SearchResultsUI,
|
|
5
|
+
SearchTabsUI,
|
|
6
|
+
SearchFiltersDialog,
|
|
7
|
+
} from "@canopy-iiif/app/ui";
|
|
4
8
|
|
|
5
9
|
// Lightweight IndexedDB utilities (no deps) with defensive guards
|
|
6
10
|
function hasIDB() {
|
|
7
|
-
try {
|
|
11
|
+
try {
|
|
12
|
+
return typeof indexedDB !== "undefined";
|
|
13
|
+
} catch (_) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
8
16
|
}
|
|
9
17
|
function idbOpen() {
|
|
10
18
|
return new Promise((resolve, reject) => {
|
|
11
19
|
if (!hasIDB()) return resolve(null);
|
|
12
20
|
try {
|
|
13
|
-
const req = indexedDB.open(
|
|
21
|
+
const req = indexedDB.open("canopy-search", 1);
|
|
14
22
|
req.onupgradeneeded = () => {
|
|
15
23
|
const db = req.result;
|
|
16
|
-
if (!db.objectStoreNames.contains(
|
|
24
|
+
if (!db.objectStoreNames.contains("indexes"))
|
|
25
|
+
db.createObjectStore("indexes", {keyPath: "version"});
|
|
17
26
|
};
|
|
18
27
|
req.onsuccess = () => resolve(req.result);
|
|
19
28
|
req.onerror = () => resolve(null);
|
|
20
|
-
} catch (_) {
|
|
29
|
+
} catch (_) {
|
|
30
|
+
resolve(null);
|
|
31
|
+
}
|
|
21
32
|
});
|
|
22
33
|
}
|
|
23
34
|
async function idbGet(store, key) {
|
|
@@ -25,12 +36,14 @@ async function idbGet(store, key) {
|
|
|
25
36
|
if (!db) return null;
|
|
26
37
|
return new Promise((resolve) => {
|
|
27
38
|
try {
|
|
28
|
-
const tx = db.transaction(store,
|
|
39
|
+
const tx = db.transaction(store, "readonly");
|
|
29
40
|
const st = tx.objectStore(store);
|
|
30
41
|
const req = st.get(key);
|
|
31
42
|
req.onsuccess = () => resolve(req.result || null);
|
|
32
43
|
req.onerror = () => resolve(null);
|
|
33
|
-
} catch (_) {
|
|
44
|
+
} catch (_) {
|
|
45
|
+
resolve(null);
|
|
46
|
+
}
|
|
34
47
|
});
|
|
35
48
|
}
|
|
36
49
|
async function idbPut(store, value) {
|
|
@@ -38,12 +51,14 @@ async function idbPut(store, value) {
|
|
|
38
51
|
if (!db) return false;
|
|
39
52
|
return new Promise((resolve) => {
|
|
40
53
|
try {
|
|
41
|
-
const tx = db.transaction(store,
|
|
54
|
+
const tx = db.transaction(store, "readwrite");
|
|
42
55
|
const st = tx.objectStore(store);
|
|
43
56
|
st.put(value);
|
|
44
57
|
tx.oncomplete = () => resolve(true);
|
|
45
58
|
tx.onerror = () => resolve(false);
|
|
46
|
-
} catch (_) {
|
|
59
|
+
} catch (_) {
|
|
60
|
+
resolve(false);
|
|
61
|
+
}
|
|
47
62
|
});
|
|
48
63
|
}
|
|
49
64
|
async function idbPruneOld(store, keepKey) {
|
|
@@ -51,48 +66,69 @@ async function idbPruneOld(store, keepKey) {
|
|
|
51
66
|
if (!db) return false;
|
|
52
67
|
return new Promise((resolve) => {
|
|
53
68
|
try {
|
|
54
|
-
const tx = db.transaction(store,
|
|
69
|
+
const tx = db.transaction(store, "readwrite");
|
|
55
70
|
const st = tx.objectStore(store);
|
|
56
71
|
const req = st.getAllKeys();
|
|
57
72
|
req.onsuccess = () => {
|
|
58
|
-
try {
|
|
73
|
+
try {
|
|
74
|
+
(req.result || []).forEach((k) => {
|
|
75
|
+
if (k !== keepKey) st.delete(k);
|
|
76
|
+
});
|
|
77
|
+
} catch (_) {}
|
|
59
78
|
resolve(true);
|
|
60
79
|
};
|
|
61
80
|
req.onerror = () => resolve(false);
|
|
62
|
-
} catch (_) {
|
|
81
|
+
} catch (_) {
|
|
82
|
+
resolve(false);
|
|
83
|
+
}
|
|
63
84
|
});
|
|
64
85
|
}
|
|
65
86
|
async function sha256Hex(str) {
|
|
66
87
|
try {
|
|
67
|
-
if (typeof crypto !==
|
|
88
|
+
if (typeof crypto !== "undefined" && crypto.subtle) {
|
|
68
89
|
const data = new TextEncoder().encode(str);
|
|
69
|
-
const digest = await crypto.subtle.digest(
|
|
70
|
-
return Array.from(new Uint8Array(digest))
|
|
90
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
91
|
+
return Array.from(new Uint8Array(digest))
|
|
92
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
93
|
+
.join("");
|
|
71
94
|
}
|
|
72
95
|
} catch (_) {}
|
|
73
96
|
// Defensive: simple non-crypto hash when Web Crypto is unavailable
|
|
74
97
|
try {
|
|
75
|
-
let h = 5381;
|
|
98
|
+
let h = 5381;
|
|
99
|
+
for (let i = 0; i < str.length; i++) h = ((h << 5) + h) ^ str.charCodeAt(i);
|
|
76
100
|
return (h >>> 0).toString(16);
|
|
77
|
-
} catch (_) {
|
|
101
|
+
} catch (_) {
|
|
102
|
+
return String(str && str.length ? str.length : 0);
|
|
103
|
+
}
|
|
78
104
|
}
|
|
79
105
|
|
|
80
106
|
function createSearchStore() {
|
|
81
107
|
let state = {
|
|
82
|
-
query: new URLSearchParams(location.search).get(
|
|
83
|
-
type: new URLSearchParams(location.search).get(
|
|
108
|
+
query: new URLSearchParams(location.search).get("q") || "",
|
|
109
|
+
type: new URLSearchParams(location.search).get("type") || "all",
|
|
84
110
|
loading: true,
|
|
85
111
|
records: [],
|
|
86
112
|
types: [],
|
|
87
113
|
index: null,
|
|
88
114
|
counts: {},
|
|
115
|
+
facets: [],
|
|
116
|
+
facetsDocsMap: {},
|
|
117
|
+
filters: {},
|
|
118
|
+
activeFilterCount: 0,
|
|
89
119
|
};
|
|
90
120
|
const listeners = new Set();
|
|
91
|
-
function notify() {
|
|
121
|
+
function notify() {
|
|
122
|
+
listeners.forEach((fn) => {
|
|
123
|
+
try {
|
|
124
|
+
fn();
|
|
125
|
+
} catch (_) {}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
92
128
|
// Keep a memoized snapshot so getSnapshot returns stable references
|
|
93
129
|
let snapshot = null;
|
|
94
130
|
function recomputeSnapshot() {
|
|
95
|
-
const {
|
|
131
|
+
const {index, records, query, type, filters, facetsDocsMap} = state;
|
|
96
132
|
let base = [];
|
|
97
133
|
let results = [];
|
|
98
134
|
let totalForType = Array.isArray(records) ? records.length : 0;
|
|
@@ -101,89 +137,363 @@ function createSearchStore() {
|
|
|
101
137
|
if (!query) {
|
|
102
138
|
base = records;
|
|
103
139
|
} else {
|
|
104
|
-
try {
|
|
140
|
+
try {
|
|
141
|
+
const ids = (index && index.search(query, {limit: 200})) || [];
|
|
142
|
+
base = ids.map((i) => records[i]).filter(Boolean);
|
|
143
|
+
} catch (_) {
|
|
144
|
+
base = [];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const normalizedFilters =
|
|
148
|
+
filters && typeof filters === "object" ? filters : {};
|
|
149
|
+
const totalActiveFilters = Object.keys(normalizedFilters).reduce(
|
|
150
|
+
(sum, slug) => {
|
|
151
|
+
const arr = Array.isArray(normalizedFilters[slug])
|
|
152
|
+
? normalizedFilters[slug]
|
|
153
|
+
: [];
|
|
154
|
+
return sum + arr.filter(Boolean).length;
|
|
155
|
+
},
|
|
156
|
+
0
|
|
157
|
+
);
|
|
158
|
+
state.activeFilterCount = totalActiveFilters;
|
|
159
|
+
|
|
160
|
+
const shouldFilterWorks =
|
|
161
|
+
totalActiveFilters > 0 && String(type).toLowerCase() === "work";
|
|
162
|
+
let allowed = null; // Set of record indices that satisfy filters
|
|
163
|
+
if (shouldFilterWorks) {
|
|
164
|
+
Object.keys(normalizedFilters).forEach((facetSlug) => {
|
|
165
|
+
const values = Array.isArray(normalizedFilters[facetSlug])
|
|
166
|
+
? normalizedFilters[facetSlug]
|
|
167
|
+
: [];
|
|
168
|
+
if (!values.length) return;
|
|
169
|
+
const docMap =
|
|
170
|
+
facetsDocsMap && facetsDocsMap[facetSlug]
|
|
171
|
+
? facetsDocsMap[facetSlug]
|
|
172
|
+
: null;
|
|
173
|
+
if (!docMap) {
|
|
174
|
+
allowed = new Set();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const union = new Set();
|
|
178
|
+
values.forEach((valueSlugRaw) => {
|
|
179
|
+
const valueSlug = String(valueSlugRaw);
|
|
180
|
+
const docsSet = docMap[valueSlug];
|
|
181
|
+
if (docsSet && docsSet.size) {
|
|
182
|
+
docsSet.forEach((idx) => union.add(idx));
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
if (union.size === 0) {
|
|
186
|
+
allowed = new Set();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!allowed) {
|
|
190
|
+
allowed = new Set(union);
|
|
191
|
+
} else {
|
|
192
|
+
allowed = new Set(
|
|
193
|
+
Array.from(allowed).filter((idx) => union.has(idx))
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
105
197
|
}
|
|
106
|
-
|
|
198
|
+
|
|
107
199
|
try {
|
|
108
200
|
counts = base.reduce((acc, r) => {
|
|
109
|
-
const t = String((r && r.type) ||
|
|
201
|
+
const t = String((r && r.type) || "page").toLowerCase();
|
|
202
|
+
if (shouldFilterWorks && t === "work") {
|
|
203
|
+
if (!allowed || !allowed.has(r && r.__docIndex)) return acc;
|
|
204
|
+
}
|
|
110
205
|
acc[t] = (acc[t] || 0) + 1;
|
|
111
206
|
return acc;
|
|
112
207
|
}, {});
|
|
113
|
-
} catch (_) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
208
|
+
} catch (_) {
|
|
209
|
+
counts = {};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
results =
|
|
213
|
+
type === "all"
|
|
214
|
+
? base
|
|
215
|
+
: base.filter(
|
|
216
|
+
(r) => String(r.type).toLowerCase() === String(type).toLowerCase()
|
|
217
|
+
);
|
|
218
|
+
if (shouldFilterWorks && allowed) {
|
|
219
|
+
results = results.filter((r) => allowed.has(r && r.__docIndex));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (type !== "all") {
|
|
223
|
+
try {
|
|
224
|
+
totalForType = records.filter(
|
|
225
|
+
(r) => String(r.type).toLowerCase() === String(type).toLowerCase()
|
|
226
|
+
).length;
|
|
227
|
+
} catch (_) {}
|
|
119
228
|
}
|
|
120
229
|
}
|
|
121
|
-
|
|
230
|
+
const {facetsDocsMap: _fMap, ...publicState} = state;
|
|
231
|
+
snapshot = {
|
|
232
|
+
...publicState,
|
|
233
|
+
results,
|
|
234
|
+
total: totalForType,
|
|
235
|
+
shown: results.length,
|
|
236
|
+
counts,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function set(partial) {
|
|
240
|
+
state = {...state, ...partial};
|
|
241
|
+
recomputeSnapshot();
|
|
242
|
+
notify();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function syncFiltersToUrl(nextFilters) {
|
|
246
|
+
try {
|
|
247
|
+
const url = new URL(location.href);
|
|
248
|
+
const facetSlugs = new Set();
|
|
249
|
+
if (Array.isArray(state.facets)) {
|
|
250
|
+
state.facets.forEach((facet) => {
|
|
251
|
+
if (facet && facet.slug) facetSlugs.add(String(facet.slug));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
Object.keys(nextFilters || {}).forEach((slug) => {
|
|
255
|
+
if (slug) facetSlugs.add(String(slug));
|
|
256
|
+
});
|
|
257
|
+
facetSlugs.forEach((slug) => url.searchParams.delete(slug));
|
|
258
|
+
Object.keys(nextFilters || {}).forEach((slug) => {
|
|
259
|
+
const vals = Array.isArray(nextFilters[slug])
|
|
260
|
+
? nextFilters[slug].filter(Boolean)
|
|
261
|
+
: [];
|
|
262
|
+
if (vals.length)
|
|
263
|
+
url.searchParams.set(slug, Array.from(new Set(vals)).join(","));
|
|
264
|
+
});
|
|
265
|
+
const qs = url.searchParams.toString();
|
|
266
|
+
history.replaceState(
|
|
267
|
+
null,
|
|
268
|
+
"",
|
|
269
|
+
qs ? `${url.pathname}?${qs}` : url.pathname
|
|
270
|
+
);
|
|
271
|
+
} catch (_) {}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function toggleFilter(facetSlug, valueSlug, nextChecked) {
|
|
275
|
+
const slug = String(facetSlug || "");
|
|
276
|
+
const value = String(valueSlug || "");
|
|
277
|
+
if (!slug || !value) return;
|
|
278
|
+
const current = Array.isArray(state.filters && state.filters[slug])
|
|
279
|
+
? [...state.filters[slug]]
|
|
280
|
+
: [];
|
|
281
|
+
const setValues = new Set(current);
|
|
282
|
+
const shouldCheck =
|
|
283
|
+
typeof nextChecked === "boolean" ? nextChecked : !setValues.has(value);
|
|
284
|
+
if (shouldCheck) setValues.add(value);
|
|
285
|
+
else setValues.delete(value);
|
|
286
|
+
const nextFilters = {...state.filters};
|
|
287
|
+
if (setValues.size) nextFilters[slug] = Array.from(setValues);
|
|
288
|
+
else delete nextFilters[slug];
|
|
289
|
+
syncFiltersToUrl(nextFilters);
|
|
290
|
+
set({filters: nextFilters});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function clearFilters() {
|
|
294
|
+
syncFiltersToUrl({});
|
|
295
|
+
set({filters: {}});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function hydrateFacets() {
|
|
299
|
+
try {
|
|
300
|
+
const res = await fetch("./api/search/facets.json");
|
|
301
|
+
if (!res || !res.ok) return;
|
|
302
|
+
const json = await res.json().catch(() => null);
|
|
303
|
+
if (!Array.isArray(json)) return;
|
|
304
|
+
const sanitized = [];
|
|
305
|
+
const docsMap = {};
|
|
306
|
+
json.forEach((facet) => {
|
|
307
|
+
if (
|
|
308
|
+
!facet ||
|
|
309
|
+
!facet.label ||
|
|
310
|
+
!facet.slug ||
|
|
311
|
+
!Array.isArray(facet.values)
|
|
312
|
+
)
|
|
313
|
+
return;
|
|
314
|
+
const slug = String(facet.slug);
|
|
315
|
+
const values = [];
|
|
316
|
+
const valueMap = {};
|
|
317
|
+
facet.values.forEach((valueEntry) => {
|
|
318
|
+
if (!valueEntry || !valueEntry.slug) return;
|
|
319
|
+
const vSlug = String(valueEntry.slug);
|
|
320
|
+
const vLabel =
|
|
321
|
+
valueEntry.value != null ? String(valueEntry.value) : "";
|
|
322
|
+
const docCount = Number.isFinite(Number(valueEntry.doc_count))
|
|
323
|
+
? Number(valueEntry.doc_count)
|
|
324
|
+
: undefined;
|
|
325
|
+
const docs = Array.isArray(valueEntry.docs)
|
|
326
|
+
? valueEntry.docs.map((idx) => Number(idx))
|
|
327
|
+
: [];
|
|
328
|
+
if (!valueMap[vSlug]) valueMap[vSlug] = new Set();
|
|
329
|
+
docs.forEach((idx) => {
|
|
330
|
+
if (Number.isInteger(idx)) valueMap[vSlug].add(idx);
|
|
331
|
+
});
|
|
332
|
+
values.push({value: vLabel, slug: vSlug, doc_count: docCount});
|
|
333
|
+
});
|
|
334
|
+
docsMap[slug] = valueMap;
|
|
335
|
+
sanitized.push({label: String(facet.label), slug, values});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Pre-populate filters from query params once facets are known
|
|
339
|
+
const params = new URLSearchParams(location.search);
|
|
340
|
+
const nextFilters = {};
|
|
341
|
+
sanitized.forEach((facet) => {
|
|
342
|
+
const slug = facet.slug;
|
|
343
|
+
const valid = new Set(facet.values.map((item) => item.slug));
|
|
344
|
+
const requested = [];
|
|
345
|
+
params.getAll(slug).forEach((raw) => {
|
|
346
|
+
const parts = String(raw || "").split(",");
|
|
347
|
+
parts.forEach((part) => {
|
|
348
|
+
const trimmed = part && part.trim ? part.trim() : part;
|
|
349
|
+
if (trimmed && valid.has(trimmed)) requested.push(trimmed);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
if (requested.length)
|
|
353
|
+
nextFilters[slug] = Array.from(new Set(requested));
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
set({facets: sanitized, facetsDocsMap: docsMap, filters: nextFilters});
|
|
357
|
+
syncFiltersToUrl(nextFilters);
|
|
358
|
+
} catch (_) {}
|
|
359
|
+
}
|
|
360
|
+
function subscribe(fn) {
|
|
361
|
+
listeners.add(fn);
|
|
362
|
+
return () => listeners.delete(fn);
|
|
363
|
+
}
|
|
364
|
+
function getSnapshot() {
|
|
365
|
+
return snapshot;
|
|
122
366
|
}
|
|
123
|
-
function set(partial) { state = { ...state, ...partial }; recomputeSnapshot(); notify(); }
|
|
124
|
-
function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
|
|
125
|
-
function getSnapshot() { return snapshot; }
|
|
126
367
|
// Initialize snapshot
|
|
127
368
|
recomputeSnapshot();
|
|
128
369
|
// init
|
|
129
370
|
(async () => {
|
|
130
371
|
try {
|
|
131
|
-
const DEBUG = (() => {
|
|
132
|
-
|
|
372
|
+
const DEBUG = (() => {
|
|
373
|
+
try {
|
|
374
|
+
const p = new URLSearchParams(location.search);
|
|
375
|
+
return (
|
|
376
|
+
p.has("searchDebug") || localStorage.CANOPY_SEARCH_DEBUG === "1"
|
|
377
|
+
);
|
|
378
|
+
} catch (_) {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
})();
|
|
382
|
+
const Flex =
|
|
383
|
+
(window && window.FlexSearch) || (await import("flexsearch")).default;
|
|
133
384
|
// Broadcast new index installs to other tabs
|
|
134
385
|
let bc = null;
|
|
135
|
-
try {
|
|
386
|
+
try {
|
|
387
|
+
if (typeof BroadcastChannel !== "undefined")
|
|
388
|
+
bc = new BroadcastChannel("canopy-search");
|
|
389
|
+
} catch (_) {}
|
|
136
390
|
// Try to load meta for cache-busting and tab order; fall back to hash of JSON
|
|
137
|
-
let version =
|
|
391
|
+
let version = "";
|
|
138
392
|
let tabsOrder = [];
|
|
139
393
|
try {
|
|
140
|
-
const meta = await fetch(
|
|
141
|
-
|
|
142
|
-
|
|
394
|
+
const meta = await fetch("./api/index.json")
|
|
395
|
+
.then((r) => (r && r.ok ? r.json() : null))
|
|
396
|
+
.catch(() => null);
|
|
397
|
+
if (meta && typeof meta.version === "string") version = meta.version;
|
|
398
|
+
const ord =
|
|
399
|
+
meta &&
|
|
400
|
+
meta.search &&
|
|
401
|
+
meta.search.tabs &&
|
|
402
|
+
Array.isArray(meta.search.tabs.order)
|
|
403
|
+
? meta.search.tabs.order
|
|
404
|
+
: [];
|
|
143
405
|
tabsOrder = ord.map((s) => String(s)).filter(Boolean);
|
|
144
406
|
} catch (_) {}
|
|
145
|
-
const res = await fetch(
|
|
407
|
+
const res = await fetch(
|
|
408
|
+
"./api/search-index.json" +
|
|
409
|
+
(version ? `?v=${encodeURIComponent(version)}` : "")
|
|
410
|
+
);
|
|
146
411
|
const text = await res.text();
|
|
147
|
-
const parsed = (() => {
|
|
148
|
-
|
|
149
|
-
|
|
412
|
+
const parsed = (() => {
|
|
413
|
+
try {
|
|
414
|
+
return JSON.parse(text);
|
|
415
|
+
} catch {
|
|
416
|
+
return [];
|
|
417
|
+
}
|
|
418
|
+
})();
|
|
419
|
+
const rawData = Array.isArray(parsed)
|
|
420
|
+
? parsed
|
|
421
|
+
: parsed && parsed.records
|
|
422
|
+
? parsed.records
|
|
423
|
+
: [];
|
|
424
|
+
const data = rawData.map((rec, i) => ({...(rec || {}), __docIndex: i}));
|
|
425
|
+
if (!version)
|
|
426
|
+
version = (parsed && parsed.version) || (await sha256Hex(text));
|
|
150
427
|
|
|
151
|
-
const idx = new Flex.Index({
|
|
428
|
+
const idx = new Flex.Index({tokenize: "forward"});
|
|
152
429
|
let hydrated = false;
|
|
153
|
-
const t0 =
|
|
430
|
+
const t0 =
|
|
431
|
+
typeof performance !== "undefined" && performance.now
|
|
432
|
+
? performance.now()
|
|
433
|
+
: Date.now();
|
|
154
434
|
try {
|
|
155
|
-
const cached = await idbGet(
|
|
435
|
+
const cached = await idbGet("indexes", version);
|
|
156
436
|
if (cached && cached.exportData) {
|
|
157
437
|
try {
|
|
158
438
|
const dataObj = cached.exportData || {};
|
|
159
439
|
for (const k in dataObj) {
|
|
160
440
|
if (Object.prototype.hasOwnProperty.call(dataObj, k)) {
|
|
161
|
-
try {
|
|
441
|
+
try {
|
|
442
|
+
idx.import(k, dataObj[k]);
|
|
443
|
+
} catch (_) {}
|
|
162
444
|
}
|
|
163
445
|
}
|
|
164
446
|
hydrated = true;
|
|
165
|
-
} catch (_) {
|
|
447
|
+
} catch (_) {
|
|
448
|
+
hydrated = false;
|
|
449
|
+
}
|
|
166
450
|
}
|
|
167
|
-
} catch (_) {
|
|
451
|
+
} catch (_) {
|
|
452
|
+
/* no-op */
|
|
453
|
+
}
|
|
168
454
|
|
|
169
455
|
if (!hydrated) {
|
|
170
|
-
data.forEach((rec, i) => {
|
|
456
|
+
data.forEach((rec, i) => {
|
|
457
|
+
try {
|
|
458
|
+
idx.add(i, rec && rec.title ? String(rec.title) : "");
|
|
459
|
+
} catch (_) {}
|
|
460
|
+
});
|
|
171
461
|
try {
|
|
172
462
|
const dump = {};
|
|
173
|
-
try {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
463
|
+
try {
|
|
464
|
+
await idx.export((key, val) => {
|
|
465
|
+
dump[key] = val;
|
|
466
|
+
});
|
|
467
|
+
} catch (_) {}
|
|
468
|
+
await idbPut("indexes", {version, exportData: dump, ts: Date.now()});
|
|
469
|
+
await idbPruneOld("indexes", version);
|
|
470
|
+
try {
|
|
471
|
+
if (bc) bc.postMessage({type: "search-index-installed", version});
|
|
472
|
+
} catch (_) {}
|
|
177
473
|
} catch (_) {}
|
|
178
474
|
if (DEBUG) {
|
|
179
|
-
const t1 =
|
|
475
|
+
const t1 =
|
|
476
|
+
typeof performance !== "undefined" && performance.now
|
|
477
|
+
? performance.now()
|
|
478
|
+
: Date.now();
|
|
180
479
|
// eslint-disable-next-line no-console
|
|
181
|
-
console.info(
|
|
480
|
+
console.info(
|
|
481
|
+
`[Search] Index built in ${Math.round(t1 - t0)}ms (records=${
|
|
482
|
+
data.length
|
|
483
|
+
}) v=${String(version).slice(0, 8)}`
|
|
484
|
+
);
|
|
182
485
|
}
|
|
183
486
|
} else if (DEBUG) {
|
|
184
|
-
const t1 =
|
|
487
|
+
const t1 =
|
|
488
|
+
typeof performance !== "undefined" && performance.now
|
|
489
|
+
? performance.now()
|
|
490
|
+
: Date.now();
|
|
185
491
|
// eslint-disable-next-line no-console
|
|
186
|
-
console.info(
|
|
492
|
+
console.info(
|
|
493
|
+
`[Search] Index imported from IndexedDB in ${Math.round(
|
|
494
|
+
t1 - t0
|
|
495
|
+
)}ms v=${String(version).slice(0, 8)}`
|
|
496
|
+
);
|
|
187
497
|
}
|
|
188
498
|
// Optional: debug-listen for install events from other tabs
|
|
189
499
|
try {
|
|
@@ -191,63 +501,171 @@ function createSearchStore() {
|
|
|
191
501
|
bc.onmessage = (ev) => {
|
|
192
502
|
try {
|
|
193
503
|
const msg = ev && ev.data;
|
|
194
|
-
if (
|
|
504
|
+
if (
|
|
505
|
+
msg &&
|
|
506
|
+
msg.type === "search-index-installed" &&
|
|
507
|
+
msg.version &&
|
|
508
|
+
msg.version !== version
|
|
509
|
+
) {
|
|
195
510
|
// eslint-disable-next-line no-console
|
|
196
|
-
console.info(
|
|
511
|
+
console.info(
|
|
512
|
+
"[Search] Another tab installed version",
|
|
513
|
+
String(msg.version).slice(0, 8)
|
|
514
|
+
);
|
|
197
515
|
}
|
|
198
516
|
} catch (_) {}
|
|
199
517
|
};
|
|
200
518
|
}
|
|
201
519
|
} catch (_) {}
|
|
202
520
|
|
|
203
|
-
const ts = Array.from(
|
|
204
|
-
|
|
205
|
-
|
|
521
|
+
const ts = Array.from(
|
|
522
|
+
new Set(data.map((r) => String((r && r.type) || "page")))
|
|
523
|
+
);
|
|
524
|
+
const order =
|
|
525
|
+
Array.isArray(tabsOrder) && tabsOrder.length
|
|
526
|
+
? tabsOrder
|
|
527
|
+
: ["work", "docs", "page"];
|
|
528
|
+
ts.sort((a, b) => {
|
|
529
|
+
const ia = order.indexOf(a);
|
|
530
|
+
const ib = order.indexOf(b);
|
|
531
|
+
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
|
|
532
|
+
});
|
|
206
533
|
// Default to configured first tab if no type param present
|
|
207
534
|
try {
|
|
208
535
|
const p = new URLSearchParams(location.search);
|
|
209
|
-
const hasType = p.has(
|
|
536
|
+
const hasType = p.has("type");
|
|
210
537
|
if (!hasType) {
|
|
211
|
-
let def =
|
|
212
|
-
if (!ts.includes(def)) def = ts[0] ||
|
|
213
|
-
if (def && def !==
|
|
214
|
-
p.set(
|
|
215
|
-
history.replaceState(
|
|
538
|
+
let def = order && order.length ? order[0] : "all";
|
|
539
|
+
if (!ts.includes(def)) def = ts[0] || "all";
|
|
540
|
+
if (def && def !== "all") {
|
|
541
|
+
p.set("type", def);
|
|
542
|
+
history.replaceState(
|
|
543
|
+
null,
|
|
544
|
+
"",
|
|
545
|
+
`${location.pathname}?${p.toString()}`
|
|
546
|
+
);
|
|
216
547
|
}
|
|
217
|
-
set({
|
|
548
|
+
set({type: def});
|
|
218
549
|
}
|
|
219
550
|
} catch (_) {}
|
|
220
|
-
set({
|
|
221
|
-
|
|
551
|
+
set({index: idx, records: data, types: ts, loading: false});
|
|
552
|
+
await hydrateFacets();
|
|
553
|
+
} catch (_) {
|
|
554
|
+
set({loading: false});
|
|
555
|
+
}
|
|
222
556
|
})();
|
|
223
557
|
// API
|
|
224
|
-
function setQuery(q) {
|
|
225
|
-
|
|
226
|
-
|
|
558
|
+
function setQuery(q) {
|
|
559
|
+
set({query: q});
|
|
560
|
+
const u = new URL(location.href);
|
|
561
|
+
if (q) u.searchParams.set("q", q);
|
|
562
|
+
else u.searchParams.delete("q");
|
|
563
|
+
history.replaceState(
|
|
564
|
+
null,
|
|
565
|
+
"",
|
|
566
|
+
u.searchParams.toString()
|
|
567
|
+
? `${u.pathname}?${u.searchParams.toString()}`
|
|
568
|
+
: u.pathname
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
function setType(t) {
|
|
572
|
+
set({type: t});
|
|
573
|
+
const u = new URL(location.href);
|
|
574
|
+
if (t && t !== "all") u.searchParams.set("type", t);
|
|
575
|
+
else u.searchParams.delete("type");
|
|
576
|
+
history.replaceState(
|
|
577
|
+
null,
|
|
578
|
+
"",
|
|
579
|
+
u.searchParams.toString()
|
|
580
|
+
? `${u.pathname}?${u.searchParams.toString()}`
|
|
581
|
+
: u.pathname
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
subscribe,
|
|
586
|
+
getSnapshot,
|
|
587
|
+
setQuery,
|
|
588
|
+
setType,
|
|
589
|
+
toggleFilter,
|
|
590
|
+
clearFilters,
|
|
591
|
+
};
|
|
227
592
|
}
|
|
228
593
|
|
|
229
|
-
const store = typeof window !==
|
|
594
|
+
const store = typeof window !== "undefined" ? createSearchStore() : null;
|
|
230
595
|
|
|
231
596
|
function useStore() {
|
|
232
|
-
const snap = useSyncExternalStore(
|
|
233
|
-
|
|
597
|
+
const snap = useSyncExternalStore(
|
|
598
|
+
store.subscribe,
|
|
599
|
+
store.getSnapshot,
|
|
600
|
+
store.getSnapshot
|
|
601
|
+
);
|
|
602
|
+
return {
|
|
603
|
+
...snap,
|
|
604
|
+
setQuery: store.setQuery,
|
|
605
|
+
setType: store.setType,
|
|
606
|
+
toggleFilter: store.toggleFilter,
|
|
607
|
+
clearFilters: store.clearFilters,
|
|
608
|
+
};
|
|
234
609
|
}
|
|
235
610
|
|
|
236
611
|
function ResultsMount(props = {}) {
|
|
237
|
-
const {
|
|
612
|
+
const {results, type, loading} = useStore();
|
|
238
613
|
if (loading) return <div className="text-slate-600">Loading…</div>;
|
|
239
|
-
const layout = (props && props.layout) ||
|
|
614
|
+
const layout = (props && props.layout) || "grid";
|
|
240
615
|
return <SearchResultsUI results={results} type={type} layout={layout} />;
|
|
241
616
|
}
|
|
242
617
|
function TabsMount() {
|
|
243
|
-
const {
|
|
244
|
-
|
|
618
|
+
const {
|
|
619
|
+
type,
|
|
620
|
+
setType,
|
|
621
|
+
types,
|
|
622
|
+
counts,
|
|
623
|
+
facets,
|
|
624
|
+
filters,
|
|
625
|
+
activeFilterCount,
|
|
626
|
+
toggleFilter,
|
|
627
|
+
clearFilters,
|
|
628
|
+
} = useStore();
|
|
629
|
+
const [open, setOpen] = useState(false);
|
|
630
|
+
const hasFilters = Array.isArray(facets) && facets.length > 0;
|
|
631
|
+
const allowFilters = hasFilters && String(type).toLowerCase() === "work";
|
|
632
|
+
useEffect(() => {
|
|
633
|
+
if (!allowFilters && open) setOpen(false);
|
|
634
|
+
}, [allowFilters, open]);
|
|
635
|
+
const handleToggle = (facetSlug, valueSlug, checked) => {
|
|
636
|
+
if (toggleFilter) toggleFilter(facetSlug, valueSlug, checked);
|
|
637
|
+
};
|
|
638
|
+
return (
|
|
639
|
+
<>
|
|
640
|
+
<SearchTabsUI
|
|
641
|
+
type={type}
|
|
642
|
+
onTypeChange={setType}
|
|
643
|
+
types={types}
|
|
644
|
+
counts={counts}
|
|
645
|
+
onOpenFilters={allowFilters ? () => setOpen(true) : undefined}
|
|
646
|
+
activeFilterCount={activeFilterCount || 0}
|
|
647
|
+
filtersOpen={allowFilters ? open : false}
|
|
648
|
+
/>
|
|
649
|
+
{allowFilters ? (
|
|
650
|
+
<SearchFiltersDialog
|
|
651
|
+
open={open}
|
|
652
|
+
onOpenChange={setOpen}
|
|
653
|
+
facets={facets}
|
|
654
|
+
selected={filters}
|
|
655
|
+
onToggle={handleToggle}
|
|
656
|
+
onClear={() => clearFilters && clearFilters()}
|
|
657
|
+
/>
|
|
658
|
+
) : null}
|
|
659
|
+
</>
|
|
660
|
+
);
|
|
245
661
|
}
|
|
246
662
|
function SummaryMount() {
|
|
247
|
-
const {
|
|
663
|
+
const {query, type, shown, total} = useStore();
|
|
248
664
|
const text = useMemo(() => {
|
|
249
665
|
if (!query) return `Showing ${shown} of ${total} items`;
|
|
250
|
-
return `Found ${shown} of ${total} in ${
|
|
666
|
+
return `Found ${shown} of ${total} in ${
|
|
667
|
+
type === "all" ? "all types" : type
|
|
668
|
+
} for "${query}"`;
|
|
251
669
|
}, [query, type, shown, total]);
|
|
252
670
|
return <div className="text-sm text-slate-600">{text}</div>;
|
|
253
671
|
}
|
|
@@ -255,35 +673,39 @@ function SummaryMount() {
|
|
|
255
673
|
function parseProps(el) {
|
|
256
674
|
try {
|
|
257
675
|
const s = el.querySelector('script[type="application/json"]');
|
|
258
|
-
if (s) return JSON.parse(s.textContent ||
|
|
676
|
+
if (s) return JSON.parse(s.textContent || "{}");
|
|
259
677
|
} catch (_) {}
|
|
260
678
|
return {};
|
|
261
679
|
}
|
|
262
680
|
|
|
263
681
|
function bindSearchInputToStore() {
|
|
264
|
-
if (!store || typeof document ===
|
|
682
|
+
if (!store || typeof document === "undefined") return;
|
|
265
683
|
try {
|
|
266
|
-
const input = document.querySelector(
|
|
267
|
-
if (!input || input.dataset.canopySearchSync ===
|
|
268
|
-
input.dataset.canopySearchSync =
|
|
684
|
+
const input = document.querySelector("[data-canopy-command-input]");
|
|
685
|
+
if (!input || input.dataset.canopySearchSync === "1") return;
|
|
686
|
+
input.dataset.canopySearchSync = "1";
|
|
269
687
|
|
|
270
688
|
const syncFromStore = () => {
|
|
271
689
|
try {
|
|
272
690
|
const snap = store.getSnapshot();
|
|
273
|
-
const nextVal =
|
|
691
|
+
const nextVal =
|
|
692
|
+
snap && typeof snap.query === "string" ? snap.query : "";
|
|
274
693
|
if (input.value !== nextVal) input.value = nextVal;
|
|
275
694
|
} catch (_) {}
|
|
276
695
|
};
|
|
277
696
|
|
|
278
697
|
const onInput = (event) => {
|
|
279
698
|
try {
|
|
280
|
-
const val =
|
|
699
|
+
const val =
|
|
700
|
+
event && event.target && typeof event.target.value === "string"
|
|
701
|
+
? event.target.value
|
|
702
|
+
: "";
|
|
281
703
|
const current = (() => {
|
|
282
704
|
try {
|
|
283
705
|
const snap = store.getSnapshot();
|
|
284
|
-
return snap && typeof snap.query ===
|
|
706
|
+
return snap && typeof snap.query === "string" ? snap.query : "";
|
|
285
707
|
} catch (_) {
|
|
286
|
-
return
|
|
708
|
+
return "";
|
|
287
709
|
}
|
|
288
710
|
})();
|
|
289
711
|
if (val === current) return;
|
|
@@ -291,15 +713,19 @@ function bindSearchInputToStore() {
|
|
|
291
713
|
} catch (_) {}
|
|
292
714
|
};
|
|
293
715
|
|
|
294
|
-
input.addEventListener(
|
|
716
|
+
input.addEventListener("input", onInput);
|
|
295
717
|
const unsubscribe = store.subscribe(syncFromStore);
|
|
296
718
|
syncFromStore();
|
|
297
719
|
|
|
298
720
|
const cleanup = () => {
|
|
299
|
-
try {
|
|
300
|
-
|
|
721
|
+
try {
|
|
722
|
+
input.removeEventListener("input", onInput);
|
|
723
|
+
} catch (_) {}
|
|
724
|
+
try {
|
|
725
|
+
if (typeof unsubscribe === "function") unsubscribe();
|
|
726
|
+
} catch (_) {}
|
|
301
727
|
};
|
|
302
|
-
window.addEventListener(
|
|
728
|
+
window.addEventListener("beforeunload", cleanup, {once: true});
|
|
303
729
|
} catch (_) {}
|
|
304
730
|
}
|
|
305
731
|
|
|
@@ -312,28 +738,39 @@ function mountAt(selector, Comp) {
|
|
|
312
738
|
root.render(<Comp {...props} />);
|
|
313
739
|
} catch (e) {
|
|
314
740
|
// Surface helpful diagnostics in dev
|
|
315
|
-
try {
|
|
741
|
+
try {
|
|
742
|
+
console.error(
|
|
743
|
+
"[Search] mount error at",
|
|
744
|
+
selector,
|
|
745
|
+
e && e.message ? e.message : e,
|
|
746
|
+
e && e.stack ? e.stack : ""
|
|
747
|
+
);
|
|
748
|
+
} catch (_) {}
|
|
316
749
|
}
|
|
317
750
|
});
|
|
318
751
|
}
|
|
319
752
|
|
|
320
|
-
if (typeof document !==
|
|
753
|
+
if (typeof document !== "undefined") {
|
|
321
754
|
const run = () => {
|
|
322
755
|
// Mount tabs and other search UI pieces
|
|
323
|
-
mountAt(
|
|
324
|
-
mountAt(
|
|
325
|
-
mountAt(
|
|
756
|
+
mountAt("[data-canopy-search-tabs]", TabsMount);
|
|
757
|
+
mountAt("[data-canopy-search-results]", ResultsMount);
|
|
758
|
+
mountAt("[data-canopy-search-summary]", SummaryMount);
|
|
326
759
|
bindSearchInputToStore();
|
|
327
760
|
// Total mount removed
|
|
328
761
|
try {
|
|
329
|
-
window.addEventListener(
|
|
762
|
+
window.addEventListener("canopy:search:setQuery", (ev) => {
|
|
330
763
|
try {
|
|
331
|
-
const q =
|
|
332
|
-
|
|
764
|
+
const q =
|
|
765
|
+
ev && ev.detail && typeof ev.detail.query === "string"
|
|
766
|
+
? ev.detail.query
|
|
767
|
+
: document.querySelector("[data-canopy-command-input]")?.value ||
|
|
768
|
+
"";
|
|
769
|
+
if (typeof q === "string") store.setQuery(q);
|
|
333
770
|
} catch (_) {}
|
|
334
771
|
});
|
|
335
772
|
} catch (_) {}
|
|
336
773
|
};
|
|
337
|
-
if (document.readyState !==
|
|
338
|
-
else document.addEventListener(
|
|
774
|
+
if (document.readyState !== "loading") run();
|
|
775
|
+
else document.addEventListener("DOMContentLoaded", run, {once: true});
|
|
339
776
|
}
|