@etsoo/shared
Version:
TypeScript shared utilities and functions
892 lines (775 loc) • 22.9 kB
text/typescript
/// <reference lib="dom" />
import { DataTypes } from "./DataTypes";
import { DateUtils } from "./DateUtils";
import { Utils } from "./Utils";
import { ErrorData, ErrorType } from "./types/ErrorData";
import { FormDataFieldValue, IFormData } from "./types/FormData";
if (typeof navigator === "undefined") {
// Test mock only
globalThis.navigator = { language: "en-US" } as any;
globalThis.location = { href: "http://localhost/" } as any;
}
/**
* User agent data, maybe replaced by navigator.userAgentData in future
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgentData
*/
export type UserAgentData = {
/**
* Browser brands
*/
brands: {
brand: string;
version: string;
}[];
/**
* Is mobile device
*/
mobile: boolean;
/**
* Device brand (name)
*/
device: string;
/**
* Platform (OS)
*/
platform: string;
/**
* Platform version
*/
platformVersion?: string;
};
/**
* Dom Utilities
* Not all methods support Node
*/
export namespace DomUtils {
/**
* Language cache parameter name
*/
export const CultureField = "culture";
/**
* Country cache parameter name
*/
export const CountryField = "country";
/**
* Clear form data
* @param data Form data
* @param source Source data to match
* @param keepFields Fields need to be kept
*/
export function clearFormData(
data: IFormData,
source?: object,
keepFields?: string[]
) {
// Unique keys, FormData may have same name keys
const keys = new Set(data.keys());
// Remove empty key
const removeEmpty = (key: string) => {
// Need to be kept
if (keepFields != null && keepFields.includes(key)) return;
// Get all values
const formValues = data.getAll(key);
if (formValues.length == 1 && formValues[0] === "") {
// Remove empty field
data.delete(key);
}
};
if (source == null) {
// Remove all empty strings
for (const key of keys) {
removeEmpty(key);
}
} else {
const sourceKeys = Object.keys(source);
for (const key of sourceKeys) {
// Need to be kept
if (keepFields != null && keepFields.includes(key)) continue;
// Get all values
const formValues = data.getAll(key);
if (formValues.length > 0) {
// Matched
// Source value
const sourceValue = Reflect.get(source, key);
if (Array.isArray(sourceValue)) {
// Array, types may differ
if (formValues.join("`") === sourceValue.join("`")) {
// Equal value, remove the key
data.delete(key);
}
} else if (formValues.length == 1) {
// Other
if (formValues[0].toString() === `${sourceValue}`) {
// Equal value, remove the key
data.delete(key);
}
}
}
}
// Left fields
for (const key of keys) {
// Already cleared
if (sourceKeys.includes(key)) continue;
// Remove empties
removeEmpty(key);
}
}
// Return
return data;
}
function dataAsTraveller(
source: IFormData | object,
data: object,
template: object,
keepSource: boolean,
isValue: boolean
) {
// Properties
const properties = Object.keys(template);
// Entries
const entries = Object.entries(
isFormData(source) ? formDataToObject(source) : source
);
for (const [key, value] of entries) {
// Is included or keepSource
const property =
properties.find(
(p) => p.localeCompare(key, "en", { sensitivity: "base" }) === 0
) ?? (keepSource ? key : undefined);
if (property == null) continue;
// Template value
const templateValue = Reflect.get(template, property);
// Formatted value
let propertyValue: any;
if (templateValue == null) {
// Just read the source value
propertyValue = value;
} else {
if (isValue) {
// With template value
propertyValue = DataTypes.convert(value, templateValue);
} else {
// With template type
propertyValue = DataTypes.convertByType(value, templateValue);
}
}
// Set value
// Object.assign(data, { [property]: propertyValue });
// Object.defineProperty(data, property, { value: propertyValue });
Reflect.set(data, property, propertyValue);
}
}
/**
* Cast data as template format
* @param source Source data
* @param template Format template
* @param keepSource Keep other source properties
* @returns Result
*/
export function dataAs<T extends DataTypes.BasicTemplate>(
source: unknown,
template: T,
keepSource: boolean = false
): DataTypes.BasicTemplateType<T> {
// New data
// Object.create(...)
const data = <DataTypes.BasicTemplateType<T>>{};
if (source != null && typeof source === "object") {
// Travel all properties
dataAsTraveller(source, data, template, keepSource, false);
}
// Return
return data;
}
/**
* Cast data to target type
* @param source Source data
* @param template Template for generation
* @param keepSource Means even the template does not include the definition, still keep the item
* @returns Result
*/
export function dataValueAs<T extends object>(
source: IFormData | object,
templateValue: T,
keepSource: boolean = false
): Partial<T> {
// New data
// Object.create(...)
const data = <Partial<T>>{};
// Travel all properties
dataAsTraveller(source, data, templateValue, keepSource, true);
// Return
return data;
}
/**
* Current detected country
*/
export const detectedCountry = (() => {
// URL first, then local storage
let country: string | null;
try {
country =
new URL(location.href).searchParams.get(CountryField) ??
sessionStorage.getItem(CountryField) ??
localStorage.getItem(CountryField);
} catch {
country = null;
}
// Return
return country;
})();
/**
* Current detected culture
*/
export const detectedCulture = (() => {
// URL first, then local storage
let culture: string | null;
try {
culture =
new URL(location.href).searchParams.get(CultureField) ??
sessionStorage.getItem(CultureField) ??
localStorage.getItem(CultureField);
} catch {
culture = null;
}
// Browser detected
if (culture == null) {
culture =
(navigator.languages && navigator.languages[0]) || navigator.language;
}
// Return
return culture;
})();
/**
* Is two dimensions equal
* @param d1 Dimension 1
* @param d2 Dimension 2
*/
export function dimensionEqual(d1?: DOMRect, d2?: DOMRect) {
if (d1 == null && d2 == null) {
return true;
}
if (d1 == null || d2 == null) {
return false;
}
if (
d1.left === d2.left &&
d1.top === d2.top &&
d1.right === d2.right &&
d1.bottom === d2.bottom
) {
return true;
}
return false;
}
/**
* Download file from API fetch response body
* @param data Data
* @param suggestedName Suggested file name
* @param autoDetect Auto detect, false will use link click way
*/
export async function downloadFile(
data: ReadableStream | Blob,
suggestedName?: string,
autoDetect: boolean = true
) {
try {
if (autoDetect && "showSaveFilePicker" in globalThis) {
// AbortError - Use dismisses the window
const handle = await (globalThis as any).showSaveFilePicker({
suggestedName
});
if (!(await verifyPermission(handle, true))) return undefined;
const stream = await handle.createWritable();
if (data instanceof Blob) {
data.stream().pipeTo(stream);
} else {
await data.pipeTo(stream);
}
return true;
} else {
const url = window.URL.createObjectURL(
data instanceof Blob ? data : await new Response(data).blob()
);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
if (suggestedName) a.download = suggestedName;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
return true;
}
} catch (e) {
console.error("DomUtils.downloadFile with error", e);
}
return false;
}
/**
* File to data URL
* @param file File
* @returns Data URL
*/
export async function fileToDataURL(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = () => {
const data = reader.result;
if (data == null) {
reject();
return;
}
resolve(data as string);
};
reader.readAsDataURL(file);
});
}
/**
* Form data to object
* @param form Form data
* @returns Object
*/
export function formDataToObject(form: IFormData) {
const dic: Record<string, FormDataFieldValue | FormDataFieldValue[]> = {};
for (const key of new Set(form.keys())) {
const values = form.getAll(key);
dic[key] = values.length == 1 ? values[0] : values;
}
return dic;
}
/**
* Is wechat client
* @param data User agent data
* @returns Result
*/
export function isWechatClient(data?: UserAgentData | null) {
data ??= parseUserAgent();
if (!data) return false;
return data.brands.some(
(item) => item.brand.toLowerCase() === "micromessenger"
);
}
/**
* Culture match case Enum
*/
export enum CultureMatch {
Exact,
Compatible,
SamePart,
Default
}
/**
* Get English resources definition
* @param resources Resources
* @returns Result
*/
export const en = <T extends DataTypes.StringRecord = DataTypes.StringRecord>(
resources: T | (() => Promise<T>)
): DataTypes.CultureDefinition<T> => ({
name: "en",
label: "English",
resources
});
/**
* Get simplified Chinese resources definition
* @param resources Resources
* @returns Result
*/
export const zhHans = <
T extends DataTypes.StringRecord = DataTypes.StringRecord
>(
resources: T | (() => Promise<T>)
): DataTypes.CultureDefinition<T> => ({
name: "zh-Hans",
label: "简体中文",
resources,
compatibleNames: ["zh-CN", "zh-SG"]
});
/**
* Get traditional Chinese resources definition
* @param resources Resources
* @returns Result
*/
export const zhHant = <
T extends DataTypes.StringRecord = DataTypes.StringRecord
>(
resources: T | (() => Promise<T>)
): DataTypes.CultureDefinition<T> => ({
name: "zh-Hant",
label: "繁體中文",
resources,
compatibleNames: ["zh-HK", "zh-TW", "zh-MO"]
});
/**
* Get the available culture definition
* @param items Available cultures
* @param culture Detected culture
*/
export const getCulture = <T extends DataTypes.StringRecord>(
items: DataTypes.CultureDefinition<T>[],
culture: string
): [DataTypes.CultureDefinition<T>, CultureMatch] => {
if (items.length === 0) {
throw new Error("Culture items cannot be empty");
}
// Exact match
const exactMatch = items.find((item) => item.name === culture);
if (exactMatch) return [exactMatch, CultureMatch.Exact];
// Compatible match
const compatibleMatch = items.find(
(item) =>
item.compatibleNames?.includes(culture) ||
culture.startsWith(item + "-")
);
if (compatibleMatch) return [compatibleMatch, CultureMatch.Compatible];
// Same part, like zh-CN and zh-HK
const samePart = culture.split("-")[0];
const samePartMatch = items.find((item) => item.name.startsWith(samePart));
if (samePartMatch) return [samePartMatch, CultureMatch.SamePart];
// Default
return [items[0], CultureMatch.Default];
};
/**
* Get input value depending on its type
* @param input HTML input
* @returns Result
*/
export function getInputValue(input: HTMLInputElement) {
const type = input.type;
if (type === "number" || type === "range") {
const num = input.valueAsNumber;
if (isNaN(num)) return null;
return num;
} else if (type === "date" || type === "datetime-local")
return input.valueAsDate ?? DateUtils.parse(input.value);
return input.value;
}
/**
* Get an unique key combined with current URL
* @param key Key
*/
export const getLocationKey = (key: string) => `${location.href}:${key}`;
function isIterable<T>(
headers: Record<string, string> | Iterable<T>
): headers is Iterable<T> {
return Symbol.iterator in headers;
}
/**
* Convert headers to object
* @param headers Heaers
*/
export function headersToObject(
headers: HeadersInit | Iterable<[string, string]>
): Record<string, string> {
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
if (typeof Headers === "undefined") {
return Object.fromEntries(Object.entries(headers));
}
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (isIterable(headers)) {
return Object.fromEntries(headers);
}
return headers;
}
/**
* Is IFormData type guard
* @param input Input object
* @returns result
*/
export function isFormData(input: unknown): input is IFormData {
if (
typeof input === "object" &&
input != null &&
"entries" in input &&
"getAll" in input &&
"keys" in input
) {
return true;
}
return false;
}
/**
* Is JSON content type
* @param contentType Content type string
*/
export function isJSONContentType(contentType: string) {
if (
contentType &&
// application/problem+json
// application/json
(contentType.includes("json") ||
contentType.startsWith("application/javascript"))
)
return true;
return false;
}
/**
* Merge form data to primary one
* @param form Primary form data
* @param forms Other form data
* @returns Merged form data
*/
export function mergeFormData(form: IFormData, ...forms: IFormData[]) {
for (const newForm of forms) {
for (const key of new Set(newForm.keys())) {
form.delete(key);
newForm.getAll(key).forEach((value) => form.append(key, value as any));
}
}
return form;
}
/**
* Merge URL search parameters
* @param base URL search parameters
* @param data New simple object data to merge
*/
export function mergeURLSearchParams(
base: URLSearchParams,
data: DataTypes.SimpleObject
) {
Object.entries(data).forEach(([key, value]) => {
if (value == null) return;
base.set(key, value.toString());
});
return base;
}
/**
* Parse navigator's user agent string
* Lightweight User-Agent string parser
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent
* @param ua User agent string
* @returns User agent data
*/
export function parseUserAgent(ua?: string): UserAgentData | null {
ua ??= globalThis.navigator.userAgent;
if (!ua) {
return null;
}
const parts = ua.split(/(?!\(.*)\s+(?!\()(?![^(]*?\))/g);
let mobile = false;
let platform = "";
let platformVersion: string | undefined;
let device = "Desktop";
const brands: UserAgentData["brands"] = [];
// with the 'g' will causing failures for multiple calls
const platformVersionReg =
/^[a-zA-Z0-9-\s]+\s+(0|\d+)(\.(0|\d+)){0,3}(\(|$)/;
const versionReg = /^[a-zA-Z0-9]+\/(0|\d+)(\.(0|\d+)){0,3}(\(|$)/;
parts.forEach((part) => {
const pl = part.toLowerCase();
if (pl.startsWith("mozilla/")) {
const data = /\((.*)\)$/.exec(part);
if (data && data.length > 1) {
const pfItems = data[1].split(/;\s*/);
// Platform + Version
const pfIndex = pfItems.findIndex((item) =>
platformVersionReg.test(item)
);
if (pfIndex !== -1) {
const pfParts = pfItems[pfIndex].split(/\s+/);
platformVersion = pfParts.pop();
platform = pfParts.join(" ");
} else {
const appleVersionReg =
/((iPhone|Mac)\s+OS(\s+\w+)?)\s+((0|\d+)(_(0|\d+)){0,3})/i;
for (let i = 0; i < pfItems.length; i++) {
const match = appleVersionReg.exec(pfItems[i]);
if (match && match.length > 4) {
platform = match[1];
platformVersion = match[4].replace(/_/g, ".");
pfItems.splice(i, 1);
break;
}
}
}
// Device
const deviceIndex = pfItems.findIndex((item) =>
item.includes(" Build/")
);
if (deviceIndex === -1) {
const firstItem = pfItems[0];
if (
firstItem.toLowerCase() !== "linux" &&
!firstItem.startsWith(platform)
) {
device = firstItem;
pfItems.shift();
}
} else {
device = pfItems[deviceIndex].split(" Build/")[0];
pfItems.splice(deviceIndex, 1);
}
}
return;
}
if (pl === "mobile" || pl.startsWith("mobile/")) {
mobile = true;
return;
}
if (pl === "version" || pl.startsWith("version/")) {
// No process
return;
}
if (versionReg.test(part)) {
let [brand, version] = part.split("/");
const pindex = version.indexOf("(");
if (pindex > 0) {
version = version.substring(0, pindex);
}
brands.push({
brand,
version: Utils.trimEnd(version, ".0")
});
return;
}
});
return { mobile, platform, platformVersion, brands, device };
}
/**
* Set HTML element focus by name
* @param name Element name or first collection item
* @param container Container, limits the element range
*/
export function setFocus(name: string | object, container?: HTMLElement) {
const elementName = typeof name === "string" ? name : Object.keys(name)[0];
container ??= document.body;
const element = container.querySelector<HTMLElement>(
`[name="${elementName}"]`
);
if (element != null) element.focus();
}
/**
* Setup frontend logging
* @param action Logging action
* @param preventDefault Is prevent default action
* @param window Window object
*/
export function setupLogging(
action: (data: ErrorData) => void | Promise<void>,
preventDefault?: ((type: ErrorType) => boolean) | boolean,
window: Window & typeof globalThis = globalThis.window
) {
// Avoid multiple setup, if there is already a handler, please set "window.onunhandledrejection = null" first
if (window.onunhandledrejection) return;
const errorType: ErrorType = "error";
const errorPD = Utils.getResult(preventDefault, errorType) ?? true;
window.onerror = (message, source, lineNo, colNo, error) => {
// Default source
source ||= window.location.href;
let data: ErrorData;
if (typeof message === "string") {
data = {
type: errorType,
message, // Share the same message with error
source,
lineNo,
colNo,
stack: error?.stack
};
} else {
data = {
type: errorType,
subType: message.type,
message: error?.message ?? `${message.currentTarget} event error`,
source,
lineNo,
colNo,
stack: error?.stack
};
}
action(data);
// Return true to suppress error alert
return errorPD;
};
const rejectionType: ErrorType = "unhandledrejection";
const rejectionPD = Utils.getResult(preventDefault, rejectionType) ?? true;
window.onunhandledrejection = (event) => {
if (rejectionPD) event.preventDefault();
const reason = event.reason;
const source = window.location.href;
let data: ErrorData;
if (reason instanceof Error) {
const { name: subType, message, stack } = reason;
data = {
type: rejectionType,
subType,
message,
stack,
source
};
} else {
data = {
type: rejectionType,
message: typeof reason === "string" ? reason : JSON.stringify(reason),
source
};
}
action(data);
};
const localConsole = (
type: "consoleWarn" | "consoleError",
orgin: (...args: any[]) => void
) => {
const consolePD = Utils.getResult(preventDefault, type) ?? false;
return (...args: any[]) => {
// Keep original action
if (!consolePD) orgin(...args);
const [first, ...rest] = args;
let message: string;
if (typeof first === "string") {
message = first;
} else {
message = JSON.stringify(first);
}
const stack =
rest.length > 0
? rest.map((item) => JSON.stringify(item)).join(", ")
: undefined;
const data: ErrorData = {
type,
message,
source: window.location.href,
stack
};
action(data);
};
};
window.console.warn = localConsole("consoleWarn", window.console.warn);
window.console.error = localConsole("consoleError", window.console.error);
}
/**
* Verify file system permission
* https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle/requestPermission
* @param fileHandle FileSystemHandle
* @param withWrite With write permission
* @returns Result
*/
export async function verifyPermission(
fileHandle: any,
withWrite: boolean = false
) {
if (
!("queryPermission" in fileHandle) ||
!("requestPermission" in fileHandle)
)
return false;
// FileSystemHandlePermissionDescriptor
const opts = { mode: withWrite ? "readwrite" : "read" };
// Check if we already have permission, if so, return true.
if ((await fileHandle.queryPermission(opts)) === "granted") {
return true;
}
// Request permission to the file, if the user grants permission, return true.
if ((await fileHandle.requestPermission(opts)) === "granted") {
return true;
}
// The user did not grant permission, return false.
return false;
}
}