history.js 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import { isValidBranchParam } from "@/lib/frontend/params";
  2. import { buildSearchHref } from "@/lib/frontend/search/pageHelpers";
  3. import {
  4. SEARCH_SCOPE,
  5. SEARCH_LIMITS,
  6. DEFAULT_SEARCH_LIMIT,
  7. serializeSearchUrlState,
  8. } from "@/lib/frontend/search/urlState";
  9. export const SEARCH_HISTORY_STORAGE_PREFIX = "rhl.searchHistory.v1";
  10. export const DEFAULT_SEARCH_HISTORY_MAX_ITEMS = 10;
  11. function normalizeUserId(userId) {
  12. if (typeof userId !== "string") return null;
  13. const trimmed = userId.trim();
  14. return trimmed ? trimmed : null;
  15. }
  16. function normalizeQuery(value) {
  17. if (typeof value !== "string") return null;
  18. const trimmed = value.trim();
  19. return trimmed ? trimmed : null;
  20. }
  21. function normalizeRouteBranch(value) {
  22. if (typeof value !== "string") return null;
  23. const normalized = value.trim().toUpperCase();
  24. if (!normalized) return null;
  25. if (!isValidBranchParam(normalized)) return null;
  26. return normalized;
  27. }
  28. function toBranchNumber(branchId) {
  29. const match = /^NL(\d+)$/i.exec(String(branchId || "").trim());
  30. if (!match) return null;
  31. const n = Number(match[1]);
  32. return Number.isInteger(n) ? n : null;
  33. }
  34. function compareBranchIds(a, b) {
  35. const aa = String(a || "");
  36. const bb = String(b || "");
  37. const na = toBranchNumber(aa);
  38. const nb = toBranchNumber(bb);
  39. if (na !== null && nb !== null) return na - nb;
  40. return aa.localeCompare(bb, "en");
  41. }
  42. function normalizeBranches(value) {
  43. const source = Array.isArray(value)
  44. ? value
  45. : typeof value === "string"
  46. ? value.split(",")
  47. : [];
  48. const normalized = source
  49. .map((item) => String(item || "").trim().toUpperCase())
  50. .filter((item) => item && isValidBranchParam(item));
  51. const unique = Array.from(new Set(normalized));
  52. unique.sort(compareBranchIds);
  53. return unique;
  54. }
  55. function normalizeScope(value, branches) {
  56. if (value === SEARCH_SCOPE.ALL) return SEARCH_SCOPE.ALL;
  57. if (value === SEARCH_SCOPE.MULTI) return SEARCH_SCOPE.MULTI;
  58. if (value === SEARCH_SCOPE.SINGLE) return SEARCH_SCOPE.SINGLE;
  59. return branches.length > 0 ? SEARCH_SCOPE.MULTI : SEARCH_SCOPE.SINGLE;
  60. }
  61. function normalizeLimit(value) {
  62. const n = Number(value);
  63. if (!Number.isInteger(n)) return DEFAULT_SEARCH_LIMIT;
  64. if (!SEARCH_LIMITS.includes(n)) return DEFAULT_SEARCH_LIMIT;
  65. return n;
  66. }
  67. function normalizeDate(value) {
  68. if (typeof value !== "string") return null;
  69. const trimmed = value.trim();
  70. return trimmed ? trimmed : null;
  71. }
  72. function normalizeCreatedAt(value) {
  73. const n = Number(value);
  74. if (!Number.isFinite(n) || n <= 0) return Date.now();
  75. return Math.trunc(n);
  76. }
  77. function buildEntryState(entry) {
  78. return {
  79. q: entry.q,
  80. scope: entry.scope,
  81. branches: entry.branches,
  82. limit: entry.limit,
  83. from: entry.from,
  84. to: entry.to,
  85. };
  86. }
  87. function buildHistoryIdentity(entry) {
  88. const qs = serializeSearchUrlState(buildEntryState(entry));
  89. return `${entry.routeBranch}|${qs}`;
  90. }
  91. function normalizeMaxItems(value) {
  92. const n = Number(value);
  93. if (!Number.isInteger(n) || n < 1) return DEFAULT_SEARCH_HISTORY_MAX_ITEMS;
  94. return n;
  95. }
  96. function normalizeEntries(entries, { maxItems = Infinity } = {}) {
  97. const safeMax = Number.isFinite(maxItems)
  98. ? Math.max(1, Math.trunc(maxItems))
  99. : Infinity;
  100. const out = [];
  101. const seen = new Set();
  102. for (const raw of Array.isArray(entries) ? entries : []) {
  103. const normalized = normalizeSearchHistoryEntry(raw);
  104. if (!normalized) continue;
  105. const id = buildHistoryIdentity(normalized);
  106. if (seen.has(id)) continue;
  107. seen.add(id);
  108. out.push(normalized);
  109. if (out.length >= safeMax) break;
  110. }
  111. return out;
  112. }
  113. export function buildSearchHistoryStorageKey(userId) {
  114. const normalizedUserId = normalizeUserId(userId);
  115. if (!normalizedUserId) return null;
  116. return `${SEARCH_HISTORY_STORAGE_PREFIX}.${normalizedUserId}`;
  117. }
  118. /**
  119. * Normalize one search history entry into canonical form.
  120. *
  121. * Cursor is intentionally excluded from the schema because it is not shareable.
  122. *
  123. * @param {any} raw
  124. * @returns {{
  125. * routeBranch: string,
  126. * q: string,
  127. * scope: "single"|"multi"|"all",
  128. * branches: string[],
  129. * limit: number,
  130. * from: string|null,
  131. * to: string|null,
  132. * createdAt: number
  133. * }|null}
  134. */
  135. export function normalizeSearchHistoryEntry(raw) {
  136. if (!raw || typeof raw !== "object") return null;
  137. const routeBranch = normalizeRouteBranch(raw.routeBranch);
  138. const q = normalizeQuery(raw.q);
  139. if (!routeBranch || !q) return null;
  140. const allBranches = normalizeBranches(raw.branches);
  141. const scope = normalizeScope(raw.scope, allBranches);
  142. const branches = scope === SEARCH_SCOPE.MULTI ? allBranches : [];
  143. return {
  144. routeBranch,
  145. q,
  146. scope,
  147. branches,
  148. limit: normalizeLimit(raw.limit),
  149. from: normalizeDate(raw.from),
  150. to: normalizeDate(raw.to),
  151. createdAt: normalizeCreatedAt(raw.createdAt),
  152. };
  153. }
  154. export function loadSearchHistory(userId) {
  155. const storageKey = buildSearchHistoryStorageKey(userId);
  156. if (!storageKey) return [];
  157. if (typeof window === "undefined") return [];
  158. try {
  159. const raw = window.localStorage.getItem(storageKey);
  160. if (!raw) return [];
  161. const parsed = JSON.parse(raw);
  162. return normalizeEntries(parsed);
  163. } catch {
  164. return [];
  165. }
  166. }
  167. export function saveSearchHistory(userId, entries) {
  168. const normalized = normalizeEntries(entries);
  169. const storageKey = buildSearchHistoryStorageKey(userId);
  170. if (!storageKey) return normalized;
  171. if (typeof window === "undefined") return normalized;
  172. try {
  173. if (normalized.length === 0) {
  174. window.localStorage.removeItem(storageKey);
  175. } else {
  176. window.localStorage.setItem(storageKey, JSON.stringify(normalized));
  177. }
  178. } catch {
  179. // ignore storage quota and privacy mode errors
  180. }
  181. return normalized;
  182. }
  183. export function addSearchHistoryEntry(
  184. userId,
  185. entry,
  186. { maxItems = DEFAULT_SEARCH_HISTORY_MAX_ITEMS } = {},
  187. ) {
  188. const normalizedEntry = normalizeSearchHistoryEntry(entry);
  189. if (!normalizedEntry) return loadSearchHistory(userId);
  190. const current = loadSearchHistory(userId);
  191. const currentId = buildHistoryIdentity(normalizedEntry);
  192. const deduped = current.filter((it) => buildHistoryIdentity(it) !== currentId);
  193. const capped = [normalizedEntry, ...deduped].slice(0, normalizeMaxItems(maxItems));
  194. return saveSearchHistory(userId, capped);
  195. }
  196. export function clearSearchHistory(userId) {
  197. const storageKey = buildSearchHistoryStorageKey(userId);
  198. if (!storageKey) return false;
  199. if (typeof window === "undefined") return false;
  200. try {
  201. window.localStorage.removeItem(storageKey);
  202. return true;
  203. } catch {
  204. return false;
  205. }
  206. }
  207. export function buildSearchHrefFromEntry(entry) {
  208. const normalized = normalizeSearchHistoryEntry(entry);
  209. if (!normalized) return null;
  210. const href = buildSearchHref({
  211. routeBranch: normalized.routeBranch,
  212. state: buildEntryState(normalized),
  213. });
  214. // Defensive safety gate: only allow internal paths.
  215. if (typeof href !== "string") return null;
  216. if (!href.startsWith("/")) return null;
  217. if (href.startsWith("//")) return null;
  218. return href;
  219. }