snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
76
packages/common-lib/debounce.test.ts
Normal file
76
packages/common-lib/debounce.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { debounce } from "./debounce";
|
||||
|
||||
describe("debounce", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("calls once with the latest args after the delay", () => {
|
||||
const fn = vi.fn();
|
||||
const d = debounce(fn, 100);
|
||||
|
||||
d("a");
|
||||
d("b");
|
||||
d("c");
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(fn).toHaveBeenCalledExactlyOnceWith("c");
|
||||
});
|
||||
|
||||
it("flush invokes a pending call immediately", () => {
|
||||
const fn = vi.fn();
|
||||
const d = debounce(fn, 100);
|
||||
|
||||
d("a");
|
||||
d("b");
|
||||
d.flush();
|
||||
expect(fn).toHaveBeenCalledExactlyOnceWith("b");
|
||||
|
||||
// The scheduled call was cancelled, so waiting out the delay adds nothing
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("flush is a no-op with nothing pending", () => {
|
||||
const fn = vi.fn();
|
||||
const d = debounce(fn, 100);
|
||||
|
||||
d.flush();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
d("a");
|
||||
vi.advanceTimersByTime(100);
|
||||
d.flush();
|
||||
expect(fn).toHaveBeenCalledExactlyOnceWith("a");
|
||||
});
|
||||
|
||||
it("cancel drops the pending call and its args", () => {
|
||||
const fn = vi.fn();
|
||||
const d = debounce(fn, 100);
|
||||
|
||||
d("a");
|
||||
d.cancel();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
// A cancelled call must not leak its args into the next one
|
||||
d.flush();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts a fresh delay after firing", () => {
|
||||
const fn = vi.fn();
|
||||
const d = debounce(fn, 100);
|
||||
|
||||
d("a");
|
||||
vi.advanceTimersByTime(100);
|
||||
d("b");
|
||||
vi.advanceTimersByTime(99);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(fn).toHaveBeenLastCalledWith("b");
|
||||
});
|
||||
});
|
||||
32
packages/common-lib/debounce.ts
Normal file
32
packages/common-lib/debounce.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
export function debounce(fn: (...args: any[]) => void, delay = 500) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
let lastArgs: any[] | null = null;
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
const result = (...args: any[]) => {
|
||||
lastArgs = args;
|
||||
if (timer != null) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
const argsToUse = lastArgs ?? [];
|
||||
lastArgs = null;
|
||||
fn(...argsToUse);
|
||||
}, delay);
|
||||
};
|
||||
result.cancel = () => {
|
||||
if (timer != null) clearTimeout(timer);
|
||||
timer = null;
|
||||
lastArgs = null;
|
||||
};
|
||||
// Invoke a pending call immediately instead of waiting out the delay
|
||||
result.flush = () => {
|
||||
if (timer == null) return;
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
const argsToUse = lastArgs ?? [];
|
||||
lastArgs = null;
|
||||
fn(...argsToUse);
|
||||
};
|
||||
return result;
|
||||
}
|
||||
36
packages/common-lib/eagerDebounceAsync.ts
Normal file
36
packages/common-lib/eagerDebounceAsync.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export function eagerDebounceAsync(fn: () => Promise<void>, delay: number) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let runAfterInFlight = false;
|
||||
|
||||
const run = async () => {
|
||||
if (inFlight != null) {
|
||||
runAfterInFlight = true;
|
||||
return;
|
||||
}
|
||||
|
||||
runAfterInFlight = false;
|
||||
inFlight = fn()
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
if (runAfterInFlight && timer == null) {
|
||||
void run();
|
||||
}
|
||||
});
|
||||
await inFlight;
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (timer == null) {
|
||||
void run();
|
||||
} else {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
void run();
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
20
packages/common-lib/formatSize.ts
Normal file
20
packages/common-lib/formatSize.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function formatSize(bytes: number): string {
|
||||
let num: number;
|
||||
let unit: string;
|
||||
|
||||
if (bytes > 1000 * 1000 * 1000) {
|
||||
num = bytes / 1000 / 1000 / 1000;
|
||||
unit = "GB";
|
||||
} else if (bytes > 1000 * 1000) {
|
||||
num = bytes / 1000 / 1000;
|
||||
unit = "MB";
|
||||
} else if (bytes > 1000) {
|
||||
num = bytes / 1000;
|
||||
unit = "KB";
|
||||
} else {
|
||||
num = bytes;
|
||||
unit = "B";
|
||||
}
|
||||
|
||||
return `${Math.round(num * 10) / 10} ${unit}`;
|
||||
}
|
||||
4
packages/common-lib/index.ts
Normal file
4
packages/common-lib/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./debounce";
|
||||
export * from "./eagerDebounceAsync";
|
||||
export * from "./formatSize";
|
||||
export * from "./templateFunction";
|
||||
39
packages/common-lib/jsonPath.ts
Normal file
39
packages/common-lib/jsonPath.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/** Literal steps into a JSON response, distinct from JSONPath operators. */
|
||||
export type JsonPathSegment =
|
||||
| { readonly kind: "key"; readonly key: string }
|
||||
| { readonly kind: "index"; readonly index: number };
|
||||
|
||||
const PATH_SEGMENT = /^(?:\.([A-Za-z_$][A-Za-z0-9_$]*)|\[(0|[1-9]\d*)\]|\[("(?:[^"\\]|\\.)*")\])/;
|
||||
|
||||
/**
|
||||
* Parse a single location with JSON-quoted keys (`$.a[0]["b.c"]`). Other
|
||||
* JSONPath expressions belong to the filter engine, not to the breadcrumb trail.
|
||||
*/
|
||||
export function jsonPathToSegments(path: string): JsonPathSegment[] | null {
|
||||
let rest = path.trim();
|
||||
if (!rest.startsWith("$")) return null;
|
||||
rest = rest.slice(1);
|
||||
|
||||
const segments: JsonPathSegment[] = [];
|
||||
while (rest.length > 0) {
|
||||
const match = PATH_SEGMENT.exec(rest);
|
||||
if (match == null) return null;
|
||||
const [whole, bareKey, index, quotedKey] = match;
|
||||
if (bareKey != null) {
|
||||
if (bareKey === "$") return null; // The library's root operator, not a literal key.
|
||||
segments.push({ kind: "key", key: bareKey });
|
||||
} else if (index != null) {
|
||||
const value = Number(index);
|
||||
if (!Number.isSafeInteger(value)) return null;
|
||||
segments.push({ kind: "index", index: value });
|
||||
} else if (quotedKey != null) {
|
||||
try {
|
||||
segments.push({ kind: "key", key: JSON.parse(quotedKey) as string });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
rest = rest.slice(whole.length);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
6
packages/common-lib/package.json
Normal file
6
packages/common-lib/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/lib",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts"
|
||||
}
|
||||
49
packages/common-lib/templateFunction.ts
Normal file
49
packages/common-lib/templateFunction.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
CallTemplateFunctionArgs,
|
||||
JsonPrimitive,
|
||||
TemplateFunctionArg,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
|
||||
export function validateTemplateFunctionArgs(
|
||||
fnName: string,
|
||||
args: TemplateFunctionArg[],
|
||||
values: CallTemplateFunctionArgs["values"],
|
||||
): string | null {
|
||||
for (const arg of args) {
|
||||
if ("inputs" in arg && arg.inputs) {
|
||||
// Recurse down
|
||||
const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);
|
||||
if (err) return err;
|
||||
}
|
||||
if (!("name" in arg)) continue;
|
||||
if (arg.optional) continue;
|
||||
if (arg.defaultValue != null) continue;
|
||||
if (arg.hidden) continue;
|
||||
if (values[arg.name] != null) continue;
|
||||
|
||||
return `Missing required argument "${arg.label || arg.name}" for template function ${fnName}()`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recursively apply form input defaults to a set of values */
|
||||
export function applyFormInputDefaults(
|
||||
inputs: TemplateFunctionArg[],
|
||||
values: { [p: string]: JsonPrimitive | undefined },
|
||||
) {
|
||||
let newValues: { [p: string]: JsonPrimitive | undefined } = { ...values };
|
||||
for (const input of inputs) {
|
||||
if ("defaultValue" in input && values[input.name] === undefined) {
|
||||
newValues[input.name] = input.defaultValue;
|
||||
}
|
||||
if (input.type === "checkbox" && values[input.name] === undefined) {
|
||||
newValues[input.name] = false;
|
||||
}
|
||||
// Recurse down to all child inputs
|
||||
if ("inputs" in input) {
|
||||
newValues = applyFormInputDefaults(input.inputs ?? [], newValues);
|
||||
}
|
||||
}
|
||||
return newValues;
|
||||
}
|
||||
Reference in New Issue
Block a user