214 lines
6.2 KiB
TypeScript
214 lines
6.2 KiB
TypeScript
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 ? " (без подпапок)" : " (с подпапками)") : ""}`;
|
||
}
|