diff --git a/src/lib/components/search-filter.svelte b/src/lib/components/search-filter.svelte new file mode 100644 index 0000000..8614b53 --- /dev/null +++ b/src/lib/components/search-filter.svelte @@ -0,0 +1,215 @@ + + +
+
+ + +
+ +
+ {#each criteria as criterion, index (criterion.id)} +
+ {#if index > 0} +
+ updateCriterion(criterion.id, { operator: v as "AND" | "OR" })} + > + + {criterion.operator || "AND"} + + + + + + +
+ {:else} +
+ {/if} + + updateCriterionType(criterion.id, v as CriterionType)} + > + + {criterionTypeLabels[criterion.type]} + + + + + + + + + + + updateCriterion(criterion.id, { negate: v === "NOT" })} + > + + {criterion.negate ? "NOT" : ""} + + + + + + + + {#if criterion.type === "badge" && availableBadges.length > 0} +
+ updateCriterion(criterion.id, { value: v.join(',') })} + > + +
+ {#each (criterion.value ? criterion.value.split(',').filter(Boolean) : []) as badgeId (badgeId)} + {@const badge = availableBadges.find(b => b.id === badgeId)} + {#if badge} + {badge.title} + {badge.title} + {/if} + {/each} + {#if !criterion.value || criterion.value.split(',').filter(Boolean).length === 0} + Select badges... + {/if} +
+
+ + {#each availableBadges as badge (badge.id)} + +
+ {badge.title} + {badge.title} +
+
+ {/each} +
+
+
+ {:else} + emitChange()} + placeholder={criterion.type === "badge" ? "e.g., moderator,subscriber" : "Enter value"} + class="h-8 flex-1 border" + onkeydown={handleKeydown} + /> + {/if} + + +
+ {/each} +
+ + {#if criteria.length === 0} +

No filters added. Click "Add Filter" to get started.

+ {/if} +
diff --git a/src/lib/twitch/logs.ts b/src/lib/twitch/logs.ts index d857a83..579fbf7 100644 --- a/src/lib/twitch/logs.ts +++ b/src/lib/twitch/logs.ts @@ -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 Message[]> = { regex(searchString, chatLogs) { try { @@ -63,13 +87,123 @@ const searchPrefixes: Record { + const evaluators: Record 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) diff --git a/src/routes/firehose/+page.svelte b/src/routes/firehose/+page.svelte index b22337b..2b70b79 100644 --- a/src/routes/firehose/+page.svelte +++ b/src/routes/firehose/+page.svelte @@ -11,6 +11,7 @@ import * as Card from "$lib/components/ui/card/index.js"; import FocusTrap from "$lib/components/focus-trap.svelte"; + import SearchFilter from "$lib/components/search-filter.svelte"; import VirtualList from "svelte-tiny-virtual-list"; @@ -19,10 +20,10 @@ import Link from "$lib/components/message/link.svelte"; import Badge from "$lib/components/message/badge.svelte"; - import { ChevronsDownIcon } from "@lucide/svelte"; + import { ChevronsDownIcon, SettingsIcon } from "@lucide/svelte"; import { getContext, onDestroy, onMount, tick, untrack } from "svelte"; - import { SvelteMap } from "svelte/reactivity"; + import { SvelteMap, SvelteSet } from "svelte/reactivity"; import { browser } from "$app/environment"; import { goto } from "$app/navigation"; @@ -30,8 +31,8 @@ import { timeFormat, type TitleContext } from "$lib/common"; - import type { EmoteProps, BadgeProps, Message, ChatComponents, TMIEmote } from "$lib/twitch/logs"; - import { messageSearch } from "$lib/twitch/logs"; + import type { EmoteProps, BadgeProps, Message, ChatComponents, TMIEmote, SearchFilter as SearchFilterType, SearchCriterion } from "$lib/twitch/logs"; + import { messageSearch, advancedMessageSearch, serializeFilter, deserializeFilter } from "$lib/twitch/logs"; import * as TwitchServices from "$lib/twitch/services/index.js"; @@ -67,9 +68,13 @@ // Badges const globalBadges = new SvelteMap(); let badgeUpdates = $state(0); + let availableBadges = $state>([]); let instanceValue = $state(""); let searchValue = $state(""); + let searchFilter = $state({ criteria: [] }); + let useAdvancedSearch = $state(false); + let isFilterOpen = $state(false); onMount(() => { fetchGlobalBadges(); @@ -81,10 +86,17 @@ if (instanceParam && instanceParam in instances) { instanceValue = instanceParam; } else { - instanceValue = "logs.spanix.team"; + instanceValue = "firehose.catquery.com"; } searchValue = q.get("s") || ""; + + const filterParam = q.get("f"); + if (filterParam) { + searchFilter = deserializeFilter(filterParam); + useAdvancedSearch = true; + isFilterOpen = true; + } }); onDestroy(() => { @@ -95,6 +107,7 @@ $effect(() => { const i = instanceValue; const s = searchValue; + const f = useAdvancedSearch && searchFilter.criteria.length > 0 ? serializeFilter(searchFilter) : null; untrack(() => { const q = page.url.searchParams; @@ -103,6 +116,9 @@ if (s) q.set("s", s); else q.delete("s"); + if (f) q.set("f", f); + else q.delete("f"); + goto(page.url.search, { replaceState: true, keepFocus: true }); }); }); @@ -149,7 +165,12 @@ }); }); - let filteredChatLogs = $derived(messageSearch(searchValue, chatLogs, null)); + let filteredChatLogs = $derived.by(() => { + if (useAdvancedSearch && searchFilter.criteria.length > 0) { + return advancedMessageSearch(searchFilter, chatLogs, null); + } + return messageSearch(searchValue, chatLogs, null); + }); const logsAfterScroll = ({ detail }: { detail: { event: Event; offset: number } }) => { const el = detail.event.target as HTMLDivElement; @@ -195,6 +216,26 @@ return badges; }; + $effect(() => { + const badges: Array<{ id: string; title: string; imageUrl: string }> = []; + const seenIds = new SvelteSet(); + + globalBadges.forEach((badge, key) => { + const badgeId = key.split("/")[0]; + if (!seenIds.has(badgeId)) { + badges.push({ + id: badgeId, + title: badge.title, + imageUrl: badge.url, + }); + seenIds.add(badgeId); + } + }); + + badges.sort((a, b) => a.title.localeCompare(b.title)); + availableBadges = badges; + }); + const fetchGlobalBadges = async () => { const globalBadgesList = await TwitchServices.IVR.getGlobalBadges(); @@ -348,11 +389,43 @@ -
- +
+ {#if !useAdvancedSearch} + + {/if} +
+ {#if useAdvancedSearch && isFilterOpen} + (searchFilter = filter)} /> + {/if} +
diff --git a/src/routes/logs/+page.svelte b/src/routes/logs/+page.svelte index 12ddcbe..a501bdc 100644 --- a/src/routes/logs/+page.svelte +++ b/src/routes/logs/+page.svelte @@ -22,6 +22,7 @@ import * as Card from "$lib/components/ui/card/index.js"; import FocusTrap from "$lib/components/focus-trap.svelte"; + import SearchFilter from "$lib/components/search-filter.svelte"; import VirtualList from "svelte-tiny-virtual-list"; @@ -32,18 +33,18 @@ import Reply from "$lib/components/message/reply.svelte"; import { getContext, onDestroy, onMount, tick, untrack } from "svelte"; - import { SvelteMap } from "svelte/reactivity"; + import { SvelteMap, SvelteSet } from "svelte/reactivity"; import { browser } from "$app/environment"; import { page } from "$app/state"; import { goto } from "$app/navigation"; - import { LoaderCircleIcon, FileTextIcon, ArrowDownWideNarrowIcon, ArrowUpNarrowWideIcon, CalendarIcon, ExternalLinkIcon, FilterIcon, SearchIcon, ChartColumnIcon } from "@lucide/svelte"; + import { LoaderCircleIcon, FileTextIcon, ArrowDownWideNarrowIcon, ArrowUpNarrowWideIcon, CalendarIcon, ExternalLinkIcon, FilterIcon, SearchIcon, ChartColumnIcon, SettingsIcon } from "@lucide/svelte"; import { dateTimeFormat, type TitleContext } from "$lib/common"; - import type { EmoteProps, BadgeProps, Message, ChatComponents, TMIEmote } from "$lib/twitch/logs"; - import { messageSearch } from "$lib/twitch/logs"; + import type { EmoteProps, BadgeProps, Message, ChatComponents, TMIEmote, SearchFilter as SearchFilterType, SearchCriterion } from "$lib/twitch/logs"; + import { messageSearch, advancedMessageSearch, serializeFilter, deserializeFilter } from "$lib/twitch/logs"; import * as TwitchServices from "$lib/twitch/services/index.js"; @@ -187,6 +188,13 @@ dateValue = q.get("d") || ""; searchValue = q.get("s") || ""; isJumpMode = (q.get("sm") || window.localStorage.getItem("logs-search-mode")) === "jump"; + + const filterParam = q.get("f"); + if (filterParam) { + searchFilter = deserializeFilter(filterParam); + useAdvancedSearch = true; + isFilterOpen = true; + } }); onDestroy(() => { @@ -204,6 +212,12 @@ let channelId = $state(""); + // Search filter state + let searchFilter = $state({ criteria: [] }); + let isFilterOpen = $state(false); + let useAdvancedSearch = $state(false); + let availableBadges = $state>([]); + // Channel Stats let statsPopoverOpen = $state(false); let channelStats = $state(null); @@ -219,6 +233,38 @@ const globalBadges = new SvelteMap(); let badgeUpdates = $state(0); + $effect(() => { + const badges: Array<{ id: string; title: string; imageUrl: string }> = []; + const seenIds = new SvelteSet(); + + globalBadges.forEach((badge, key) => { + const badgeId = key.split("/")[0]; + if (!seenIds.has(badgeId)) { + badges.push({ + id: badgeId, + title: badge.title, + imageUrl: badge.url, + }); + seenIds.add(badgeId); + } + }); + + channelBadges.forEach((badge, key) => { + const badgeId = key.split("/")[0]; + if (!seenIds.has(badgeId)) { + badges.push({ + id: badgeId, + title: badge.title, + imageUrl: badge.url, + }); + seenIds.add(badgeId); + } + }); + + badges.sort((a, b) => a.title.localeCompare(b.title)); + availableBadges = badges; + }); + $effect(() => { const search = { c: channelName, @@ -226,6 +272,7 @@ d: dateValue, s: searchValue, sm: searchValue && isJumpMode ? "jump" : null, + f: useAdvancedSearch && searchFilter.criteria.length > 0 ? serializeFilter(searchFilter) : null, }; untrack(() => { @@ -297,15 +344,20 @@ let contentRef = $state(null); let searchValue = $state(""); - let searchResults = $derived(messageSearch(searchValue, chatLogs, scrollFromBottom)); + let searchResults = $derived.by(() => { + if (searchFilter.criteria.length > 0) { + return advancedMessageSearch(searchFilter, chatLogs, scrollFromBottom); + } + return messageSearch(searchValue, chatLogs, scrollFromBottom); + }); let filteredChatLogs = $derived(isJumpMode ? messageSearch("", chatLogs, scrollFromBottom) : searchResults); - let isJumpSearching = $derived(isJumpMode && searchResults.length && searchValue); + let isJumpSearching = $derived(isJumpMode && searchResults.length && (searchValue || searchFilter.criteria.length > 0)); let jumpHighlights = $derived(isJumpSearching ? new Set(searchResults.map((m) => getMessageId(m))) : void 0); let jumpIndex = $derived(isJumpSearching ? searchResults.findIndex((m) => getMessageId(m) === page.url.hash.slice(1)) : -1); let jumpInputValue = $state(1); let displayMessageCount = $derived.by(() => { - if (searchValue && !isJumpMode) { + if ((searchValue || searchFilter.criteria.length > 0) && !isJumpMode) { return `${searchResults.length.toLocaleString()} / ${chatLogs.length.toLocaleString()}`; } return chatLogs.length.toLocaleString(); @@ -1063,14 +1115,16 @@ {/if} {#if chatLogs.length}
-
-
- - - {displayMessageCount} - -
-
+ {#if !useAdvancedSearch} +
+
+ + + {displayMessageCount} + +
+
+ {/if} {#if isJumpSearching} {@const width = searchResults.length.toString().length + 5}
@@ -1081,11 +1135,37 @@ {/if}
+
+ {#if useAdvancedSearch && isFilterOpen && chatLogs.length} + (searchFilter = filter)} /> + {/if} + {#if error}

{error}

{:else if chatLogs.length}