103 lines
3.1 KiB
TypeScript
103 lines
3.1 KiB
TypeScript
export type PayloadKind =
|
||
"text" | "url" | "email" | "sms" | "wifi" | "vcard" | "geo";
|
||
|
||
export interface PayloadFields {
|
||
primary: string;
|
||
secondary?: string;
|
||
tertiary?: string;
|
||
hidden?: boolean;
|
||
}
|
||
|
||
function escapeWifi(value: string): string {
|
||
return value.replace(/[\\;,:"]/gu, (character) => `\\${character}`);
|
||
}
|
||
|
||
function escapeVcard(value: string): string {
|
||
return value
|
||
.replaceAll("\\", "\\\\")
|
||
.replaceAll("\n", "\\n")
|
||
.replaceAll(";", "\\;")
|
||
.replaceAll(",", "\\,");
|
||
}
|
||
|
||
export function buildStructuredPayload(
|
||
kind: PayloadKind,
|
||
fields: PayloadFields,
|
||
): string {
|
||
const primary = fields.primary.trim();
|
||
if (!primary) throw new Error("The primary value is required.");
|
||
switch (kind) {
|
||
case "text":
|
||
return fields.primary;
|
||
case "url": {
|
||
const url = new URL(primary);
|
||
if (!["http:", "https:"].includes(url.protocol))
|
||
throw new Error("The URL builder accepts HTTP and HTTPS URLs only.");
|
||
return url.href;
|
||
}
|
||
case "email":
|
||
return `mailto:${encodeURIComponent(primary)}${fields.secondary ? `?subject=${encodeURIComponent(fields.secondary)}` : ""}`;
|
||
case "sms":
|
||
return `SMSTO:${primary}:${fields.secondary ?? ""}`;
|
||
case "wifi": {
|
||
const supplied = (fields.tertiary || "WPA").trim();
|
||
if (!/^(?:WPA|WEP|nopass)$/iu.test(supplied))
|
||
throw new Error("Wi-Fi security must be WPA, WEP, or nopass.");
|
||
const security =
|
||
supplied.toLowerCase() === "nopass" ? "nopass" : supplied.toUpperCase();
|
||
return `WIFI:T:${security};S:${escapeWifi(primary)};P:${escapeWifi(fields.secondary ?? "")};H:${fields.hidden ? "true" : "false"};;`;
|
||
}
|
||
case "vcard":
|
||
return [
|
||
"BEGIN:VCARD",
|
||
"VERSION:3.0",
|
||
`FN:${escapeVcard(primary)}`,
|
||
fields.secondary ? `TEL:${escapeVcard(fields.secondary)}` : "",
|
||
fields.tertiary ? `EMAIL:${escapeVcard(fields.tertiary)}` : "",
|
||
"END:VCARD",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\r\n");
|
||
case "geo": {
|
||
const latitude = Number(primary);
|
||
const longitude = Number(fields.secondary);
|
||
if (
|
||
!Number.isFinite(latitude) ||
|
||
latitude < -90 ||
|
||
latitude > 90 ||
|
||
!Number.isFinite(longitude) ||
|
||
longitude < -180 ||
|
||
longitude > 180
|
||
)
|
||
throw new Error("Latitude must be −90…90 and longitude −180…180.");
|
||
return `geo:${latitude},${longitude}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
export function gtinCheckDigit(body: string): number {
|
||
if (!/^\d{1,17}$/u.test(body))
|
||
throw new Error("A GTIN body must contain 1–17 decimal digits.");
|
||
let sum = 0;
|
||
for (
|
||
let index = body.length - 1, position = 0;
|
||
index >= 0;
|
||
index -= 1, position += 1
|
||
)
|
||
sum += Number(body[index]) * (position % 2 === 0 ? 3 : 1);
|
||
return (10 - (sum % 10)) % 10;
|
||
}
|
||
|
||
export function validateGtin(value: string): {
|
||
valid: boolean;
|
||
expected: string;
|
||
} {
|
||
if (![8, 12, 13, 14].includes(value.length) || !/^\d+$/u.test(value))
|
||
return {
|
||
valid: false,
|
||
expected: "GTIN length must be 8, 12, 13, or 14 digits.",
|
||
};
|
||
const expected = String(gtinCheckDigit(value.slice(0, -1)));
|
||
return { valid: value.endsWith(expected), expected };
|
||
}
|