feat(backend): update collections, config and migration tools
Update Payload CMS configuration, collections (Audit, Posts), and add migration scripts/reports.
This commit is contained in:
377
apps/backend/scripts/migration/utils.ts
Normal file
377
apps/backend/scripts/migration/utils.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Migration Utilities
|
||||
* Story 1.3: Content Migration Script
|
||||
*/
|
||||
|
||||
import { MigrationConfig } from './types'
|
||||
|
||||
// ============================================================
|
||||
// LOGGING UTILITIES
|
||||
// ============================================================
|
||||
|
||||
export const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private verbose: boolean
|
||||
|
||||
constructor(verbose: boolean = false) {
|
||||
this.verbose = verbose
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
console.log(`${colors.blue}ℹ${colors.reset} ${message}`)
|
||||
}
|
||||
|
||||
success(message: string): void {
|
||||
console.log(`${colors.green}✓${colors.reset} ${message}`)
|
||||
}
|
||||
|
||||
error(message: string): void {
|
||||
console.log(`${colors.red}✗${colors.reset} ${message}`)
|
||||
}
|
||||
|
||||
warn(message: string): void {
|
||||
console.log(`${colors.yellow}⚠${colors.reset} ${message}`)
|
||||
}
|
||||
|
||||
debug(message: string): void {
|
||||
if (this.verbose) {
|
||||
console.log(`${colors.dim} →${colors.reset} ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
header(message: string): void {
|
||||
console.log(`\n${colors.bright}${colors.cyan}${message}${colors.reset}`)
|
||||
}
|
||||
|
||||
progress(current: number, total: number, message: string = ''): void {
|
||||
const percent = Math.round((current / total) * 100)
|
||||
const bar = '█'.repeat(Math.floor(percent / 2)) + '░'.repeat(50 - Math.floor(percent / 2))
|
||||
process.stdout.write(
|
||||
`\r${colors.cyan}[${bar}]${colors.reset} ${percent}% ${message}`.padEnd(100),
|
||||
)
|
||||
if (current === total) {
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// STRING UTILITIES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Convert any string to a URL-friendly slug
|
||||
*/
|
||||
export function toSlug(value: string): string {
|
||||
return value
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.normalize('NFD') // Separate accented characters
|
||||
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5/-]/g, '-') // Replace non-alphanumeric with hyphen (keep Chinese)
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
||||
.replace(/^-+|-+$/g, '') // Trim hyphens from start/end
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from URL
|
||||
*/
|
||||
export function getFilenameFromUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathname = urlObj.pathname
|
||||
const filename = pathname.split('/').pop()
|
||||
return filename || `file-${Date.now()}`
|
||||
} catch {
|
||||
return `file-${Date.now()}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from filename or URL
|
||||
*/
|
||||
export function getFileExtension(filename: string): string {
|
||||
const match = filename.match(/\.([^.]+)$/)
|
||||
return match ? match[1].toLowerCase() : 'jpg'
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse comma-separated tags into array
|
||||
*/
|
||||
export function parseTagsString(tagsString: string): Array<{ tag: string }> {
|
||||
if (!tagsString || typeof tagsString !== 'string') {
|
||||
return []
|
||||
}
|
||||
return tagsString
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
.map((tag) => ({ tag }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color to text/background pair
|
||||
* Uses luminance to determine if text should be black or white
|
||||
*/
|
||||
export function splitColorToTextBackground(
|
||||
hexColor: string,
|
||||
): { textColor: string; backgroundColor: string } {
|
||||
// Default to black text on white background
|
||||
const defaultResult = {
|
||||
textColor: '#000000',
|
||||
backgroundColor: '#ffffff',
|
||||
}
|
||||
|
||||
if (!hexColor) {
|
||||
return defaultResult
|
||||
}
|
||||
|
||||
// Ensure hex format
|
||||
let hex = hexColor.replace('#', '')
|
||||
if (hex.length === 3) {
|
||||
hex = hex.split('').map((c) => c + c).join('')
|
||||
}
|
||||
if (hex.length !== 6) {
|
||||
return defaultResult
|
||||
}
|
||||
|
||||
// Calculate luminance
|
||||
const r = parseInt(hex.substr(0, 2), 16) / 255
|
||||
const g = parseInt(hex.substr(2, 2), 16) / 255
|
||||
const b = parseInt(hex.substr(4, 2), 16) / 255
|
||||
|
||||
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
|
||||
// Use original color as background, choose contrasting text
|
||||
return {
|
||||
textColor: luminance > 0.5 ? '#000000' : '#ffffff',
|
||||
backgroundColor: `#${hex}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to max length
|
||||
*/
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
if (!text || text.length <= maxLength) {
|
||||
return text || ''
|
||||
}
|
||||
return text.substring(0, maxLength - 3) + '...'
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DATE UTILITIES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Parse various date formats
|
||||
*/
|
||||
export function parseDate(dateValue: string | Date): Date {
|
||||
if (dateValue instanceof Date) {
|
||||
return dateValue
|
||||
}
|
||||
const parsed = new Date(dateValue)
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return new Date() // Fallback to now
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
export function formatDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HTML CLEANING UTILITIES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Clean HTML content by removing Webflow-specific classes and attributes
|
||||
*/
|
||||
export function cleanHTML(html: string): string {
|
||||
if (!html) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return html
|
||||
// Remove Webflow-specific classes
|
||||
.replace(/\sclass="[^"]*w-[^"]*"/g, '')
|
||||
.replace(/\sclass="[^"]*wf-[^"]*"/g, '')
|
||||
// Remove data attributes used by Webflow
|
||||
.replace(/\sdata-[a-z-]+="[^"]*"/gi, '')
|
||||
// Remove empty style attributes
|
||||
.replace(/\sstyle=""/g, '')
|
||||
// Clean up multiple spaces
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from HTML (for excerpts)
|
||||
*/
|
||||
export function htmlToPlainText(html: string, maxLength: number = 200): string {
|
||||
if (!html) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Remove script and style tags
|
||||
let text = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
|
||||
// Replace block elements with newlines
|
||||
text = text.replace(/<\/(div|p|h[1-6]|li|tr)>/gi, '\n')
|
||||
text = text.replace(/<(br|hr)\s*\/?>/gi, '\n')
|
||||
|
||||
// Remove all other tags
|
||||
text = text.replace(/<[^>]+>/g, '')
|
||||
|
||||
// Decode HTML entities
|
||||
text = text.replace(/ /g, ' ')
|
||||
text = text.replace(/&/g, '&')
|
||||
text = text.replace(/</g, '<')
|
||||
text = text.replace(/>/g, '>')
|
||||
text = text.replace(/"/g, '"')
|
||||
text = text.replace(/'/g, "'")
|
||||
|
||||
// Clean up whitespace
|
||||
text = text.replace(/\n\s*\n/g, '\n\n').trim()
|
||||
|
||||
return truncate(text, maxLength)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VALIDATION UTILITIES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Check if a value is a valid URL
|
||||
*/
|
||||
export function isValidUrl(value: string): boolean {
|
||||
try {
|
||||
new URL(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a valid image URL
|
||||
*/
|
||||
export function isValidImageUrl(value: string): boolean {
|
||||
if (!isValidUrl(value)) {
|
||||
return false
|
||||
}
|
||||
const ext = getFileExtension(value)
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CONFIG UTILITIES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Parse CLI arguments into MigrationConfig
|
||||
*/
|
||||
export function parseCliArgs(args: string[]): MigrationConfig {
|
||||
const config: MigrationConfig = {
|
||||
dryRun: false,
|
||||
verbose: false,
|
||||
collections: ['all'],
|
||||
force: false,
|
||||
batchSize: 5,
|
||||
sourcePath: './data/webflow-export.json',
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
switch (arg) {
|
||||
case '--dry-run':
|
||||
case '-n':
|
||||
config.dryRun = true
|
||||
break
|
||||
case '--verbose':
|
||||
case '-v':
|
||||
config.verbose = true
|
||||
break
|
||||
case '--force':
|
||||
case '-f':
|
||||
config.force = true
|
||||
break
|
||||
case '--collection':
|
||||
case '-c':
|
||||
if (args[i + 1]) {
|
||||
const collection = args[++i]
|
||||
if (collection === 'all') {
|
||||
config.collections = ['all']
|
||||
} else if (['categories', 'posts', 'portfolio'].includes(collection)) {
|
||||
config.collections = [collection as any]
|
||||
}
|
||||
}
|
||||
break
|
||||
case '--source':
|
||||
case '-s':
|
||||
if (args[i + 1]) {
|
||||
config.sourcePath = args[++i]
|
||||
}
|
||||
break
|
||||
case '--batch-size':
|
||||
if (args[i + 1]) {
|
||||
config.batchSize = parseInt(args[++i], 10) || 5
|
||||
}
|
||||
break
|
||||
case '--help':
|
||||
case '-h':
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Print help message
|
||||
*/
|
||||
export function printHelp(): void {
|
||||
console.log(`
|
||||
${colors.bright}Webflow to Payload CMS Migration Script${colors.reset}
|
||||
|
||||
${colors.cyan}Usage:${colors.reset}
|
||||
pnpm tsx scripts/migration/migrate.ts [options]
|
||||
|
||||
${colors.cyan}Options:${colors.reset}
|
||||
-n, --dry-run Run without making changes (preview mode)
|
||||
-v, --verbose Show detailed logging output
|
||||
-f, --force Overwrite existing items (skip deduplication)
|
||||
-c, --collection <name> Specific collection to migrate (categories|posts|portfolio|all)
|
||||
-s, --source <path> Path to HTML/JSON export file
|
||||
--batch-size <number> Number of items to process in parallel (default: 5)
|
||||
-h, --help Show this help message
|
||||
|
||||
${colors.cyan}Examples:${colors.reset}
|
||||
pnpm tsx scripts/migration/migrate.ts --dry-run --verbose
|
||||
pnpm tsx scripts/migration/migrate.ts --collection posts
|
||||
pnpm tsx scripts/migration/migrate.ts --source ./data/export.json
|
||||
|
||||
${colors.cyan}Environment Variables:${colors.reset}
|
||||
PAYLOAD_CMS_URL Payload CMS URL (default: http://localhost:3000)
|
||||
MIGRATION_ADMIN_EMAIL Admin user email for authentication
|
||||
MIGRATION_ADMIN_PASSWORD Admin user password
|
||||
`)
|
||||
}
|
||||
Reference in New Issue
Block a user