- Migrate historical posts, localized assets, and 103 comments - Colocate media into posts/<slug>/images/ and remove wp-content - Add native bilingual support for ai-can-change post (English default, /zh variant) - Support clean taxonomy routing (/category/:category and /tag/:tag) - Dynamically populate categories and tags in sitemap.xml - Fix header scroll padding/margin offsets for anchor and reference links - Constrain image and media widths to prevent content overflow - Redesign homepage recent posts to 4 compact editorial cards - Restore valid binary favicon.ico and high-res icon assets - Remove placeholder content and sample test comments
541 lines
22 KiB
JavaScript
541 lines
22 KiB
JavaScript
const fs = require('fs');
|
||
const path = require('path');
|
||
const https = require('https');
|
||
const http = require('http');
|
||
const { DatabaseSync } = require('node:sqlite');
|
||
|
||
const XML_FILE = path.join(__dirname, '../pengzhan.WordPress.2026-09-15.xml');
|
||
const CONTENT_DIR = path.join(__dirname, '../sample-content');
|
||
const POSTS_DIR = path.join(CONTENT_DIR, 'posts');
|
||
const STATIC_DIR = path.join(CONTENT_DIR, 'static');
|
||
const DOCS_DIR = path.join(STATIC_DIR, 'documents');
|
||
const DB_PATH = path.join(CONTENT_DIR, 'data/site.db');
|
||
const PUBLIC_DIR = path.join(__dirname, '../public');
|
||
|
||
// Ensure destination directories exist
|
||
[POSTS_DIR, STATIC_DIR, DOCS_DIR, path.dirname(DB_PATH)].forEach(dir => {
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
});
|
||
|
||
// Helper to decode HTML entities
|
||
function decodeHtmlEntities(str) {
|
||
if (!str) return '';
|
||
return str
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/’/g, "'")
|
||
.replace(/‘/g, "'")
|
||
.replace(/“/g, '"')
|
||
.replace(/”/g, '"')
|
||
.replace(/–/g, '–')
|
||
.replace(/—/g, '—')
|
||
.replace(/…/g, '…')
|
||
.replace(/ /g, ' ');
|
||
}
|
||
|
||
// Download file helper with follow redirects
|
||
function downloadFile(url, destPath) {
|
||
return new Promise((resolve, reject) => {
|
||
if (fs.existsSync(destPath) && fs.statSync(destPath).size > 0) {
|
||
return resolve(destPath);
|
||
}
|
||
const destDir = path.dirname(destPath);
|
||
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
||
|
||
function get(currentUrl, redirectCount = 0) {
|
||
if (redirectCount > 5) return reject(new Error('Too many redirects'));
|
||
const client = currentUrl.startsWith('https') ? https : http;
|
||
|
||
const req = client.get(currentUrl, {
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||
},
|
||
timeout: 15000
|
||
}, res => {
|
||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||
const nextUrl = res.headers.location.startsWith('http')
|
||
? res.headers.location
|
||
: new URL(res.headers.location, currentUrl).href;
|
||
res.resume();
|
||
return get(nextUrl, redirectCount + 1);
|
||
}
|
||
|
||
if (res.statusCode !== 200) {
|
||
res.resume();
|
||
return reject(new Error(`HTTP ${res.statusCode} for ${currentUrl}`));
|
||
}
|
||
|
||
const fileStream = fs.createWriteStream(destPath);
|
||
res.pipe(fileStream);
|
||
fileStream.on('finish', () => {
|
||
fileStream.close();
|
||
resolve(destPath);
|
||
});
|
||
fileStream.on('error', err => {
|
||
fs.unlink(destPath, () => {});
|
||
reject(err);
|
||
});
|
||
});
|
||
|
||
req.on('error', err => reject(err));
|
||
req.on('timeout', () => {
|
||
req.destroy();
|
||
reject(new Error(`Timeout downloading ${currentUrl}`));
|
||
});
|
||
}
|
||
|
||
get(url);
|
||
});
|
||
}
|
||
|
||
// Convert WordPress Gutenberg HTML to clean Markdown
|
||
function htmlToMarkdown(html) {
|
||
if (!html) return '';
|
||
|
||
let md = html;
|
||
|
||
// Remove Gutenberg comments
|
||
md = md.replace(/<!--\s*\/?wp:[^>]*-->/g, '');
|
||
|
||
// Handle <pre class="... language-xxx"><code>...</code></pre>
|
||
md = md.replace(/<pre[^>]*><code[^>]*class="[^"]*language-([a-zA-Z0-9_-]+)[^"]*"[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (m, lang, code) => {
|
||
return `\n\n\`\`\`${lang}\n${decodeHtmlEntities(code).trim()}\n\`\`\`\n\n`;
|
||
});
|
||
|
||
md = md.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (m, code) => {
|
||
const raw = decodeHtmlEntities(code).trim();
|
||
let lang = '';
|
||
if (raw.startsWith('#') || raw.startsWith('$ ') || raw.includes('sudo ') || raw.includes('kubectl ') || raw.includes('./')) {
|
||
lang = 'bash';
|
||
} else if (raw.includes('def ') || raw.includes('import ') || raw.includes('print(')) {
|
||
lang = 'python';
|
||
} else if (raw.includes('func ') || raw.includes('package ')) {
|
||
lang = 'go';
|
||
}
|
||
return `\n\n\`\`\`${lang}\n${raw}\n\`\`\`\n\n`;
|
||
});
|
||
|
||
md = md.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (m, code) => {
|
||
return `\n\n\`\`\`\n${decodeHtmlEntities(code).trim()}\n\`\`\`\n\n`;
|
||
});
|
||
|
||
// Images inside figures: <figure class="wp-block-image..."><img src="..." alt="..."/><figcaption>...</figcaption></figure>
|
||
md = md.replace(/<figure[^>]*>\s*<img[^>]*src="([^"]+)"[^>]*alt="([^"]*)"[^>]*\/?>\s*(?:<figcaption[^>]*>([\s\S]*?)<\/figcaption>)?\s*<\/figure>/gi, (m, src, alt, caption) => {
|
||
const title = caption ? ` "${decodeHtmlEntities(caption).trim()}"` : '';
|
||
return `\n\n\n\n`;
|
||
});
|
||
|
||
// Plain <img> tags
|
||
md = md.replace(/<img[^>]*src="([^"]+)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, (m, src, alt) => {
|
||
return ``;
|
||
});
|
||
|
||
// Headings
|
||
md = md.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, (m, c) => `\n\n# ${decodeHtmlEntities(c).trim()}\n\n`);
|
||
md = md.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, (m, c) => `\n\n## ${decodeHtmlEntities(c).trim()}\n\n`);
|
||
md = md.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, (m, c) => `\n\n### ${decodeHtmlEntities(c).trim()}\n\n`);
|
||
md = md.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, (m, c) => `\n\n#### ${decodeHtmlEntities(c).trim()}\n\n`);
|
||
md = md.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, (m, c) => `\n\n##### ${decodeHtmlEntities(c).trim()}\n\n`);
|
||
md = md.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, (m, c) => `\n\n###### ${decodeHtmlEntities(c).trim()}\n\n`);
|
||
|
||
// Blockquotes
|
||
md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (m, c) => {
|
||
const lines = decodeHtmlEntities(c).trim().split('\n');
|
||
return '\n\n' + lines.map(l => `> ${l.trim()}`).join('\n') + '\n\n';
|
||
});
|
||
|
||
// Lists
|
||
md = md.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (m, c) => `\n- ${decodeHtmlEntities(c).trim()}`);
|
||
md = md.replace(/<\/?ul[^>]*>/gi, '\n');
|
||
md = md.replace(/<\/?ol[^>]*>/gi, '\n');
|
||
|
||
// Paragraphs
|
||
md = md.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, (m, c) => `\n\n${decodeHtmlEntities(c).trim()}\n\n`);
|
||
|
||
// Inline formatting
|
||
md = md.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, (m, tag, c) => `**${decodeHtmlEntities(c).trim()}**`);
|
||
md = md.replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, (m, tag, c) => `*${decodeHtmlEntities(c).trim()}*`);
|
||
md = md.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (m, c) => `\`${decodeHtmlEntities(c).trim()}\``);
|
||
md = md.replace(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, (m, href, c) => `[${decodeHtmlEntities(c).trim()}](${href})`);
|
||
|
||
// Horizontal rules
|
||
md = md.replace(/<hr[^>]*\/?>/gi, '\n\n---\n\n');
|
||
|
||
// Stripped tags
|
||
md = md.replace(/<br\s*\/?>/gi, '\n');
|
||
md = md.replace(/<[^>]+>/g, '');
|
||
|
||
// Clean excessive newlines
|
||
md = md.replace(/\n{3,}/g, '\n\n').trim();
|
||
|
||
return md;
|
||
}
|
||
|
||
async function runMigration() {
|
||
console.log('====================================================');
|
||
console.log(' Starting WordPress Migration for pengzhan.dev');
|
||
console.log(` XML Source: ${XML_FILE}`);
|
||
console.log('====================================================');
|
||
|
||
if (!fs.existsSync(XML_FILE)) {
|
||
console.error(`❌ Export file not found at ${XML_FILE}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
const xml = fs.readFileSync(XML_FILE, 'utf8');
|
||
const items = xml.split('<item>');
|
||
console.log(`Found ${items.length - 1} total items in XML export.`);
|
||
|
||
// 1. Map all Attachments
|
||
const attachments = new Map(); // id -> { id, title, url, filename, postName }
|
||
const attachmentsByUrl = new Map();
|
||
|
||
for (let i = 1; i < items.length; i++) {
|
||
const item = items[i];
|
||
const typeMatch = item.match(/<wp:post_type>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:post_type>/);
|
||
if (typeMatch && typeMatch[1] === 'attachment') {
|
||
const idMatch = item.match(/<wp:post_id>(\d+)<\/wp:post_id>/);
|
||
const titleMatch = item.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/s);
|
||
const urlMatch = item.match(/<wp:attachment_url>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:attachment_url>/s);
|
||
const postNameMatch = item.match(/<wp:post_name>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:post_name>/s);
|
||
|
||
if (idMatch && urlMatch) {
|
||
const id = idMatch[1];
|
||
const url = urlMatch[1].trim();
|
||
const filename = path.basename(new URL(url).pathname);
|
||
const attachment = {
|
||
id,
|
||
title: titleMatch ? titleMatch[1].trim() : filename,
|
||
url,
|
||
filename,
|
||
postName: postNameMatch ? postNameMatch[1].trim() : ''
|
||
};
|
||
attachments.set(id, attachment);
|
||
attachmentsByUrl.set(url, attachment);
|
||
}
|
||
}
|
||
}
|
||
console.log(`\n[Phase 1] Cataloged ${attachments.size} media attachments.`);
|
||
|
||
// Download key attachments (PDFs, favicons, site logo)
|
||
console.log('\n[Phase 2] Downloading static documents, publication PDFs, and site icons...');
|
||
for (const [id, att] of attachments.entries()) {
|
||
const { url, filename } = att;
|
||
|
||
// Academic publications / PDFs -> sample-content/static/documents/
|
||
if (filename.endsWith('.pdf')) {
|
||
const docPath = path.join(DOCS_DIR, filename);
|
||
process.stdout.write(` 📄 Downloading PDF: ${filename} ... `);
|
||
try {
|
||
await downloadFile(url, docPath);
|
||
console.log('✓');
|
||
} catch (err) {
|
||
console.log(`⚠️ Failed (${err.message})`);
|
||
}
|
||
}
|
||
|
||
// Site icons / favicons -> public/ and sample-content/static/
|
||
if (filename.includes('android-chrome') || filename.includes('logo') || filename.endsWith('.ico')) {
|
||
const pubPath = path.join(PUBLIC_DIR, filename);
|
||
const staticPath = path.join(STATIC_DIR, filename);
|
||
process.stdout.write(` 🎨 Downloading icon/logo: ${filename} ... `);
|
||
try {
|
||
await downloadFile(url, pubPath);
|
||
fs.copyFileSync(pubPath, staticPath);
|
||
console.log('✓');
|
||
} catch (err) {
|
||
console.log(`⚠️ Failed (${err.message})`);
|
||
}
|
||
}
|
||
|
||
// Fonts -> sample-content/static/fonts/
|
||
if (filename.endsWith('.otf') || filename.endsWith('.ttf') || filename.endsWith('.woff2')) {
|
||
const fontsDir = path.join(STATIC_DIR, 'fonts');
|
||
if (!fs.existsSync(fontsDir)) fs.mkdirSync(fontsDir, { recursive: true });
|
||
const destPath = path.join(fontsDir, filename);
|
||
process.stdout.write(` 🔤 Downloading font: ${filename} ... `);
|
||
try {
|
||
await downloadFile(url, destPath);
|
||
console.log('✓');
|
||
} catch (err) {
|
||
console.log(`⚠️ Failed (${err.message})`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Create standard favicon aliases in public/
|
||
const baseIcon = [
|
||
path.join(PUBLIC_DIR, 'cropped-android-chrome-192x192-1.png'),
|
||
path.join(PUBLIC_DIR, 'android-chrome-192x192-1.png'),
|
||
path.join(PUBLIC_DIR, 'cropped-android-chrome-192x192-1-32x32.png')
|
||
].find(f => fs.existsSync(f));
|
||
|
||
if (baseIcon) {
|
||
[
|
||
path.join(PUBLIC_DIR, 'favicon-32x32.png'),
|
||
path.join(PUBLIC_DIR, 'favicon.ico'),
|
||
path.join(PUBLIC_DIR, 'android-chrome-192x192.png'),
|
||
path.join(PUBLIC_DIR, 'apple-touch-icon.png'),
|
||
path.join(STATIC_DIR, 'favicon.ico'),
|
||
path.join(STATIC_DIR, 'favicon-32x32.png'),
|
||
path.join(STATIC_DIR, 'android-chrome-192x192.png'),
|
||
path.join(STATIC_DIR, 'apple-touch-icon.png')
|
||
].forEach(dst => {
|
||
try { fs.copyFileSync(baseIcon, dst); } catch (_) {}
|
||
});
|
||
}
|
||
|
||
// 2. Parse & Migrate Published Posts
|
||
console.log('\n[Phase 3] Migrating published blog posts to Markdown...');
|
||
const postsMigrated = [];
|
||
|
||
for (let i = 1; i < items.length; i++) {
|
||
const item = items[i];
|
||
const typeMatch = item.match(/<wp:post_type>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:post_type>/);
|
||
const statusMatch = item.match(/<wp:status>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:status>/);
|
||
const type = typeMatch ? typeMatch[1] : '';
|
||
const status = statusMatch ? statusMatch[1] : '';
|
||
|
||
if (type !== 'post' || status !== 'publish') continue;
|
||
|
||
const titleMatch = item.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/s);
|
||
const title = titleMatch ? decodeHtmlEntities(titleMatch[1].trim()) : 'Untitled';
|
||
|
||
const slugMatch = item.match(/<wp:post_name>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:post_name>/s);
|
||
let slug = slugMatch ? slugMatch[1].trim() : '';
|
||
if (!slug) {
|
||
slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||
}
|
||
|
||
const dateMatch = item.match(/<wp:post_date>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:post_date>/s);
|
||
const dateStr = dateMatch ? dateMatch[1].trim() : new Date().toISOString();
|
||
const date = new Date(dateStr);
|
||
|
||
const categories = [...item.matchAll(/<category domain=\"category\" nicename=\"(.*?)\">(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/category>/g)].map(m => decodeHtmlEntities(m[2].trim()));
|
||
const tags = [...item.matchAll(/<category domain=\"post_tag\" nicename=\"(.*?)\">(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/category>/g)].map(m => decodeHtmlEntities(m[2].trim()));
|
||
|
||
const category = categories[0] || (tags.includes('Kubernetes') || tags.includes('Debug') ? 'Tech' : 'Tech');
|
||
|
||
const contentMatch = item.match(/<content:encoded>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/content:encoded>/);
|
||
const rawHtml = contentMatch ? contentMatch[1] : '';
|
||
|
||
// Convert body HTML to Markdown
|
||
let mdBody = htmlToMarkdown(rawHtml);
|
||
|
||
// Find thumbnail / cover image
|
||
const thumbMatch = item.match(/<wp:meta_key>(?:<!\[CDATA\[)?_thumbnail_id(?:\]\]>)?<\/wp:meta_key>\s*<wp:meta_value>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:meta_value>/);
|
||
let coverUrl = null;
|
||
let localCoverRel = null;
|
||
|
||
if (thumbMatch && thumbMatch[1] && attachments.has(thumbMatch[1])) {
|
||
const att = attachments.get(thumbMatch[1]);
|
||
coverUrl = att.url;
|
||
}
|
||
|
||
// Support bilingual posts (e.g. ai-can-change-coding-for-kids)
|
||
let isBilingualEn = (slug === 'ai-can-change-coding-for-kids-en');
|
||
let targetSlug = isBilingualEn ? 'ai-can-change-coding-for-kids' : slug;
|
||
let mdFilename = isBilingualEn ? 'en.md' : 'index.md';
|
||
let lang = isBilingualEn ? 'en' : (slug === 'ai-can-change-coding-for-kids' ? 'zh' : 'en');
|
||
|
||
// Prepare post folder: sample-content/posts/<targetSlug>/
|
||
const postDir = path.join(POSTS_DIR, targetSlug);
|
||
const postImagesDir = path.join(postDir, 'images');
|
||
if (!fs.existsSync(postImagesDir)) fs.mkdirSync(postImagesDir, { recursive: true });
|
||
|
||
// Download cover image to post's images folder
|
||
if (coverUrl) {
|
||
const coverExt = path.extname(new URL(coverUrl).pathname) || '.webp';
|
||
const coverFilename = isBilingualEn ? `cover-en${coverExt}` : `cover${coverExt}`;
|
||
const localCoverPath = path.join(postImagesDir, coverFilename);
|
||
try {
|
||
await downloadFile(coverUrl, localCoverPath);
|
||
localCoverRel = `/posts-assets/${targetSlug}/images/${coverFilename}`;
|
||
} catch (err) {
|
||
localCoverRel = coverUrl;
|
||
}
|
||
}
|
||
|
||
// Extract inline images and video in post body and download them
|
||
const inlineImageMatches = [...mdBody.matchAll(/!\[(.*?)\]\((https?:\/\/pengzhan\.dev\/wp-content\/uploads\/[^)]+)\)/g)];
|
||
for (const imgMatch of inlineImageMatches) {
|
||
const remoteImgUrl = imgMatch[2].split(' ')[0].replace(/['"]/g, '');
|
||
const imgFilename = path.basename(new URL(remoteImgUrl).pathname);
|
||
const localImgPath = path.join(postImagesDir, imgFilename);
|
||
try {
|
||
await downloadFile(remoteImgUrl, localImgPath);
|
||
mdBody = mdBody.replace(remoteImgUrl, `/posts-assets/${targetSlug}/images/${imgFilename}`);
|
||
} catch (_) {}
|
||
}
|
||
|
||
// Extract video if present
|
||
const videoMatches = [...rawHtml.matchAll(/<video[^>]*src=\"(https?:\/\/pengzhan\.dev\/wp-content\/uploads\/[^"]+)\"/g)];
|
||
for (const vMatch of videoMatches) {
|
||
const remoteVidUrl = vMatch[1];
|
||
const vidFilename = path.basename(new URL(remoteVidUrl).pathname);
|
||
const localVidPath = path.join(postImagesDir, vidFilename);
|
||
try {
|
||
await downloadFile(remoteVidUrl, localVidPath);
|
||
const vidHtml = `\n\n<video controls loop muted playsinline style="max-width: 100%; border-radius: 8px; margin: 1.5rem 0;" src="/posts-assets/${targetSlug}/images/${vidFilename}"></video>\n\n`;
|
||
mdBody = mdBody.replace("Here's a demo:", "Here's a demo:" + vidHtml);
|
||
} catch (_) {}
|
||
}
|
||
|
||
// Clean up any manual cross-link prefixes in bilingual posts
|
||
mdBody = mdBody.replace(/^\[(English Version|中文原文)\]\(https?:\/\/[^)]+\)\s*\n+/i, '');
|
||
|
||
// Generate excerpt from first paragraph
|
||
const firstPara = (mdBody.split('\n\n').find(p => p.trim() && !p.startsWith('#') && !p.startsWith('!') && !p.startsWith('<video')) || '')
|
||
.replace(/[#*`_\[\]]/g, '')
|
||
.slice(0, 180)
|
||
.trim();
|
||
const excerpt = firstPara ? `${firstPara}...` : '';
|
||
|
||
// Write YAML Frontmatter + Markdown
|
||
const frontmatter = [
|
||
'---',
|
||
`title: ${JSON.stringify(title)}`,
|
||
`date: ${date.toISOString().split('T')[0]}`,
|
||
`author: "Pengzhan Hao"`,
|
||
`category: ${JSON.stringify(category)}`,
|
||
`tags: ${JSON.stringify(tags)}`,
|
||
localCoverRel ? `cover: ${JSON.stringify(localCoverRel)}` : null,
|
||
excerpt ? `excerpt: ${JSON.stringify(excerpt)}` : null,
|
||
(lang !== 'en' || isBilingualEn) ? `lang: ${JSON.stringify(lang)}` : null,
|
||
'---',
|
||
''
|
||
].filter(Boolean).join('\n');
|
||
|
||
const fullContent = `${frontmatter}\n${mdBody}\n`;
|
||
fs.writeFileSync(path.join(postDir, mdFilename), fullContent, 'utf8');
|
||
|
||
postsMigrated.push({ slug: targetSlug, title, lang, date: date.toISOString().split('T')[0] });
|
||
console.log(` ✓ Migrated: [${date.toISOString().split('T')[0]}] ${title} (/${targetSlug}${isBilingualEn ? '/en' : ''})`);
|
||
}
|
||
|
||
// 3. Migrate Comments to SQLite site.db
|
||
console.log('\n[Phase 4] Migrating historical comments into SQLite site.db...');
|
||
const db = new DatabaseSync(DB_PATH);
|
||
|
||
// Ensure comments table exists
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS comments (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
post_slug TEXT NOT NULL,
|
||
author_name TEXT NOT NULL,
|
||
author_email TEXT,
|
||
author_website TEXT,
|
||
body TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
approved INTEGER DEFAULT 0,
|
||
ip TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_comments_slug ON comments(post_slug, approved);
|
||
CREATE INDEX IF NOT EXISTS idx_comments_email ON comments(author_email);
|
||
`);
|
||
|
||
const insertCommentStmt = db.prepare(`
|
||
INSERT INTO comments (id, post_slug, author_name, author_email, author_website, body, created_at, approved, ip)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`);
|
||
|
||
const fallbackCommentStmt = db.prepare(`
|
||
INSERT INTO comments (post_slug, author_name, author_email, author_website, body, created_at, approved, ip)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
`);
|
||
|
||
const parsedComments = [];
|
||
for (let i = 1; i < items.length; i++) {
|
||
const item = items[i];
|
||
const postNameMatch = item.match(/<wp:post_name>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:post_name>/s);
|
||
const postSlug = postNameMatch ? postNameMatch[1].trim() : '';
|
||
if (!postSlug) continue;
|
||
|
||
const commentMatches = item.match(/<wp:comment>[\s\S]*?<\/wp:comment>/g) || [];
|
||
for (const cXml of commentMatches) {
|
||
const idMatch = cXml.match(/<wp:comment_id>(\d+)<\/wp:comment_id>/);
|
||
const authorMatch = cXml.match(/<wp:comment_author>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/wp:comment_author>/);
|
||
const emailMatch = cXml.match(/<wp:comment_author_email>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/wp:comment_author_email>/);
|
||
const urlMatch = cXml.match(/<wp:comment_author_url>([\s\S]*?)<\/wp:comment_author_url>/);
|
||
const ipMatch = cXml.match(/<wp:comment_author_IP>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/wp:comment_author_IP>/);
|
||
const dateMatch = cXml.match(/<wp:comment_date>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/wp:comment_date>/);
|
||
const contentMatch = cXml.match(/<wp:comment_content>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/wp:comment_content>/);
|
||
const approvedMatch = cXml.match(/<wp:comment_approved>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/wp:comment_approved>/);
|
||
|
||
const approvedRaw = approvedMatch ? approvedMatch[1].trim() : '0';
|
||
let approved = 0;
|
||
if (approvedRaw === '1') approved = 1;
|
||
else if (approvedRaw === 'spam' || approvedRaw === '-1') approved = -1;
|
||
else approved = 0;
|
||
|
||
const body = decodeHtmlEntities(contentMatch ? contentMatch[1].trim() : '');
|
||
if (!body) continue;
|
||
|
||
parsedComments.push({
|
||
id: idMatch ? Number(idMatch[1]) : null,
|
||
post_slug: postSlug,
|
||
author_name: decodeHtmlEntities(authorMatch ? authorMatch[1].trim() : 'Anonymous'),
|
||
author_email: emailMatch ? emailMatch[1].trim() : null,
|
||
author_website: urlMatch ? urlMatch[1].trim() : null,
|
||
body,
|
||
created_at: dateMatch ? dateMatch[1].trim() : new Date().toISOString(),
|
||
approved,
|
||
ip: ipMatch ? ipMatch[1].trim() : null
|
||
});
|
||
}
|
||
}
|
||
|
||
// Clear existing comments before full sync
|
||
db.exec('DELETE FROM comments');
|
||
db.exec('BEGIN');
|
||
let commentCount = 0;
|
||
for (const c of parsedComments) {
|
||
try {
|
||
insertCommentStmt.run(
|
||
c.id,
|
||
c.post_slug,
|
||
c.author_name,
|
||
c.author_email,
|
||
c.author_website,
|
||
c.body,
|
||
c.created_at,
|
||
c.approved,
|
||
c.ip
|
||
);
|
||
commentCount++;
|
||
} catch (_) {
|
||
try {
|
||
fallbackCommentStmt.run(
|
||
c.post_slug,
|
||
c.author_name,
|
||
c.author_email,
|
||
c.author_website,
|
||
c.body,
|
||
c.created_at,
|
||
c.approved,
|
||
c.ip
|
||
);
|
||
commentCount++;
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
db.exec('COMMIT');
|
||
db.close();
|
||
console.log(` ✓ Inserted ${commentCount} historical comments into SQLite site.db.`);
|
||
|
||
console.log('\n====================================================');
|
||
console.log(` MIGRATION COMPLETE!`);
|
||
console.log(` - ${postsMigrated.length} Blog posts migrated to Markdown`);
|
||
console.log(` - ${attachments.size} Attachments cataloged & localized`);
|
||
console.log(` - ${commentCount} Comments synced to SQLite`);
|
||
console.log('====================================================\n');
|
||
}
|
||
|
||
runMigration().catch(err => {
|
||
console.error('Migration failed with error:', err);
|
||
process.exit(1);
|
||
});
|