|
|
@@ -0,0 +1,276 @@
|
|
|
+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;
|
|
|
+}
|
|
|
+
|