Files
vrcnext-club-security/src/filters.ts
T

86 lines
3.3 KiB
TypeScript

/**
* Decides whether the current instance is one the user wants reports for.
*/
import type { SettingsValues } from '@vrcnext/plugin-api';
import { groupIdOf } from './vrcnext-data.js';
import type { Settings } from './settings.js';
/** The three things a filter looks at; both a live instance and a past location provide them. */
export interface InstanceShape {
readonly worldId: string;
readonly instanceType: string;
readonly groupId: string;
}
/**
* VRCNext's `ParseLocation`, ported: the instance type a location string encodes. Needed for
* past locations from the timeline, which carry no parsed type.
*/
export function instanceTypeOf(location: string): string {
const instance = location.split(':')[1] ?? '';
if (instance.includes('~private(')) return instance.includes('~canRequestInvite') ? 'invite_plus' : 'private';
if (instance.includes('~friends+(')) return 'friends+';
if (instance.includes('~friends(')) return 'friends';
if (instance.includes('~hidden(')) return 'hidden';
if (instance.includes('~group(')) {
const access = /groupAccessType\(([^)]+)\)/.exec(instance)?.[1] ?? '';
if (access === 'public') return 'group-public';
if (access === 'plus') return 'group-plus';
if (access === 'members') return 'group-members';
return 'group';
}
return 'public';
}
export function shapeOfLocation(location: string): InstanceShape {
const worldId = location.split(':')[0] ?? '';
return { worldId: worldId.startsWith('wrld_') ? worldId : '', instanceType: instanceTypeOf(location), groupId: groupIdOf(location) };
}
export interface InstanceFilter {
readonly instanceTypes: readonly string[];
readonly groupId: string;
readonly worldIds: readonly string[];
}
export function splitList(value: string): readonly string[] {
return value
.split(/[,\s]+/)
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length > 0);
}
export function filterFrom(values: SettingsValues<Settings>): InstanceFilter {
return {
instanceTypes: splitList(values.instanceTypes),
groupId: values.groupId.trim().toLowerCase(),
worldIds: splitList(values.worldIds),
};
}
function typeMatches(wanted: readonly string[], actual: string): boolean {
if (wanted.length === 0) return true;
const type = actual.toLowerCase();
return wanted.some((w) => w === type || (w === 'group' && type.startsWith('group')));
}
export function instanceMatches(filter: InstanceFilter, instance: InstanceShape): boolean {
if (!typeMatches(filter.instanceTypes, instance.instanceType)) return false;
const groupId = filter.groupId.toLowerCase();
if (groupId !== '' && instance.groupId.toLowerCase() !== groupId) return false;
const worldIds = filter.worldIds.map((w) => w.toLowerCase());
if (worldIds.length > 0 && !worldIds.includes(instance.worldId.toLowerCase())) return false;
return true;
}
/** One line for the status card: what the filter currently restricts to. */
export function describeFilter(filter: InstanceFilter): string {
const parts: string[] = [];
if (filter.instanceTypes.length > 0) parts.push(`types: ${filter.instanceTypes.join(', ')}`);
if (filter.groupId !== '') parts.push(`group: ${filter.groupId}`);
if (filter.worldIds.length > 0) parts.push(`worlds: ${String(filter.worldIds.length)}`);
return parts.length === 0 ? 'every instance' : parts.join(' · ');
}