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:
2026-02-11 11:50:23 +08:00
parent 8ca609a889
commit be7fc902fb
46 changed files with 5442 additions and 15 deletions

View 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(/&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
`)
}