mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-28 05:40:25 +02:00
refactor: table style unification
This commit is contained in:
+2
-1
@@ -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",
|
||||
|
||||
Generated
+651
-749
File diff suppressed because it is too large
Load Diff
+11
-7
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "起動",
|
||||
|
||||
@@ -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": "실행",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Запустить",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "启动",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user