mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-05 22:56:34 +02:00
687 lines
22 KiB
TypeScript
687 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { save } from "@tauri-apps/plugin-dialog";
|
|
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { LuChevronDown, LuChevronRight, LuUpload } from "react-icons/lu";
|
|
import { toast } from "sonner";
|
|
import { LoadingButton } from "@/components/loading-button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
|
|
import { Label } from "@/components/ui/label";
|
|
import { RippleButton } from "@/components/ui/ripple";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import type {
|
|
BrowserProfile,
|
|
CookieReadResult,
|
|
DomainCookies,
|
|
UnifiedCookie,
|
|
} from "@/types";
|
|
|
|
interface CookieImportResult {
|
|
cookies_imported: number;
|
|
cookies_replaced: number;
|
|
errors: string[];
|
|
}
|
|
|
|
interface CookieManagementDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
profile: BrowserProfile | null;
|
|
initialTab?: "import" | "export";
|
|
}
|
|
|
|
type SelectionState = Record<
|
|
string,
|
|
{
|
|
allSelected: boolean;
|
|
cookies: Set<string>;
|
|
}
|
|
>;
|
|
|
|
const countCookies = (content: string): number => {
|
|
const trimmed = content.trim();
|
|
if (trimmed.startsWith("[")) {
|
|
try {
|
|
const arr = JSON.parse(trimmed);
|
|
if (Array.isArray(arr)) return arr.length;
|
|
} catch {
|
|
// Fall through to Netscape counting
|
|
}
|
|
}
|
|
return content.split("\n").filter((line) => {
|
|
const l = line.trim();
|
|
return l && !l.startsWith("#");
|
|
}).length;
|
|
};
|
|
|
|
function formatJsonCookies(cookies: UnifiedCookie[]): string {
|
|
const arr = cookies.map((c) => {
|
|
const sameSite =
|
|
c.same_site === 1
|
|
? "lax"
|
|
: c.same_site === 2
|
|
? "strict"
|
|
: "no_restriction";
|
|
return {
|
|
name: c.name,
|
|
value: c.value,
|
|
domain: c.domain,
|
|
path: c.path,
|
|
secure: c.is_secure,
|
|
httpOnly: c.is_http_only,
|
|
sameSite,
|
|
expirationDate: c.expires,
|
|
session: c.expires === 0,
|
|
hostOnly: !c.domain.startsWith("."),
|
|
};
|
|
});
|
|
return JSON.stringify(arr, null, 2);
|
|
}
|
|
|
|
function formatNetscapeCookies(cookies: UnifiedCookie[]): string {
|
|
const lines = ["# Netscape HTTP Cookie File"];
|
|
for (const c of cookies) {
|
|
const flag = c.domain.startsWith(".") ? "TRUE" : "FALSE";
|
|
const secure = c.is_secure ? "TRUE" : "FALSE";
|
|
lines.push(
|
|
`${c.domain}\t${flag}\t${c.path}\t${secure}\t${c.expires}\t${c.name}\t${c.value}`,
|
|
);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function initSelectionFromCookieData(data: CookieReadResult): SelectionState {
|
|
const sel: SelectionState = {};
|
|
for (const d of data.domains) {
|
|
sel[d.domain] = {
|
|
allSelected: true,
|
|
cookies: new Set(d.cookies.map((c) => c.name)),
|
|
};
|
|
}
|
|
return sel;
|
|
}
|
|
|
|
export function CookieManagementDialog({
|
|
isOpen,
|
|
onClose,
|
|
profile,
|
|
initialTab = "import",
|
|
}: CookieManagementDialogProps) {
|
|
const { t } = useTranslation();
|
|
// Import state
|
|
const [fileContent, setFileContent] = useState<string | null>(null);
|
|
const [fileName, setFileName] = useState<string | null>(null);
|
|
const [cookieCount, setCookieCount] = useState(0);
|
|
const [isImporting, setIsImporting] = useState(false);
|
|
const [importResult, setImportResult] = useState<CookieImportResult | null>(
|
|
null,
|
|
);
|
|
|
|
// Export state
|
|
const [format, setFormat] = useState<"netscape" | "json">("json");
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
const [exportCookieData, setExportCookieData] =
|
|
useState<CookieReadResult | null>(null);
|
|
const [isLoadingExportCookies, setIsLoadingExportCookies] = useState(false);
|
|
const [exportSelection, setExportSelection] = useState<SelectionState>({});
|
|
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
const [activeTab, setActiveTab] = useState<string>(initialTab);
|
|
|
|
const selectedExportCount = useMemo(() => {
|
|
let count = 0;
|
|
for (const domain of Object.keys(exportSelection)) {
|
|
const ds = exportSelection[domain];
|
|
if (ds.allSelected) {
|
|
const domainData = exportCookieData?.domains.find(
|
|
(d) => d.domain === domain,
|
|
);
|
|
count += domainData?.cookie_count ?? 0;
|
|
} else {
|
|
count += ds.cookies.size;
|
|
}
|
|
}
|
|
return count;
|
|
}, [exportSelection, exportCookieData]);
|
|
|
|
const loadExportCookies = useCallback(
|
|
async (profileId: string) => {
|
|
if (exportCookieData) return;
|
|
setIsLoadingExportCookies(true);
|
|
try {
|
|
const result = await invoke<CookieReadResult>("read_profile_cookies", {
|
|
profileId,
|
|
});
|
|
setExportCookieData(result);
|
|
setExportSelection(initSelectionFromCookieData(result));
|
|
} catch (err) {
|
|
toast.error(
|
|
t("cookies.management.loadFailed", {
|
|
error: err instanceof Error ? err.message : String(err),
|
|
}),
|
|
);
|
|
} finally {
|
|
setIsLoadingExportCookies(false);
|
|
}
|
|
},
|
|
[exportCookieData, t],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (activeTab === "export" && profile && !exportCookieData) {
|
|
void loadExportCookies(profile.id);
|
|
}
|
|
}, [activeTab, profile, exportCookieData, loadExportCookies]);
|
|
|
|
const resetImportState = useCallback(() => {
|
|
setFileContent(null);
|
|
setFileName(null);
|
|
setCookieCount(0);
|
|
setIsImporting(false);
|
|
setImportResult(null);
|
|
}, []);
|
|
|
|
const resetExportState = useCallback(() => {
|
|
setFormat("json");
|
|
setIsExporting(false);
|
|
setExportCookieData(null);
|
|
setExportSelection({});
|
|
setExpandedDomains(new Set());
|
|
}, []);
|
|
|
|
const handleClose = useCallback(() => {
|
|
resetImportState();
|
|
resetExportState();
|
|
setActiveTab(initialTab);
|
|
onClose();
|
|
}, [resetImportState, resetExportState, onClose, initialTab]);
|
|
|
|
const handleTabChange = useCallback(
|
|
(tab: string) => {
|
|
setActiveTab(tab);
|
|
resetImportState();
|
|
if (tab !== "export") {
|
|
resetExportState();
|
|
}
|
|
},
|
|
[resetImportState, resetExportState],
|
|
);
|
|
|
|
const handleFileRead = useCallback(
|
|
(file: File) => {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const content = e.target?.result as string;
|
|
setFileContent(content);
|
|
setFileName(file.name);
|
|
setCookieCount(countCookies(content));
|
|
};
|
|
reader.onerror = () => {
|
|
toast.error(t("cookies.management.fileReadError"));
|
|
};
|
|
reader.readAsText(file);
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const handleImport = useCallback(async () => {
|
|
if (!fileContent || !profile) return;
|
|
setIsImporting(true);
|
|
try {
|
|
const result = await invoke<CookieImportResult>(
|
|
"import_cookies_from_file",
|
|
{
|
|
profileId: profile.id,
|
|
content: fileContent,
|
|
},
|
|
);
|
|
setImportResult(result);
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : String(error));
|
|
} finally {
|
|
setIsImporting(false);
|
|
}
|
|
}, [fileContent, profile]);
|
|
|
|
const getSelectedCookies = useCallback((): UnifiedCookie[] => {
|
|
if (!exportCookieData) return [];
|
|
const result: UnifiedCookie[] = [];
|
|
for (const domain of exportCookieData.domains) {
|
|
const ds = exportSelection[domain.domain];
|
|
if (!ds) continue;
|
|
if (ds.allSelected) {
|
|
result.push(...domain.cookies);
|
|
} else {
|
|
result.push(...domain.cookies.filter((c) => ds.cookies.has(c.name)));
|
|
}
|
|
}
|
|
return result;
|
|
}, [exportCookieData, exportSelection]);
|
|
|
|
const handleExport = useCallback(async () => {
|
|
if (!profile) return;
|
|
setIsExporting(true);
|
|
try {
|
|
const cookies = getSelectedCookies();
|
|
const content =
|
|
format === "json"
|
|
? formatJsonCookies(cookies)
|
|
: formatNetscapeCookies(cookies);
|
|
|
|
const ext = format === "json" ? "json" : "txt";
|
|
const defaultName = `${profile.name}_cookies.${ext}`;
|
|
|
|
const filePath = await save({
|
|
defaultPath: defaultName,
|
|
filters: [
|
|
{
|
|
name: format === "json" ? "JSON" : "Text",
|
|
extensions: [ext],
|
|
},
|
|
],
|
|
});
|
|
|
|
if (!filePath) {
|
|
setIsExporting(false);
|
|
return;
|
|
}
|
|
|
|
await writeTextFile(filePath, content);
|
|
toast.success(t("cookies.export.success"));
|
|
handleClose();
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : String(error));
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
}, [profile, format, getSelectedCookies, handleClose, t]);
|
|
|
|
const toggleDomain = useCallback(
|
|
(domain: string, cookies: UnifiedCookie[]) => {
|
|
setExportSelection((prev) => {
|
|
// `prev[domain]` is `undefined` when the domain was previously fully
|
|
// deselected (entries are deleted on empty — see toggleCookie). Treat
|
|
// missing as "not selected" so re-enabling falls through to the add
|
|
// branch instead of crashing on `.allSelected`.
|
|
if (prev[domain]?.allSelected) {
|
|
const next = { ...prev };
|
|
delete next[domain];
|
|
return next;
|
|
}
|
|
return {
|
|
...prev,
|
|
[domain]: {
|
|
allSelected: true,
|
|
cookies: new Set(cookies.map((c) => c.name)),
|
|
},
|
|
};
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const toggleCookie = useCallback(
|
|
(domain: string, cookieName: string, totalCookies: number) => {
|
|
setExportSelection((prev) => {
|
|
const current = prev[domain] ?? {
|
|
allSelected: false,
|
|
cookies: new Set<string>(),
|
|
};
|
|
const newCookies = new Set(current.cookies);
|
|
if (newCookies.has(cookieName)) {
|
|
newCookies.delete(cookieName);
|
|
} else {
|
|
newCookies.add(cookieName);
|
|
}
|
|
if (newCookies.size === 0) {
|
|
const next = { ...prev };
|
|
delete next[domain];
|
|
return next;
|
|
}
|
|
return {
|
|
...prev,
|
|
[domain]: {
|
|
allSelected: newCookies.size === totalCookies,
|
|
cookies: newCookies,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const toggleExpand = useCallback((domain: string) => {
|
|
setExpandedDomains((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(domain)) {
|
|
next.delete(domain);
|
|
} else {
|
|
next.add(domain);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const toggleSelectAll = useCallback(() => {
|
|
if (!exportCookieData) return;
|
|
if (selectedExportCount === exportCookieData.total_count) {
|
|
setExportSelection({});
|
|
} else {
|
|
setExportSelection(initSelectionFromCookieData(exportCookieData));
|
|
}
|
|
}, [exportCookieData, selectedExportCount]);
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("cookies.management.title")}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<Tabs
|
|
defaultValue={initialTab}
|
|
onValueChange={handleTabChange}
|
|
className="w-full"
|
|
>
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
<TabsTrigger value="import">
|
|
{t("cookies.management.tabImport")}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="export">
|
|
{t("cookies.management.tabExport")}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="import" className="space-y-4 mt-4">
|
|
{!fileContent && (
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("cookies.management.importDescription")}
|
|
</p>
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 transition-colors cursor-pointer border-muted-foreground/25 hover:border-muted-foreground/50"
|
|
onClick={() =>
|
|
document.getElementById("cookie-file-input")?.click()
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
document.getElementById("cookie-file-input")?.click();
|
|
}
|
|
}}
|
|
>
|
|
<LuUpload className="size-10 text-muted-foreground mb-4" />
|
|
<p className="text-sm text-muted-foreground text-center">
|
|
{t("cookies.management.dropPrompt")}
|
|
<br />
|
|
<span className="text-xs">
|
|
{t("cookies.management.fileFormats")}
|
|
</span>
|
|
</p>
|
|
<input
|
|
id="cookie-file-input"
|
|
type="file"
|
|
accept=".txt,.cookies,.json"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) handleFileRead(file);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{fileContent && !importResult && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
|
|
<div>
|
|
<div className="font-medium">{fileName}</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{t("cookies.management.cookiesFound", {
|
|
count: cookieCount,
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<RippleButton variant="outline" onClick={resetImportState}>
|
|
{t("cookies.management.backButton")}
|
|
</RippleButton>
|
|
<LoadingButton
|
|
isLoading={isImporting}
|
|
onClick={() => void handleImport()}
|
|
disabled={cookieCount === 0}
|
|
>
|
|
{t("cookies.management.importButton")}
|
|
</LoadingButton>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{importResult && (
|
|
<div className="space-y-4">
|
|
<div className="p-4 rounded-lg bg-success/10">
|
|
<div className="font-medium text-success">
|
|
{t("cookies.management.importedSuccess", {
|
|
imported: importResult.cookies_imported,
|
|
replaced: importResult.cookies_replaced,
|
|
})}
|
|
</div>
|
|
{importResult.errors.length > 0 && (
|
|
<div className="mt-2 text-sm text-muted-foreground">
|
|
{t("cookies.management.linesSkipped", {
|
|
count: importResult.errors.length,
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<RippleButton onClick={handleClose}>
|
|
{t("cookies.management.doneButton")}
|
|
</RippleButton>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="export" className="space-y-3 mt-4">
|
|
<div className="space-y-2">
|
|
<Label>{t("cookies.export.formatLabel")}</Label>
|
|
<Select
|
|
value={format}
|
|
onValueChange={(v) => {
|
|
setFormat(v as "netscape" | "json");
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="json">
|
|
{t("cookies.export.json")}
|
|
</SelectItem>
|
|
<SelectItem value="netscape">
|
|
{t("cookies.export.netscape")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label>
|
|
{t("cookies.management.cookiesLabel")}{" "}
|
|
{exportCookieData && (
|
|
<span className="text-muted-foreground font-normal">
|
|
{t("cookies.management.selectionStatus", {
|
|
selected: selectedExportCount,
|
|
total: exportCookieData.total_count,
|
|
})}
|
|
</span>
|
|
)}
|
|
</Label>
|
|
{exportCookieData && exportCookieData.total_count > 0 && (
|
|
<button
|
|
type="button"
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={toggleSelectAll}
|
|
>
|
|
{selectedExportCount === exportCookieData.total_count
|
|
? t("cookies.management.deselectAll")
|
|
: t("cookies.management.selectAll")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isLoadingExportCookies ? (
|
|
<div className="flex items-center justify-center h-24">
|
|
<div className="animate-spin size-5 border-2 border-primary border-t-transparent rounded-full" />
|
|
</div>
|
|
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
|
|
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
|
|
{t("cookies.management.noCookies")}
|
|
</div>
|
|
) : (
|
|
<FadingScrollArea className="h-[200px]">
|
|
<div className="p-2 space-y-1">
|
|
{exportCookieData.domains.map((domain) => (
|
|
<ExportDomainRow
|
|
key={domain.domain}
|
|
domain={domain}
|
|
selection={exportSelection}
|
|
isExpanded={expandedDomains.has(domain.domain)}
|
|
onToggleDomain={toggleDomain}
|
|
onToggleCookie={toggleCookie}
|
|
onToggleExpand={toggleExpand}
|
|
/>
|
|
))}
|
|
</div>
|
|
</FadingScrollArea>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<RippleButton variant="outline" onClick={handleClose}>
|
|
{t("common.buttons.cancel")}
|
|
</RippleButton>
|
|
<LoadingButton
|
|
isLoading={isExporting}
|
|
onClick={() => void handleExport()}
|
|
disabled={selectedExportCount === 0}
|
|
>
|
|
{t("cookies.management.exportButton")}
|
|
</LoadingButton>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
interface ExportDomainRowProps {
|
|
domain: DomainCookies;
|
|
selection: SelectionState;
|
|
isExpanded: boolean;
|
|
onToggleDomain: (domain: string, cookies: UnifiedCookie[]) => void;
|
|
onToggleCookie: (
|
|
domain: string,
|
|
cookieName: string,
|
|
totalCookies: number,
|
|
) => void;
|
|
onToggleExpand: (domain: string) => void;
|
|
}
|
|
|
|
function ExportDomainRow({
|
|
domain,
|
|
selection,
|
|
isExpanded,
|
|
onToggleDomain,
|
|
onToggleCookie,
|
|
onToggleExpand,
|
|
}: ExportDomainRowProps) {
|
|
const domainSelection = selection[domain.domain];
|
|
const isAllSelected = domainSelection?.allSelected ?? false;
|
|
const selectedCount = domainSelection?.cookies.size ?? 0;
|
|
const isPartial =
|
|
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center gap-2 p-1.5 hover:bg-accent/50 rounded">
|
|
<Checkbox
|
|
checked={isAllSelected || isPartial}
|
|
onCheckedChange={() => {
|
|
onToggleDomain(domain.domain, domain.cookies);
|
|
}}
|
|
className={isPartial ? "opacity-70" : ""}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1 flex-1 text-left text-sm bg-transparent border-none cursor-pointer"
|
|
onClick={() => {
|
|
onToggleExpand(domain.domain);
|
|
}}
|
|
>
|
|
{isExpanded ? (
|
|
<LuChevronDown className="size-3.5" />
|
|
) : (
|
|
<LuChevronRight className="size-3.5" />
|
|
)}
|
|
<span className="font-medium truncate">{domain.domain}</span>
|
|
<span className="text-xs text-muted-foreground shrink-0">
|
|
({domain.cookie_count})
|
|
</span>
|
|
</button>
|
|
</div>
|
|
{isExpanded && (
|
|
<div className="ml-7 pl-2 border-l space-y-0.5">
|
|
{domain.cookies.map((cookie) => {
|
|
const isSelected =
|
|
domainSelection?.cookies.has(cookie.name) ?? false;
|
|
return (
|
|
<div
|
|
key={`${domain.domain}-${cookie.name}`}
|
|
className="flex items-center gap-2 p-1 text-sm hover:bg-accent/30 rounded"
|
|
>
|
|
<Checkbox
|
|
checked={isSelected || isAllSelected}
|
|
onCheckedChange={() => {
|
|
onToggleCookie(
|
|
domain.domain,
|
|
cookie.name,
|
|
domain.cookie_count,
|
|
);
|
|
}}
|
|
/>
|
|
<span className="truncate">{cookie.name}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|