211 lines
6.4 KiB
TypeScript
211 lines
6.4 KiB
TypeScript
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<string, string> = {
|
||
'.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<string[]> {
|
||
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<string>()
|
||
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<string>()
|
||
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<File> {
|
||
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)
|
||
})
|