Ctrl K
ComunidadGuías…

lib/app-tabs.ts

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

Archivo original ↗Preguntar a KiBot
import { normalizeAppTabLocation } from "./app-tab-location";

export interface AppTab {
  id: string;
  user_id: string;
  href: string;
  custom_name: string | null;
  pinned: boolean;
  position: number;
  revision: number;
  created_at: string;
  updated_at: string;
}
export type AppTabPatch = Partial<Pick<AppTab, "href" | "custom_name" | "pinned">>;
export const APP_TAB_MAX_NAME = 200;
export const isAppTabId = (value: unknown): value is string =>
  typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);

export function createHomeTab(userId: string, id = crypto.randomUUID(), position = 0): AppTab {
  const now = new Date().toISOString();
  return { id, user_id: userId, href: "/home", custom_name: null, pinned: false,
    position, revision: 1, created_at: now, updated_at: now };
}

export function sortAppTabs(tabs: readonly AppTab[]): AppTab[] {
  return [...tabs].sort((a, b) => Number(b.pinned) - Number(a.pinned) ||
    a.position - b.position || a.id.localeCompare(b.id));
}

/** Move within the pinned or regular group without changing pin state. */
export function moveAppTabBefore(tabs: readonly AppTab[], id: string, beforeId: string | null): AppTab[] {
  const moved = tabs.find((tab) => tab.id === id);
  const before = tabs.find((tab) => tab.id === beforeId);
  if (!moved || beforeId === id || (beforeId && (!before || before.pinned !== moved.pinned))) return [...tabs];
  const group = sortAppTabs(tabs).filter((tab) => tab.pinned === moved.pinned && tab.id !== id);
  const index = before ? group.findIndex((tab) => tab.id === before.id) : group.length;
  group.splice(index, 0, moved);
  const positions = new Map(group.map((tab, position) => [tab.id, position]));
  return sortAppTabs(tabs.map((tab) => positions.has(tab.id) ? { ...tab, position: positions.get(tab.id)! } : tab));
}

/** Keep valid account rows; malformed destinations remain recoverable at Home. */
export function reconcileAppTabs(tabs: readonly AppTab[], userId: string): AppTab[] {
  const unique = new Map<string, AppTab>();
  for (const tab of tabs) {
    if (!isAppTabId(tab.id) || tab.user_id !== userId || !Number.isSafeInteger(tab.revision) || tab.revision < 1) continue;
    const previous = unique.get(tab.id);
    if (previous && previous.revision >= tab.revision) continue;
    unique.set(tab.id, { ...tab, href: normalizeAppTabLocation(tab.href) ?? "/home" });
  }
  return sortAppTabs([...unique.values()]);
}

/** Closing the last tab is refused. Prefer the next neighbor, then the previous. */
export function selectTabAfterClose(tabs: readonly AppTab[], id: string, activeId: string): string {
  const ordered = sortAppTabs(tabs);
  if (ordered.length <= 1 || id !== activeId) return activeId;
  const index = ordered.findIndex((tab) => tab.id === id);
  return ordered[index + 1]?.id ?? ordered[index - 1]?.id ?? ordered[0].id;
}

export function normalizeAppTabPatch(raw: unknown): AppTabPatch | null {
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
  const input = raw as Record<string, unknown>;
  const patch: AppTabPatch = {};
  if ("href" in input) {
    const href = normalizeAppTabLocation(input.href);
    if (!href) return null;
    patch.href = href;
  }
  if ("custom_name" in input) {
    if (input.custom_name !== null && typeof input.custom_name !== "string") return null;
    const name = (input.custom_name as string | null)?.trim().replace(/\s+/g, " ") || null;
    // eslint-disable-next-line no-control-regex -- Reject non-printable characters in tab names.
    if (name && (name.length > APP_TAB_MAX_NAME || /[\u0000-\u001f\u007f]/.test(name))) return null;
    patch.custom_name = name;
  }
  if ("pinned" in input) {
    if (typeof input.pinned !== "boolean") return null;
    patch.pinned = input.pinned;
  }
  return Object.keys(patch).length ? patch : null;
}