Files
ProxyWarden/src/app/lib/snapshots.ts
T
dokril efda8eb98f
CI / Windows baseline (push) Canceled after 0s
Release v2.0.0
2026-09-10 20:59:52 +03:00

214 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { parseProxy } from "./parseProxy";
import {
itemTypeLabel,
normalizeItemValue,
type DraftItemType,
} from "./profileItems";
export type RouteMode = "external" | "local-singbox";
export interface ConfigSnapshotItem {
type: DraftItemType;
value: string;
recursive?: boolean;
}
export interface ConfigSnapshot {
enabled?: boolean;
routeMode: RouteMode;
proxy: string;
selectedServerId: string;
selectedServerTag: string;
items: ConfigSnapshotItem[];
}
export interface PendingChangeRow {
id: string;
label: string;
before?: string;
after: string;
tone?: "added" | "removed" | "changed";
}
export function configSnapshotFromUi(
routeMode: RouteMode,
proxyInput: string,
items: Array<{ type: DraftItemType; value: string; recursive?: boolean }>,
selectedServerId?: string,
selectedServerTag?: string,
enabled = true,
): ConfigSnapshot {
return {
enabled,
routeMode,
proxy: routeMode === "external" ? normalizeProxySnapshot(proxyInput) : "",
selectedServerId:
routeMode === "local-singbox" ? (selectedServerId?.trim() ?? "") : "",
selectedServerTag:
routeMode === "local-singbox" ? (selectedServerTag?.trim() ?? "") : "",
items: normalizeSnapshotItems(items),
};
}
export function configChangeRows(
applied: ConfigSnapshot,
current: ConfigSnapshot,
): PendingChangeRow[] {
if (sameConfigSnapshot(applied, current)) return [];
const rows: PendingChangeRow[] = [];
if ((applied.enabled ?? true) !== (current.enabled ?? true)) {
rows.push({
id: "profile-enabled",
label: "Профиль",
before: applied.enabled === false ? "Выключен" : "Включён",
after: current.enabled === false ? "Выключен" : "Включён",
});
}
if (applied.routeMode !== current.routeMode) {
rows.push({
id: "route-mode",
label: "Маршрут",
before: routeModeLabel(applied.routeMode),
after: routeModeLabel(current.routeMode),
});
}
if (
applied.proxy !== current.proxy &&
(applied.routeMode === "external" || current.routeMode === "external")
) {
rows.push({
id: "external-proxy",
label: "SOCKS5",
before: snapshotProxyChangeText(applied),
after: snapshotProxyChangeText(current),
});
}
if (
applied.selectedServerId !== current.selectedServerId &&
(applied.routeMode === "local-singbox" ||
current.routeMode === "local-singbox")
) {
rows.push({
id: "vpn-server",
label: "VPN сервер",
before: snapshotServerChangeText(applied),
after: snapshotServerChangeText(current),
});
}
rows.push(...snapshotItemChangeRows(applied.items, current.items));
return rows;
}
export function sameConfigSnapshot(
left: ConfigSnapshot,
right: ConfigSnapshot,
) {
return (
(left.enabled ?? true) === (right.enabled ?? true) &&
left.routeMode === right.routeMode &&
left.proxy === right.proxy &&
left.selectedServerId === right.selectedServerId &&
left.selectedServerTag === right.selectedServerTag &&
left.items.length === right.items.length &&
left.items.every((item, index) => {
const other = right.items[index];
return snapshotItemKey(item) === snapshotItemKey(other);
})
);
}
export function routeModeLabel(routeMode: RouteMode) {
return routeMode === "local-singbox" ? "Локальный прокси" : "Внешний прокси";
}
export function displaySnapshotProxy(proxy: string) {
return proxy.replace(/^socks5:\/\//, "") || "не указан";
}
export function displayServerTag(tag: string) {
const withoutFlags = tag
.replace(/[\u{1f1e6}-\u{1f1ff}]/gu, "")
.replace(/\s*->\s*/g, " -> ")
.replace(/\s*->\s*$/g, "")
.replace(/^\s*->\s*/g, "")
.replace(/\s{2,}/g, " ")
.trim();
return withoutFlags || tag;
}
function normalizeProxySnapshot(value: string) {
try {
const parsed = parseProxy(value);
return `${parsed.protocol}://${parsed.host.trim().toLowerCase()}:${parsed.port}`;
} catch {
return value.trim().toLowerCase();
}
}
function normalizeSnapshotItems(
items: Array<{ type: DraftItemType; value: string; recursive?: boolean }>,
): ConfigSnapshotItem[] {
return items
.map((item) => ({
type: item.type,
value: normalizeItemValue(item.value, item.type).toLowerCase(),
recursive: item.type === "folder" ? (item.recursive ?? true) : false,
}))
.filter((item) => item.value)
.sort((left, right) =>
snapshotItemKey(left).localeCompare(snapshotItemKey(right)),
);
}
function snapshotProxyChangeText(snapshot: ConfigSnapshot) {
return snapshot.routeMode === "external"
? displaySnapshotProxy(snapshot.proxy)
: "не используется";
}
function snapshotServerChangeText(snapshot: ConfigSnapshot) {
if (snapshot.routeMode !== "local-singbox") return "не используется";
return snapshot.selectedServerTag
? displayServerTag(snapshot.selectedServerTag)
: "сервер не выбран";
}
function snapshotItemChangeRows(
appliedItems: ConfigSnapshotItem[],
currentItems: ConfigSnapshotItem[],
): PendingChangeRow[] {
const appliedKeys = new Set(appliedItems.map(snapshotItemKey));
const currentKeys = new Set(currentItems.map(snapshotItemKey));
const added = currentItems.filter(
(item) => !appliedKeys.has(snapshotItemKey(item)),
);
const removed = appliedItems.filter(
(item) => !currentKeys.has(snapshotItemKey(item)),
);
return [
...added.map((item) => ({
id: `app-add-${snapshotItemKey(item)}`,
label: "Добавлено",
after: `+ ${formatSnapshotItem(item)}`,
tone: "added" as const,
})),
...removed.map((item) => ({
id: `app-remove-${snapshotItemKey(item)}`,
label: "Удалено",
after: `- ${formatSnapshotItem(item)}`,
tone: "removed" as const,
})),
];
}
function snapshotItemKey(item: ConfigSnapshotItem) {
return `${item.type}:${item.value}:${item.type === "folder" ? (item.recursive ?? true) : false}`;
}
function formatSnapshotItem(item: ConfigSnapshotItem) {
return `${itemTypeLabel(item.type)} ${item.value}${item.type === "folder" ? (item.recursive === false ? " (без подпапок)" : " (с подпапками)") : ""}`;
}