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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+237
View File
@@ -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;
}