Files
VRCX/src/stores/quickSearchWorker.js

373 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Web Worker for search operations.
*
* Offloads CPU-heavy confusable-character normalization and
* locale-aware string search from the main thread.
*
* Protocol — Main → Worker:
* { type: 'updateIndex', payload: { friends, avatars, worlds, groups, favAvatars, favWorlds } }
* { type: 'search', payload: { seq, query, currentUserId, language } }
*
* Protocol — Worker → Main:
* { type: 'searchResult', payload: { seq, friends, ownAvatars, favAvatars, ownWorlds, favWorlds, ownGroups, joinedGroups } }
*/
// ── Confusables (inlined for Worker isolation) ──────────────────────
// We inline the minimal confusables logic to avoid importing from a
// module that might rely on non-Worker-safe globals.
const charToConfusables = new Map([
[' ', ' '],
['0', '⓿'],
['1', '11⓵➊⑴¹𝟏𝟙𝟷𝟣𝟭1➀₁①❶⥠'],
['2', '⓶⒉⑵➋ƻ²ᒿ𝟚2𝟮𝟤ᒾ𝟸Ƨ𝟐②ᴤ₂➁❷ᘝƨ'],
['3', '³ȝჳⳌꞫ𝟑ℨ𝟛𝟯𝟥Ꝫ➌ЗȜ⓷ӠƷ3𝟹⑶⒊ʒʓǯǮƺ𝕴ᶾзᦡ➂③₃ᶚᴣᴟ❸ҘҙӬӡӭӟӞ'],
['4', '𝟰𝟺𝟦𝟒➍ҶᏎ𝟜ҷ⓸ҸҹӴӵᶣ4чㄩ⁴➃₄④❹Ӌ⑷⒋'],
['5', '𝟱⓹➎Ƽ𝟓𝟻𝟝𝟧5➄₅⑤⁵❺ƽ⑸⒌'],
['6', 'Ⳓ🄇𝟼Ꮾ𝟲𝟞𝟨𝟔➏⓺Ϭϭ⁶б6ᧈ⑥➅₆❻⑹⒍'],
['7', '𝟕𝟟𝟩𝟳𝟽🄈⓻𐓒➐7⁷⑦₇❼➆⑺⒎'],
['8', '𐌚🄉➑⓼8𝟠𝟪৪⁸₈𝟴➇⑧❽𝟾𝟖⑻⒏'],
['9', '൭Ꝯ𝝑𝞋𝟅🄊𝟡𝟵Ⳋ⓽➒੧৭୨9𝟫𝟿𝟗⁹₉Գ➈⑨❾⑼⒐'],
['a', '∂⍺ⓐ'],
['b', 'ꮟᏏ'],
['c', '🝌'],
['d', ''],
['e', 'əәⅇꬲ'],
['f', ''],
['g', 'ᶃᶢⓖ'],
['h', 'ꞕ৸'],
['i', ''],
['j', 'ꭻⅉⓙ'],
['k', 'ⓚꝁ'],
['l', 'ⓛ'],
['m', '₥ᵯ'],
['n', 'ոռח'],
['o', ''],
['p', 'ⲣҏ℗ⓟ'],
['q', 'ꝗ'],
['r', 'ⓡ'],
['s', 'ᣵⓢꜱ'],
['t', 'ⓣ'],
['u', 'ⓤ'],
['v', '∨⌄⋁ⅴ'],
['w', 'ⓦ'],
['x', ''],
['y', 'ʏỿ'],
['z', 'ⓩꮓ']
]);
const confusablesToChar = new Map();
for (const [char_, confusables] of charToConfusables) {
for (const confusable of confusables) {
confusablesToChar.set(confusable, char_);
}
}
const nonConfusables = /^[!-~]*$/;
const regexLineBreakCombiningMarks =
/[\0-\x08\x0E-\x1F\x7F-\x84\x86-\x9F\u0300-\u034E\u0350-\u035B\u0363-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u061C\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08D4-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C03\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D01-\u0D03\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F7E\u0F80-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u180B-\u180D\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A7F\u1AB0-\u1ABE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1CF8\u1CF9\u1DC0-\u1DF5\u1DFB-\u1DFF\u200C\u200E\u200F\u202A-\u202E\u2066-\u206F\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3035\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F\uFFF9-\uFFFB]/g;
const regexSymbolWithCombiningMarks =
/([\0-\u02FF\u0370-\u1AAF\u1B00-\u1DBF\u1E00-\u20CF\u2100-\uD7FF\uE000-\uFE1F\uFE30-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF])([\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]+)/g;
function removeConfusables(a) {
if (nonConfusables.test(a)) {
return a;
}
let ret = '';
for (const char_ of a
.normalize()
.replace(regexLineBreakCombiningMarks, '')
.replace(regexSymbolWithCombiningMarks, '$1')
.replace(/\s/g, '')) {
ret += confusablesToChar.get(char_) || char_;
}
return ret;
}
function removeWhitespace(a) {
return a.replace(/\s/g, '');
}
function localeIncludes(str, search, comparer) {
if (search === '') return true;
if (!str || !search) return false;
const strObj = String(str);
const searchObj = String(search);
if (strObj.length === 0) return false;
if (searchObj.length > strObj.length) return false;
for (let i = 0; i < str.length - searchObj.length + 1; i++) {
const substr = strObj.substring(i, i + searchObj.length);
if (comparer.compare(substr, searchObj) === 0) return true;
}
return false;
}
function matchName(name, cleanQuery, comparer, normalizedName) {
if (!name || !cleanQuery) return false;
const cleanName = normalizedName || removeConfusables(name);
if (localeIncludes(cleanName, cleanQuery, comparer)) return true;
return localeIncludes(name, cleanQuery, comparer);
}
function isPrefixMatch(name, cleanQuery, comparer) {
if (!name || !cleanQuery) return false;
return (
comparer.compare(name.substring(0, cleanQuery.length), cleanQuery) === 0
);
}
let indexedFriends = []; // { id, name, memo, note, imageUrl }
let indexedAvatars = []; // { id, name, authorId, imageUrl }
let indexedWorlds = []; // { id, name, authorId, imageUrl }
let indexedGroups = []; // { id, name, ownerId, imageUrl }
let indexedFavAvatars = []; // { id, name, imageUrl }
let indexedFavWorlds = []; // { id, name, imageUrl }
/**
* Update the search index with fresh data snapshots.
* Pre-computes normalized names to avoid per-search confusables overhead.
* @param payload
*/
function updateIndex(payload) {
if (payload.friends) {
indexedFriends = payload.friends;
for (const f of indexedFriends) {
f._normalized = f.name ? removeConfusables(f.name) : '';
}
}
if (payload.avatars) {
indexedAvatars = payload.avatars;
for (const a of indexedAvatars) {
a._normalized = a.name ? removeConfusables(a.name) : '';
}
}
if (payload.worlds) {
indexedWorlds = payload.worlds;
for (const w of indexedWorlds) {
w._normalized = w.name ? removeConfusables(w.name) : '';
}
}
if (payload.groups) {
indexedGroups = payload.groups;
for (const g of indexedGroups) {
g._normalized = g.name ? removeConfusables(g.name) : '';
}
}
if (payload.favAvatars) {
indexedFavAvatars = payload.favAvatars;
for (const a of indexedFavAvatars) {
a._normalized = a.name ? removeConfusables(a.name) : '';
}
}
if (payload.favWorlds) {
indexedFavWorlds = payload.favWorlds;
for (const w of indexedFavWorlds) {
w._normalized = w.name ? removeConfusables(w.name) : '';
}
}
}
// ── Search functions ────────────────────────────────────────────────
function searchFriends(query, cleanQuery, comparer, limit = 10) {
const results = [];
for (const ctx of indexedFriends) {
let match = matchName(ctx.name, cleanQuery, comparer, ctx._normalized);
let matchedField = match ? 'name' : null;
if (!match && ctx.memo && query.length >= 2) {
match = localeIncludes(ctx.memo, query, comparer);
if (match) matchedField = 'memo';
}
if (!match && ctx.note && query.length >= 2) {
match = localeIncludes(ctx.note, query, comparer);
if (match) matchedField = 'note';
}
if (match) {
results.push({
id: ctx.id,
name: ctx.name,
type: 'friend',
imageUrl: ctx.imageUrl,
memo: ctx.memo || '',
note: ctx.note || '',
matchedField
});
}
}
// Pre-compute prefix flags to avoid repeated Collator calls in sort
for (const r of results) {
r._isPrefix = isPrefixMatch(r.name, cleanQuery, comparer);
}
results.sort((a, b) => {
if (a._isPrefix && !b._isPrefix) return -1;
if (b._isPrefix && !a._isPrefix) return 1;
return comparer.compare(a.name, b.name);
});
if (results.length > limit) results.length = limit;
// Clean up internal sort field before returning
for (const r of results) {
delete r._isPrefix;
}
return results;
}
function searchItems(
cleanQuery,
items,
type,
comparer,
ownerKey,
ownerId,
limit = 10
) {
const results = [];
for (const ref of items) {
if (!ref || !ref.name) continue;
if (ownerId && ref[ownerKey] !== ownerId) continue;
if (matchName(ref.name, cleanQuery, comparer, ref._normalized)) {
results.push({
id: ref.id,
name: ref.name,
type,
imageUrl: ref.imageUrl
});
}
}
// Pre-compute prefix flags to avoid repeated Collator calls in sort
for (const r of results) {
r._isPrefix = isPrefixMatch(r.name, cleanQuery, comparer);
}
results.sort((a, b) => {
if (a._isPrefix && !b._isPrefix) return -1;
if (b._isPrefix && !a._isPrefix) return 1;
return comparer.compare(a.name, b.name);
});
if (results.length > limit) results.length = limit;
// Clean up internal sort field before returning
for (const r of results) {
delete r._isPrefix;
}
return results;
}
function handleSearch(payload) {
const { seq, query, currentUserId, language } = payload;
if (!query || query.length < 1) {
self.postMessage({
type: 'searchResult',
payload: {
seq,
friends: [],
ownAvatars: [],
favAvatars: [],
ownWorlds: [],
favWorlds: [],
ownGroups: [],
joinedGroups: []
}
});
return;
}
const comparer = new Intl.Collator((language || 'en').replace('_', '-'), {
usage: 'search',
sensitivity: 'base'
});
// Pre-compute cleaned query once for all name searches
const cleanQuery = removeWhitespace(query);
const friends = searchFriends(query, cleanQuery, comparer);
const ownAvatars = searchItems(
cleanQuery,
indexedAvatars,
'avatar',
comparer,
'authorId',
currentUserId
);
const favAvatars = searchItems(
cleanQuery,
indexedFavAvatars,
'avatar',
comparer,
null,
null
);
const ownWorlds = searchItems(
cleanQuery,
indexedWorlds,
'world',
comparer,
'authorId',
currentUserId
);
const favWorlds = searchItems(
cleanQuery,
indexedFavWorlds,
'world',
comparer,
null,
null
);
const ownGroups = searchItems(
cleanQuery,
indexedGroups,
'group',
comparer,
'ownerId',
currentUserId
);
const joinedGroups = searchItems(
cleanQuery,
indexedGroups,
'group',
comparer,
null,
null
);
// Deduplicate favorites against own
const ownAvatarIds = new Set(ownAvatars.map((r) => r.id));
const dedupedFavAvatars = favAvatars.filter((r) => !ownAvatarIds.has(r.id));
const ownWorldIds = new Set(ownWorlds.map((r) => r.id));
const dedupedFavWorlds = favWorlds.filter((r) => !ownWorldIds.has(r.id));
const ownGroupIds = new Set(ownGroups.map((r) => r.id));
const dedupedJoinedGroups = joinedGroups.filter(
(r) => !ownGroupIds.has(r.id)
);
self.postMessage({
type: 'searchResult',
payload: {
seq,
friends,
ownAvatars,
favAvatars: dedupedFavAvatars,
ownWorlds,
favWorlds: dedupedFavWorlds,
ownGroups,
joinedGroups: dedupedJoinedGroups
}
});
}
self.addEventListener('message', (event) => {
const { type, payload } = event.data;
switch (type) {
case 'updateIndex':
updateIndex(payload);
break;
case 'search':
handleSearch(payload);
break;
default:
console.warn('[SearchWorker] Unknown message type:', type);
}
});