Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 215 additions & 0 deletions src/lib/components/search-filter.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import Plus from "@lucide/svelte/icons/plus";
import Trash2 from "@lucide/svelte/icons/trash-2";
import type { SearchCriterion, SearchFilter } from "$lib/twitch/logs";

type CriterionType = "regex" | "text" | "user" | "channel" | "badge";

interface BadgeOption {
id: string;
title: string;
imageUrl: string;
}

interface Props {
filter?: SearchFilter;
availableBadges?: BadgeOption[];
onFilterChange?: (filter: SearchFilter) => void;
}

let { filter = { criteria: [] }, availableBadges = [], onFilterChange }: Props = $props();

let criteria: SearchCriterion[] = $state([...filter.criteria]);

const criterionTypeLabels: Record<CriterionType, string> = {
regex: "Regex",
text: "Text",
user: "User",
channel: "Channel",
badge: "Badge",
};

const addCriterion = (type: CriterionType = "text") => {
const nextId = Math.max(...criteria.map((c) => parseInt(c.id)), -1) + 1;
const newCriterion: SearchCriterion = {
id: String(nextId),
type,
value: "",
operator: criteria.length > 0 ? "AND" : undefined,
};
criteria = [...criteria, newCriterion];
emitChange();
};

const removeCriterion = (id: string) => {
criteria = criteria.filter((c) => c.id !== id);
if (criteria.length > 0) {
criteria[0].operator = undefined; // First criterion doesn't have an operator
}
emitChange();
};

const updateCriterion = (id: string, updates: Partial<SearchCriterion>) => {
const index = criteria.findIndex((c) => c.id === id);
if (index !== -1) {
criteria[index] = { ...criteria[index], ...updates };
emitChange();
}
};

const updateCriterionType = (id: string, type: CriterionType) => {
const index = criteria.findIndex((c) => c.id === id);
if (index !== -1) {
criteria[index] = {
...criteria[index],
type,
};
emitChange();
}
};

const emitChange = () => {
onFilterChange?.({ criteria });
};

const handleKeydown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
addCriterion();
}
};
</script>

<div class="space-y-3 rounded-lg border border-border bg-background p-3">
<div class="flex items-center justify-between">
<Label class="text-sm font-semibold">Search Filters</Label>
<Button
size="sm"
variant="outline"
onclick={() => addCriterion()}
onkeydown={handleKeydown}
title="Add criterion (Ctrl+Enter)"
class="h-7"
>
<Plus class="mr-1 size-3" />
Add Filter
</Button>
</div>

<div class="space-y-2">
{#each criteria as criterion, index (criterion.id)}
<div class="flex items-center gap-2">
{#if index > 0}
<div class="w-12">
<Select.Root
type="single"
value={criterion.operator || "AND"}
onValueChange={(v) => updateCriterion(criterion.id, { operator: v as "AND" | "OR" })}
>
<Select.Trigger class="h-8 w-full border">
<span class="text-xs">{criterion.operator || "AND"}</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="AND" label="AND" />
<Select.Item value="OR" label="OR" />
</Select.Content>
</Select.Root>
</div>
{:else}
<div class="w-12"></div>
{/if}

<Select.Root
type="single"
value={criterion.type}
onValueChange={(v) => updateCriterionType(criterion.id, v as CriterionType)}
>
<Select.Trigger class="h-8 w-24 border">
{criterionTypeLabels[criterion.type]}
</Select.Trigger>
<Select.Content>
<Select.Item value="regex" label="Regex" />
<Select.Item value="text" label="Text" />
<Select.Item value="user" label="User" />
<Select.Item value="channel" label="Channel" />
<Select.Item value="badge" label="Badge" />
</Select.Content>
</Select.Root>

<Select.Root
type="single"
value={criterion.negate ? "NOT" : ""}
onValueChange={(v) => updateCriterion(criterion.id, { negate: v === "NOT" })}
>
<Select.Trigger class="h-8 w-16 border text-xs">
{criterion.negate ? "NOT" : ""}
</Select.Trigger>
<Select.Content>
<Select.Item value="" label="" />
<Select.Item value="NOT" label="NOT" />
</Select.Content>
</Select.Root>

{#if criterion.type === "badge" && availableBadges.length > 0}
<div class="relative flex-1">
<Select.Root
type="multiple"
value={criterion.value ? criterion.value.split(',').filter(Boolean) : []}
onValueChange={(v: string[]) => updateCriterion(criterion.id, { value: v.join(',') })}
>
<Select.Trigger class="h-8 flex-1 border">
<div class="flex items-center gap-1 flex-wrap">
{#each (criterion.value ? criterion.value.split(',').filter(Boolean) : []) as badgeId (badgeId)}
{@const badge = availableBadges.find(b => b.id === badgeId)}
{#if badge}
<img src={badge.imageUrl} alt={badge.title} class="h-4 w-4" />
<span class="text-xs truncate">{badge.title}</span>
{/if}
{/each}
{#if !criterion.value || criterion.value.split(',').filter(Boolean).length === 0}
<span class="text-muted-foreground">Select badges...</span>
{/if}
</div>
</Select.Trigger>
<Select.Content class="max-h-[300px] overflow-y-auto">
{#each availableBadges as badge (badge.id)}
<Select.Item value={badge.id} label={badge.title}>
<div class="flex items-center gap-2">
<img src={badge.imageUrl} alt={badge.title} class="h-4 w-4" />
<span>{badge.title}</span>
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{:else}
<Input
bind:value={criterion.value}
onchange={() => emitChange()}
placeholder={criterion.type === "badge" ? "e.g., moderator,subscriber" : "Enter value"}
class="h-8 flex-1 border"
onkeydown={handleKeydown}
/>
{/if}

<Button
size="icon"
variant="ghost"
onclick={() => removeCriterion(criterion.id)}
title="Remove criterion"
class="h-8 w-8 border"
>
<Trash2 class="size-4" />
</Button>
</div>
{/each}
</div>

{#if criteria.length === 0}
<p class="py-2 text-center text-sm text-muted-foreground">No filters added. Click "Add Filter" to get started.</p>
{/if}
</div>
136 changes: 135 additions & 1 deletion src/lib/twitch/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,30 @@ export type BadgeProps = {
title: string;
};

export type SearchCriterion = {
id: string;
type: "regex" | "text" | "user" | "channel" | "badge";
value: string;
operator?: "AND" | "OR";
negate?: boolean;
};

export type SearchFilter = {
criteria: SearchCriterion[];
};

export const serializeFilter = (filter: SearchFilter): string => {
return btoa(JSON.stringify(filter));
};

export const deserializeFilter = (encoded: string): SearchFilter => {
try {
return JSON.parse(atob(encoded));
} catch {
return { criteria: [] };
}
};

const searchPrefixes: Record<string, (searchString: string, chatLogs: Message[]) => Message[]> = {
regex(searchString, chatLogs) {
try {
Expand Down Expand Up @@ -63,13 +87,123 @@ const searchPrefixes: Record<string, (searchString: string, chatLogs: Message[])

type SearchPrefixKey = keyof typeof searchPrefixes;

/**
* Evaluates a single search criterion against a message.
* @param criterion The search criterion to evaluate.
* @param message The message to test against.
* @returns True if the criterion matches, false otherwise.
*/
const evaluateCriterion = (criterion: SearchCriterion, message: Message): boolean => {
const evaluators: Record<SearchCriterion["type"], (value: string, msg: Message) => boolean> = {
regex: (value, msg) => {
try {
const regex = new RegExp(value, "i");
return regex.test(msg.text);
} catch {
return false;
}
},
text: (value, msg) => msg.text.toLowerCase().includes(value.toLowerCase()),
user: (value, msg) => {
const users = value.toLowerCase().split(",").map((u) => u.trim());
return users.includes(msg.displayName.toLowerCase());
},
channel: (value, msg) => {
const channels = value.toLowerCase().split(",").map((c) => c.trim());
return channels.includes(msg.channel?.toLowerCase() ?? "");
},
badge: (value, msg) => {
const badges = value.toLowerCase().split(",").map((b) => b.trim());
const messageBadges = msg.tags["badges"]?.split(",").map((b) => b.split("/")[0].toLowerCase()) ?? [];
return badges.some((badge) => messageBadges.includes(badge));
},
};

const evaluator = evaluators[criterion.type];
if (!evaluator) return false;

const result = evaluator(criterion.value, message);
return criterion.negate ? !result : result;
};

/**
* Groups search criteria into OR groups based on their operators.
* Criteria are grouped where consecutive ANDs are in the same group, and OR starts a new group.
* @param criteria The list of search criteria.
* @returns An array of criterion groups, where each group should be ANDed together, and groups ORed.
*/
const groupCriteriaByOr = (criteria: SearchCriterion[]): SearchCriterion[][] => {
const groups: SearchCriterion[][] = [];
for (const criterion of criteria) {
if (groups.length === 0 || criterion.operator === "OR") {
groups.push([criterion]);
} else {
groups[groups.length - 1].push(criterion);
}
}
return groups;
};

/**
* Evaluates whether a message matches the given search filter.
* The filter uses AND/OR logic: criteria are grouped by OR operators, with AND within groups.
* @param filter The search filter containing criteria.
* @param message The message to evaluate.
* @returns True if the message matches the filter, false otherwise.
*/
const messageMatchesFilter = (filter: SearchFilter, message: Message): boolean => {
if (!filter.criteria.length) return true;

const orGroups = groupCriteriaByOr(filter.criteria);

// Evaluate each OR group (AND within groups)
for (const group of orGroups) {
let groupMatches = true;
for (const criterion of group) {
if (!evaluateCriterion(criterion, message)) {
groupMatches = false;
break;
}
}
if (groupMatches) return true;
}

return false;
};

/**
* Performs advanced message search using a structured filter.
* @param filter The search filter with criteria.
* @param chatLogs The array of messages to search.
* @param scrollFromBottom Whether to reverse the order for bottom-scrolling.
* @returns The filtered array of messages.
*/
export const advancedMessageSearch = (filter: SearchFilter, chatLogs: Message[], scrollFromBottom: boolean | null): Message[] => {
if (!filter.criteria.length) {
return scrollFromBottom === false ? [...chatLogs].reverse() : chatLogs;
}

const results = chatLogs.filter((msg) => messageMatchesFilter(filter, msg));
return scrollFromBottom === false ? [...results].reverse() : results;
};

/**
* Performs basic message search using prefixes or fuzzy search.
* @param searchValue The search string.
* @param chatLogs The array of messages to search.
* @param scrollFromBottom Whether to reverse the order for bottom-scrolling.
* @returns The filtered array of messages.
*/
export const messageSearch = (searchValue: string, chatLogs: Message[], scrollFromBottom: boolean | null): Message[] => {
const searchKey = searchValue.split(":", 1)[0].toLowerCase();
const searchString = searchValue.slice(searchKey.length + 1);

if (searchKey in searchPrefixes && searchString) {
chatLogs = searchPrefixes[searchKey as SearchPrefixKey](searchString, chatLogs);
} else if (searchValue) {
const searchOptions = scrollFromBottom === null ? { keys: ["channel", "displayName", "text"], threshold: 0.5 } : { keys: ["displayName", "text"], threshold: 0.5, limit: 5000 };
const searchOptions = scrollFromBottom === null
? { keys: ["channel", "displayName", "text"], threshold: 0.5 }
: { keys: ["displayName", "text"], threshold: 0.5, limit: 5000 };

chatLogs = fuzzysort
.go(searchValue, chatLogs, searchOptions)
Expand Down
Loading
Loading