import type { CollectionBeforeOperationHook } from 'payload' import slugify from 'slugify' /** * Normalizes a filename: slugifies the NAME part (diacritics, spaces, case) * while preserving the extension. Keeps uploaded media URLs clean and portable. * * "Zdjęcie jeden nad morzem.jpg" → "zdjecie-jeden-nad-morzem.jpg" * "Faktura #12 (2024).PDF" → "faktura-12-2024.pdf" * "already-clean.webp" → "already-clean.webp" * * Why not toSlug(): toSlug uses strict:true, which would strip the dot and * merge name+extension. Here we split on the LAST dot, slug the stem, lowercase * the extension, and rejoin. */ export function normalizeFilename(filename: string): string { const lastDot = filename.lastIndexOf('.') // No extension (or leading-dot dotfile) → slug the whole thing. if (lastDot <= 0) { return slugify(filename, { lower: true, strict: true, trim: true }) } const stem = filename.slice(0, lastDot) const ext = filename.slice(lastDot + 1).toLowerCase() const cleanStem = slugify(stem, { lower: true, strict: true, trim: true }) const cleanExt = slugify(ext, { lower: true, strict: true, trim: true }) // Stem could slug to empty (e.g. filename was all symbols) — fall back so we // never produce a nameless file. const safeStem = cleanStem || 'plik' return cleanExt ? `${safeStem}.${cleanExt}` : safeStem } /** * beforeOperation hook for an upload collection (e.g. Media). Rewrites the * incoming file's name to its normalized form before Payload stores it, so both * the stored file and its DB filename are clean. Works with local disk and with * cloud storage adapters (R2/S3) — it runs before the storage layer. * * Wire into your Media collection: * import { normalizeFilenameHook } from '@intecion/ipal-kit' * hooks: { beforeOperation: [normalizeFilenameHook] } */ export const normalizeFilenameHook: CollectionBeforeOperationHook = ({ req, operation }) => { if (operation !== 'create' && operation !== 'update') return const file = req.file if (file?.name) { file.name = normalizeFilename(file.name) } }