const BYTES_PER_MB = 1024 * 1024 /** * Max bytes for a single file when `maxFileSizeMb` is set (> 0). * Returns null when no per-field limit is configured (0 = server default on backend). */ export function getMaxFileSizeBytes(maxFileSizeMb: unknown): number | null { let mb = 0 if (typeof maxFileSizeMb === 'number' && !Number.isNaN(maxFileSizeMb)) { mb = maxFileSizeMb } else if (typeof maxFileSizeMb === 'string' && maxFileSizeMb.trim() !== '') { mb = parseInt(maxFileSizeMb, 10) if (Number.isNaN(mb)) mb = 0 } if (mb <= 0) return null return mb * BYTES_PER_MB } export function isFileWithinSizeLimit(file: File, maxFileSizeMb: unknown): boolean { const maxBytes = getMaxFileSizeBytes(maxFileSizeMb) if (maxBytes === null) return true return file.size <= maxBytes } export function filterFilesBySizeLimit(files: File[], maxFileSizeMb: unknown): File[] { return files.filter((f) => isFileWithinSizeLimit(f, maxFileSizeMb)) } /** Extensions without leading dot, lowercase (matches backend normalization). */ export function normalizeAllowedExtensions(allowed: unknown): string[] { if (!Array.isArray(allowed)) return [] const out: string[] = [] const seen = new Set() for (const item of allowed) { if (typeof item !== 'string') continue const ext = item.trim().replace(/^\.+/, '').toLowerCase() if (ext === '' || seen.has(ext)) continue seen.add(ext) out.push(ext) } return out } export function buildAcceptAttribute(allowed: unknown): string { return normalizeAllowedExtensions(allowed) .map((ext) => `.${ext}`) .join(',') } export function getFileExtension(fileName: string): string { const base = fileName.trim() const dot = base.lastIndexOf('.') if (dot <= 0 || dot === base.length - 1) return '' return base.slice(dot + 1).toLowerCase() } export function isFileExtensionAllowed(file: File, allowed: unknown): boolean { const exts = normalizeAllowedExtensions(allowed) if (exts.length === 0) return true const ext = getFileExtension(file.name) return ext !== '' && exts.includes(ext) } export function filterFilesByExtension(files: File[], allowed: unknown): File[] { return files.filter((f) => isFileExtensionAllowed(f, allowed)) } export function resolveMaxUploadCount( field: Record, allowMultiple: boolean, ): number | null { if (!allowMultiple) return 1 for (const key of ['maxNumberOfUploads', 'maxUploadCount'] as const) { const raw = field[key] let n = NaN if (typeof raw === 'number') { n = raw } else if (typeof raw === 'string' && raw.trim() !== '') { n = parseInt(raw, 10) } if (Number.isFinite(n) && n > 0) return Math.floor(n) } return null } export type FileUploadVariant = 'dropzone' | 'button' const BUTTON_PRESENTATION_VALUES = new Set(['button', 'button_only', 'simple']) /** Maps field upload presentation settings to IvyFileUploadDropzone variant. */ export function resolveFileUploadVariant(field: Record): FileUploadVariant { for (const key of ['uploadPresentation', 'uploadVariant', 'presentation', 'variant'] as const) { const raw = field[key] if (typeof raw !== 'string') continue const normalized = raw.trim().toLowerCase().replace(/-/g, '_') if (BUTTON_PRESENTATION_VALUES.has(normalized)) { return 'button' } if (normalized === 'dropzone' || normalized === 'drag_drop') { return 'dropzone' } } return 'dropzone' } /** Resolves the button-only upload label from field settings. */ export function resolveFileUploadChooseLabel( field: Record, fallback: string, ): string { for (const key of ['chooseLabel', 'uploadButtonLabel', 'buttonLabel'] as const) { const raw = field[key] if (typeof raw === 'string' && raw.trim() !== '') { return raw.trim() } } return fallback }