Files
website-enchun-mgr/apps/backend/scripts/migration/utils.ts
pkupuk be7fc902fb feat(backend): update collections, config and migration tools
Update Payload CMS configuration, collections (Audit, Posts), and add migration scripts/reports.
2026-02-11 11:50:23 +08:00

378 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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(/&nbsp;/g, ' ')
text = text.replace(/&amp;/g, '&')
text = text.replace(/&lt;/g, '<')
text = text.replace(/&gt;/g, '>')
text = text.replace(/&quot;/g, '"')
text = text.replace(/&#39;/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
`)
}