fix V2
This commit is contained in:
165
src/lib/bulk-content.ts
Normal file
165
src/lib/bulk-content.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user