fix(backups): восстанавливать вкладку и фильтры из ссылки

This commit is contained in:
Denozordec committed 2026-10-01 16:37:34 +07:00
1 parent 6f152c8dfe
commit 11e357cc94
8 files changed
+187 -12

No files matched your search

@@ -0,0 +1,18 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { emptyBackupQuery, encodeBackupQuery, patchBackupsUrl, readBackupsUrl, validateBackupQuery } from "./backups-url-state"
const query = () => ({ ...emptyBackupQuery(), rules: [{ id: "rule-1", type: "rule" as const, path: ["server"], operator: "contains", value: "Москва & /" }] })
test("backup context roundtrips text/select filters and retains foreign keys", () => {
const href = patchBackupsUrl(new URLSearchParams("keep=1"), { q: "Тест & /", kind: "manual", tab: "settings", filters: encodeBackupQuery(query()) })
const params = new URL(href, "https://example.test").searchParams, result = readBackupsUrl(params)
assert.equal(params.get("keep"), "1"); assert.equal(result.tab, "settings"); assert.equal(result.kind, "manual"); assert.equal(result.search, "Тест & /"); assert.deepEqual(result.query, query()); assert.equal(result.error, null)
const select = { ...query(), rules: [{ id: "rule-1", type: "rule" as const, path: ["storage"], operator: "is_any_of", value: ["s3", "both"] }] }
assert.equal(validateBackupQuery(select), "valid"); assert.equal(encodeBackupQuery(emptyBackupQuery()), null)
})
test("bad, duplicate and oversized URL parameters remain visible errors", () => {
for (const raw of ["tab=bad", "kind=bad", "kind=auto&kind=manual", "q=a&q=b", "filters={}", "filters=bad", "q=" + "x".repeat(201)]) assert.ok(readBackupsUrl(new URLSearchParams(raw)).error)
for (const patch of [{ path: ["unknown"] }, { operator: "gt" }, { value: 1 }, { path: ["storage"], operator: "is", value: "unknown" }]) assert.equal(validateBackupQuery({ ...query(), rules: [{ ...query().rules[0], ...patch }] }), "invalid")
assert.equal(validateBackupQuery({ ...query(), rules: [query().rules[0], query().rules[0]] }), "invalid")
assert.equal(validateBackupQuery({ ...query(), rules: [{ ...query().rules[0], value: undefined }] }), "draft"); assert.throws(() => encodeBackupQuery({ ...query(), rules: [{ ...query().rules[0], value: undefined }] }))
const tooMany = { ...query(), rules: Array.from({ length: 51 }, (_, i) => ({ ...query().rules[0], id: "r" + i })) }; assert.equal(validateBackupQuery(tooMany), "invalid")
})
+73
View File
@@ -0,0 +1,73 @@
import type { FilterQuery } from "@/components/reui/filters/filters-types"
import { BACKUP_FILTER_FIELDS } from "./backup-filter-fields"
export type BackupKindFilter = "all" | "auto" | "manual"
export type BackupsTab = "history" | "settings"
export const emptyBackupQuery = (): FilterQuery => ({ id: "root", type: "group", combinator: "and", rules: [] })
const MAX_BYTES = 8192
const options = new Map(BACKUP_FILTER_FIELDS.map(field => [field.id, new Set((field.options ?? []).map(option => option.value))]))
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
export function validateBackupQuery(value: unknown): "valid" | "draft" | "invalid" {
const ids = new Set<string>()
let count = 0
let draft = false
function visit(node: unknown, depth: number): boolean {
if (!record(node) || depth > 4 || ++count > 50 || typeof node.id !== "string" || !node.id || ids.has(node.id)) return false
ids.add(node.id)
if (node.type === "group") return (node.combinator === "and" || node.combinator === "or") && Array.isArray(node.rules) && node.rules.every(child => visit(child, depth + 1))
if (node.type !== "rule" || !Array.isArray(node.path) || node.path.length !== 1 || typeof node.path[0] !== "string") return false
const allowed = options.get(node.path[0])
if (!allowed || (node.negated !== undefined && typeof node.negated !== "boolean")) return false
if (node.operator === "" || node.operator === undefined) { draft = true; return true }
if (node.operator === "empty" || node.operator === "not_empty") return node.value === undefined
const text = node.path[0] === "server"
const operators = text ? ["contains", "not_contains", "starts_with", "ends_with", "is", "is_not"] : ["is", "is_not", "is_any_of", "is_none_of"]
if (!operators.includes(String(node.operator))) return false
if (node.value === undefined || node.value === "" || (Array.isArray(node.value) && node.value.length === 0)) { draft = true; return true }
if (text) return typeof node.value === "string" && node.value.trim().length > 0 && node.value.length <= 256
if (node.operator === "is" || node.operator === "is_not") return typeof node.value === "string" && allowed.has(node.value)
return Array.isArray(node.value) && node.value.every(item => typeof item === "string" && allowed.has(item))
}
if (!record(value) || value.type !== "group" || !visit(value, 0)) return "invalid"
return draft ? "draft" : "valid"
}
export function encodeBackupQuery(query: FilterQuery): string | null {
if (validateBackupQuery(query) !== "valid") throw new Error("Завершите условия фильтра и проверьте их значения.")
if (query.rules.length === 0) return null
const encoded = JSON.stringify({ v: 1, query })
if (new TextEncoder().encode(encoded).length > MAX_BYTES) throw new Error("Слишком большой набор фильтров для ссылки.")
return encoded
}
export function readBackupsUrl(params: URLSearchParams) {
const search = params.get("q") ?? "", rawKind = params.get("kind"), rawTab = params.get("tab")
const kind: BackupKindFilter = rawKind === "auto" || rawKind === "manual" ? rawKind : "all"
const tab: BackupsTab = rawTab === "settings" ? "settings" : "history"
let error: string | null = null
if ((rawKind !== null && !["auto", "manual"].includes(rawKind)) || (rawTab !== null && !["history", "settings"].includes(rawTab))) error = "В ссылке указан неизвестный тип или раздел резервных копий."
if (search.length > 200) error = "Поиск в ссылке слишком длинный."
let query = emptyBackupQuery()
const raw = params.get("filters")
if (["q", "kind", "tab", "filters"].some(key => params.getAll(key).length > 1)) error = "В ссылке повторяются параметры списка."
if (raw !== null) {
try {
if (new TextEncoder().encode(raw).length > MAX_BYTES) throw new Error()
const parsed: unknown = JSON.parse(raw)
if (!record(parsed) || parsed.v !== 1 || validateBackupQuery(parsed.query) !== "valid") throw new Error()
query = parsed.query as FilterQuery
} catch { error = "Не удалось прочитать фильтры из ссылки. Сбросьте фильтры и задайте их заново." }
}
return { search, kind, tab, query, error }
}
/** Update only owned keys; retain object context and parameters from other features. */
export function patchBackupsUrl(params: URLSearchParams, changes: Record<string, string | null>) {
const next = new URLSearchParams(params)
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === "") next.delete(key)
else next.set(key, value)
}
return "/backups" + (next.size ? "?" + next.toString() : "")
}