commit 38764a9933b13865dfd9d8ab4b362ac509b26052
parent 931dc5296392a1ab189f23d333b033036b939321
Author: triesap <triesap@radroots.dev>
Date: Tue, 18 Nov 2025 03:31:20 +0000
utils: add geospatial, http, numeric, text, currency, unit, model, and validation utilities, and integrate external error bindings
Diffstat:
16 files changed, 926 insertions(+), 10 deletions(-)
diff --git a/utils/README.md b/utils/README.md
@@ -1 +0,0 @@
-# utils
diff --git a/utils/package.json b/utils/package.json
@@ -35,6 +35,7 @@
"access": "public"
},
"dependencies": {
+ "@radroots/types-bindings": "*",
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.4.0",
"convert": "^5.5.1",
diff --git a/utils/src/currency.ts b/utils/src/currency.ts
@@ -0,0 +1,46 @@
+import { util_rxp } from "./validation/regex.js";
+
+export type FiatCurrency = `usd` | `eur`;
+export const fiat_currencies: FiatCurrency[] = [`usd`, `eur`] as const;
+
+// @todo
+export const price_to_formatted = (n: number, _currency: string) => Math.round(n * 100) / 100;
+
+export const parse_currency = (val?: string): FiatCurrency => {
+ const cur = val?.trim().toLowerCase()
+ switch (cur) {
+ case `usd`:
+ case `eur`:
+ return cur;
+ default:
+ return `usd`;
+ };
+};
+
+export const fmt_price = (locale: string, value: string, currency: string): string => {
+ const fmt = new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency: currency.toUpperCase(),
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2
+ });
+ return fmt.format(parseFloat(value));
+};
+
+export const parse_currency_marker = (locale: string, currency: string): string => {
+ const cur = parse_currency(currency);
+ const fmt = new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency: cur.toUpperCase(),
+ minimumFractionDigits: 2,
+ });
+ const fmt_basis = fmt.format(1);
+ let fmt_res: string | undefined = undefined;
+ fmt_res = fmt_basis.match(util_rxp.currency_marker)?.[0];
+ if (fmt_res) return fmt_res;
+ fmt_res = fmt_basis.match(util_rxp.currency_symbol)?.[0];
+ if (fmt_res) return fmt_res;
+ fmt_res = fmt_basis.match(new RegExp(cur, `i`))?.[0];
+ if (fmt_res) return fmt_res;
+ return cur.toUpperCase();
+};
+\ No newline at end of file
diff --git a/utils/src/errors/lib.ts b/utils/src/errors/lib.ts
@@ -1,15 +1,16 @@
-import { ErrorMessage } from "../types/lib.js";
+import { IError } from "@radroots/types-bindings";
-export const err_msg = <T extends string>(err: T): ErrorMessage<T> => {
- return { err };
+export const err_msg = <T extends string>(err: T | IError<T>): IError<T> => {
+ return typeof err === "string" ? { err } : err;
};
-export const throw_err = (param: string | ErrorMessage<string>): never => {
+export const throw_err = (param: string | IError<string>): never => {
if (typeof param === `string`) throw new Error(param);
else throw new Error(param.err);
};
-export const handle_error = (e: unknown, append?: string): ErrorMessage<string> => {
+export const handle_err = (e: unknown, append?: string): IError<string> => {
+ console.log(`[handle_err] `, e, append || "");
const msg = (e as Error).message ? (e as Error).message : String(e);
const err = `${msg}${append ? ` ${append}` : ``}`;
return { err };
diff --git a/utils/src/geo.ts b/utils/src/geo.ts
@@ -0,0 +1,267 @@
+import { decodeBase32, encodeBase32 } from "geohashing";
+
+export type GeometryPoint = {
+ type: string;
+ coordinates: number[];
+};
+
+export type GeometryPolygon = {
+ type: string;
+ coordinates: number[][][];
+};
+
+export type GeolocationPointTuple = [number, number];
+
+export type GeolocationAddress = {
+ primary: string;
+ admin: string;
+ country: string;
+};
+
+export type GeolocationPoint = {
+ lat: number;
+ lng: number;
+};
+
+export type LocationPoint = GeolocationCoordinatesPoint & {
+ error: GeolocationCoordinatesPoint;
+}
+
+export type GeocoderReverseResult = {
+ id: number;
+ name: string;
+ admin1_id: string | number;
+ admin1_name: string;
+ country_id: string;
+ country_name: string;
+ latitude: number;
+ longitude: number;
+};
+
+export type IClientGeolocationPosition = {
+ lat: number;
+ lng: number;
+ accuracy?: number;
+ altitude?: number;
+ altitude_accuracy?: number;
+};
+
+export type GeolocationCoordinatesPoint = {
+ lat: number;
+ lng: number;
+}
+
+export type GeolocationLatitudeFmtOption = 'dms' | 'd' | 'dm';
+
+export type LocationBasis = {
+ id: string;
+ point: GeolocationPoint;
+ address?: GeolocationAddress;
+};
+
+export const geohash_encode = (opts: {
+ lat: string | number;
+ lng: string | number;
+}): string => {
+ const lat = typeof opts.lat === `string` ? parseFloat(opts.lat) : opts.lat;
+ const lng = typeof opts.lng === `string` ? parseFloat(opts.lng) : opts.lng;
+ const geohash = encodeBase32(lat, lng);
+ return geohash;
+};
+
+export const geohash_decode = (geohash: string): LocationPoint => {
+ const { lat, lng, error: { lat: lat_err, lng: lng_err } } = decodeBase32(geohash);
+ return {
+ lat,
+ lng,
+ error: {
+ lat: lat_err,
+ lng: lng_err
+ }
+ };
+};
+
+export const location_geohash = (point: GeolocationCoordinatesPoint): string => {
+ const { lat, lng } = point;
+ const res = geohash_encode({ lat, lng });
+ return res;
+};
+
+export const parse_geop_point = (point: GeolocationCoordinatesPoint): GeolocationPoint => {
+ const { lat, lng } = point;
+ return { lat, lng };
+};
+
+export const parse_geol_coords = (number: number): number => {
+ return Math.round(number * 1e7) / 1e7;
+};
+
+export const parse_geolocation_address = (addr?: GeolocationAddress): GeolocationAddress | undefined => {
+ if (!addr) return undefined;
+ const { primary, admin, country } = addr;
+ return { primary, admin, country };
+};
+
+export const parse_geolocation_point = (point?: GeometryPoint): GeolocationPoint | undefined => {
+ if (!point) return undefined;
+ return {
+ lat: point.coordinates[1],
+ lng: point.coordinates[0],
+ };
+};
+
+export const geo_point_to_geometry = (point?: GeolocationPoint): GeometryPoint | undefined => {
+ if (!point) return undefined;
+ return {
+ type: 'Point',
+ coordinates: [point.lng, point.lat]
+ };
+};
+
+export const location_basis_to_geo_point = (basis?: LocationBasis): GeolocationPoint | undefined => {
+ if (!basis) return undefined;
+ return {
+ lat: basis.point.lat,
+ lng: basis.point.lng
+ };
+};
+
+export const parse_geocode_address = (geoc?: GeocoderReverseResult): GeolocationAddress | undefined => {
+ if (!geoc) return undefined;
+ const { name: primary, admin1_name: admin, country_id: country } = geoc;
+ return { primary, admin, country };
+};
+
+export const fmt_geocode_address = (geoc: GeocoderReverseResult): string => {
+ const addr = parse_geocode_address(geoc);
+ return addr ? `${addr.primary}, ${addr.admin}, ${addr.country}` : ``;
+};
+
+export const fmt_geolocation_address = (addr: GeolocationAddress): string => {
+ return `${addr.primary}, ${addr.admin}, ${addr.country}`;
+};
+
+export const fmt_geometry_point_coords = (point: GeometryPoint, locale: string): string => {
+ const lat = geol_lat_fmt(point.coordinates[0], `dms`, locale, 3);
+ const lng = geol_lng_fmt(point.coordinates[1], `dms`, locale, 3);
+ return `${lat}, ${lng}`;
+};
+
+export const parse_geom_point_tup = (point: GeometryPoint): GeolocationPointTuple => {
+ return [
+ point.coordinates[0],
+ point.coordinates[1],
+ ];
+};
+
+export const parse_geol_point_tup = (point: GeolocationPoint): GeolocationPointTuple => {
+ return [
+ point.lng,
+ point.lat
+ ];
+};
+
+export const parse_tup_geop_point = (map_center: GeolocationPointTuple): GeolocationPoint => {
+ return {
+ lat: map_center[1],
+ lng: map_center[0]
+ }
+};
+
+export const geol_lat_fmt = (lat: number, fmt_opt: GeolocationLatitudeFmtOption, locale: string, precision: number = 5): string => {
+ const options: Intl.NumberFormatOptions = {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ };
+ const fmt_deg = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 });
+ const fmt_min = new Intl.NumberFormat(locale, options);
+ const fmt_sec = new Intl.NumberFormat(locale, options);
+ if (fmt_opt === 'dms') {
+ const deg = Math.floor(Math.abs(lat));
+ const min = Math.floor((Math.abs(lat) - deg) * 60);
+ const sec = ((Math.abs(lat) - deg - min / 60) * 3600);
+ return `${fmt_deg.format(deg)}° ${fmt_min.format(min)}' ${fmt_sec.format(sec)}" ${lat >= 0 ? 'N' : 'S'}`;
+ } else if (fmt_opt === 'dm') {
+ const deg = Math.floor(Math.abs(lat));
+ const min = (Math.abs(lat) - deg) * 60;
+ return `${fmt_deg.format(deg)}° ${fmt_min.format(min)}' ${lat >= 0 ? 'N' : 'S'}`;
+ } else {
+ return `${lat.toLocaleString(locale, { maximumFractionDigits: precision })}° ${lat >= 0 ? 'N' : 'S'}`;
+ }
+};
+
+export const geol_lng_fmt = (lng: number, fmt_opt: GeolocationLatitudeFmtOption, locale: string, precision: number = 5): string => {
+ const options: Intl.NumberFormatOptions = {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ };
+ const fmt_deg = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 });
+ const fmt_min = new Intl.NumberFormat(locale, options);
+ const fmt_sec = new Intl.NumberFormat(locale, options);
+ if (fmt_opt === 'dms') {
+ const degrees = Math.floor(Math.abs(lng));
+ const minutes = Math.floor((Math.abs(lng) - degrees) * 60);
+ const seconds = ((Math.abs(lng) - degrees - minutes / 60) * 3600);
+ return `${fmt_deg.format(degrees)}° ${fmt_min.format(minutes)}' ${fmt_sec.format(seconds)}" ${lng >= 0 ? 'E' : 'W'}`;
+ } else if (fmt_opt === 'dm') {
+ const degrees = Math.floor(Math.abs(lng));
+ const minutes = (Math.abs(lng) - degrees) * 60;
+ return `${fmt_deg.format(degrees)}° ${fmt_min.format(minutes)}' ${lng >= 0 ? 'E' : 'W'}`;
+ } else {
+ return `${lng.toLocaleString(locale, { maximumFractionDigits: precision })}° ${lng >= 0 ? 'E' : 'W'}`;
+ }
+};
+
+export const compute_bounding_box = (lat: number, lng: number, distance_km: number): { nw: GeolocationPoint; ne: GeolocationPoint; se: GeolocationPoint; sw: GeolocationPoint; } => {
+ const deg_to_rad = (deg: number) => deg * (Math.PI / 180);
+ const rad_to_deg = (rad: number) => rad * (180 / Math.PI);
+
+ const R = 6371;
+
+ function destination_point(lat: number, lng: number, bearing: number, distance_km: number): GeolocationPoint {
+ const lat1 = deg_to_rad(lat);
+ const lon1 = deg_to_rad(lng);
+ const angular_distance = distance_km / R;
+
+ const lat2 = Math.asin(Math.sin(lat1) * Math.cos(angular_distance) + Math.cos(lat1) * Math.sin(angular_distance) * Math.cos(deg_to_rad(bearing)));
+ const lon2 = lon1 + Math.atan2(Math.sin(deg_to_rad(bearing)) * Math.sin(angular_distance) * Math.cos(lat1), Math.cos(angular_distance) - Math.sin(lat1) * Math.sin(lat2));
+
+ return { lat: rad_to_deg(lat2), lng: rad_to_deg(lon2) };
+ }
+
+ const bearings = [0, 90, 180, 270];
+
+ const coords = bearings.map(bearing => destination_point(lat, lng, bearing, distance_km / Math.sqrt(2)));
+
+ return {
+ nw: coords[0],
+ ne: coords[1],
+ se: coords[2],
+ sw: coords[3]
+ };
+};
+
+export const geo_bounds_calc = (lat: number, lng: number, distance_km: number): { north: GeolocationPoint; south: GeolocationPoint; east: GeolocationPoint; west: GeolocationPoint; } => {
+ const deg_to_rad = (deg: number) => deg * (Math.PI / 180);
+ const rad_to_deg = (rad: number) => rad * (180 / Math.PI);
+
+ const R = 6371;
+
+ function destination_point(lat: number, lng: number, bearing: number, distance_km: number): GeolocationPoint {
+ const lat1 = deg_to_rad(lat);
+ const lon1 = deg_to_rad(lng);
+ const angular_distance = distance_km / R;
+
+ const lat2 = Math.asin(Math.sin(lat1) * Math.cos(angular_distance) + Math.cos(lat1) * Math.sin(angular_distance) * Math.cos(deg_to_rad(bearing)));
+ const lon2 = lon1 + Math.atan2(Math.sin(deg_to_rad(bearing)) * Math.sin(angular_distance) * Math.cos(lat1), Math.cos(angular_distance) - Math.sin(lat1) * Math.sin(lat2));
+
+ return { lat: rad_to_deg(lat2), lng: rad_to_deg(lon2) };
+ }
+
+ return {
+ north: destination_point(lat, lng, 0, distance_km),
+ south: destination_point(lat, lng, 180, distance_km),
+ east: destination_point(lat, lng, 90, distance_km),
+ west: destination_point(lat, lng, 270, distance_km)
+ };
+};
diff --git a/utils/src/http.ts b/utils/src/http.ts
@@ -0,0 +1,141 @@
+import type { IError } from "@radroots/types-bindings";
+import { FieldRecord, NotifyMessage } from "./types.js";
+
+export const is_err_response = (response: any): response is { err: string } => {
+ return "err" in response && typeof response.err === "string";
+}
+
+export const is_pass_response = (response: any): response is { pass: true } => {
+ return "pass" in response && response.pass === true;
+}
+
+export const is_result_response = (response: any): response is { result: string } => {
+ return "result" in response && typeof response.result === "string";
+}
+
+export const is_results_response = (response: any): response is { results: string[] } => {
+ return "results" in response && Array.isArray(response.results);
+}
+
+export const is_error_response = (response: any): response is { error: string } => {
+ return "error" in response && response.error;
+}
+
+export const is_message_response = (response: any): response is NotifyMessage => {
+ return (
+ typeof response === "object" &&
+ response !== null &&
+ "message" in response &&
+ typeof response.message === "string" &&
+ (response.ok === undefined || typeof response.ok === "string") &&
+ (response.cancel === undefined || typeof response.cancel === "string")
+ );
+};
+export type IClientHttp = {
+ fetch(opts: IHttpOpts): Promise<IHttpResponse | IError<string>>;
+};
+
+export type IHttpImageResponse = {
+ status: number;
+ blob?: Blob;
+ headers: FieldRecord;
+ url: string;
+};
+
+export type IHttpResponse = {
+ status: number;
+ data?: any;
+ error?: string;
+ message?: NotifyMessage;
+ headers: FieldRecord;
+ url: string;
+};
+
+export type IHttpOptsData = any;
+export type IHttpOptsParams = Record<string, string | string[]>;
+
+export type IHttpOpts = {
+ url: string;
+ method?: `get` | `post` | `put`;
+ params?: IHttpOptsParams;
+ data?: IHttpOptsData;
+ data_bin?: Uint8Array;
+ authorization?: string;
+ headers?: FieldRecord;
+ connect_timeout?: number;
+};
+
+export const lib_http_to_bodyinit = (data: any): any => { // @todo BodyInit
+ if (typeof data === 'string') return data;
+ else if (data instanceof FormData) return data;
+ else if (data instanceof Blob) return data;
+ else if (data instanceof ArrayBuffer) return data;
+ else if (data instanceof URLSearchParams) return data;
+ return JSON.stringify(data);
+}
+
+export const lib_http_parse_headers = (headers: Headers): FieldRecord => {
+ const acc: FieldRecord = {};
+ headers.forEach((value, key) => acc[key] = value);
+ return acc;
+};
+
+export const http_fetch_opts = (opts: IHttpOpts): { url: string; options: RequestInit; } => {
+ const { url } = opts;
+ const method = opts.method ? opts.method.toUpperCase() : `GET`;
+ const headers = new Headers();
+ if (method === `POST`) headers.append(`Content-Type`, `application/json`);
+ if (opts.authorization) headers.append(`Authorization`, `Bearer ${encodeURIComponent(opts.authorization)}`);
+ if (opts.headers) Object.entries(opts.headers).forEach(([k, v]) => headers.append(k, v))
+ const options: RequestInit = {
+ method,
+ headers,
+ }
+ if (opts.data) options.body = lib_http_to_bodyinit(opts.data);
+ if (opts.data_bin) options.body = opts.data_bin as any;
+ return {
+ url,
+ options
+ }
+};
+
+export const lib_http_parse_response = async (res: Response): Promise<Promise<IHttpResponse>> => {
+ let data: any = null;
+ try {
+ const res_json = await res.json();
+ if (typeof res_json === `string`) data = JSON.parse(res_json);
+ else data = res_json;
+ } catch { }
+ if (!data) data = await res.text();
+ return {
+ status: res.status,
+ url: res.url,
+ data: res.ok && data ? data : null,
+ error: !res.ok && is_error_response(data) ? data.error : undefined,
+ message: res.ok && is_message_response(data) ? data : undefined,
+ headers: lib_http_parse_headers(res.headers)
+ };
+};
+
+export const http_fetch = async (opts: IHttpOpts): Promise<IHttpResponse> => {
+ const { url, options } = http_fetch_opts(opts);
+ const response = await fetch(url, options);
+ let data: any = null;
+ try {
+ const res_json = await response.json();
+ data = typeof res_json === `string` ? JSON.parse(res_json) : res_json;
+ } catch { }
+ if (!data) {
+ try {
+ const res_text = await response.text();
+ data = res_text;
+ } catch { }
+ }
+ return {
+ status: response.status,
+ url: response.url,
+ data,
+ headers: lib_http_parse_headers(response.headers)
+ };
+};
+
diff --git a/utils/src/index.ts b/utils/src/index.ts
@@ -1,5 +1,15 @@
+export * from "./currency.js"
export * from "./errors/lib.js"
+export * from "./geo.js"
+export * from "./http.js"
export * from "./id/lib.js"
+export * from "./lib.js"
+export * from "./model.js"
+export * from "./numbers/lib.js"
export * from "./text/lib.js"
export * from "./time/lib.js"
+export * from "./types.js"
export * from "./types/lib.js"
+export * from "./unit.js"
+export * from "./validation/regex.js"
+export * from "./validation/schemas.js"
diff --git a/utils/src/lib.ts b/utils/src/lib.ts
@@ -0,0 +1,27 @@
+import { CallbackPromise } from "./types/lib.js";
+
+export const exe_iter = async (callback: CallbackPromise, num: number = 1, delay: number = 400): Promise<void> => {
+ try {
+ const iter_fn = (count: number) => {
+ if (count > 0) {
+ callback();
+ if (count > 1) {
+ setTimeout(() => {
+ iter_fn(count - 1);
+ }, delay);
+ }
+ }
+ };
+ iter_fn(num);
+ } catch (e) {
+ console.log(`(error) exe_iter `, e);
+ }
+};
+
+export type MediaImageUploadResult = {
+ base_url: string;
+ file_hash: string;
+ file_ext: string;
+};
+
+export const fmt_media_image_upload_result_url = (res: MediaImageUploadResult): string => `${res.base_url}/${res.file_hash}.${res.file_ext}`;
diff --git a/utils/src/model.ts b/utils/src/model.ts
@@ -0,0 +1,129 @@
+export type IModelsQueryValue = string | number | boolean | null;
+export type IModelsQueryBindValue = string | number | boolean | null;;
+export type IModelsQueryBindValueTuple = [string, IModelsQueryValue];
+export type IModelsQueryBindValueOpt = (IModelsQueryBindValue | null);
+export type IModelsQueryFilterOption = `equals` | `starts-with` | `ends-with` | `contains` | `ne`;
+export type IModelsQueryFilterOptionList = `between` | `in`;
+export type IModelsQueryFilterCondition = `and` | `or` | `not`
+
+export type IModelsSortCreatedAt = 'newest' | 'oldest';
+export type IModelsQueryParam = { query: string; bind_values: IModelsQueryBindValue[] };
+export type IModelsFormErrorTuple = [boolean, string];
+export type IModelsFormValidationTuple = [RegExp, string];
+export type IModelsSchemaErrors = { err_s: string[]; };
+export type IModelsForm = {
+ label?: string;
+ placeholder?: string;
+ validateKeypress?: boolean;
+ preventFocusRest?: boolean;
+ validation: RegExp;
+ charset: RegExp;
+ hidden?: boolean;
+ optional?: boolean;
+ default?: string | number;
+};
+
+export type IModelQueryFilterMapValuesTuplesOption = [IModelsQueryValue, IModelsQueryFilterOption];
+export type IModelQueryFilterMapValuesTuplesOptionList = [IModelsQueryValue[], IModelsQueryFilterOptionList];
+export type IModelQueryFilterMapValuesTuples = ModelQueryFilterMapTuple<IModelQueryFilterMapValuesTuplesOption> | ModelQueryFilterMapTuple<IModelQueryFilterMapValuesTuplesOptionList>;
+export type IModelQueryFilterMapValues = IModelsQueryValue | IModelQueryFilterMapValuesTuples;
+
+export type ModelQueryFilterMapTupleBasis =
+ | [IModelsQueryValue, IModelsQueryFilterOption]
+ | [IModelsQueryValue, IModelsQueryFilterOption, IModelsQueryFilterCondition]
+ | [IModelsQueryValue[], IModelsQueryFilterOptionList]
+ | [IModelsQueryValue[], IModelsQueryFilterOptionList, IModelsQueryFilterCondition];
+
+export type ModelQueryFilterMapTuple<T extends ModelQueryFilterMapTupleBasis> =
+ T extends [IModelsQueryValue, IModelsQueryFilterOption]
+ ? [IModelsQueryValue, IModelsQueryFilterOption, IModelsQueryFilterCondition]
+ : T extends [IModelsQueryValue[], IModelsQueryFilterOptionList]
+ ? [IModelsQueryValue[], IModelsQueryFilterOptionList, IModelsQueryFilterCondition]
+ : T;
+
+export type IModelQueryFilterMap<ModelFilter extends object> = {
+ [K in keyof ModelFilter]: ModelFilter[K] | [ModelFilter[K], IModelsQueryFilterOption] | [ModelFilter[K], IModelsQueryFilterOption, IModelsQueryFilterCondition] | [ModelFilter[K][], IModelsQueryFilterOptionList] | [ModelFilter[K][], IModelsQueryFilterOptionList, IModelsQueryFilterCondition];
+};
+export type IModelQueryFilterMapParsed = { query_values: string[]; bind_values: IModelsQueryValue[]; };
+
+export const parse_model_query_value = (val: IModelsQueryValue): IModelsQueryBindValue => {
+ if (typeof val === `boolean`) return val ? '1' : '0';
+ else if (typeof val === `number`) return val;
+ else if (typeof val === `string` && val) return val;
+ return null;
+};
+
+export const is_model_query_filter_option = (value: string): value is IModelsQueryFilterOption => {
+ return ['equals', 'starts-with', 'ends-with', 'contains', 'ne'].includes(value);
+}
+
+export const is_model_query_filter_option_list = (value: string): value is IModelsQueryFilterOptionList => {
+ return ['between', 'in'].includes(value);
+}
+
+export const is_model_query_values = (value: unknown): value is IModelsQueryValue => {
+ return typeof value === `string` || typeof value === `number` || typeof value === `boolean`;
+}
+
+export const list_model_query_values_assert = (arr: (IModelsQueryValue | undefined)[]): (IModelsQueryValue)[] => {
+ return arr.filter((item): item is string | number | boolean => item !== undefined);
+}
+
+export const parse_model_filter_map = <T extends object>(opts: IModelQueryFilterMap<T>): IModelQueryFilterMapParsed => {
+ const bind_values: IModelsQueryValue[] = [];
+ const query_values: string[] = [];
+
+ for (const [index, entry] of Object.entries(opts).entries()) {
+ const [field, filters] = entry as [string, IModelQueryFilterMapValues];
+
+ if (is_model_query_values(filters)) {
+ query_values.push(`${field} = ?`);
+ bind_values.push(filters);
+ } else if (Array.isArray(filters)) {
+ const [filters_val, filters_opt] = filters;
+ const filter_condition = index === 0 ? `` : typeof filters[2] === `undefined` ? `AND ` : ` ${filters[2]}`;
+ if (is_model_query_values(filters_val) && is_model_query_filter_option(filters_opt)) {
+ switch (filters_opt) {
+ case `starts-with`: {
+ query_values.push(`${filter_condition}${field} LIKE ?`);
+ bind_values.push(`${filters[0]}%`);
+ } break;
+ case `ends-with`: {
+ query_values.push(`${filter_condition}${field} LIKE ?`);
+ bind_values.push(`%${filters[0]}`);
+ } break;
+ case `contains`: {
+ query_values.push(`${filter_condition}${field} LIKE ?`);
+ bind_values.push(`%${filters[0]}%`);
+ } break;
+ case `ne`: {
+ query_values.push(`${filter_condition}${field} != ?`);
+ bind_values.push(`${filters[0]}`);
+ } break;
+ case `equals`: {
+ query_values.push(`${filter_condition}${field} = ?`);
+ bind_values.push(filters[0]);
+ } break;
+ default:
+ throw new Error("util.model.parse_model_filter_map.invalid_condition");
+ };
+ } else if (is_model_query_filter_option_list(filters_opt)) {
+ switch (filters_opt) {
+ case `between`: {
+ query_values.push(`${filter_condition}${field} BETWEEN ? AND ?`);
+ bind_values.push(...filters[0].slice(0, 2));
+ } break;
+ case `in`: {
+ query_values.push(`${filter_condition}${field} IN (${`? `.repeat(filters[0].length).trim().split(" ").join(", ")})`);
+ bind_values.push(...list_model_query_values_assert(filters[0]));
+ } break;
+ default:
+ throw new Error("util.model.parse_model_filter_map.invalid_condition");
+ };
+ }
+ }
+ }
+ if (!query_values.length) throw new Error("Error: invalid filter.");
+ if (!bind_values.length) throw new Error("Error: invalid filter.");
+ return { query_values, bind_values };
+};
diff --git a/utils/src/numbers/lib.ts b/utils/src/numbers/lib.ts
@@ -0,0 +1,13 @@
+export const parse_int = (val: string, fallback: number = 0): number => {
+ const num = parseInt(val);
+ return isNaN(num) ? fallback : num;
+};
+
+export const parse_float = (val: string, fallback: number = 0): number => {
+ const num = parseFloat(val);
+ return isNaN(num) ? fallback : num;
+};
+
+export const num_str = (num: number): string => num.toString();
+
+export const num_interval_range = (min: number, max: number): number => Math.floor(Math.random() * (max - min + 1)) + min;
diff --git a/utils/src/text/lib.ts b/utils/src/text/lib.ts
@@ -1 +1,9 @@
export const root_symbol = "»`,-";
+
+export function text_enc(data: string): Uint8Array {
+ return new TextEncoder().encode(data);
+}
+
+export function text_dec(data: Uint8Array): string {
+ return new TextDecoder().decode(data);
+}
+\ No newline at end of file
diff --git a/utils/src/types.ts b/utils/src/types.ts
@@ -0,0 +1,15 @@
+export type FieldRecord = Record<string, string>;
+
+export type ResolveStatus = "info" | "warning" | "error" | "success";
+
+export type NotifyMessage = {
+ message: string;
+ ok?: string;
+ cancel?: string;
+};
+
+export type FileBytesFormat = `kb` | `mb` | `gb`;
+export type FileMimeType = string;
+export type FilePath = { file_path: string; file_name: string; mime_type: FileMimeType; }
+
+export type ValStr = string | undefined | null
+\ No newline at end of file
diff --git a/utils/src/types/lib.ts b/utils/src/types/lib.ts
@@ -1,3 +1,5 @@
+import type { IError } from "@radroots/types-bindings";
+
export type CallbackPromise = () => Promise<void>;
export type CallbackPromiseFigureResult<Ti, Tr> = (value: Ti) => Promise<Tr | undefined>;
export type CallbackPromiseFull<Ti, Tr> = (value: Ti) => Promise<Tr>;
@@ -5,14 +7,13 @@ export type CallbackPromiseGeneric<T> = (value: T) => Promise<void>;
export type CallbackPromiseReturn<T> = () => Promise<T>;
export type CallbackPromiseResult<Tr> = () => Promise<Tr | undefined>;
-export type ErrorMessage<T extends string> = { err: T };
-
export type ResultId = { id: string; };
export type ResultPass = { pass: true; };
+export type ResultBool = ResultObj<boolean>;
export type ResultsList<T> = { results: T[]; };
export type ResultObj<T> = { result: T; };
export type ResultPublicKey = { public_key: string; };
export type ResultSecretKey = { secret_key: string; };
-export type ResolveError<T> = T | ErrorMessage<string>;
-export type ResolveErrorMsg<TRes, TMsg extends string> = TRes | ErrorMessage<TMsg>;
+export type ResolveError<T> = T | IError<string>;
+export type ResolveErrorMsg<TRes, TMsg extends string> = TRes | IError<TMsg>;
diff --git a/utils/src/unit.ts b/utils/src/unit.ts
@@ -0,0 +1,56 @@
+import { z } from "zod";
+import { zf_area_unit, zf_mass_unit } from "./validation/schemas.js";
+
+export type AreaUnit = z.infer<typeof zf_area_unit>;
+export const area_units: AreaUnit[] = [`ac`, `ha`, `ft2`, `m2`] as const;
+
+export type MassUnit = z.infer<typeof zf_mass_unit>;
+export const mass_units: MassUnit[] = [`kg`, `lb`, `g`] as const;
+
+export function parse_mass_unit_default(val?: string): MassUnit {
+ const unit = parse_mass_unit(val);
+ return unit ?? `kg`
+}
+
+export function parse_mass_unit(val?: string): MassUnit | undefined {
+ switch (val) {
+ case `kg`:
+ case `lb`:
+ case `g`:
+ return val;
+ default:
+ return undefined;
+ };
+};
+
+export function mass_to_g(val: number, unit: string): number {
+ const mass_unit = parse_mass_unit(unit);
+ switch (mass_unit) {
+ case `kg`:
+ return val * 1000;
+ case `lb`:
+ return val * 453.592;
+ case `g`:
+ return val;
+ default:
+ throw new Error(`unsupported unit ${unit}`);
+ }
+}
+
+
+export function parse_area_unit_default(val?: string): AreaUnit {
+ const unit = parse_area_unit(val);
+ return unit ?? `ac`
+}
+
+export function parse_area_unit(val?: string): AreaUnit | undefined {
+ switch (val) {
+ case `ac`:
+ case `ha`:
+ case `ft2`:
+ case `m2`:
+ return val;
+ default:
+ return undefined;
+ };
+};
diff --git a/utils/src/validation/regex.ts b/utils/src/validation/regex.ts
@@ -0,0 +1,141 @@
+export const util_rxp = {
+ product_key: /^[A-Za-z_]+$/,
+ product_key_ch: /^[A-Za-z_]$/,
+ product_title: /[A-Za-z0-9 ]+$/,
+ product_title_ch: /[A-Za-z0-9 ]$/,
+ float: /^[+-]?(\d+(\.\d*)?|\.\d+)$/,
+ float_ch: /^[0-9\.\+\-]$/,
+ float_pos: /^\d+(\.\d+)?$/,
+ float_pos_ch: /^[0-9\.]$/,
+ description: /^(?:\S+(?:\s+\S+)*)$/,
+ description_ch: /[^a-zA-Z0-9.,!?;:'"(){}[]\s\u0600-\u06FF\u0900-\u097F\u0400-\u04FF\u0500-\u052F\u1F00-\u1FFF\u4E00-\u9FFF\uAC00-\uD7AF\u3040-\u309F\u30A0-\u30FF ]+/,
+ nbsp: /[\u00A0]/g,
+ nbsp_rp: /[\u00A0]+/g,
+ rtlm: /[\u200F]/g,
+ rtlm_rp: /[\u200F]+/g,
+ commas: /[,]+/g,
+ periods: /[.]+/g,
+ word_only: /^[a-zA-Z]+$/,
+ alpha: /[a-zA-Z ]$/,
+ alpha_ch: /[a-zA-Z ]$/,
+ num: /^[0-9]+$/,
+ lat: /^[-+]?([1-8]?[0-9](\.\d{1,6})?|90(\.0{1,6})?)$/,
+ lat_ch: /^[\d\.\+\-]$/,
+ lng: /^[-+]?((1[0-7]?[0-9]|180)(\.\d{1,6})?|(\d{1,2})(\.\d{1,6})?)$/,
+ lng_ch: /^[\d\.\+\-]$/,
+ alphanum: /[a-zA-Z0-9., ]$/,
+ alphanum_ch: /[a-zA-Z0-9.,\s\u0600-\u06FF\u0900-\u097F\u0400-\u04FF\u0500-\u052F\u1F00-\u1FFF\u4E00-\u9FFF\uAC00-\uD7AF\u3040-\u309F\u30A0-\u30FF ]+/,
+ price: /^\d+(\.\d+)?$/,
+ price_ch: /[0-9.]$/,
+ price_cur: /^[A-Za-z]{3}$/,
+ price_cur_ch: /[A-Za-z]$/,
+ profile_name: /^[a-zA-Z0-9._]{3,30}$/,
+ profile_name_ch: /[a-zA-Z0-9._]/,
+ trade_product_key: /^(?:[a-zA-Z0-9]+(?:\s+[a-zA-Z0-9]+){0,2})$/,
+ trade_product_category: /^(?:[a-zA-Z0-9]+(?:\s+[a-zA-Z0-9]+){0,2})$/,
+ currency_symbol: /(?:[A-Za-z]{3,5}\$|\p{Sc})/u,
+ currency_marker: /(?:[A-Za-z]{2,4}[^\d\s]+|[^\d\s]{1,3}[A-Za-z]{2,4})/,
+ ws_proto: /^(wss:\/\/|ws:\/\/)/,
+ quantity_unit: /^(kg|lb|g)$/,
+ quantity_unit_ch: /[A-Za-z]$/,
+ url_image_upload: /^file:\/\/.*\.(png|jpg|jpeg|gif|webp|bmp|svg)$/,
+ ///^blob:https:\/\/domain\.tld\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,
+ url_image_upload_dev: /^file:\/\/.*\.(png|jpg|jpeg|gif|webp|bmp|svg)$/,
+ // /^blob:http:\/\/localhost:\d+\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,
+ country_code_a2: /^[A-Za-z]{2}$/,
+ addr_primary: /[a-zA-Z0-9., ]$/,
+ addr_admin: /[a-zA-Z0-9., ]$/,
+ num_int: /^[0-9]$/,
+ area_unit: /^(ac|ha|ft2|m2)$/,
+ area_unit_ch: /[A-Za-z2]$/,
+};
+
+export type FormFieldsKey =
+ | `nostr_secret_key`
+ | `product_title`
+ | `product_key`
+ | `product_process`
+ | `product_description`
+ | `price`
+ | `price_currency`
+ | `quantity_unit`
+ | `quantity`
+ | `quantity_label`
+ | `farm_name`
+ | `farm_size`
+ | `area`
+ | `area_unit`
+ | `contact_name`
+ | `profile_name`
+
+export type FormField = {
+ validate: RegExp;
+ charset: RegExp;
+};
+
+export const form_fields: Record<FormFieldsKey, FormField> = {
+ profile_name: {
+ charset: util_rxp.profile_name_ch,
+ validate: util_rxp.profile_name,
+ },
+ product_description: {
+ charset: util_rxp.alpha_ch,
+ validate: util_rxp.alpha,
+ },
+ product_key: {
+ charset: util_rxp.product_key_ch,
+ validate: util_rxp.product_key,
+ },
+ product_title: {
+ charset: util_rxp.product_title_ch,
+ validate: util_rxp.product_title,
+ },
+ product_process: {
+ charset: util_rxp.alphanum_ch,
+ validate: util_rxp.alphanum,
+ },
+ price: {
+ charset: util_rxp.price_ch,
+ validate: util_rxp.price,
+ },
+ price_currency: {
+ charset: util_rxp.price_cur_ch,
+ validate: util_rxp.price_cur,
+ },
+ quantity: {
+ charset: util_rxp.num,
+ validate: util_rxp.num,
+ },
+ quantity_unit: {
+ charset: util_rxp.quantity_unit_ch,
+ validate: util_rxp.quantity_unit,
+ },
+ quantity_label: {
+ charset: util_rxp.alphanum_ch,
+ validate: util_rxp.alphanum,
+ },
+ area: {
+ charset: util_rxp.float_ch,
+ validate: util_rxp.float,
+ },
+ area_unit: {
+ charset: util_rxp.area_unit_ch,
+ validate: util_rxp.area_unit,
+ },
+ farm_name: {
+ charset: util_rxp.alpha_ch,
+ validate: util_rxp.alpha,
+ },
+ farm_size: {
+ charset: util_rxp.num_int,
+ validate: util_rxp.num_int,
+ },
+ contact_name: {
+ charset: util_rxp.alpha_ch,
+ validate: util_rxp.alpha,
+ },
+ nostr_secret_key: {
+ charset: util_rxp.alpha_ch,
+ validate: util_rxp.alpha,
+ }
+};
diff --git a/utils/src/validation/schemas.ts b/utils/src/validation/schemas.ts
@@ -0,0 +1,57 @@
+import { z } from "zod";
+import { GeocoderReverseResult, GeolocationAddress, GeolocationPoint } from "../geo.js";
+import { parse_int } from "../numbers/lib.js";
+import { util_rxp } from "./regex.js";
+
+export const zf_area_unit = z.union([
+ z.literal(`ac`),
+ z.literal(`ft2`),
+ z.literal(`ha`),
+ z.literal(`m2`),
+]);
+
+export const zf_mass_unit = z.union([
+ z.literal(`kg`),
+ z.literal(`lb`),
+ z.literal(`g`),
+]);
+
+export const schema_geolocation_address: z.ZodSchema<GeolocationAddress> = z.object({
+ primary: z.string().regex(util_rxp.addr_primary),
+ admin: z.string().regex(util_rxp.addr_admin),
+ country: z.string().regex(util_rxp.country_code_a2)
+});
+
+export const schema_geocode_result: z.ZodSchema<GeocoderReverseResult> = z.object({
+ id: z.number(),
+ name: z.string(),
+ admin1_id: z.union([z.string(), z.number()]),
+ admin1_name: z.string(),
+ country_id: z.string(),
+ country_name: z.string(),
+ latitude: z.number(),
+ longitude: z.number(),
+});
+
+export const schema_geolocation_point: z.ZodSchema<GeolocationPoint> = z.object({
+ lat: z.number().min(-90).max(90),
+ lng: z.number().min(-180).max(180),
+});
+
+export const zf_price_amount = z.preprocess((input) => {
+ return parse_int(String(input), 1.00);
+}, z.number().positive().multipleOf(0.01));
+
+export const zf_quantity_amount = z.preprocess((input) => {
+ return parse_int(String(input), 1);
+}, z.number().int().positive());
+
+export const zf_price = z.number().positive().multipleOf(0.01);
+
+export const zf_numi_pos = z.number().int().positive();
+
+export const zf_numf_pos = z.number().positive();
+
+export const zf_email = z.string().email();
+
+export const zf_username = z.string().regex(util_rxp.profile_name);
+\ No newline at end of file