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:
307
apps/backend/scripts/migration/csvParser.ts
Normal file
307
apps/backend/scripts/migration/csvParser.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* CSV Parser for Webflow Exports
|
||||
* Story 1.3: Content Migration Script
|
||||
*
|
||||
* Parses Webflow CSV export files and converts to WebflowExportData format
|
||||
*/
|
||||
|
||||
import type { WebflowExportData, WebflowPost, WebflowCategory } from './types'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { parse } from 'csv-parse/sync'
|
||||
|
||||
// ============================================================
|
||||
// CSV ROW INTERFACES
|
||||
// ============================================================
|
||||
|
||||
interface WebflowPostCsvRow {
|
||||
'文章標題': string
|
||||
'Slug': string
|
||||
'Collection ID': string
|
||||
'Item ID': string
|
||||
'Archived': string
|
||||
'Draft': string
|
||||
'Created On': string
|
||||
'Updated On': string
|
||||
'Published On': string
|
||||
'強調圖片': string
|
||||
'Open Graph 顯示圖片': string
|
||||
'文章簡述': string
|
||||
'發文日期': string
|
||||
'文章分類': string
|
||||
'發文內容': string
|
||||
'是否放在頁尾': string
|
||||
}
|
||||
|
||||
interface WebflowCategoryCsvRow {
|
||||
name: string
|
||||
slug: string
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
interface WebflowPortfolioCsvRow {
|
||||
Name: string
|
||||
Slug: string
|
||||
'website-link': string
|
||||
'preview-image': string
|
||||
description: string
|
||||
'website-type': string
|
||||
tags: string
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MAIN CSV PARSER
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Parse Webflow CSV file and convert to WebflowExportData
|
||||
*/
|
||||
export async function parseWebflowCSV(filePath: string): Promise<WebflowExportData> {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
const records: any[] = parse(content, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
})
|
||||
|
||||
// Detect collection type from file name or headers
|
||||
if (filePath.includes('行銷放大鏡集') || records[0]?.['文章標題']) {
|
||||
return parsePostsCSV(records as WebflowPostCsvRow[])
|
||||
}
|
||||
|
||||
if (filePath.includes('Categories') || filePath.includes('分類')) {
|
||||
return parseCategoriesCSV(records as WebflowCategoryCsvRow[])
|
||||
}
|
||||
|
||||
if (filePath.includes('Portfolio') || filePath.includes('作品')) {
|
||||
return parsePortfolioCSV(records as WebflowPortfolioCsvRow[])
|
||||
}
|
||||
|
||||
// Default: try to detect from structure
|
||||
if (records[0]?.['文章標題'] || records[0]?.['發文內容']) {
|
||||
return parsePostsCSV(records as WebflowPostCsvRow[])
|
||||
}
|
||||
|
||||
return { posts: [], categories: [], portfolio: [] }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POSTS CSV PARSER
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Parse Posts collection CSV
|
||||
* Webflow CSV headers: 文章標題, Slug, ..., 強調圖片, 發文日期, 文章分類, 發文內容, ...
|
||||
*/
|
||||
function parsePostsCSV(records: WebflowPostCsvRow[]): WebflowExportData {
|
||||
const posts: WebflowPost[] = []
|
||||
const categoryNames = new Set<string>()
|
||||
|
||||
for (const row of records) {
|
||||
// Skip archived posts if needed
|
||||
if (row.Archived === 'true') continue
|
||||
|
||||
// Extract category name
|
||||
const categoryName = row['文章分類'] || ''
|
||||
if (categoryName) {
|
||||
categoryNames.add(categoryName)
|
||||
}
|
||||
|
||||
// Parse published date
|
||||
const publishedDate = parseWebflowDate(row['發文日期'] || row['Published On'] || row['Created On'])
|
||||
|
||||
posts.push({
|
||||
title: row['文章標題'] || '',
|
||||
slug: row.Slug || '',
|
||||
content: row['發文內容'] || '',
|
||||
publishedDate,
|
||||
postCategory: categoryName || undefined,
|
||||
featuredImage: row['強調圖片'] || undefined,
|
||||
seoTitle: undefined, // Could be extracted from content if needed
|
||||
seoDescription: row['文章簡述'] || undefined,
|
||||
excerpt: row['文章簡述'] || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// Generate categories from posts
|
||||
const categories = generateCategoriesFromPosts(Array.from(categoryNames))
|
||||
|
||||
return { posts, categories, portfolio: [] }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CATEGORIES CSV PARSER
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Parse Categories collection CSV
|
||||
*/
|
||||
function parseCategoriesCSV(records: WebflowCategoryCsvRow[]): WebflowExportData {
|
||||
const categories: WebflowCategory[] = []
|
||||
|
||||
// Known categories with colors (from story requirements)
|
||||
const knownCategories: Record<string, string> = {
|
||||
'google-xiao-xue-tang': '#4285f4', // Google blue
|
||||
'google-workshop': '#4285f4',
|
||||
'meta-xiao-xue-tang': '#0668e1', // Meta blue
|
||||
'meta-workshop': '#0668e1',
|
||||
'xing-xiao-shi-shi-zui-qian-xian': '#34a853', // Green
|
||||
'marketing-news': '#34a853',
|
||||
'enchun-announcements': '#ea4335', // Red
|
||||
'恩群數位最新公告': '#ea4335',
|
||||
}
|
||||
|
||||
for (const row of records) {
|
||||
const name = row.name || ''
|
||||
const slug = row.slug || ''
|
||||
|
||||
categories.push({
|
||||
name,
|
||||
slug,
|
||||
colorHex: knownCategories[slug] || knownCategories[name] || '#0066cc',
|
||||
})
|
||||
}
|
||||
|
||||
return { posts: [], categories, portfolio: [] }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PORTFOLIO CSV PARSER
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Parse Portfolio collection CSV
|
||||
*/
|
||||
function parsePortfolioCSV(records: WebflowPortfolioCsvRow[]): WebflowExportData {
|
||||
const portfolio: any[] = []
|
||||
|
||||
for (const row of records) {
|
||||
// Map website type strings to enum values
|
||||
const typeMapping: Record<string, 'corporate' | 'ecommerce' | 'landing' | 'brand' | 'other'> = {
|
||||
'corporate': 'corporate',
|
||||
'ecommerce': 'ecommerce',
|
||||
'landing': 'landing',
|
||||
'brand': 'brand',
|
||||
}
|
||||
|
||||
const websiteType = typeMapping[row['website-type']?.toLowerCase()] || 'other'
|
||||
|
||||
portfolio.push({
|
||||
name: row.Name || '',
|
||||
slug: row.Slug || '',
|
||||
websiteLink: row['website-link'] || '',
|
||||
previewImage: row['preview-image'] || '',
|
||||
description: row.description || '',
|
||||
websiteType,
|
||||
tags: row.tags || '',
|
||||
})
|
||||
}
|
||||
|
||||
return { posts: [], categories: [], portfolio }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Parse Webflow date format to Date object
|
||||
* Webflow dates: "Thu Jan 20 2022 00:00:00 GMT+0000 (Coordinated Universal Time)"
|
||||
*/
|
||||
function parseWebflowDate(dateStr: string): Date {
|
||||
if (!dateStr) return new Date()
|
||||
|
||||
// Remove timezone info and parse
|
||||
const cleanDate = dateStr.replace(/\(.*\)$/, '').trim()
|
||||
const parsed = new Date(cleanDate)
|
||||
|
||||
return isNaN(parsed.getTime()) ? new Date() : parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate category objects from category names found in posts
|
||||
*/
|
||||
function generateCategoriesFromPosts(categoryNames: string[]): WebflowCategory[] {
|
||||
const nameToSlug: Record<string, string> = {
|
||||
'Google小學堂': 'google-xiao-xue-tang',
|
||||
'Meta小學堂': 'meta-xiao-xue-tang',
|
||||
'行銷時事最前線': 'xing-xiao-shi-shi-zui-qian-xian',
|
||||
'恩群數位最新公告': 'enchun-announcements',
|
||||
}
|
||||
|
||||
const slugToColor: Record<string, string> = {
|
||||
'google-xiao-xue-tang': '#4285f4',
|
||||
'meta-xiao-xue-tang': '#0668e1',
|
||||
'xing-xiao-shi-shi-zui-qian-xian': '#34a853',
|
||||
'enchun-announcements': '#ea4335',
|
||||
}
|
||||
|
||||
const categories: WebflowCategory[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const name of categoryNames) {
|
||||
if (seen.has(name)) continue
|
||||
seen.add(name)
|
||||
|
||||
const slug = nameToSlug[name] || toSlug(name)
|
||||
const colorHex = slugToColor[slug] || '#0066cc'
|
||||
|
||||
categories.push({ name, slug, colorHex })
|
||||
}
|
||||
|
||||
return categories
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert string to URL-friendly slug (Chinese-friendly)
|
||||
*/
|
||||
function toSlug(value: string): string {
|
||||
return value
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5/-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BATCH CSV PARSER
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Parse multiple CSV files at once
|
||||
*/
|
||||
export async function parseMultipleCSVs(filePaths: string[]): Promise<WebflowExportData> {
|
||||
const combined: WebflowExportData = {
|
||||
posts: [],
|
||||
categories: [],
|
||||
portfolio: [],
|
||||
}
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
try {
|
||||
const data = await parseWebflowCSV(filePath)
|
||||
|
||||
if (data.posts) combined.posts?.push(...data.posts)
|
||||
if (data.categories) combined.categories?.push(...data.categories)
|
||||
if (data.portfolio) combined.portfolio?.push(...data.portfolio)
|
||||
} catch (error) {
|
||||
console.error(`Error parsing ${filePath}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate categories by slug
|
||||
if (combined.categories) {
|
||||
const seen = new Set<string>()
|
||||
combined.categories = combined.categories.filter((cat) => {
|
||||
if (seen.has(cat.slug)) return false
|
||||
seen.add(cat.slug)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return combined
|
||||
}
|
||||
Reference in New Issue
Block a user