| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276 |
- 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;
- }
|