mirror of
https://github.com/vrcx-team/VRCX.git
synced 2026-04-06 00:32:02 +02:00
373 lines
13 KiB
JavaScript
373 lines
13 KiB
JavaScript
/**
|
||
* 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𝟷𝟣⒈𝟭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);
|
||
}
|
||
});
|