web_lib

Common web application libraries
git clone https://radroots.dev/git/web_lib.git
Log | Files | Refs | LICENSE

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:
Dutils/README.md | 1-
Mutils/package.json | 1+
Autils/src/currency.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mutils/src/errors/lib.ts | 11++++++-----
Autils/src/geo.ts | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autils/src/http.ts | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mutils/src/index.ts | 10++++++++++
Autils/src/lib.ts | 27+++++++++++++++++++++++++++
Autils/src/model.ts | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autils/src/numbers/lib.ts | 13+++++++++++++
Mutils/src/text/lib.ts | 9+++++++++
Autils/src/types.ts | 16++++++++++++++++
Mutils/src/types/lib.ts | 9+++++----
Autils/src/unit.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autils/src/validation/regex.ts | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autils/src/validation/schemas.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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