Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
238 lines
7.2 KiB
TypeScript
238 lines
7.2 KiB
TypeScript
import type { ListResponse } from "$lib/types";
|
|
|
|
/**
|
|
* UI list page size (explicit `limit=` on fetches).
|
|
* API default when limit is omitted remains 50 (pagination.go); keep requests explicit.
|
|
*/
|
|
export const DEFAULT_PAGE_SIZE = 25;
|
|
export const MAX_PAGE_LIMIT = 200;
|
|
/** Cap for filter/option dropdowns (API maxPageLimit). */
|
|
export const OPTION_LIST_LIMIT = 200;
|
|
/** Cap for category/tree loads (API maxTreePageLimit). */
|
|
export const TREE_LIST_LIMIT = 2000;
|
|
/** Dashboard / checklist recent jobs strip - avoid unbounded job list payloads. */
|
|
export const RECENT_JOBS_LIMIT = 10;
|
|
export const SEARCH_DEBOUNCE_MS = 300;
|
|
|
|
export function clampLimit(limit: number, max = MAX_PAGE_LIMIT): number {
|
|
if (!Number.isFinite(limit) || limit <= 0) return DEFAULT_PAGE_SIZE;
|
|
return Math.min(Math.floor(limit), max);
|
|
}
|
|
|
|
/** True for aborted fetch/AbortController errors (DOMException or Error). */
|
|
export function isAbortError(err: unknown): boolean {
|
|
return (
|
|
(err instanceof DOMException && err.name === "AbortError") ||
|
|
(err instanceof Error && err.name === "AbortError")
|
|
);
|
|
}
|
|
|
|
/** Run async work over items with a fixed concurrency cap; preserves result order. */
|
|
export async function mapPool<
|
|
T,
|
|
R
|
|
>(items: readonly T[], concurrency: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
|
const n = items.length;
|
|
if (n === 0) return [];
|
|
const limit = Math.max(1, Math.min(Math.floor(concurrency) || 1, n));
|
|
const results = new Array<R>(n);
|
|
let next = 0;
|
|
async function worker() {
|
|
while (true) {
|
|
const i = next++;
|
|
if (i >= n) return;
|
|
results[i] = await fn(items[i]!, i);
|
|
}
|
|
}
|
|
await Promise.all(Array.from({ length: limit }, () => worker()));
|
|
return results;
|
|
}
|
|
|
|
/** 1-based page -> offset for limit/offset APIs. */
|
|
export function pageOffset(page: number, pageSize = DEFAULT_PAGE_SIZE): number {
|
|
const p = Math.max(1, Math.floor(Number(page)) || 1);
|
|
const size = clampLimit(pageSize);
|
|
return (p - 1) * size;
|
|
}
|
|
|
|
/**
|
|
* Align with apps/api/internal/catalog MaxOffsetWithoutCursor.
|
|
* Product list rejects OFFSET above this without cursor/after_id.
|
|
*/
|
|
export const MAX_OFFSET_WITHOUT_CURSOR = 5000;
|
|
|
|
/** Last 1-based page still reachable via OFFSET alone. */
|
|
export function maxOffsetSafePage(pageSize = DEFAULT_PAGE_SIZE): number {
|
|
const size = clampLimit(pageSize);
|
|
return Math.floor(MAX_OFFSET_WITHOUT_CURSOR / size) + 1;
|
|
}
|
|
|
|
/**
|
|
* Clamp a requested page so OFFSET stays within the API keyset gate.
|
|
* Deep URL pages fall back to page 1 (cursor-first — do not deep-OFFSET).
|
|
*/
|
|
export function clampPageForOffset(
|
|
page: number,
|
|
pageSize = DEFAULT_PAGE_SIZE
|
|
): number {
|
|
const p = Math.max(1, Math.floor(Number(page)) || 1);
|
|
if (pageOffset(p, pageSize) <= MAX_OFFSET_WITHOUT_CURSOR) return p;
|
|
return 1;
|
|
}
|
|
|
|
/** Safe OFFSET for a page, or null when the page requires a keyset cursor. */
|
|
export function offsetOrNullForPage(
|
|
page: number,
|
|
pageSize = DEFAULT_PAGE_SIZE
|
|
): number | null {
|
|
const offset = pageOffset(page, pageSize);
|
|
if (offset > MAX_OFFSET_WITHOUT_CURSOR) return null;
|
|
return offset;
|
|
}
|
|
|
|
function readTrimmedString(payload: unknown, key: string): string | null {
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
const value = (payload as Record<string, unknown>)[key];
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
|
|
/** Opaque keyset bookmark from product list responses (`next_cursor`). */
|
|
export function unwrapNextCursor(payload: unknown): string | null {
|
|
return readTrimmedString(payload, "next_cursor");
|
|
}
|
|
|
|
/** Tie-break id from product list responses (`next_after_id`). */
|
|
export function unwrapNextAfterId(payload: unknown): string | null {
|
|
return readTrimmedString(payload, "next_after_id");
|
|
}
|
|
|
|
export type SequentialNextQuery = { cursor: string } | { after_id: string };
|
|
|
|
/** Bookmark that loads a page: `null` = first page (no cursor), else keyset query. */
|
|
export type PageBookmark = SequentialNextQuery | null;
|
|
|
|
/**
|
|
* @deprecated Products UI always uses keyset (preferSequentialPagination).
|
|
* Kept for older callers that multiply by page size.
|
|
*/
|
|
export const OFFSET_SAFE_MAX_PAGES = 0;
|
|
|
|
/**
|
|
* Products list: always prefer sequential keyset (cursor/after_id) over OFFSET.
|
|
* API rejects offset > MaxOffsetWithoutCursor (5000) without a cursor.
|
|
*/
|
|
export function preferSequentialPagination(
|
|
_total?: number | null,
|
|
_pageSize = DEFAULT_PAGE_SIZE
|
|
): boolean {
|
|
return true;
|
|
}
|
|
|
|
/** Params for sequential "next" when the API returned a keyset bookmark.
|
|
* Prefers opaque `cursor` over `after_id`. Null -> caller should use offset.
|
|
*/
|
|
export function sequentialNextQuery(
|
|
nextCursor?: string | null,
|
|
nextAfterId?: string | null
|
|
): SequentialNextQuery | null {
|
|
const cursor = typeof nextCursor === "string" ? nextCursor.trim() : "";
|
|
if (cursor) return { cursor };
|
|
const afterId = typeof nextAfterId === "string" ? nextAfterId.trim() : "";
|
|
if (afterId) return { after_id: afterId };
|
|
return null;
|
|
}
|
|
|
|
/** Fresh stack for page 1 (no cursor). */
|
|
export function resetPageBookmarks(): PageBookmark[] {
|
|
return [null];
|
|
}
|
|
|
|
/** Bookmark that loads 1-based `page`, or `undefined` if that page was not visited. */
|
|
export function bookmarkForPage(
|
|
stack: readonly PageBookmark[],
|
|
page: number
|
|
): PageBookmark | undefined {
|
|
const p = Math.max(1, Math.floor(Number(page)) || 1);
|
|
if (p === 1) return null;
|
|
const idx = p - 1;
|
|
if (idx < 0 || idx >= stack.length) return undefined;
|
|
return stack[idx] ?? undefined;
|
|
}
|
|
|
|
/** Record the bookmark used to load 1-based `page` (truncates any deeper entries). */
|
|
export function recordPageBookmark(
|
|
stack: readonly PageBookmark[],
|
|
page: number,
|
|
bookmark: PageBookmark
|
|
): PageBookmark[] {
|
|
const p = Math.max(1, Math.floor(Number(page)) || 1);
|
|
const out = stack.slice(0, p - 1);
|
|
while (out.length < p - 1) out.push(null);
|
|
out.push(bookmark);
|
|
return out;
|
|
}
|
|
|
|
export function debounce<Args extends unknown[]>(
|
|
fn: (...args: Args) => void,
|
|
ms: number
|
|
): ((...args: Args) => void) & { cancel: () => void } {
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
const wrapped = ((...args: Args) => {
|
|
if (timer) clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
timer = null;
|
|
fn(...args);
|
|
}, ms);
|
|
}) as ((...args: Args) => void) & { cancel: () => void };
|
|
wrapped.cancel = () => {
|
|
if (timer) clearTimeout(timer);
|
|
timer = null;
|
|
};
|
|
return wrapped;
|
|
}
|
|
|
|
const NAMED_LIST_KEYS = [
|
|
"products",
|
|
"categories",
|
|
"attributes",
|
|
"category_attributes",
|
|
"api_keys",
|
|
"files",
|
|
"feeds",
|
|
"export_feeds",
|
|
"variables",
|
|
"jobs",
|
|
"members",
|
|
"team",
|
|
"items",
|
|
"data",
|
|
"fields",
|
|
"groups",
|
|
"campaigns",
|
|
"templates",
|
|
"orders",
|
|
"reviews",
|
|
"tickets",
|
|
"notifications",
|
|
"messages"
|
|
] as const;
|
|
|
|
export function unwrapList<T>(payload: ListResponse<T> | Record<string, unknown> | T[]): T[] {
|
|
if (Array.isArray(payload)) return payload;
|
|
if (!payload || typeof payload !== "object") return [];
|
|
const record = payload as Record<string, unknown>;
|
|
for (const key of NAMED_LIST_KEYS) {
|
|
const value = record[key];
|
|
if (Array.isArray(value)) return value as T[];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export function unwrapTotal(payload: unknown): number | null {
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
const total = (payload as Record<string, unknown>).total;
|
|
return typeof total === "number" ? total : null;
|
|
}
|