166 lines
5.5 KiB
TypeScript
166 lines
5.5 KiB
TypeScript
/**
|
|
* Shared interpretation of a single spreadsheet cell for the bulk generator.
|
|
*
|
|
* The bulk page has always advertised URL, vCard, geo, phone and text support,
|
|
* but the save path hardcoded contentType 'URL'. The result was a code that
|
|
* scanned as a phone number in the downloaded SVG and as a broken link in the
|
|
* dashboard - the same row, two different codes.
|
|
*
|
|
* Both the client (which renders the SVG) and the API payload now go through
|
|
* this one function, so what is previewed, downloaded and stored cannot drift.
|
|
*/
|
|
|
|
export type BulkContentType = 'URL' | 'PHONE' | 'GEO' | 'VCARD' | 'TEXT';
|
|
|
|
export interface DetectedContent {
|
|
contentType: BulkContentType;
|
|
/** Shape expected by /api/qrs for this contentType. */
|
|
content: Record<string, any>;
|
|
/** Exactly what the QR modules should encode. */
|
|
qrValue: string;
|
|
}
|
|
|
|
const URL_RE = /^https?:\/\/\S+$/i;
|
|
const PHONE_RE = /^\+?[\d][\d\s()/.-]{5,}$/;
|
|
const GEO_RE = /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*(?:,\s*(.+))?$/;
|
|
|
|
/**
|
|
* Cells do not arrive as strings. ExcelJS hands back numbers, Dates and rich
|
|
* text objects, and calling .substring on those is how the preview table used
|
|
* to throw on a perfectly valid file.
|
|
*/
|
|
export function cellToString(value: unknown): string {
|
|
if (value === null || value === undefined) return '';
|
|
if (typeof value === 'string') return value.trim();
|
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
if (value instanceof Date) return value.toISOString();
|
|
if (typeof value === 'object') {
|
|
const v = value as any;
|
|
// ExcelJS hyperlink / formula / rich-text cell values.
|
|
if (typeof v.text === 'string') return v.text.trim();
|
|
if (typeof v.hyperlink === 'string') return v.hyperlink.trim();
|
|
if (typeof v.result === 'string' || typeof v.result === 'number') return String(v.result).trim();
|
|
if (Array.isArray(v.richText)) return v.richText.map((r: any) => r?.text ?? '').join('').trim();
|
|
}
|
|
return String(value).trim();
|
|
}
|
|
|
|
export function detectBulkContent(raw: unknown): DetectedContent {
|
|
const value = cellToString(raw);
|
|
|
|
if (!value) {
|
|
return { contentType: 'TEXT', content: { text: '' }, qrValue: '' };
|
|
}
|
|
|
|
if (URL_RE.test(value)) {
|
|
return { contentType: 'URL', content: { url: value }, qrValue: value };
|
|
}
|
|
|
|
// A bare domain is what people actually put in spreadsheets. Treating it as
|
|
// plain text produces a code that does nothing when scanned.
|
|
if (/^(www\.|[a-z0-9-]+\.[a-z]{2,}(\/|$))/i.test(value) && !value.includes(' ')) {
|
|
const url = `https://${value}`;
|
|
return { contentType: 'URL', content: { url }, qrValue: url };
|
|
}
|
|
|
|
const geo = GEO_RE.exec(value);
|
|
if (geo) {
|
|
const latitude = parseFloat(geo[1]);
|
|
const longitude = parseFloat(geo[2]);
|
|
if (Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180) {
|
|
const label = geo[3]?.trim() || '';
|
|
return {
|
|
contentType: 'GEO',
|
|
content: { latitude, longitude, label },
|
|
qrValue: `geo:${latitude},${longitude}${label ? `?q=${encodeURIComponent(label)}` : ''}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// firstName,lastName,email,phone,organization,title
|
|
if (value.includes('@') && value.split(',').length >= 3) {
|
|
const [firstName = '', lastName = '', email = '', phone = '', organization = '', title = ''] =
|
|
value.split(',').map((p) => p.trim());
|
|
return {
|
|
contentType: 'VCARD',
|
|
content: { firstName, lastName, email, phone, organization, title },
|
|
qrValue: buildVcard({ firstName, lastName, email, phone, organization, title }),
|
|
};
|
|
}
|
|
|
|
if (PHONE_RE.test(value)) {
|
|
const phone = value.replace(/[\s()/.-]/g, '');
|
|
return { contentType: 'PHONE', content: { phone }, qrValue: `tel:${phone}` };
|
|
}
|
|
|
|
return { contentType: 'TEXT', content: { text: value }, qrValue: value };
|
|
}
|
|
|
|
/**
|
|
* Kept byte-identical to the vCard the API builds for a static code, so the
|
|
* downloaded SVG and the stored code encode the same thing.
|
|
*/
|
|
export function buildVcard(c: {
|
|
firstName?: string;
|
|
lastName?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
organization?: string;
|
|
title?: string;
|
|
}): string {
|
|
return `BEGIN:VCARD
|
|
VERSION:3.0
|
|
FN:${c.firstName || ''} ${c.lastName || ''}
|
|
N:${c.lastName || ''};${c.firstName || ''};;;
|
|
${c.organization ? `ORG:${c.organization}` : ''}
|
|
${c.title ? `TITLE:${c.title}` : ''}
|
|
${c.email ? `EMAIL:${c.email}` : ''}
|
|
${c.phone ? `TEL:${c.phone}` : ''}
|
|
END:VCARD`;
|
|
}
|
|
|
|
/**
|
|
* A saved design preset and a stored QR style describe the same design with
|
|
* different field names for the logo. Converting once, here, is why a preset
|
|
* applied to a batch still shows up on the dashboard afterwards.
|
|
*/
|
|
export function presetStyleToQrStyle(style: any | null | undefined): Record<string, any> | undefined {
|
|
if (!style) return undefined;
|
|
|
|
const {
|
|
logoUrl,
|
|
logoSize,
|
|
foregroundColor,
|
|
backgroundColor,
|
|
moduleShape,
|
|
eyeFrameShape,
|
|
eyeBallShape,
|
|
gradientMode,
|
|
gradientTo,
|
|
frameType,
|
|
} = style;
|
|
|
|
return {
|
|
foregroundColor: foregroundColor || '#000000',
|
|
backgroundColor: backgroundColor || '#FFFFFF',
|
|
cornerStyle: 'square' as const,
|
|
size: 200,
|
|
...(moduleShape ? { moduleShape } : {}),
|
|
...(eyeFrameShape ? { eyeFrameShape } : {}),
|
|
...(eyeBallShape ? { eyeBallShape } : {}),
|
|
...(gradientMode ? { gradientMode } : {}),
|
|
...(gradientTo ? { gradientTo } : {}),
|
|
...(frameType ? { frameType } : {}),
|
|
...(logoUrl
|
|
? {
|
|
imageSettings: {
|
|
src: logoUrl,
|
|
height: logoSize ?? 24,
|
|
width: logoSize ?? 24,
|
|
excavate: true,
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
}
|