export const SEARCH_SCOPE = Object.freeze({ SINGLE: "single", MULTI: "multi", ALL: "all", }); /** * Read a query parameter from either: * - URLSearchParams (client-side `useSearchParams()`) * - Next.js server `searchParams` object (plain object with string or string[]) * * @param {any} searchParams * @param {string} key * @returns {string|null} */ function readParam(searchParams, key) { if (!searchParams) return null; // URLSearchParams-like object: has .get() if (typeof searchParams.get === "function") { const value = searchParams.get(key); return typeof value === "string" ? value : null; } // Next.js server searchParams: plain object const raw = searchParams[key]; if (Array.isArray(raw)) { return typeof raw[0] === "string" ? raw[0] : null; } return typeof raw === "string" ? raw : null; } function normalizeTrimmedOrNull(value) { if (typeof value !== "string") return null; const s = value.trim(); return s ? s : null; } function uniqueStable(items) { const out = []; const seen = new Set(); for (const it of items) { const s = String(it); if (!s) continue; if (seen.has(s)) continue; seen.add(s); out.push(s); } return out; } /** * Parse comma-separated branches param (branches=NL01,NL02). * * @param {string|null} raw * @returns {string[]} */ export function parseBranchesCsv(raw) { const s = typeof raw === "string" ? raw.trim() : ""; if (!s) return []; const parts = s .split(",") .map((x) => String(x).trim()) .filter(Boolean); return uniqueStable(parts); } /** * Serialize branches as comma-separated string. * * @param {string[]|null|undefined} branches * @returns {string|null} */ export function serializeBranchesCsv(branches) { if (!Array.isArray(branches) || branches.length === 0) return null; const cleaned = uniqueStable( branches.map((b) => String(b).trim()).filter(Boolean) ); return cleaned.length > 0 ? cleaned.join(",") : null; } /** * Parse search URL state from query params. * * Precedence (robust + shareable): * 1) scope=all -> ALL * 2) scope=multi OR branches=... -> MULTI * 3) branch=... or fallback routeBranch -> SINGLE * * Notes: * - For SINGLE we default to the route branch if branch param is missing. * - For MULTI/ALL we intentionally return branch=null (UI is route-context but API scope is not single). * * @param {URLSearchParams|Record|any} searchParams * @param {{ routeBranch?: string|null }=} options * @returns {{ * q: string|null, * scope: "single"|"multi"|"all", * branch: string|null, * branches: string[], * from: string|null, * to: string|null * }} */ export function parseSearchUrlState(searchParams, { routeBranch = null } = {}) { const q = normalizeTrimmedOrNull(readParam(searchParams, "q")); const scopeRaw = normalizeTrimmedOrNull(readParam(searchParams, "scope")); const branchRaw = normalizeTrimmedOrNull(readParam(searchParams, "branch")); const branches = parseBranchesCsv(readParam(searchParams, "branches")); const from = normalizeTrimmedOrNull(readParam(searchParams, "from")); const to = normalizeTrimmedOrNull(readParam(searchParams, "to")); let scope = SEARCH_SCOPE.SINGLE; if (scopeRaw === "all") { scope = SEARCH_SCOPE.ALL; } else if (scopeRaw === "multi" || branches.length > 0) { scope = SEARCH_SCOPE.MULTI; } if (scope === SEARCH_SCOPE.SINGLE) { const fallbackRouteBranch = normalizeTrimmedOrNull(routeBranch); const branch = branchRaw || fallbackRouteBranch || null; return { q, scope, branch, branches: [], from, to, }; } if (scope === SEARCH_SCOPE.MULTI) { return { q, scope, branch: null, branches, from, to, }; } // ALL return { q, scope: SEARCH_SCOPE.ALL, branch: null, branches: [], from, to, }; } /** * Serialize search URL state into a stable query string (no leading "?"). * * Stable param ordering: * - q * - scope * - branch * - branches * - from * - to * * @param {{ * q?: string|null, * scope?: "single"|"multi"|"all"|string|null, * branch?: string|null, * branches?: string[]|null, * from?: string|null, * to?: string|null * }} state * @returns {string} */ export function serializeSearchUrlState(state) { const s = state || {}; const params = new URLSearchParams(); const q = normalizeTrimmedOrNull(s.q); const from = normalizeTrimmedOrNull(s.from); const to = normalizeTrimmedOrNull(s.to); if (q) params.set("q", q); // Scope: we only emit what the UI needs for shareability. if (s.scope === SEARCH_SCOPE.ALL) { params.set("scope", "all"); } else if (s.scope === SEARCH_SCOPE.MULTI) { params.set("scope", "multi"); const csv = serializeBranchesCsv(s.branches); if (csv) params.set("branches", csv); } else { // SINGLE const branch = normalizeTrimmedOrNull(s.branch); if (branch) params.set("branch", branch); } // from/to are additive for RHL-025. We allow carrying them already. if (from) params.set("from", from); if (to) params.set("to", to); return params.toString(); }