import { isUuid, projectSegmentCode, shortCode } from "./app-urls"; import { expandProjectSegment, expandRef } from "./ref-registry"; /** Persist only authenticated destinations and repeatable selection state. */ const ROUTES = new Set([ "home", "all", "inbox", "routines", "pull-requests", "trash", "settings", "billing", "admin", ]); /** Retired screens whose content moved elsewhere: restored tabs follow it. */ const MOVED_ROUTES: Readonly> = { // Personal statistics live in the profile since MIN-737. statistics: "settings", }; const PROJECT_SECTIONS = new Set(["feedback", "objectives", "pages", "settings", "triage"]); const SELECTION_PARAMS = new Set([ "view", "objective", "family", "tab", "open", "post", "routine", "pr", "entry", ]); export const APP_TAB_MAX_HREF = 2000; /** Match the whole destination, including parameters persistence does not keep. */ export function sameAppTabDestination(left: string, right: string): boolean { const canonical = (href: string) => { if (!normalizeAppTabLocation(href)) return null; const url = new URL(href, "https://minddy.invalid"); url.searchParams.sort(); return url.pathname.replace(/\/$/, "") + url.search + url.hash; }; const destination = canonical(left); return destination !== null && destination === canonical(right); } export function normalizeAppTabLocation(raw: unknown): string | null { if (typeof raw !== "string" || !raw.startsWith("/") || raw.startsWith("//") || // eslint-disable-next-line no-control-regex -- Reject control characters in stored destinations. raw.length > APP_TAB_MAX_HREF || /[\\\s\u0000-\u001f\u007f]/.test(raw)) return null; let url: URL; try { url = new URL(raw, "https://minddy.invalid"); } catch { return null; } if (url.origin !== "https://minddy.invalid") return null; const trimmed = url.pathname.replace(/\/$/, ""); const moved = MOVED_ROUTES[trimmed.slice(1)]; const path = moved ? `/${moved}` : trimmed; const segments = path.slice(1).split("/"); if (segments.some((segment) => !/^[a-zA-Z0-9_-]+$/.test(segment))) return null; const [root, project, section] = segments; if (root === "projects") { if (!project || segments.length > 4 || (section && !PROJECT_SECTIONS.has(section))) return null; if (segments.length === 4 && section !== "pages") return null; } else if (!ROUTES.has(root) || segments.length !== 1) return null; const params = new URLSearchParams(); for (const key of SELECTION_PARAMS) { const value = url.searchParams.get(key); if (value) params.set(key, value); } params.sort(); // Document anchors are repeatable; other fragments are transient UI instructions. const hash = root === "projects" && section === "pages" ? url.hash : ""; return path + (params.size ? `?${params}` : "") + hash; } /** Record parameters a destination may carry as a full id or as its short code. */ const RECORD_PARAMS = ["issue", "objective", "open", "post", "routine", "pr", "family", "view", "entry"]; /** * The destination a location names, for comparing a tab's stored href with the * address: every record reference is reduced to its short code, so a stored * full id, its short code and a keyed project segment (`aur-9e3d0c35`) name the * same destination. Navigation writes short codes into the address while a * stored tab may still hold full ids (MIN-875). */ export function appTabDestinationKey(href: string | null | undefined): string | null { const normalized = normalizeAppTabLocation(href); if (!normalized) return null; const url = new URL(normalized, "https://minddy.invalid"); const code = (value: string) => isUuid(value) ? shortCode(value) : value.toLowerCase(); const segments = url.pathname.split("/").map((segment, index, all) => all[1] === "projects" && index === 2 ? code(projectSegmentCode(segment)) : all[1] === "projects" && index === 4 ? code(segment) : segment); for (const name of RECORD_PARAMS) { const value = url.searchParams.get(name); if (value) url.searchParams.set(name, code(value)); } url.searchParams.sort(); return segments.join("/") + url.search + url.hash; } type RefExpander = (ref: string | null) => string | null; const rawRef: RefExpander = (ref) => ref; /** * The route with its record references expanded to ids: a short code becomes * the id this client has loaded for it, and null until then. */ export function appTabRoute(href: string): AppTabRoute { return parseAppTabRoute(href, expandRef); } /** The route as written, for the server, which resolves short codes itself. */ export function rawAppTabRoute(href: string): AppTabRoute { return parseAppTabRoute(href, rawRef); } export interface AppTabRoute { section: string; projectId: string | null; /** The objective the tab shows: the board's `objective` param (its * tickets), or the Objectives page's `open` selection (its details). * Either way the tab names it. */ objectiveId: string | null; /** The `family` selection param — a board scoped to one parent and its * direct children; the tab names the parent. */ familyId: string | null; /** The `view` selection param — a board view id, or the `cycle` sentinel * when the global board shows its cycle mode. */ view: string | null; /** A specific wiki page (…/pages/{pageId}) the tab is pinned on. */ pageId: string | null; /** `pull-requests?pr=` — a selected pull request. */ prId: string | null; /** `routines?routine=` — a selected routine. */ routineId: string | null; /** `admin?tab=` — the admin dashboard tab the app tab is on. */ adminTab: string | null; } function parseAppTabRoute(href: string, expand: RefExpander): AppTabRoute { const normalized = normalizeAppTabLocation(href) ?? "/home"; const [path, query] = normalized.split(/[?#]/); const parts = path.slice(1).split("/"); const params = new URLSearchParams(query); if (parts[0] !== "projects") { return { section: parts[0], projectId: null, objectiveId: null, familyId: null, view: expand(params.get("view")), pageId: null, prId: expand(params.get("pr")), routineId: expand(params.get("routine")), adminTab: parts[0] === "admin" ? params.get("tab") : null }; } return { section: parts[2] ?? "tickets", projectId: expand === rawRef ? parts[1] : expandProjectSegment(parts[1]), objectiveId: expand(params.get("objective") ?? (parts[2] === "objectives" ? params.get("open") : null)), familyId: expand(params.get("family")), view: expand(params.get("view")), // `trash` is the wiki's own section, not a page reference. pageId: parts[2] === "pages" && parts[3] ? (parts[3] === "trash" ? parts[3] : expand(parts[3])) : null, prId: expand(params.get("pr")), routineId: expand(params.get("routine")), adminTab: null, }; }