import config from '@payload-config' import crypto from 'crypto' import 'dotenv/config' import fs from 'fs/promises' import path from 'path' import { getPayload, type File, type Payload } from 'payload' const root = process.cwd() const spaDir = path.join(root, 'src/spa') const publicDir = path.join(root, 'public') const mediaExtensions = new Set([ '.avif', '.gif', '.jpeg', '.jpg', '.mov', '.mp4', '.png', '.svg', '.webm', '.webp', '.zip', ]) const mimeByExt: Record = { '.avif': 'image/avif', '.gif': 'image/gif', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.mov': 'video/quicktime', '.mp4': 'video/mp4', '.png': 'image/png', '.svg': 'image/svg+xml', '.webm': 'video/webm', '.webp': 'image/webp', '.zip': 'application/zip', } async function walk(dir: string): Promise { const entries = await fs.readdir(dir, { withFileTypes: true }) const files = await Promise.all( entries.map((entry) => { const full = path.join(dir, entry.name) return entry.isDirectory() ? walk(full) : Promise.resolve([full]) }), ) return files.flat() } function isMediaSource(value: string) { try { const parsed = value.startsWith('http') ? new URL(value) : undefined const pathname = parsed?.pathname || value.split('?')[0] return mediaExtensions.has(path.extname(pathname).toLowerCase()) || value.includes('images.unsplash.com') } catch { return false } } async function findSpaSources() { const files = (await walk(spaDir)).filter((file) => /\.(tsx?|jsx?)$/.test(file)) const sources = new Set() const quotedUrl = /["'`]((?:https?:\/\/images\.unsplash\.com\/[^"'`\s)]+)|(?:\/(?:images\/)?[^"'`\s)]+\.(?:avif|gif|jpe?g|mov|mp4|png|svg|webm|webp|zip)(?:\?[^"'`\s)]*)?))["'`]/gi const cssUrl = /url\((['"]?)(.*?)\1\)/g for (const file of files) { const content = await fs.readFile(file, 'utf8') for (const match of content.matchAll(quotedUrl)) { if (isMediaSource(match[1])) sources.add(match[1]) } for (const match of content.matchAll(cssUrl)) { if (isMediaSource(match[2])) sources.add(match[2]) } } return sources } async function findPublicMedia() { const sources = new Set() const files = await walk(publicDir) for (const file of files) { const relative = path.relative(publicDir, file).split(path.sep).join('/') if (relative.startsWith('media/')) continue const ext = path.extname(file).toLowerCase() if (!mediaExtensions.has(ext)) continue sources.add(`/${relative}`) } return sources } function safeRemoteName(url: string) { const parsed = new URL(url) const ext = path.extname(parsed.pathname) || '.jpg' const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 10) return `${parsed.hostname.replace(/[^a-z0-9]+/gi, '-')}-${hash}${ext}`.toLowerCase() } async function fileForSource(source: string): Promise { if (source.startsWith('http')) { const res = await fetch(source) if (!res.ok) throw new Error(`Failed to fetch ${source}: ${res.status}`) const data = Buffer.from(await res.arrayBuffer()) const name = safeRemoteName(source) return { name, data, mimetype: res.headers.get('content-type')?.split(';')[0] || mimeByExt[path.extname(name)] || 'application/octet-stream', size: data.byteLength, } } const cleanSource = source.split('?')[0] const fullPath = path.join(publicDir, cleanSource.replace(/^\//, '')) const data = await fs.readFile(fullPath) const ext = path.extname(fullPath).toLowerCase() return { name: path.basename(fullPath), data, mimetype: mimeByExt[ext] || 'application/octet-stream', size: data.byteLength, } } function altForSource(source: string) { const basename = source.startsWith('http') ? safeRemoteName(source) : path.basename(source.split('?')[0]) return basename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ') } async function findExistingMedia(payload: Payload, filename: string) { const existing = await payload.find({ collection: 'media', depth: 0, limit: 1, where: { filename: { equals: filename } }, }) return existing.docs[0] } async function main() { const payload = await getPayload({ config }) const sources = new Set([...(await findPublicMedia()), ...(await findSpaSources())]) const sortedSources = [...sources].sort((a, b) => a.localeCompare(b)) const assets: { source: string; media: number | string }[] = [] payload.logger.info(`Migrating ${sortedSources.length} SPA media assets into Payload...`) for (const source of sortedSources) { const file = await fileForSource(source) let doc = await findExistingMedia(payload, file.name) if (!doc) { doc = await payload.create({ collection: 'media', data: { alt: altForSource(source) }, file, }) payload.logger.info(`created media: ${source} -> ${doc.filename}`) } else { payload.logger.info(`reused media: ${source} -> ${doc.filename}`) } assets.push({ source, media: doc.id }) } const bySource = new Map(assets.map((asset) => [asset.source, asset])) const assetId = (source: string) => bySource.get(source)?.media await Promise.all([ assetId('/bmp-logo.png') ? payload.updateGlobal({ slug: 'header', data: { logo: assetId('/bmp-logo.png') } as never, context: { disableRevalidate: true }, }) : Promise.resolve(), assetId('/bmp-logo.png') ? payload.updateGlobal({ slug: 'footer', data: { logo: assetId('/bmp-logo.png'), partnerLogos: [ { alt: 'EWIF – Europäisches Wirtschaftsforum', image: assetId('/images/ewif-logo.png') }, { alt: 'Hochschule für angewandtes Management · Gipfeldialog Altaussee', image: assetId('/images/partner-logos.png'), }, ].filter((item) => item.image), } as never, context: { disableRevalidate: true }, }) : Promise.resolve(), assetId('/munich-skyline.jpg') ? payload.updateGlobal({ slug: 'site-settings', data: { skylineImage: assetId('/munich-skyline.jpg') } as never, context: { disableRevalidate: true }, }) : Promise.resolve(), ]) payload.logger.info('SPA media migration complete.') process.exit(0) } main().catch((error) => { console.error(error) process.exit(1) })