Ctrl K
ComunidadGuías…

lib/app-tab-location.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<Record<string, string>> = {
  // 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,
  };
}