import { isValidBranchParam } from "@/lib/frontend/params"; import { buildSearchHref } from "@/lib/frontend/search/pageHelpers"; import { SEARCH_SCOPE, SEARCH_LIMITS, DEFAULT_SEARCH_LIMIT, serializeSearchUrlState, } from "@/lib/frontend/search/urlState"; export const SEARCH_HISTORY_STORAGE_PREFIX = "rhl.searchHistory.v1"; export const DEFAULT_SEARCH_HISTORY_MAX_ITEMS = 10; function normalizeUserId(userId) { if (typeof userId !== "string") return null; const trimmed = userId.trim(); return trimmed ? trimmed : null; } function normalizeQuery(value) { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed ? trimmed : null; } function normalizeRouteBranch(value) { if (typeof value !== "string") return null; const normalized = value.trim().toUpperCase(); if (!normalized) return null; if (!isValidBranchParam(normalized)) return null; return normalized; } function toBranchNumber(branchId) { const match = /^NL(\d+)$/i.exec(String(branchId || "").trim()); if (!match) return null; const n = Number(match[1]); return Number.isInteger(n) ? n : null; } function compareBranchIds(a, b) { const aa = String(a || ""); const bb = String(b || ""); const na = toBranchNumber(aa); const nb = toBranchNumber(bb); if (na !== null && nb !== null) return na - nb; return aa.localeCompare(bb, "en"); } function normalizeBranches(value) { const source = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : []; const normalized = source .map((item) => String(item || "").trim().toUpperCase()) .filter((item) => item && isValidBranchParam(item)); const unique = Array.from(new Set(normalized)); unique.sort(compareBranchIds); return unique; } function normalizeScope(value, branches) { if (value === SEARCH_SCOPE.ALL) return SEARCH_SCOPE.ALL; if (value === SEARCH_SCOPE.MULTI) return SEARCH_SCOPE.MULTI; if (value === SEARCH_SCOPE.SINGLE) return SEARCH_SCOPE.SINGLE; return branches.length > 0 ? SEARCH_SCOPE.MULTI : SEARCH_SCOPE.SINGLE; } function normalizeLimit(value) { const n = Number(value); if (!Number.isInteger(n)) return DEFAULT_SEARCH_LIMIT; if (!SEARCH_LIMITS.includes(n)) return DEFAULT_SEARCH_LIMIT; return n; } function normalizeDate(value) { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed ? trimmed : null; } function normalizeCreatedAt(value) { const n = Number(value); if (!Number.isFinite(n) || n <= 0) return Date.now(); return Math.trunc(n); } function buildEntryState(entry) { return { q: entry.q, scope: entry.scope, branches: entry.branches, limit: entry.limit, from: entry.from, to: entry.to, }; } function buildHistoryIdentity(entry) { const qs = serializeSearchUrlState(buildEntryState(entry)); return `${entry.routeBranch}|${qs}`; } function normalizeMaxItems(value) { const n = Number(value); if (!Number.isInteger(n) || n < 1) return DEFAULT_SEARCH_HISTORY_MAX_ITEMS; return n; } function normalizeEntries(entries, { maxItems = Infinity } = {}) { const safeMax = Number.isFinite(maxItems) ? Math.max(1, Math.trunc(maxItems)) : Infinity; const out = []; const seen = new Set(); for (const raw of Array.isArray(entries) ? entries : []) { const normalized = normalizeSearchHistoryEntry(raw); if (!normalized) continue; const id = buildHistoryIdentity(normalized); if (seen.has(id)) continue; seen.add(id); out.push(normalized); if (out.length >= safeMax) break; } return out; } export function buildSearchHistoryStorageKey(userId) { const normalizedUserId = normalizeUserId(userId); if (!normalizedUserId) return null; return `${SEARCH_HISTORY_STORAGE_PREFIX}.${normalizedUserId}`; } /** * Normalize one search history entry into canonical form. * * Cursor is intentionally excluded from the schema because it is not shareable. * * @param {any} raw * @returns {{ * routeBranch: string, * q: string, * scope: "single"|"multi"|"all", * branches: string[], * limit: number, * from: string|null, * to: string|null, * createdAt: number * }|null} */ export function normalizeSearchHistoryEntry(raw) { if (!raw || typeof raw !== "object") return null; const routeBranch = normalizeRouteBranch(raw.routeBranch); const q = normalizeQuery(raw.q); if (!routeBranch || !q) return null; const allBranches = normalizeBranches(raw.branches); const scope = normalizeScope(raw.scope, allBranches); const branches = scope === SEARCH_SCOPE.MULTI ? allBranches : []; return { routeBranch, q, scope, branches, limit: normalizeLimit(raw.limit), from: normalizeDate(raw.from), to: normalizeDate(raw.to), createdAt: normalizeCreatedAt(raw.createdAt), }; } export function loadSearchHistory(userId) { const storageKey = buildSearchHistoryStorageKey(userId); if (!storageKey) return []; if (typeof window === "undefined") return []; try { const raw = window.localStorage.getItem(storageKey); if (!raw) return []; const parsed = JSON.parse(raw); return normalizeEntries(parsed); } catch { return []; } } export function saveSearchHistory(userId, entries) { const normalized = normalizeEntries(entries); const storageKey = buildSearchHistoryStorageKey(userId); if (!storageKey) return normalized; if (typeof window === "undefined") return normalized; try { if (normalized.length === 0) { window.localStorage.removeItem(storageKey); } else { window.localStorage.setItem(storageKey, JSON.stringify(normalized)); } } catch { // ignore storage quota and privacy mode errors } return normalized; } export function addSearchHistoryEntry( userId, entry, { maxItems = DEFAULT_SEARCH_HISTORY_MAX_ITEMS } = {}, ) { const normalizedEntry = normalizeSearchHistoryEntry(entry); if (!normalizedEntry) return loadSearchHistory(userId); const current = loadSearchHistory(userId); const currentId = buildHistoryIdentity(normalizedEntry); const deduped = current.filter((it) => buildHistoryIdentity(it) !== currentId); const capped = [normalizedEntry, ...deduped].slice(0, normalizeMaxItems(maxItems)); return saveSearchHistory(userId, capped); } export function clearSearchHistory(userId) { const storageKey = buildSearchHistoryStorageKey(userId); if (!storageKey) return false; if (typeof window === "undefined") return false; try { window.localStorage.removeItem(storageKey); return true; } catch { return false; } } export function buildSearchHrefFromEntry(entry) { const normalized = normalizeSearchHistoryEntry(entry); if (!normalized) return null; const href = buildSearchHref({ routeBranch: normalized.routeBranch, state: buildEntryState(normalized), }); // Defensive safety gate: only allow internal paths. if (typeof href !== "string") return null; if (!href.startsWith("/")) return null; if (href.startsWith("//")) return null; return href; }