mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
174 lines
6.3 KiB
JavaScript
174 lines
6.3 KiB
JavaScript
// Operator Command Resolvers
|
|
// Purpose: Provides shared selector parsing and fuzzy matching for command handlers.
|
|
// Scope: Keeps potentially destructive commands from each inventing their own lookup rules.
|
|
const Fuse = require('fuse.js');
|
|
const { normalizeIp } = require('../../../helpers/ipResolver');
|
|
|
|
const FUZZY_THRESHOLD = 0.38;
|
|
const AMBIGUOUS_SCORE_GAP = 0.08;
|
|
const MAX_SUGGESTIONS = 5;
|
|
|
|
function normalizeText(value) {
|
|
return String(value || '').trim();
|
|
}
|
|
|
|
function normalizeSearchText(value) {
|
|
return normalizeText(value).toLowerCase().replace(/\s+/g, ' ');
|
|
}
|
|
|
|
function compactJoin(parts, separator = ', ') {
|
|
return (Array.isArray(parts) ? parts : []).map(normalizeText).filter(Boolean).join(separator);
|
|
}
|
|
|
|
function uniqueBy(items, getKey) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
(Array.isArray(items) ? items : []).forEach((item) => {
|
|
const key = getKey(item);
|
|
if (!key || seen.has(key)) return;
|
|
seen.add(key);
|
|
out.push(item);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function buildResultError(kind, label, candidates = []) {
|
|
const suggestions = uniqueBy(candidates, (entry) => entry.key).slice(0, MAX_SUGGESTIONS);
|
|
const suffix = suggestions.length
|
|
? ` Suggestions: ${compactJoin(suggestions.map((entry) => entry.label || entry.key))}.`
|
|
: '';
|
|
if (kind === 'ambiguous') return `${label} matched multiple records.${suffix}`;
|
|
return `${label} not found.${suffix}`;
|
|
}
|
|
|
|
function resolveRoverSelector(selector, rovers) {
|
|
const query = normalizeText(selector);
|
|
if (!query) return { error: 'Specify a rover.' };
|
|
|
|
const candidates = Array.from(rovers.values()).map((record) => {
|
|
const id = normalizeText(record?.id);
|
|
const name = normalizeText(record?.meta?.name || record?.name || id);
|
|
return {
|
|
key: id,
|
|
id,
|
|
label: name,
|
|
record,
|
|
searchId: normalizeSearchText(id),
|
|
searchLabel: normalizeSearchText(name),
|
|
};
|
|
}).filter((entry) => entry.id);
|
|
|
|
const normalized = normalizeSearchText(query);
|
|
const exact = candidates.filter((entry) => entry.searchId === normalized || entry.searchLabel === normalized);
|
|
if (exact.length === 1) return { record: exact[0].record, id: exact[0].id, label: exact[0].label };
|
|
if (exact.length > 1) return { error: buildResultError('ambiguous', 'Rover', exact) };
|
|
|
|
const fuse = new Fuse(candidates, {
|
|
includeScore: true,
|
|
threshold: FUZZY_THRESHOLD,
|
|
ignoreLocation: true,
|
|
keys: [
|
|
{ name: 'label', weight: 0.65 },
|
|
{ name: 'id', weight: 0.35 },
|
|
],
|
|
});
|
|
const results = fuse.search(query);
|
|
if (!results.length) return { error: buildResultError('not_found', 'Rover', candidates) };
|
|
|
|
const first = results[0];
|
|
const second = results[1];
|
|
// Fuzzy matches are allowed for convenience, but destructive commands should
|
|
// not act when two targets are similarly plausible. The score gap keeps typo
|
|
// tolerance without turning near-ties into accidental locks or removals.
|
|
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < AMBIGUOUS_SCORE_GAP) {
|
|
return { error: buildResultError('ambiguous', 'Rover', results.map((entry) => entry.item)) };
|
|
}
|
|
return { record: first.item.record, id: first.item.id, label: first.item.label };
|
|
}
|
|
|
|
function createIdentityCandidates(records = [], { includeId = true } = {}) {
|
|
return (Array.isArray(records) ? records : []).map((record) => {
|
|
const id = normalizeText(record?.id);
|
|
const userId = normalizeText(record?.userId);
|
|
const cookieUserId = normalizeText(record?.cookieUserId);
|
|
const fingerprintId = normalizeText(record?.fingerprintId);
|
|
const nickname = normalizeText(record?.nickname);
|
|
const knownIps = Array.isArray(record?.knownIps) ? record.knownIps.map(normalizeText).filter(Boolean) : [];
|
|
return {
|
|
key: userId || id || cookieUserId || fingerprintId || nickname,
|
|
id,
|
|
userId,
|
|
cookieUserId,
|
|
fingerprintId,
|
|
nickname,
|
|
knownIps,
|
|
label: compactJoin([nickname || 'unknown', includeId && (userId || id) ? (userId || id) : '', cookieUserId ? mask(cookieUserId) : '']),
|
|
record,
|
|
searchId: normalizeSearchText(id),
|
|
searchUserId: normalizeSearchText(userId),
|
|
searchCookie: normalizeSearchText(cookieUserId),
|
|
searchFingerprint: normalizeSearchText(fingerprintId),
|
|
searchNickname: normalizeSearchText(nickname),
|
|
searchIps: knownIps.map(normalizeSearchText),
|
|
};
|
|
}).filter((entry) => entry.key);
|
|
}
|
|
|
|
function resolveIdentitySelector(selector, records = [], options = {}) {
|
|
const query = normalizeText(selector);
|
|
if (!query) return { error: 'Selector required.' };
|
|
|
|
const candidates = createIdentityCandidates(records, options);
|
|
const normalized = normalizeSearchText(query);
|
|
const ip = normalizeIp(query);
|
|
|
|
const exact = candidates.filter((entry) => (
|
|
entry.searchId === normalized ||
|
|
entry.searchUserId === normalized ||
|
|
entry.searchCookie === normalized ||
|
|
entry.searchFingerprint === normalized ||
|
|
entry.searchNickname === normalized ||
|
|
(ip && entry.searchIps.includes(normalizeSearchText(ip)))
|
|
));
|
|
if (exact.length === 1) return { record: exact[0].record, label: exact[0].label };
|
|
if (exact.length > 1) return { error: buildResultError('ambiguous', 'Selector', exact) };
|
|
|
|
const fuse = new Fuse(candidates, {
|
|
includeScore: true,
|
|
threshold: FUZZY_THRESHOLD,
|
|
ignoreLocation: true,
|
|
keys: [
|
|
{ name: 'nickname', weight: 0.78 },
|
|
{ name: 'cookieUserId', weight: 0.12 },
|
|
{ name: 'fingerprintId', weight: 0.08 },
|
|
{ name: 'id', weight: 0.08 },
|
|
{ name: 'knownIps', weight: 0.02 },
|
|
],
|
|
});
|
|
const results = fuse.search(query);
|
|
if (!results.length) return { error: buildResultError('not_found', 'Selector', candidates) };
|
|
|
|
const first = results[0];
|
|
const second = results[1];
|
|
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < AMBIGUOUS_SCORE_GAP) {
|
|
return { error: buildResultError('ambiguous', 'Selector', results.map((entry) => entry.item)) };
|
|
}
|
|
return { record: first.item.record, label: first.item.label };
|
|
}
|
|
|
|
function mask(v) {
|
|
const key = normalizeText(v);
|
|
if (!key) return 'n/a';
|
|
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
|
|
return `${key.slice(0, 6)}...${key.slice(-6)}`;
|
|
}
|
|
|
|
module.exports = {
|
|
compactJoin,
|
|
mask,
|
|
normalizeText,
|
|
normalizeSearchText,
|
|
resolveIdentitySelector,
|
|
resolveRoverSelector,
|
|
};
|