Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user