This commit is contained in:
2026-07-27 20:47:36 +02:00
parent 90dfedf098
commit ab63d4b916
9 changed files with 1199 additions and 547 deletions

165
src/lib/bulk-content.ts Normal file
View 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,
},
}
: {}),
};
}

View File

@@ -139,6 +139,15 @@ export const RateLimits = {
windowSeconds: 60,
},
// Bulk create: 10 batches per minute.
// One upload is one request here, so this limits uploads rather than rows.
// Reusing QR_CREATE would have capped a batch at 20 codes.
QR_BULK_CREATE: {
name: 'qr-bulk-create',
maxRequests: 10,
windowSeconds: 60,
},
// Modify QR: 30 per minute
QR_MODIFY: {
name: 'qr-modify',