refactor: table style unification

This commit is contained in:
zhom
2026-08-26 10:29:45 +04:00
parent b7e1c791db
commit 82271d8c3b
22 changed files with 2518 additions and 852 deletions
+2 -1
View File
@@ -10,11 +10,12 @@
"prebuild": "pnpm licenses:generate",
"build": "next build",
"start": "next start",
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:proxy-string && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:proxy-string && pnpm test:profile-search && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test:themes": "node --test src/lib/themes.test.mjs",
"test:window-decorations": "node --test src/lib/window-decorations.test.mjs",
"test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs",
"test:proxy-string": "node --test src/lib/proxy-string.test.mjs",
"test:profile-search": "node --test src/lib/profile-search.test.mjs",
"test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check",
"test:xray-packaging": "node --test src-tauri/download-xray.test.mjs",
"licenses:generate": "node scripts/generate-licenses.mjs",
+651 -749
View File
File diff suppressed because it is too large Load Diff
+11 -7
View File
@@ -26,7 +26,7 @@ path = "src/bin/proxy_server.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
resvg = "0.47"
resvg = "0.48"
[dependencies]
serde_json = "1"
@@ -51,7 +51,11 @@ tokio = { version = "1", features = ["full", "sync"] }
tokio-util = "0.7"
sysinfo = "0.39"
lazy_static = "1.5"
base64 = "0.22"
# 0.23 turns on `simd-unsafe` by default, decoding via hand-written unsafe
# AVX2/NEON. This crate decodes attacker-influenced input (proxy CONNECT auth,
# extension payloads, os_crypt key blobs), and none of those paths are hot
# enough to be worth it, so stay on the scalar engine.
base64 = { version = "0.23", default-features = false, features = ["std"] }
libc = "0.2"
async-trait = "0.1"
futures-util = "0.3"
@@ -93,19 +97,19 @@ async-socks5 = "0.6"
# Wayfern CDP integration
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
tokio-tungstenite = { version = "0.30", features = ["native-tls"] }
rusqlite = { version = "0.40", features = ["bundled"] }
serde_yaml = "0.9"
toml = "1.1"
thiserror = "2.0"
regex-lite = "0.1"
tempfile = "3"
maxminddb = "0.29"
quick-xml = { version = "0.41", features = ["serialize"] }
maxminddb = "0.30"
quick-xml = { version = "0.42", features = ["serialize"] }
# VPN support
boringtun = "0.7"
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp", "socket-dns"] }
smoltcp = { version = "0.14", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp", "socket-dns"] }
# Tray icon decoding (main-process system tray)
image = "0.25"
@@ -158,7 +162,7 @@ http-body-util = "0.1"
tower = "0.5"
tower-http = { version = "0.7", features = ["fs", "trace"] }
futures-util = "0.3"
serial_test = "3"
serial_test = "4"
# Integration test configuration
[[test]]
+8 -8
View File
@@ -112,7 +112,7 @@ impl LocaleSelector {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
let name = e.name();
let name_str = std::str::from_utf8(name.as_ref()).unwrap_or("");
let name_str = name.as_ref();
if name_str == "territory" {
if let Some(code) = current_territory.take() {
@@ -122,8 +122,8 @@ impl LocaleSelector {
}
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"type" {
current_territory = Some(String::from_utf8_lossy(&attr.value).to_uppercase());
if attr.key.as_ref() == "type" {
current_territory = Some(attr.value.to_uppercase());
}
}
} else if name_str == "languagePopulation" && current_territory.is_some() {
@@ -132,11 +132,11 @@ impl LocaleSelector {
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"type" => {
lang_type = Some(String::from_utf8_lossy(&attr.value).to_string());
"type" => {
lang_type = Some(attr.value.to_string());
}
b"populationPercent" => {
pop_percent = String::from_utf8_lossy(&attr.value).parse().unwrap_or(0.0);
"populationPercent" => {
pop_percent = attr.value.parse().unwrap_or(0.0);
}
_ => {}
}
@@ -152,7 +152,7 @@ impl LocaleSelector {
}
Ok(Event::End(ref e)) => {
let name_ref = e.name();
let name = std::str::from_utf8(name_ref.as_ref()).unwrap_or("");
let name = name_ref.as_ref();
if name == "territory" {
if let Some(code) = current_territory.take() {
if !current_languages.is_empty() {
+68 -33
View File
@@ -75,6 +75,11 @@ import {
ONBOARDING_TOUR_FINISHED_EVENT,
setOnboardingActive,
} from "@/lib/onboarding-signal";
import {
matchesProfile,
type ProfileSearchContext,
parseProfileSearch,
} from "@/lib/profile-search";
import {
matchesGroupDigit,
matchesShortcut,
@@ -91,6 +96,7 @@ import {
import type {
BrowserProfile,
ConsistencyResult,
ExtensionGroup,
PreLaunchChecks,
SyncSettings,
WayfernConfig,
@@ -266,6 +272,36 @@ export default function Home() {
const { vpnConfigs } = useVpnEvents();
// Extension groups feed both the table's Ext column and the search filter's
// `ext:` lookup, so the list is loaded here and handed down rather than
// fetched twice. Refreshed when the backend emits 'extensions-changed'
// (group rename/create/delete).
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
useEffect(() => {
let mounted = true;
let unlisten: (() => void) | undefined;
const load = async () => {
try {
const data = await invoke<ExtensionGroup[]>("list_extension_groups");
if (mounted) setExtensionGroups(data);
} catch (e) {
console.error("Failed to load extension groups:", e);
}
};
void load();
void listen("extensions-changed", () => {
void load();
}).then((u) => {
if (mounted) unlisten = u;
else u();
});
return () => {
mounted = false;
unlisten?.();
};
}, []);
// Synchronizer sessions
const { getProfileSyncInfo } = useSyncSessions();
const [syncLeaderProfile, setSyncLeaderProfile] =
@@ -1921,41 +1957,39 @@ export default function Home() {
void checkSelfHostedSync();
}, [checkSelfHostedSync]);
// Filter data by selected group and search query
// A profile stores ids, and the query asks about names, so the matcher is
// handed the resolution up front. Built off the entity lists rather than off
// `profiles`, because the alternative — a .find() per row per term — is
// O(profiles x entities) on every single keystroke.
const searchContext = useMemo<ProfileSearchContext>(
() => ({
groupNames: new Map(groupsData.map((g) => [g.id, g.name])),
proxyNames: new Map(storedProxies.map((p) => [p.id, p.name])),
vpnNames: new Map(vpnConfigs.map((v) => [v.id, v.name])),
extensionGroupNames: new Map(extensionGroups.map((e) => [e.id, e.name])),
runningProfiles,
}),
[groupsData, storedProxies, vpnConfigs, extensionGroups, runningProfiles],
);
// Filter data by selected group and search query. The two are independent
// controls and both apply: the rail narrows to a group, the query narrows
// within whatever the rail left.
const filteredProfiles = useMemo(() => {
let filtered = profiles;
// "__all__" is a virtual filter that shows every profile (including
// ungrouped ones). Any other value is a real group id; ungrouped profiles
// only show through "All".
const inGroup =
!selectedGroupId || selectedGroupId === "__all__"
? profiles
: profiles.filter((profile) => profile.group_id === selectedGroupId);
// Filter by group. "__all__" is a virtual filter that shows every
// profile (including ungrouped ones). Any other value is a real
// group id; ungrouped profiles only show through "All".
if (!selectedGroupId || selectedGroupId === "__all__") {
filtered = profiles;
} else {
filtered = profiles.filter(
(profile) => profile.group_id === selectedGroupId,
);
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase().trim();
filtered = filtered.filter((profile) => {
// Search in profile name
if (profile.name.toLowerCase().includes(query)) return true;
// Search in note
if (profile.note?.toLowerCase().includes(query)) return true;
// Search in tags
if (profile.tags?.some((tag) => tag.toLowerCase().includes(query)))
return true;
return false;
});
}
return filtered;
}, [profiles, selectedGroupId, searchQuery]);
const parsed = parseProfileSearch(searchQuery);
if (parsed.isEmpty) return inGroup;
return inGroup.filter((profile) =>
matchesProfile(profile, parsed, searchContext),
);
}, [profiles, selectedGroupId, searchQuery, searchContext]);
// Update loading states
const isLoading = profilesLoading || groupsLoading || proxiesLoading;
@@ -2015,6 +2049,7 @@ export default function Home() {
onCopyCookiesToProfile={handleCopyCookiesToProfile}
onOpenCookieManagement={handleOpenCookieManagement}
runningProfiles={runningProfiles}
extensionGroups={extensionGroups}
isUpdating={isUpdating}
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
onAssignProfilesToGroup={handleAssignProfilesToGroup}
+12 -4
View File
@@ -1492,9 +1492,12 @@ export function ExtensionManagementDialog({
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
{extTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow
key={headerGroup.id}
className="border-0!"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
@@ -1524,6 +1527,7 @@ export function ExtensionManagementDialog({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="border-0! hover:bg-muted"
>
{row.getVisibleCells().map((cell) => (
<TableCell
@@ -1613,9 +1617,12 @@ export function ExtensionManagementDialog({
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
{groupTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow
key={headerGroup.id}
className="border-0!"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
@@ -1645,6 +1652,7 @@ export function ExtensionManagementDialog({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="border-0! hover:bg-muted"
>
{row.getVisibleCells().map((cell) => (
<TableCell
+3 -2
View File
@@ -627,9 +627,9 @@ export function GroupManagementDialog({
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow key={headerGroup.id} className="border-0!">
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
@@ -659,6 +659,7 @@ export function GroupManagementDialog({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="border-0! hover:bg-muted"
>
{row.getVisibleCells().map((cell) => (
<TableCell
+112 -1
View File
@@ -4,13 +4,25 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
import {
LuChevronLeft,
LuChevronRight,
LuCircleHelp,
LuSearch,
LuX,
} from "react-icons/lu";
import { useWindowDecorations } from "@/hooks/use-window-decorations";
import { getCurrentOS } from "@/lib/browser-utils";
import {
PROFILE_SEARCH_EXAMPLES,
PROFILE_SEARCH_FIELDS,
PROFILE_SEARCH_OPERATORS,
} from "@/lib/profile-search";
import { cn } from "@/lib/utils";
import type { GroupWithCount } from "@/types";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const HOLD_MS = 150;
@@ -42,6 +54,103 @@ interface Props {
pageTitle?: string;
}
/**
* What the search box understands. The vocabulary is data owned by the parser
* (`src/lib/profile-search.ts`), so the tokens listed here cannot drift from the
* ones a query is actually matched against; only the prose beside them is
* translated, because the tokens themselves have to mean the same thing to
* everybody who is handed a query.
*/
const SearchSyntaxHelp = () => {
const { t } = useTranslation();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t("search.helpLabel")}
className="grid size-6 shrink-0 place-items-center rounded-sm text-muted-foreground transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
>
<LuCircleHelp className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-96 max-w-[calc(100vw-1.5rem)] p-0"
>
<div className="space-y-3 p-3 text-xs">
<div>
<p className="font-medium text-foreground">
{t("search.helpTitle")}
</p>
<p className="mt-1 text-muted-foreground">
{t("search.helpIntro")}
</p>
</div>
<div>
<p className="font-medium text-foreground">
{t("search.fieldsTitle")}
</p>
<div className="mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{PROFILE_SEARCH_FIELDS.map((field) => (
<div key={field.key} className="contents">
<code className="font-mono text-[11px] text-foreground">
{field.key}:
</code>
<span className="text-muted-foreground">
{t(field.labelKey)}
{field.values ? (
<span className="ml-1.5 font-mono text-[10px] text-muted-foreground/70">
{field.values.join(" ")}
</span>
) : null}
</span>
</div>
))}
</div>
</div>
<div>
<p className="font-medium text-foreground">
{t("search.operatorsTitle")}
</p>
<div className="mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{PROFILE_SEARCH_OPERATORS.map((operator) => (
<div key={operator.labelKey} className="contents">
<code className="font-mono text-[11px] text-foreground">
{operator.token}
</code>
<span className="text-muted-foreground">
{t(operator.labelKey)}
</span>
</div>
))}
</div>
</div>
<div>
<p className="font-medium text-foreground">
{t("search.examplesTitle")}
</p>
<div className="mt-1.5 space-y-1.5">
{PROFILE_SEARCH_EXAMPLES.map((example) => (
<div key={example.labelKey}>
<code className="font-mono text-[11px] text-foreground">
{example.query}
</code>
<p className="text-muted-foreground">{t(example.labelKey)}</p>
</div>
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
};
const HomeHeader = ({
onCreateProfileDialogOpen,
searchQuery,
@@ -354,6 +463,8 @@ const HomeHeader = ({
</div>
)}
{showProfileToolbar && <SearchSyntaxHelp />}
{showProfileToolbar && (
<Tooltip>
<TooltipTrigger asChild>
+73 -33
View File
@@ -70,6 +70,7 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command";
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
import {
DropdownMenu,
DropdownMenuContent,
@@ -327,6 +328,13 @@ const BOT_LABEL_WIDTH = 880;
/** Below this the bot column leaves entirely, like the other low-priority ones. */
const BOT_COLUMN_MIN_WIDTH = 400;
/**
* Above this the table has room for the profile id. Below it the name column,
* which takes whatever the fixed columns leave over, needs those 100px more
* than a value that is already one click away in the info dialog.
*/
const PROFILE_ID_MIN_WIDTH = 1152;
/** Bulk enrolments of this size or larger are confirmed, as run and stop are. */
const BULK_ENROL_CONFIRM_THRESHOLD = 10;
@@ -593,6 +601,41 @@ function DnsCell({
);
}
/**
* The first eight characters of the profile's UUID: the whole first group of a
* v4, which is also the prefix `id:` searches on, so what the row shows can be
* pasted straight back into the search box. The clipboard gets the FULL id —
* the only thing it is for is the REST and MCP APIs, which take nothing less.
*/
function ProfileIdCell({
profile,
meta,
}: {
profile: BrowserProfile;
meta: TableMeta;
}) {
return (
<div className="flex h-7 w-full items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-1 truncate font-mono text-[11px] text-muted-foreground select-text">
{profile.id.slice(0, 8)}
</span>
</TooltipTrigger>
<TooltipContent className="font-mono text-[11px]">
{profile.id}
</TooltipContent>
</Tooltip>
<CopyToClipboard
text={profile.id}
variant="ghost"
className="size-6 text-muted-foreground"
successMessage={meta.t("toasts.success.copied")}
/>
</div>
);
}
const TagsCell = React.memo<{
profile: BrowserProfile;
isDisabled: boolean;
@@ -1403,6 +1446,11 @@ interface ProfilesDataTableProps {
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
onOpenCookieManagement?: (profile: BrowserProfile) => void;
runningProfiles: Set<string>;
/**
* Loaded by the page rather than here, because the search filter resolves
* `ext:` to a group name and needs the same list. One invoke, one listener.
*/
extensionGroups: ExtensionGroup[];
isUpdating: (browser: string) => boolean;
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
onAssignProfilesToGroup: (profileIds: string[]) => void;
@@ -1461,6 +1509,7 @@ export function ProfilesDataTable({
onCopyCookiesToProfile,
onOpenCookieManagement,
runningProfiles,
extensionGroups,
isUpdating,
onAssignProfilesToGroup,
onAssignProfilesToProxy,
@@ -1675,35 +1724,6 @@ export function ProfilesDataTable({
const [countries, setCountries] = React.useState<LocationItem[]>([]);
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
// Extension groups for the Ext column lookup. Refreshed when the
// backend emits 'extensions-changed' (group rename/create/delete).
const [extensionGroups, setExtensionGroups] = React.useState<
ExtensionGroup[]
>([]);
React.useEffect(() => {
let mounted = true;
let unlisten: (() => void) | undefined;
const load = async () => {
try {
const data = await invoke<ExtensionGroup[]>("list_extension_groups");
if (mounted) setExtensionGroups(data);
} catch (e) {
console.error("Failed to load extension groups:", e);
}
};
void load();
void listen("extensions-changed", () => {
void load();
}).then((u) => {
if (mounted) unlisten = u;
else u();
});
return () => {
mounted = false;
unlisten?.();
};
}, []);
const canCreateLocationProxy = false;
const loadCountries = React.useCallback(async () => {
@@ -3148,6 +3168,19 @@ export function ProfilesDataTable({
);
},
},
{
id: "profileId",
size: 100,
enableSorting: false,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.profileId");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
return <ProfileIdCell profile={row.original} meta={meta} />;
},
},
{
id: "tags",
size: 100,
@@ -3562,11 +3595,16 @@ export function ProfilesDataTable({
// Low-priority columns leave the table as the container narrows (most
// expendable first); their data stays reachable via the profile info
// dialog. Visibility (not CSS hiding) so table-fixed reclaims the width.
// `bot` starts hidden and is switched on by the resize effect below. An
// unentitled account must never see a paid column, not even for the frame
// before the observer's first measurement lands.
// `bot` and `profileId` start hidden and are switched on by the resize effect
// below. An unentitled account must never see a paid column, not even for the
// frame before the observer's first measurement lands, and the id must not
// flash into a narrow table for that same frame.
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({ created_at: false, bot: false });
React.useState<VisibilityState>({
created_at: false,
bot: false,
profileId: false,
});
const table = useReactTable({
data: profiles,
@@ -3630,6 +3668,8 @@ export function ProfilesDataTable({
const next: VisibilityState = {
// Always hidden — sort-only column.
created_at: false,
// First to leave: pure metadata, and the info dialog still has it.
profileId: w >= PROFILE_ID_MIN_WIDTH,
dns: w >= 768,
ext: w >= 672,
note: w >= 576,
+12 -4
View File
@@ -1263,9 +1263,12 @@ export function ProxyManagementDialog({
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
{proxiesTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow
key={headerGroup.id}
className="border-0!"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
@@ -1306,6 +1309,7 @@ export function ProxyManagementDialog({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="border-0! hover:bg-muted"
>
{row.getVisibleCells().map((cell) => (
<TableCell
@@ -1370,9 +1374,12 @@ export function ProxyManagementDialog({
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
{vpnsTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow
key={headerGroup.id}
className="border-0!"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
@@ -1413,6 +1420,7 @@ export function ProxyManagementDialog({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="border-0! hover:bg-muted"
>
{row.getVisibleCells().map((cell) => (
<TableCell
+45 -1
View File
@@ -228,6 +228,49 @@
"scrollGroupsLeft": "Scroll groups left",
"scrollGroupsRight": "Scroll groups right"
},
"search": {
"helpLabel": "Search syntax",
"helpTitle": "Search syntax",
"helpIntro": "Type words to search names, notes, tags and ids. Add fields to narrow it down.",
"fieldsTitle": "Fields",
"operatorsTitle": "Operators",
"examplesTitle": "Examples",
"fields": {
"name": "Profile name",
"tag": "Tag",
"note": "Note",
"id": "Profile id, matched from the start",
"group": "Group name",
"proxy": "Proxy name",
"vpn": "VPN name",
"ext": "Extension group name",
"dns": "DNS blocklist",
"os": "Operating system",
"browser": "Browser",
"status": "Running or not",
"sync": "Sync mode",
"email": "Owner email",
"version": "Browser version",
"locked": "Password protected",
"ephemeral": "Ephemeral profile",
"created": "Creation date",
"launched": "Last launch date"
},
"operators": {
"negate": "Excludes what matches",
"quote": "Holds a value with spaces together",
"or": "Matches either term",
"comma": "Shorthand for either value",
"exact": "Matches the whole value, not a part of it",
"none": "Nothing set here; use any for the opposite",
"compare": "Compares dates and versions; 7d, 3w and 6m count back from now"
},
"examples": {
"a": "Running profiles in one group",
"b": "Untagged profiles that have a proxy",
"c": "Not launched for over 30 days, ignoring archived ones"
}
},
"profiles": {
"title": "Profiles",
"empty": "No profiles yet",
@@ -261,7 +304,8 @@
"emptyImport": "Import profiles",
"emptyFilteredTitle": "No profiles found",
"emptyFilteredHint": "No profiles match this group or search. Try another filter or create a new one.",
"bot": "Bot"
"bot": "Bot",
"profileId": "ID"
},
"actions": {
"launch": "Launch",
+45 -1
View File
@@ -228,6 +228,49 @@
"scrollGroupsLeft": "Desplazar grupos a la izquierda",
"scrollGroupsRight": "Desplazar grupos a la derecha"
},
"search": {
"helpLabel": "Sintaxis de búsqueda",
"helpTitle": "Sintaxis de búsqueda",
"helpIntro": "Escribe palabras para buscar en nombres, notas, etiquetas e ids. Añade campos para acotar más.",
"fieldsTitle": "Campos",
"operatorsTitle": "Operadores",
"examplesTitle": "Ejemplos",
"fields": {
"name": "Nombre del perfil",
"tag": "Etiqueta",
"note": "Nota",
"id": "Id del perfil, desde el principio",
"group": "Nombre del grupo",
"proxy": "Nombre del proxy",
"vpn": "Nombre de la VPN",
"ext": "Nombre del grupo de extensiones",
"dns": "Lista de bloqueo DNS",
"os": "Sistema operativo",
"browser": "Navegador",
"status": "En ejecución o no",
"sync": "Modo de sincronización",
"email": "Correo del propietario",
"version": "Versión del navegador",
"locked": "Protegido con contraseña",
"ephemeral": "Perfil efímero",
"created": "Fecha de creación",
"launched": "Fecha del último inicio"
},
"operators": {
"negate": "Excluye lo que coincide",
"quote": "Mantiene unido un valor con espacios",
"or": "Coincide con cualquiera de los dos términos",
"comma": "Atajo para cualquiera de los valores",
"exact": "Coincide con el valor completo, no con una parte",
"none": "Aquí no hay nada definido; usa any para lo contrario",
"compare": "Compara fechas y versiones; 7d, 3w y 6m cuentan hacia atrás desde ahora"
},
"examples": {
"a": "Perfiles en ejecución de un grupo",
"b": "Perfiles sin etiquetas que tienen proxy",
"c": "Sin iniciar desde hace más de 30 días, ignorando los archivados"
}
},
"profiles": {
"title": "Perfiles",
"empty": "Sin perfiles aún",
@@ -261,7 +304,8 @@
"emptyImport": "Importar perfiles",
"emptyFilteredTitle": "No se encontraron perfiles",
"emptyFilteredHint": "Ningún perfil coincide con este grupo o búsqueda. Prueba otro filtro o crea uno nuevo.",
"bot": "Bot"
"bot": "Bot",
"profileId": "ID"
},
"actions": {
"launch": "Iniciar",
+45 -1
View File
@@ -228,6 +228,49 @@
"scrollGroupsLeft": "Faire défiler les groupes vers la gauche",
"scrollGroupsRight": "Faire défiler les groupes vers la droite"
},
"search": {
"helpLabel": "Syntaxe de recherche",
"helpTitle": "Syntaxe de recherche",
"helpIntro": "Tapez des mots pour chercher dans les noms, les notes, les étiquettes et les ids. Ajoutez des champs pour affiner.",
"fieldsTitle": "Champs",
"operatorsTitle": "Opérateurs",
"examplesTitle": "Exemples",
"fields": {
"name": "Nom du profil",
"tag": "Étiquette",
"note": "Note",
"id": "Id du profil, à partir du début",
"group": "Nom du groupe",
"proxy": "Nom du proxy",
"vpn": "Nom du VPN",
"ext": "Nom du groupe d'extensions",
"dns": "Liste de blocage DNS",
"os": "Système d'exploitation",
"browser": "Navigateur",
"status": "En cours d'exécution ou non",
"sync": "Mode de synchronisation",
"email": "E-mail du propriétaire",
"version": "Version du navigateur",
"locked": "Protégé par mot de passe",
"ephemeral": "Profil éphémère",
"created": "Date de création",
"launched": "Date du dernier lancement"
},
"operators": {
"negate": "Exclut ce qui correspond",
"quote": "Garde ensemble une valeur contenant des espaces",
"or": "Correspond à l'un ou l'autre terme",
"comma": "Raccourci pour l'une ou l'autre valeur",
"exact": "Correspond à la valeur entière, pas à une partie",
"none": "Rien de défini ici ; utilisez any pour l'inverse",
"compare": "Compare les dates et les versions ; 7d, 3w et 6m comptent à rebours depuis maintenant"
},
"examples": {
"a": "Profils en cours d'exécution dans un groupe",
"b": "Profils sans étiquette qui ont un proxy",
"c": "Non lancés depuis plus de 30 jours, en ignorant les archivés"
}
},
"profiles": {
"title": "Profils",
"empty": "Aucun profil pour l'instant",
@@ -261,7 +304,8 @@
"emptyImport": "Importer des profils",
"emptyFilteredTitle": "Aucun profil trouvé",
"emptyFilteredHint": "Aucun profil ne correspond à ce groupe ou à cette recherche. Essayez un autre filtre ou créez-en un.",
"bot": "Bot"
"bot": "Bot",
"profileId": "ID"
},
"actions": {
"launch": "Lancer",
+45 -1
View File
@@ -228,6 +228,49 @@
"scrollGroupsLeft": "グループを左へスクロール",
"scrollGroupsRight": "グループを右へスクロール"
},
"search": {
"helpLabel": "検索構文",
"helpTitle": "検索構文",
"helpIntro": "単語を入力すると名前、メモ、タグ、ID を検索します。フィールドを加えるとさらに絞り込めます。",
"fieldsTitle": "フィールド",
"operatorsTitle": "演算子",
"examplesTitle": "例",
"fields": {
"name": "プロファイル名",
"tag": "タグ",
"note": "メモ",
"id": "プロファイル ID、先頭から一致",
"group": "グループ名",
"proxy": "プロキシ名",
"vpn": "VPN 名",
"ext": "拡張機能グループ名",
"dns": "DNS ブロックリスト",
"os": "オペレーティングシステム",
"browser": "ブラウザ",
"status": "実行中かどうか",
"sync": "同期モード",
"email": "所有者のメールアドレス",
"version": "ブラウザのバージョン",
"locked": "パスワード保護",
"ephemeral": "一時プロファイル",
"created": "作成日",
"launched": "最終起動日"
},
"operators": {
"negate": "一致するものを除外します",
"quote": "スペースを含む値をひとまとまりにします",
"or": "どちらかの条件に一致します",
"comma": "どちらかの値に一致する短縮形",
"exact": "一部ではなく値全体に一致します",
"none": "ここには何も設定されていません。逆は any を使います",
"compare": "日付とバージョンを比較します。7d、3w、6m は現在からさかのぼります"
},
"examples": {
"a": "あるグループ内の実行中のプロファイル",
"b": "タグがなくプロキシがあるプロファイル",
"c": "30 日以上起動しておらず、アーカイブ済みを除いたもの"
}
},
"profiles": {
"title": "プロファイル",
"empty": "プロファイルがありません",
@@ -261,7 +304,8 @@
"emptyImport": "プロファイルをインポート",
"emptyFilteredTitle": "プロファイルが見つかりません",
"emptyFilteredHint": "このグループまたは検索に一致するプロファイルはありません。別のフィルターを試すか、新規作成してください。",
"bot": "ボット"
"bot": "ボット",
"profileId": "ID"
},
"actions": {
"launch": "起動",
+45 -1
View File
@@ -228,6 +228,49 @@
"scrollGroupsLeft": "그룹 왼쪽으로 스크롤",
"scrollGroupsRight": "그룹 오른쪽으로 스크롤"
},
"search": {
"helpLabel": "검색 구문",
"helpTitle": "검색 구문",
"helpIntro": "단어를 입력하면 이름, 메모, 태그, ID를 검색합니다. 필드를 추가하면 더 좁힐 수 있습니다.",
"fieldsTitle": "필드",
"operatorsTitle": "연산자",
"examplesTitle": "예시",
"fields": {
"name": "프로필 이름",
"tag": "태그",
"note": "메모",
"id": "프로필 ID, 앞부분부터 일치",
"group": "그룹 이름",
"proxy": "프록시 이름",
"vpn": "VPN 이름",
"ext": "확장 프로그램 그룹 이름",
"dns": "DNS 차단 목록",
"os": "운영 체제",
"browser": "브라우저",
"status": "실행 중 여부",
"sync": "동기화 모드",
"email": "소유자 이메일",
"version": "브라우저 버전",
"locked": "비밀번호 보호",
"ephemeral": "임시 프로필",
"created": "생성 날짜",
"launched": "마지막 실행 날짜"
},
"operators": {
"negate": "일치하는 항목을 제외합니다",
"quote": "공백이 있는 값을 하나로 묶습니다",
"or": "둘 중 하나와 일치합니다",
"comma": "둘 중 하나의 값을 뜻하는 축약형",
"exact": "일부가 아니라 값 전체와 일치합니다",
"none": "여기에는 아무것도 설정되지 않았습니다. 반대는 any를 사용합니다",
"compare": "날짜와 버전을 비교합니다. 7d, 3w, 6m은 현재부터 거슬러 셉니다"
},
"examples": {
"a": "한 그룹에서 실행 중인 프로필",
"b": "태그가 없고 프록시가 있는 프로필",
"c": "30일 넘게 실행하지 않은 프로필, 보관된 것은 제외"
}
},
"profiles": {
"title": "프로필",
"empty": "아직 프로필이 없습니다",
@@ -261,7 +304,8 @@
"emptyImport": "프로필 가져오기",
"emptyFilteredTitle": "프로필을 찾을 수 없습니다",
"emptyFilteredHint": "이 그룹 또는 검색과 일치하는 프로필이 없습니다. 다른 필터를 사용하거나 새로 만드세요.",
"bot": "봇"
"bot": "봇",
"profileId": "ID"
},
"actions": {
"launch": "실행",
+45 -1
View File
@@ -228,6 +228,49 @@
"scrollGroupsLeft": "Rolar grupos para a esquerda",
"scrollGroupsRight": "Rolar grupos para a direita"
},
"search": {
"helpLabel": "Sintaxe de pesquisa",
"helpTitle": "Sintaxe de pesquisa",
"helpIntro": "Digite palavras para pesquisar em nomes, notas, etiquetas e ids. Adicione campos para restringir mais.",
"fieldsTitle": "Campos",
"operatorsTitle": "Operadores",
"examplesTitle": "Exemplos",
"fields": {
"name": "Nome do perfil",
"tag": "Etiqueta",
"note": "Nota",
"id": "Id do perfil, a partir do início",
"group": "Nome do grupo",
"proxy": "Nome do proxy",
"vpn": "Nome da VPN",
"ext": "Nome do grupo de extensões",
"dns": "Lista de bloqueio DNS",
"os": "Sistema operacional",
"browser": "Navegador",
"status": "Em execução ou não",
"sync": "Modo de sincronização",
"email": "E-mail do proprietário",
"version": "Versão do navegador",
"locked": "Protegido por senha",
"ephemeral": "Perfil efêmero",
"created": "Data de criação",
"launched": "Data da última execução"
},
"operators": {
"negate": "Exclui o que corresponde",
"quote": "Mantém junto um valor com espaços",
"or": "Corresponde a qualquer um dos termos",
"comma": "Atalho para qualquer um dos valores",
"exact": "Corresponde ao valor inteiro, não a uma parte",
"none": "Nada definido aqui; use any para o contrário",
"compare": "Compara datas e versões; 7d, 3w e 6m contam para trás a partir de agora"
},
"examples": {
"a": "Perfis em execução em um grupo",
"b": "Perfis sem etiquetas que têm proxy",
"c": "Sem execução há mais de 30 dias, ignorando os arquivados"
}
},
"profiles": {
"title": "Perfis",
"empty": "Nenhum perfil ainda",
@@ -261,7 +304,8 @@
"emptyImport": "Importar perfis",
"emptyFilteredTitle": "Nenhum perfil encontrado",
"emptyFilteredHint": "Nenhum perfil corresponde a este grupo ou pesquisa. Tente outro filtro ou crie um novo.",
"bot": "Bot"
"bot": "Bot",
"profileId": "ID"
},
"actions": {
"launch": "Iniciar",
+45 -1
View File
@@ -228,6 +228,49 @@
"scrollGroupsLeft": "Прокрутить группы влево",
"scrollGroupsRight": "Прокрутить группы вправо"
},
"search": {
"helpLabel": "Синтаксис поиска",
"helpTitle": "Синтаксис поиска",
"helpIntro": "Введите слова, чтобы искать по названиям, заметкам, тегам и идентификаторам. Добавьте поля, чтобы сузить поиск.",
"fieldsTitle": "Поля",
"operatorsTitle": "Операторы",
"examplesTitle": "Примеры",
"fields": {
"name": "Название профиля",
"tag": "Тег",
"note": "Заметка",
"id": "Идентификатор профиля, совпадение с начала",
"group": "Название группы",
"proxy": "Название прокси",
"vpn": "Название VPN",
"ext": "Название группы расширений",
"dns": "Список блокировки DNS",
"os": "Операционная система",
"browser": "Браузер",
"status": "Запущен или нет",
"sync": "Режим синхронизации",
"email": "Эл. почта владельца",
"version": "Версия браузера",
"locked": "Защищён паролем",
"ephemeral": "Временный профиль",
"created": "Дата создания",
"launched": "Дата последнего запуска"
},
"operators": {
"negate": "Исключает совпадения",
"quote": "Удерживает значение с пробелами как одно целое",
"or": "Совпадает с любым из двух условий",
"comma": "Сокращение для любого из значений",
"exact": "Совпадает со всем значением, а не с его частью",
"none": "Здесь ничего не задано; для обратного используйте any",
"compare": "Сравнивает даты и версии; 7d, 3w и 6m отсчитываются назад от текущего момента"
},
"examples": {
"a": "Запущенные профили в одной группе",
"b": "Профили без тегов, у которых есть прокси",
"c": "Не запускались более 30 дней, кроме архивных"
}
},
"profiles": {
"title": "Профили",
"empty": "Профилей пока нет",
@@ -261,7 +304,8 @@
"emptyImport": "Импортировать профили",
"emptyFilteredTitle": "Профили не найдены",
"emptyFilteredHint": "Нет профилей для этой группы или запроса. Попробуйте другой фильтр или создайте профиль.",
"bot": "Бот"
"bot": "Бот",
"profileId": "ID"
},
"actions": {
"launch": "Запустить",
+45 -1
View File
@@ -228,6 +228,49 @@
"scrollGroupsLeft": "Grupları sola kaydır",
"scrollGroupsRight": "Grupları sağa kaydır"
},
"search": {
"helpLabel": "Arama söz dizimi",
"helpTitle": "Arama söz dizimi",
"helpIntro": "Adlarda, notlarda, etiketlerde ve kimliklerde aramak için kelime yazın. Daraltmak için alan ekleyin.",
"fieldsTitle": "Alanlar",
"operatorsTitle": "Operatörler",
"examplesTitle": "Örnekler",
"fields": {
"name": "Profil adı",
"tag": "Etiket",
"note": "Not",
"id": "Profil kimliği, baştan eşleşir",
"group": "Grup adı",
"proxy": "Proxy adı",
"vpn": "VPN adı",
"ext": "Uzantı grubu adı",
"dns": "DNS engelleme listesi",
"os": "İşletim sistemi",
"browser": "Tarayıcı",
"status": "Çalışıyor mu",
"sync": "Senkronizasyon modu",
"email": "Sahibinin e-postası",
"version": "Tarayıcı sürümü",
"locked": "Parola korumalı",
"ephemeral": "Geçici profil",
"created": "Oluşturulma tarihi",
"launched": "Son başlatma tarihi"
},
"operators": {
"negate": "Eşleşenleri hariç tutar",
"quote": "Boşluk içeren bir değeri bir arada tutar",
"or": "İki terimden herhangi biriyle eşleşir",
"comma": "Değerlerden herhangi biri için kısayol",
"exact": "Bir parçasıyla değil, değerin tamamıyla eşleşir",
"none": "Burada bir şey ayarlı değil; tersi için any kullanın",
"compare": "Tarihleri ve sürümleri karşılaştırır; 7d, 3w ve 6m şu andan geriye sayar"
},
"examples": {
"a": "Bir gruptaki çalışan profiller",
"b": "Etiketi olmayan ama proxy'si olan profiller",
"c": "30 günden uzun süredir başlatılmayanlar, arşivlenenler hariç"
}
},
"profiles": {
"title": "Profiller",
"empty": "Henüz profil yok",
@@ -261,7 +304,8 @@
"emptyImport": "Profilleri içe aktar",
"emptyFilteredTitle": "Profil bulunamadı",
"emptyFilteredHint": "Bu grup veya aramayla eşleşen profil yok. Başka bir filtre deneyin veya yeni bir profil oluşturun.",
"bot": "Bot"
"bot": "Bot",
"profileId": "ID"
},
"actions": {
"launch": "Başlat",
+45 -1
View File
@@ -228,6 +228,49 @@
"scrollGroupsLeft": "Cuộn nhóm sang trái",
"scrollGroupsRight": "Cuộn nhóm sang phải"
},
"search": {
"helpLabel": "Cú pháp tìm kiếm",
"helpTitle": "Cú pháp tìm kiếm",
"helpIntro": "Nhập từ khóa để tìm trong tên, ghi chú, thẻ và id. Thêm trường để thu hẹp kết quả.",
"fieldsTitle": "Trường",
"operatorsTitle": "Toán tử",
"examplesTitle": "Ví dụ",
"fields": {
"name": "Tên hồ sơ",
"tag": "Thẻ",
"note": "Ghi chú",
"id": "Id hồ sơ, khớp từ đầu",
"group": "Tên nhóm",
"proxy": "Tên proxy",
"vpn": "Tên VPN",
"ext": "Tên nhóm tiện ích",
"dns": "Danh sách chặn DNS",
"os": "Hệ điều hành",
"browser": "Trình duyệt",
"status": "Đang chạy hay không",
"sync": "Chế độ đồng bộ",
"email": "Email chủ sở hữu",
"version": "Phiên bản trình duyệt",
"locked": "Được bảo vệ bằng mật khẩu",
"ephemeral": "Hồ sơ tạm thời",
"created": "Ngày tạo",
"launched": "Ngày khởi chạy gần nhất"
},
"operators": {
"negate": "Loại trừ những gì khớp",
"quote": "Giữ nguyên giá trị có dấu cách",
"or": "Khớp với một trong hai điều kiện",
"comma": "Cách viết tắt cho một trong các giá trị",
"exact": "Khớp toàn bộ giá trị, không phải một phần",
"none": "Chưa đặt gì ở đây; dùng any cho trường hợp ngược lại",
"compare": "So sánh ngày và phiên bản; 7d, 3w và 6m tính lùi từ hiện tại"
},
"examples": {
"a": "Hồ sơ đang chạy trong một nhóm",
"b": "Hồ sơ không có thẻ nhưng có proxy",
"c": "Không khởi chạy hơn 30 ngày, bỏ qua hồ sơ đã lưu trữ"
}
},
"profiles": {
"title": "Hồ sơ",
"empty": "Chưa có hồ sơ nào",
@@ -261,7 +304,8 @@
"emptyImport": "Nhập hồ sơ",
"emptyFilteredTitle": "Không tìm thấy hồ sơ",
"emptyFilteredHint": "Không có hồ sơ nào khớp với nhóm hoặc tìm kiếm này. Hãy thử bộ lọc khác hoặc tạo mới.",
"bot": "Bot"
"bot": "Bot",
"profileId": "ID"
},
"actions": {
"launch": "Khởi chạy",
+45 -1
View File
@@ -228,6 +228,49 @@
"scrollGroupsLeft": "向左滚动分组",
"scrollGroupsRight": "向右滚动分组"
},
"search": {
"helpLabel": "搜索语法",
"helpTitle": "搜索语法",
"helpIntro": "输入文字可搜索名称、备注、标签和 ID。加上字段可进一步筛选。",
"fieldsTitle": "字段",
"operatorsTitle": "运算符",
"examplesTitle": "示例",
"fields": {
"name": "配置文件名称",
"tag": "标签",
"note": "备注",
"id": "配置文件 ID,从开头匹配",
"group": "分组名称",
"proxy": "代理名称",
"vpn": "VPN 名称",
"ext": "扩展分组名称",
"dns": "DNS 拦截列表",
"os": "操作系统",
"browser": "浏览器",
"status": "是否正在运行",
"sync": "同步模式",
"email": "所有者邮箱",
"version": "浏览器版本",
"locked": "密码保护",
"ephemeral": "临时配置文件",
"created": "创建日期",
"launched": "上次启动日期"
},
"operators": {
"negate": "排除匹配的结果",
"quote": "把带空格的值作为整体",
"or": "匹配其中任一条件",
"comma": "匹配任一值的简写",
"exact": "匹配整个值,而不是其中一部分",
"none": "此处未设置任何内容;相反的情况用 any",
"compare": "比较日期和版本;7d、3w 和 6m 从当前时间往回算"
},
"examples": {
"a": "某个分组中正在运行的配置文件",
"b": "没有标签但有代理的配置文件",
"c": "超过 30 天未启动,且排除已归档的"
}
},
"profiles": {
"title": "配置文件",
"empty": "暂无配置文件",
@@ -261,7 +304,8 @@
"emptyImport": "导入配置文件",
"emptyFilteredTitle": "未找到配置文件",
"emptyFilteredHint": "没有符合此分组或搜索的配置文件。请尝试其他筛选条件或新建一个。",
"bot": "机器人"
"bot": "机器人",
"profileId": "ID"
},
"actions": {
"launch": "启动",
+365
View File
@@ -0,0 +1,365 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
matchesProfile,
PROFILE_SEARCH_FIELDS,
parseProfileSearch,
} from "./profile-search.ts";
/**
* What is pinned here is the promise the table depends on: the box behaves
* exactly as it did before for a bare word, a query being typed never blanks
* the list, and every field resolves the name a user can see rather than the id
* the profile stores.
*/
const NOW = Date.parse("2026-06-15T12:00:00Z");
const HOUR = 3600;
const DAY = 86400;
const ctx = {
groupNames: new Map([["g1", "Client A"]]),
proxyNames: new Map([["p1", "Frankfurt residential"]]),
vpnNames: new Map([["v1", "Office WireGuard"]]),
extensionGroupNames: new Map([["e1", "Ad blockers"]]),
runningProfiles: new Set(["shop"]),
now: NOW,
};
function profile(overrides = {}) {
return {
id: "a1b2c3d4-1111-2222-3333-444455556666",
name: "Shopify EU",
browser: "wayfern",
version: "140.0.3",
release_type: "stable",
...overrides,
};
}
/** Convenience: does this raw query match this profile? */
function hit(query, target, context = ctx) {
return matchesProfile(target, parseProfileSearch(query), context);
}
function names(query, targets) {
const parsed = parseProfileSearch(query);
return targets
.filter((p) => matchesProfile(p, parsed, ctx))
.map((p) => p.name);
}
test("an empty query matches everything", () => {
for (const raw of ["", " ", '""', "\t\n"]) {
const parsed = parseProfileSearch(raw);
assert.equal(parsed.isEmpty, true, `expected ${JSON.stringify(raw)} empty`);
assert.equal(hit(raw, profile()), true);
}
});
test("plain text still searches name, note and tags", () => {
assert.equal(hit("shopify", profile()), true);
assert.equal(hit("SHOPIFY", profile()), true);
assert.equal(hit("amazon", profile()), false);
assert.equal(hit("renew", profile({ note: "Renew the card" })), true);
assert.equal(hit("ads", profile({ tags: ["paid-ads", "eu"] })), true);
});
test("plain text also matches the id by prefix, so the table's short id works", () => {
assert.equal(hit("a1b2c3d4", profile()), true);
assert.equal(hit("A1B2C3D4", profile()), true);
assert.equal(hit("a1b2c3d4-1111-2222-3333-444455556666", profile()), true);
// A slice from the middle is not a prefix and must not match.
assert.equal(hit("2222", profile()), false);
});
test("a colon inside an ordinary word stays free text", () => {
const noted = profile({
note: "check https://shop.example.com:8080 at 12:30",
});
assert.equal(hit("https://shop.example.com:8080", noted), true);
assert.equal(hit("12:30", noted), true);
// An unknown field name is free text too, never a filter that matches nothing.
assert.equal(hit("warmup:done", profile({ note: "warmup:done" })), true);
assert.equal(hit("warmup:done", profile()), false);
});
test("field terms match on the resolved name, not the stored id", () => {
const target = profile({
group_id: "g1",
proxy_id: "p1",
vpn_id: "v1",
extension_group_id: "e1",
});
assert.equal(hit("group:client", target), true);
assert.equal(hit('group:"Client A"', target), true);
assert.equal(hit("group:g1", target), false);
assert.equal(hit("proxy:frankfurt", target), true);
assert.equal(hit("vpn:office", target), true);
assert.equal(hit("ext:blockers", target), true);
assert.equal(hit("folder:client", target), true, "alias");
assert.equal(hit("extension:blockers", target), true, "alias");
});
test("name, note, tag, id, browser, version and email fields", () => {
const target = profile({
note: "Renew the card",
tags: ["prod", "eu"],
created_by_email: "ops@example.com",
});
assert.equal(hit("name:shop", target), true);
assert.equal(hit("name:renew", target), false, "name must not read the note");
assert.equal(hit("note:card", target), true);
assert.equal(hit("notes:card", target), true, "alias");
assert.equal(hit("tag:prod", target), true);
assert.equal(hit("tags:eu", target), true, "alias");
assert.equal(hit("id:a1b2c3d4", target), true);
assert.equal(hit("id:b2c3", target), false, "id matches by prefix only");
assert.equal(hit("browser:wayfern", target), true);
assert.equal(hit("version:140", target), true);
assert.equal(hit("email:ops@example.com", target), true);
assert.equal(hit("owner:ops", target), true, "alias");
});
test("enum fields match a slug by prefix, so a half-typed value narrows", () => {
const running = profile({ id: "shop", name: "Live" });
assert.equal(hit("status:running", running), true);
assert.equal(hit("status:run", running), true);
assert.equal(hit("status:stopped", running), false);
assert.equal(hit("status:stopped", profile()), true);
assert.equal(hit("os:macos", profile({ host_os: "macos" })), true);
assert.equal(
hit("os:windows", profile({ wayfern_config: { os: "windows" } })),
true,
"falls back to the fingerprint OS",
);
assert.equal(
hit("dns:pro_plus", profile({ dns_blocklist: "pro_plus" })),
true,
);
assert.equal(
hit("sync:encrypted", profile({ sync_mode: "Encrypted" })),
true,
);
assert.equal(
hit("sync:disabled", profile()),
true,
"unset reads as disabled",
);
});
test("boolean fields take yes and no", () => {
assert.equal(hit("locked:yes", profile({ password_protected: true })), true);
assert.equal(hit("locked:no", profile({ password_protected: true })), false);
assert.equal(hit("password:no", profile()), true, "alias, unset is false");
assert.equal(hit("ephemeral:yes", profile({ ephemeral: true })), true);
assert.equal(
hit("locked:maybe", profile({ password_protected: true })),
false,
"an unusable value matches nothing rather than everything",
);
});
test("none and any answer the empty question on every relation", () => {
const bare = profile();
const wired = profile({ proxy_id: "p1", group_id: "g1", tags: ["eu"] });
assert.equal(hit("proxy:none", bare), true);
assert.equal(hit("proxy:none", wired), false);
assert.equal(hit("proxy:any", wired), true);
assert.equal(hit("group:none", bare), true);
assert.equal(hit("tag:none", bare), true);
assert.equal(hit("tag:any", wired), true);
assert.equal(hit("note:any", profile({ note: "x" })), true);
// A quoted value is the literal word, so a tag really called "none" is findable.
assert.equal(hit('tag:"none"', profile({ tags: ["none"] })), true);
assert.equal(hit('tag:"none"', bare), false);
// A proxy whose stored name no longer resolves still counts as having one.
assert.equal(hit("proxy:none", profile({ proxy_id: "gone" })), false);
assert.equal(hit("proxy:any", profile({ proxy_id: "gone" })), true);
});
test("negation inverts a term, on both kinds", () => {
const tagged = profile({ tags: ["banned"] });
assert.equal(hit("-tag:banned", tagged), false);
assert.equal(hit("-tag:banned", profile()), true);
assert.equal(hit("!tag:banned", tagged), false, "! is an alias for -");
assert.equal(hit("tag!=banned", tagged), false);
assert.equal(hit("tag!=banned", profile()), true);
assert.equal(hit("-shopify", profile()), false);
assert.equal(hit("-amazon", profile()), true);
});
test("quotes hold a value together and keep separators literal", () => {
const spaced = profile({ tags: ["black friday"], note: "a, b" });
assert.equal(hit('tag:"black friday"', spaced), true);
assert.equal(
hit("tag:black friday", profile({ tags: ["black"] })),
false,
"unquoted is two terms, and nothing here matches the second",
);
assert.equal(hit('note:"a, b"', spaced), true, "a quoted comma is literal");
assert.equal(hit('"-lead"', profile({ name: "-lead" })), true);
});
test("several terms are ANDed", () => {
const target = profile({ tags: ["prod"], group_id: "g1", note: "vat" });
assert.equal(hit("tag:prod group:client", target), true);
assert.equal(hit("tag:prod group:other", target), false);
assert.equal(hit("shopify tag:prod note:vat status:stopped", target), true);
assert.equal(hit("shopify tag:prod -note:vat", target), false);
});
test("or joins two terms, and binds tighter than the implicit and", () => {
const rows = [
profile({ name: "A", tags: ["ads"], id: "shop" }),
profile({ name: "B", tags: ["seo"] }),
profile({ name: "C", tags: ["other"] }),
];
assert.deepEqual(names("tag:ads or tag:seo", rows), ["A", "B"]);
assert.deepEqual(names("tag:ads or tag:seo status:running", rows), ["A"]);
assert.deepEqual(names("tag:ads,seo", rows), ["A", "B"], "comma is or");
assert.deepEqual(
names("OR tag:ads or", rows),
["A"],
"a dangling or is ignored",
);
});
test("an = prefix forces a whole-value match", () => {
const long = profile({ tags: ["production"] });
assert.equal(hit("tag:prod", long), true);
assert.equal(hit("tag:=prod", long), false);
assert.equal(hit("tag:=production", long), true);
assert.equal(hit('group:="Client A"', profile({ group_id: "g1" })), true);
assert.equal(
hit("name:=shopify", profile()),
false,
"the real name is longer",
);
});
test("dates take relative durations, read the way the question is asked", () => {
const fresh = profile({ last_launch: NOW / 1000 - 2 * DAY });
const cold = profile({ last_launch: NOW / 1000 - 90 * DAY });
assert.equal(hit("launched:<7d", fresh), true);
assert.equal(hit("launched:<7d", cold), false);
assert.equal(
hit("launched:>30d", cold),
true,
"not launched for over 30 days",
);
assert.equal(hit("launched:>30d", fresh), false);
assert.equal(hit("launched:7d", fresh), true, "bare means within");
assert.equal(
hit("launched:<12h", profile({ last_launch: NOW / 1000 - HOUR })),
true,
);
assert.equal(hit("launched:never", profile()), true);
assert.equal(hit("launched:never", fresh), false);
assert.equal(hit("launched:any", fresh), true);
assert.equal(hit("lastlaunch:<7d", fresh), true, "alias");
});
test("dates take absolute days, months and years", () => {
const made = profile({
created_at: Date.parse("2026-03-04T10:00:00") / 1000,
});
assert.equal(hit("created:2026-03-04", made), true);
assert.equal(hit("created:2026-03-05", made), false);
assert.equal(hit("created:2026-03", made), true);
assert.equal(hit("created:2026", made), true);
assert.equal(hit("created:>=2026-01-01", made), true);
assert.equal(hit("created:<2026-01-01", made), false);
assert.equal(
hit("created:>2026-03", made),
false,
"March is not after March",
);
assert.equal(
hit("created:none", profile()),
true,
"legacy profiles have none",
);
});
test("version comparisons run segment by segment", () => {
assert.equal(hit("version:>140", profile()), true);
assert.equal(hit("version:>=140.0", profile()), true);
assert.equal(hit("version:<140", profile()), false);
assert.equal(hit("version:>141", profile()), false);
assert.equal(
hit("version:>9", profile({ version: "10.0.1" })),
true,
"not lexical",
);
});
test("a query being typed never throws and never blanks the list", () => {
const target = profile({ tags: ["prod"], note: 'say "hello"' });
const halves = [
'name:"unclosed',
"name:",
"tag:",
"-",
"!",
":",
'"',
'""',
"or",
"or or or",
"tag:,,,",
"created:>",
"created:>notadate",
"launched:<abc",
"tag:prod created:notadate",
"=",
"tag:=",
">=<:",
"name:>shop",
];
for (const raw of halves) {
assert.doesNotThrow(() => parseProfileSearch(raw), raw);
assert.doesNotThrow(() => hit(raw, target), raw);
}
assert.equal(hit('name:"unclosed', profile({ name: "unclosed" })), true);
assert.equal(hit("tag:", target), true, "a bare field filters nothing");
assert.equal(
hit("tag:prod created:notadate", target),
true,
"bad date drops itself",
);
assert.equal(
hit("name:>shop", profile()),
false,
"a bad operator falls to text",
);
assert.equal(hit("name:>shop", profile({ note: "name:>shop" })), true);
});
test("unicode values compare case-insensitively", () => {
const cyrillic = profile({ name: "Профиль Магазин", tags: ["Реклама"] });
assert.equal(hit("магазин", cyrillic), true);
assert.equal(hit("МАГАЗИН", cyrillic), true);
assert.equal(hit("tag:реклама", cyrillic), true);
assert.equal(hit("name:профиль", cyrillic), true);
const cjk = profile({ name: "東京プロファイル", note: "測試" });
assert.equal(hit("東京", cjk), true);
assert.equal(hit("note:測試", cjk), true);
const emoji = profile({ name: "Store 🛒 EU", tags: ["🔥 hot"] });
assert.equal(hit("🛒", emoji), true);
assert.equal(hit('tag:"🔥 hot"', emoji), true);
assert.equal(hit("straße", profile({ name: "Straße Berlin" })), true);
});
test("every field carries a translation key and unique tokens", () => {
const seen = new Set();
for (const field of PROFILE_SEARCH_FIELDS) {
assert.match(field.labelKey, /^search\.fields\./, field.key);
for (const token of [field.key, ...field.aliases]) {
assert.equal(seen.has(token), false, `duplicate token ${token}`);
seen.add(token);
}
}
});
+751
View File
@@ -0,0 +1,751 @@
/**
* The profile search grammar.
*
* One text box carries the whole filter, so the grammar has to survive whatever
* is in it halfway through a keystroke: a bare word still means what it always
* meant, and anything the parser does not recognise degrades to that bare-word
* search instead of to an error or an empty table. `foo:bar` is free text
* because `foo` is not a field, which is what keeps a pasted `https://x:8080`
* or a `12:30` in a note searchable. Nothing here throws, and nothing here may
* answer "no rows" because of syntax.
*
* Field names and values are ASCII slugs and are never translated, so a query
* means the same thing in every locale; only the help panel's prose goes
* through `t()`, keyed off the `labelKey` each field carries. The React layer
* calls `parseProfileSearch` and `matchesProfile` and nothing else every
* lookup the matcher needs (a group's name for its id, which profiles are
* running) arrives in the `ProfileSearchContext` the caller builds.
*
* Kept free of runtime imports so `profile-search.test.mjs` can load it
* directly, the way `proxy-string.ts` is.
*/
import type { BrowserProfile } from "@/types";
export type ProfileSearchFieldKind =
| "text"
| "tags"
| "id"
| "lookup"
| "enum"
| "boolean"
| "date"
| "version";
export interface ProfileSearchField {
/** Canonical token; the one the help panel teaches. */
readonly key: string;
readonly aliases: readonly string[];
readonly kind: ProfileSearchFieldKind;
/** Translation key describing the field to a human. */
readonly labelKey: string;
/** Accepted slugs, where the set is closed. Shown in the help panel. */
readonly values?: readonly string[];
}
/**
* The closed field vocabulary. Closed on purpose: a token only becomes a field
* when it is in here, so adding a short everyday word (`ip`, `url`) would
* silently turn someone's plain-text search into a filter.
*/
export const PROFILE_SEARCH_FIELDS: readonly ProfileSearchField[] = [
{ key: "name", aliases: [], kind: "text", labelKey: "search.fields.name" },
{
key: "tag",
aliases: ["tags"],
kind: "tags",
labelKey: "search.fields.tag",
},
{
key: "note",
aliases: ["notes"],
kind: "text",
labelKey: "search.fields.note",
},
{ key: "id", aliases: [], kind: "id", labelKey: "search.fields.id" },
{
key: "group",
aliases: ["folder"],
kind: "lookup",
labelKey: "search.fields.group",
},
{
key: "proxy",
aliases: [],
kind: "lookup",
labelKey: "search.fields.proxy",
},
{ key: "vpn", aliases: [], kind: "lookup", labelKey: "search.fields.vpn" },
{
key: "ext",
aliases: ["extension"],
kind: "lookup",
labelKey: "search.fields.ext",
},
{
key: "dns",
aliases: [],
kind: "enum",
labelKey: "search.fields.dns",
values: ["light", "normal", "pro", "pro_plus", "ultimate", "custom"],
},
{
key: "os",
aliases: [],
kind: "enum",
labelKey: "search.fields.os",
values: ["macos", "windows", "linux"],
},
{
key: "browser",
aliases: [],
kind: "text",
labelKey: "search.fields.browser",
},
{
key: "status",
aliases: [],
kind: "enum",
labelKey: "search.fields.status",
values: ["running", "stopped"],
},
{
key: "sync",
aliases: [],
kind: "enum",
labelKey: "search.fields.sync",
values: ["disabled", "regular", "encrypted"],
},
{
key: "email",
aliases: ["owner"],
kind: "text",
labelKey: "search.fields.email",
},
{
key: "version",
aliases: [],
kind: "version",
labelKey: "search.fields.version",
},
{
key: "locked",
aliases: ["password"],
kind: "boolean",
labelKey: "search.fields.locked",
values: ["yes", "no"],
},
{
key: "ephemeral",
aliases: [],
kind: "boolean",
labelKey: "search.fields.ephemeral",
values: ["yes", "no"],
},
{
key: "created",
aliases: [],
kind: "date",
labelKey: "search.fields.created",
},
{
key: "launched",
aliases: ["lastlaunch"],
kind: "date",
labelKey: "search.fields.launched",
},
];
/** Operator vocabulary, for the help panel. The token is the syntax itself. */
export const PROFILE_SEARCH_OPERATORS: readonly {
readonly token: string;
readonly labelKey: string;
}[] = [
{ token: "-tag:ads", labelKey: "search.operators.negate" },
{ token: 'group:"Client A"', labelKey: "search.operators.quote" },
{ token: "tag:ads or tag:seo", labelKey: "search.operators.or" },
{ token: "tag:ads,seo", labelKey: "search.operators.comma" },
{ token: "tag:=prod", labelKey: "search.operators.exact" },
{ token: "proxy:none", labelKey: "search.operators.none" },
{ token: "created:>=2026-01-01", labelKey: "search.operators.compare" },
];
/** Whole queries worth copying, for the help panel. */
export const PROFILE_SEARCH_EXAMPLES: readonly {
readonly query: string;
readonly labelKey: string;
}[] = [
{ query: 'status:running group:"Client A"', labelKey: "search.examples.a" },
{ query: "tag:none proxy:any", labelKey: "search.examples.b" },
{ query: "launched:>30d -tag:archived", labelKey: "search.examples.c" },
];
export type ProfileSearchOperator = "match" | "lt" | "lte" | "gt" | "gte";
interface FreeTextTerm {
readonly type: "text";
/** Already lowercased. */
readonly value: string;
readonly negated: boolean;
}
interface FieldTerm {
readonly type: "field";
readonly field: ProfileSearchField;
readonly operator: ProfileSearchOperator;
/** Alternatives from the comma shorthand; any one matching matches. */
readonly values: readonly string[];
readonly negated: boolean;
/** `=value`: whole-value match rather than substring. */
readonly exact: boolean;
/** The value was quoted, so `none` and `any` are literal text. */
readonly quoted: boolean;
}
export type ProfileSearchTerm = FreeTextTerm | FieldTerm;
export interface ParsedProfileSearch {
/** AND across the groups, OR inside each one. */
readonly groups: readonly (readonly ProfileSearchTerm[])[];
/** Nothing left to filter on, so every profile matches. */
readonly isEmpty: boolean;
}
export interface ProfileSearchContext {
/** Group id to the name the table shows for it. Same for the three below. */
readonly groupNames: ReadonlyMap<string, string>;
readonly proxyNames: ReadonlyMap<string, string>;
readonly vpnNames: ReadonlyMap<string, string>;
readonly extensionGroupNames: ReadonlyMap<string, string>;
readonly runningProfiles: ReadonlySet<string>;
/** Epoch ms the relative dates count back from. Defaults to the wall clock. */
readonly now?: number;
}
const FIELD_BY_TOKEN: ReadonlyMap<string, ProfileSearchField> = new Map(
PROFILE_SEARCH_FIELDS.flatMap((field) =>
[field.key, ...field.aliases].map(
(token) => [token, field] as [string, ProfileSearchField],
),
),
);
const RESERVED_NONE = "none";
const RESERVED_ANY = "any";
const RESERVED_NEVER = "never";
const DAY_MS = 86_400_000;
const DURATION_UNITS: Readonly<Record<string, number>> = {
h: 3_600_000,
d: DAY_MS,
w: 7 * DAY_MS,
m: 30 * DAY_MS,
y: 365 * DAY_MS,
};
interface QueryChar {
readonly c: string;
readonly quoted: boolean;
}
/**
* Splits on whitespace outside double quotes. An unclosed quote runs to the end
* of the input instead of being rejected: the query is re-parsed on every
* keystroke, so `name:"unclosed` is a query being typed, not a mistake. Each
* character remembers whether it was quoted, which is what keeps a separator
* inside quotes (`group:"Acme, Inc"`) literal.
*/
function tokenize(raw: string): QueryChar[][] {
const tokens: QueryChar[][] = [];
let current: QueryChar[] = [];
let quoted = false;
for (const c of raw) {
if (c === '"') {
quoted = !quoted;
continue;
}
if (!quoted && /\s/.test(c)) {
if (current.length > 0) {
tokens.push(current);
current = [];
}
continue;
}
current.push({ c, quoted });
}
if (current.length > 0) tokens.push(current);
return tokens;
}
function textOf(chars: readonly QueryChar[]): string {
let out = "";
for (const ch of chars) out += ch.c;
return out;
}
function hasQuoted(chars: readonly QueryChar[]): boolean {
return chars.some((ch) => ch.quoted);
}
/** Splits on an unquoted separator, dropping the empty pieces. */
function splitUnquoted(chars: readonly QueryChar[], sep: string): string[] {
const parts: string[] = [];
let current = "";
for (const ch of chars) {
if (ch.c === sep && !ch.quoted) {
if (current.length > 0) parts.push(current);
current = "";
continue;
}
current += ch.c;
}
if (current.length > 0) parts.push(current);
return parts;
}
interface SeparatorToken {
readonly token: string;
readonly operator: ProfileSearchOperator;
readonly negates: boolean;
}
/** Longest first, so `>=` is never read as `>` followed by a stray `=`. */
const SEPARATORS: readonly SeparatorToken[] = [
{ token: ">=", operator: "gte", negates: false },
{ token: "<=", operator: "lte", negates: false },
{ token: "!=", operator: "match", negates: true },
{ token: ":", operator: "match", negates: false },
{ token: ">", operator: "gt", negates: false },
{ token: "<", operator: "lt", negates: false },
];
function separatorAt(
chars: readonly QueryChar[],
index: number,
): SeparatorToken | null {
for (const candidate of SEPARATORS) {
let hit = true;
for (let i = 0; i < candidate.token.length; i++) {
const ch = chars[index + i];
if (!ch || ch.quoted || ch.c !== candidate.token[i]) {
hit = false;
break;
}
}
if (hit) return candidate;
}
return null;
}
/** Comparisons only mean something where the values are ordered. */
function acceptsComparison(field: ProfileSearchField): boolean {
return field.kind === "date" || field.kind === "version";
}
function freeText(value: string, negated: boolean): FreeTextTerm | null {
const trimmed = value.trim();
if (trimmed.length === 0) return null;
return { type: "text", value: trimmed.toLowerCase(), negated };
}
function buildTerm(chars: readonly QueryChar[]): ProfileSearchTerm | null {
let negated = false;
let body = chars;
const first = body[0];
if (
body.length > 1 &&
first &&
!first.quoted &&
(first.c === "-" || first.c === "!")
) {
negated = true;
body = body.slice(1);
}
let found: { at: number; token: SeparatorToken } | null = null;
for (let i = 0; i < body.length && !found; i++) {
if (body[i].quoted) continue;
const token = separatorAt(body, i);
if (token) found = { at: i, token };
}
if (!found || found.at === 0) return freeText(textOf(body), negated);
const name = textOf(body.slice(0, found.at)).toLowerCase();
const field = FIELD_BY_TOKEN.get(name);
// An unrecognised name is never an error: it is somebody's note holding a
// URL, and returning zero rows for it would be the worst possible answer.
if (!field) return freeText(textOf(body), negated);
let operator = found.token.operator;
let value = body.slice(found.at + found.token.token.length);
// `created:>=2026-01-01` writes the comparison after the colon; `created>=...`
// writes it instead of one. Both reach the same term.
if (found.token.token === ":") {
const inner = separatorAt(value, 0);
if (inner && inner.operator !== "match") {
operator = inner.operator;
value = value.slice(inner.token.length);
}
}
if (operator !== "match" && !acceptsComparison(field)) {
return freeText(textOf(body), negated);
}
if (value.length === 0) return null;
if (found.token.negates) negated = !negated;
let exact = false;
const lead = value[0];
if (lead && !lead.quoted && lead.c === "=") {
exact = true;
value = value.slice(1);
if (value.length === 0) return null;
}
const quoted = hasQuoted(value);
const values = (quoted ? [textOf(value)] : splitUnquoted(value, ",")).map(
(v) => v.toLowerCase(),
);
if (values.length === 0) return null;
if (field.kind === "date") {
const usable = values.filter((v) => parseDateValue(v) !== null);
// A date that does not parse drops its own term and leaves the rest of the
// query running, rather than filtering everything away.
if (usable.length === 0) return null;
return {
type: "field",
field,
operator,
values: usable,
negated,
exact,
quoted,
};
}
return {
type: "field",
field,
operator,
values,
negated,
exact,
quoted,
};
}
/**
* Turns raw input into AND-ed groups of OR-ed terms. Total: every input, valid
* or not, produces a result, and an input with nothing usable in it produces an
* empty one that matches every profile.
*/
export function parseProfileSearch(raw: string): ParsedProfileSearch {
const groups: ProfileSearchTerm[][] = [];
let pendingOr = false;
for (const chars of tokenize(raw)) {
if (!hasQuoted(chars) && textOf(chars).toLowerCase() === "or") {
// A dangling `or` at either end simply has nothing to join.
pendingOr = groups.length > 0;
continue;
}
const term = buildTerm(chars);
if (!term) continue;
const last = groups[groups.length - 1];
if (pendingOr && last) {
last.push(term);
} else {
groups.push([term]);
}
pendingOr = false;
}
return { groups, isEmpty: groups.length === 0 };
}
type DateValue =
| { readonly kind: "relative"; readonly durationMs: number }
| {
readonly kind: "absolute";
readonly startMs: number;
readonly endMs: number;
}
| { readonly kind: "never" }
| { readonly kind: "any" };
const RELATIVE_PATTERN = /^(\d+)([hdwmy])$/;
const ABSOLUTE_PATTERN = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/;
/** `null` for anything that is not a date, which is how a term gets dropped. */
function parseDateValue(value: string): DateValue | null {
if (value === RESERVED_NEVER || value === RESERVED_NONE) {
return { kind: "never" };
}
if (value === RESERVED_ANY) return { kind: "any" };
const relative = RELATIVE_PATTERN.exec(value);
if (relative) {
const amount = Number.parseInt(relative[1], 10);
const unit = DURATION_UNITS[relative[2]];
if (unit === undefined) return null;
return { kind: "relative", durationMs: amount * unit };
}
const absolute = ABSOLUTE_PATTERN.exec(value);
if (!absolute) return null;
const year = Number.parseInt(absolute[1], 10);
if (absolute[2] === undefined) {
return {
kind: "absolute",
startMs: new Date(year, 0, 1).getTime(),
endMs: new Date(year + 1, 0, 1).getTime(),
};
}
const month = Number.parseInt(absolute[2], 10);
if (month < 1 || month > 12) return null;
if (absolute[3] === undefined) {
return {
kind: "absolute",
startMs: new Date(year, month - 1, 1).getTime(),
endMs: new Date(year, month, 1).getTime(),
};
}
const day = Number.parseInt(absolute[3], 10);
const start = new Date(year, month - 1, day);
// February 31st parses as March 3rd unless the roll-over is caught here.
if (start.getMonth() !== month - 1 || start.getDate() !== day) return null;
return {
kind: "absolute",
startMs: start.getTime(),
endMs: new Date(year, month - 1, day + 1).getTime(),
};
}
function matchesDate(
seconds: number | undefined,
operator: ProfileSearchOperator,
value: string,
now: number,
): boolean {
const parsed = parseDateValue(value);
if (!parsed) return false;
if (parsed.kind === "never") return !seconds;
if (parsed.kind === "any") return Boolean(seconds);
if (!seconds) return false;
const ts = seconds * 1000;
if (parsed.kind === "relative") {
// Read the way the question is asked, not the way the timestamps compare:
// `launched:<7d` is "inside the last 7 days" and `launched:>30d` is "not
// launched for over 30 days", which is the query an operator hunting cold
// profiles actually wants.
const threshold = now - parsed.durationMs;
switch (operator) {
case "gt":
return ts < threshold;
case "gte":
return ts <= threshold;
default:
return ts >= threshold;
}
}
switch (operator) {
case "lt":
return ts < parsed.startMs;
case "lte":
return ts < parsed.endMs;
case "gt":
return ts >= parsed.endMs;
case "gte":
return ts >= parsed.startMs;
default:
return ts >= parsed.startMs && ts < parsed.endMs;
}
}
function compareVersions(a: string, b: string): number {
const left = a.split(".");
const right = b.split(".");
const length = Math.max(left.length, right.length);
for (let i = 0; i < length; i++) {
const l = Number.parseInt(left[i] ?? "0", 10);
const r = Number.parseInt(right[i] ?? "0", 10);
const ln = Number.isNaN(l) ? 0 : l;
const rn = Number.isNaN(r) ? 0 : r;
if (ln !== rn) return ln < rn ? -1 : 1;
}
return 0;
}
function parseBoolean(value: string): boolean | null {
if (value === "yes" || value === "true" || value === "1") return true;
if (value === "no" || value === "false" || value === "0") return false;
return null;
}
interface FieldValue {
/** The profile has something here, even if its name cannot be resolved. */
readonly present: boolean;
/** Lowercased text to compare against. */
readonly candidates: readonly string[];
}
function lookupValue(
id: string | undefined,
names: ReadonlyMap<string, string>,
): FieldValue {
if (!id) return { present: false, candidates: [] };
const name = names.get(id);
return {
present: true,
candidates: name ? [name.toLowerCase()] : [],
};
}
function fieldValue(
profile: BrowserProfile,
field: ProfileSearchField,
ctx: ProfileSearchContext,
): FieldValue {
const one = (value: string | undefined | null): FieldValue =>
value
? { present: true, candidates: [value.toLowerCase()] }
: { present: false, candidates: [] };
switch (field.key) {
case "name":
return one(profile.name);
case "note":
return one(profile.note);
case "browser":
return one(profile.browser);
case "version":
return one(profile.version);
case "email":
return one(profile.created_by_email);
case "id":
return { present: true, candidates: [profile.id.toLowerCase()] };
case "tag": {
const tags = profile.tags ?? [];
return {
present: tags.length > 0,
candidates: tags.map((tag) => tag.toLowerCase()),
};
}
case "group":
return lookupValue(profile.group_id, ctx.groupNames);
case "proxy":
return lookupValue(profile.proxy_id, ctx.proxyNames);
case "vpn":
return lookupValue(profile.vpn_id, ctx.vpnNames);
case "ext":
return lookupValue(profile.extension_group_id, ctx.extensionGroupNames);
case "dns":
return one(profile.dns_blocklist);
case "os":
return one(profile.host_os ?? profile.wayfern_config?.os);
case "sync":
return one(profile.sync_mode ?? "Disabled");
case "status":
return one(ctx.runningProfiles.has(profile.id) ? "running" : "stopped");
default:
return { present: false, candidates: [] };
}
}
function matchesFieldValue(term: FieldTerm, value: string, actual: FieldValue) {
if (!term.quoted && !term.exact) {
if (value === RESERVED_NONE) return !actual.present;
if (value === RESERVED_ANY) return actual.present;
}
if (term.field.kind === "id") {
return actual.candidates.some((candidate) =>
term.exact ? candidate === value : candidate.startsWith(value),
);
}
if (term.field.kind === "enum") {
// A prefix is enough, so `status:run` works while the user is still typing.
return actual.candidates.some((candidate) =>
term.exact ? candidate === value : candidate.startsWith(value),
);
}
return actual.candidates.some((candidate) =>
term.exact ? candidate === value : candidate.includes(value),
);
}
function matchesFieldTerm(
profile: BrowserProfile,
term: FieldTerm,
ctx: ProfileSearchContext,
): boolean {
const field = term.field;
if (field.kind === "boolean") {
const actual =
field.key === "locked"
? profile.password_protected === true
: profile.ephemeral === true;
return term.values.some((value) => parseBoolean(value) === actual);
}
if (field.kind === "date") {
const now = ctx.now ?? Date.now();
const seconds =
field.key === "created" ? profile.created_at : profile.last_launch;
return term.values.some((value) =>
matchesDate(seconds, term.operator, value, now),
);
}
if (field.kind === "version" && term.operator !== "match") {
return term.values.some((value) => {
const order = compareVersions(profile.version, value);
switch (term.operator) {
case "lt":
return order < 0;
case "lte":
return order <= 0;
case "gt":
return order > 0;
default:
return order >= 0;
}
});
}
const actual = fieldValue(profile, field, ctx);
return term.values.some((value) => matchesFieldValue(term, value, actual));
}
/**
* What a bare word searches: the same three fields the box has always covered,
* plus the id, so the trimmed id the table shows can be pasted straight back in.
*/
function matchesFreeText(profile: BrowserProfile, value: string): boolean {
if (profile.name.toLowerCase().includes(value)) return true;
if (profile.note?.toLowerCase().includes(value)) return true;
if (profile.tags?.some((tag) => tag.toLowerCase().includes(value))) {
return true;
}
return profile.id.toLowerCase().startsWith(value);
}
export function matchesProfile(
profile: BrowserProfile,
parsed: ParsedProfileSearch,
ctx: ProfileSearchContext,
): boolean {
for (const group of parsed.groups) {
const hit = group.some((term) => {
const matched =
term.type === "text"
? matchesFreeText(profile, term.value)
: matchesFieldTerm(profile, term, ctx);
return term.negated ? !matched : matched;
});
if (!hit) return false;
}
return true;
}