/** * WordPress WXR import implementation * * Two-phase import process: * 1. Prepare: Analyze WXR, generate config and suggested live.config.ts * 2. Execute: Import content using the generated/edited config */ import { createReadStream } from "node:fs"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { gutenbergToPortableText } from "@premium-cms/gutenberg-to-portable-text"; import pc from "picocolors"; import { slugify } from "#utils/slugify.js"; import { validateExternalUrl, ssrfSafeFetch } from "../../../import/ssrf.js"; import { parseWxr, type WxrData, type WxrPost, type WxrAttachment } from "../../wxr/parser.js"; // Regex patterns for WordPress import const NUMBER_PATTERN = /^-?\d+(\.\d+)?$/; const DOT_PATTERN = /\./g; const NON_ALPHANUMERIC_UNDERSCORE_PATTERN = /[^a-zA-Z0-9_]/g; const TRAILING_SLASH_PATTERN = /\/$/; const PHP_STRING_PATTERN = /s:\d+:"(.*)";/; const PHP_ARRAY_PATTERN = /s:(\d+):"([^"]+)";(?:s:(\d+):"([^"]+)"|i:(\d+)|b:([01]))/g; /** Type guard for Record */ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } // ============================================================================ // Types // ============================================================================ export interface MigrationConfig { /** WordPress site info */ site: { title: string; url: string; }; /** Map WordPress post types to EmDash collections */ collections: Record; /** Map WordPress meta keys to EmDash field names */ fields: Record; /** Post types to skip */ skipPostTypes: string[]; /** Meta keys to skip (internal WP fields) */ skipMetaKeys: string[]; } export interface CollectionMapping { /** EmDash collection name */ collection: string; /** Whether to import this type */ enabled: boolean; /** Number of items found */ count: number; } export interface FieldMapping { /** EmDash field name (supports dot notation for nested) */ field: string; /** Field type hint */ type: "string" | "number" | "boolean" | "date" | "reference" | "json"; /** Whether to import this field */ enabled: boolean; /** Number of posts with this field */ count: number; /** Sample values for reference */ samples: string[]; } export interface PrepareOptions { outputDir: string; configPath: string; verbose: boolean; dryRun: boolean; json: boolean; } export interface ExecuteOptions { outputDir: string; mediaDir?: string; configPath: string; skipMedia: boolean; verbose: boolean; dryRun: boolean; json: boolean; resume: boolean; } /** Progress tracking for resumable imports */ export interface ImportProgress { /** ISO timestamp when import started */ startedAt: string; /** ISO timestamp of last update */ updatedAt: string; /** Source WXR file path */ sourceFile: string; /** Config file used */ configFile: string; /** Posts successfully imported (by WP ID) */ importedPosts: number[]; /** Attachments successfully downloaded (by WP ID) */ downloadedMedia: number[]; /** Items that failed with error messages */ errors: Array<{ id: number; type: string; error: string }>; /** Summary stats */ stats: { totalPosts: number; totalMedia: number; importedPosts: number; downloadedMedia: number; skippedPosts: number; errorCount: number; }; } /** Structured result for agent-friendly output */ export interface ImportResult { success: boolean; phase: "prepare" | "execute"; dryRun: boolean; /** Summary of what was/would be done */ summary: { postsAnalyzed?: number; postsImported?: number; postsSkipped?: number; mediaDownloaded?: number; mediaSkipped?: number; errors: number; }; /** Files created/would be created */ files: Array<{ path: string; action: "created" | "skipped" | "would_create"; }>; /** Errors encountered */ errors: Array<{ id?: number; message: string }>; /** Next steps for the user/agent */ nextSteps: string[]; } // ============================================================================ // Phase 1: Prepare // ============================================================================ export async function prepareWordPressImport( filePath: string, options: PrepareOptions, ): Promise { const result: ImportResult = { success: true, phase: "prepare", dryRun: options.dryRun, summary: { errors: 0 }, files: [], errors: [], nextSteps: [], }; const log = (msg: string) => !options.json && console.log(msg); if (options.dryRun) { log(pc.yellow("[DRY RUN] ") + pc.cyan("Analyzing WordPress export...\n")); } else { log(pc.cyan("Analyzing WordPress export...\n")); } log(pc.dim(`File: ${filePath}`)); // Parse WXR const stream = createReadStream(filePath, { encoding: "utf-8" }); const wxr = await parseWxr(stream); // Analyze content const analysis = analyzeWxrContent(wxr, options.verbose); // Generate migration config const config = generateMigrationConfig(wxr, analysis); result.summary.postsAnalyzed = wxr.posts.length; // Write config file (or report what would be written) if (options.dryRun) { log(pc.yellow(`\n[DRY RUN] Would write: ${options.configPath}`)); result.files.push({ path: options.configPath, action: "would_create" }); } else { await mkdir(dirname(options.configPath), { recursive: true }); await writeFile(options.configPath, JSON.stringify(config, null, 2)); log(pc.green(`\nWrote migration config: ${options.configPath}`)); result.files.push({ path: options.configPath, action: "created" }); } // Generate suggested live.config.ts const liveConfigPath = join(options.outputDir, "suggested-live.config.ts"); const liveConfigContent = generateLiveConfig(config, analysis); if (options.dryRun) { log(pc.yellow(`[DRY RUN] Would write: ${liveConfigPath}`)); result.files.push({ path: liveConfigPath, action: "would_create" }); } else { await mkdir(dirname(liveConfigPath), { recursive: true }); await writeFile(liveConfigPath, liveConfigContent); log(pc.green(`Wrote suggested config: ${liveConfigPath}`)); result.files.push({ path: liveConfigPath, action: "created" }); } // Summary log(pc.cyan("\n=== Analysis Summary ===\n")); log(pc.bold("Post Types:")); for (const [type, mapping] of Object.entries(config.collections)) { const status = mapping.enabled ? pc.green("enabled") : pc.yellow("disabled"); log(` ${type} → ${mapping.collection} (${mapping.count} items) [${status}]`); } log(pc.bold("\nCustom Fields:")); const enabledFields = Object.entries(config.fields).filter(([_, m]) => m.enabled); const disabledFields = Object.entries(config.fields).filter(([_, m]) => !m.enabled); for (const [key, mapping] of enabledFields.slice(0, 10)) { log(` ${key} → ${mapping.field} (${mapping.type}, ${mapping.count} posts)`); } if (enabledFields.length > 10) { log(pc.dim(` ... and ${enabledFields.length - 10} more`)); } if (disabledFields.length > 0) { log(pc.dim(` (${disabledFields.length} internal fields hidden)`)); } // Next steps result.nextSteps = [ `Review and edit: ${options.configPath}`, `Review suggested config: ${liveConfigPath}`, "Copy relevant parts to your src/live.config.ts", `Run: emdash import wordpress ${filePath} --execute`, ]; log(pc.cyan("\n=== Next Steps ===\n")); for (const step of result.nextSteps) { log(` ${step}`); } log(""); // JSON output for agents if (options.json) { console.log(JSON.stringify(result, null, 2)); } return result; } interface ContentAnalysis { postTypes: Map; metaKeys: Map; categories: number; tags: number; attachments: number; authors: string[]; } interface MetaKeyInfo { count: number; samples: string[]; isInternal: boolean; inferredType: "string" | "number" | "boolean" | "date" | "reference" | "json"; } function analyzeWxrContent(wxr: WxrData, _verbose: boolean): ContentAnalysis { const postTypes = new Map(); const metaKeys = new Map(); // Analyze posts for (const post of wxr.posts) { // Count post types const type = post.postType || "post"; postTypes.set(type, (postTypes.get(type) || 0) + 1); // Analyze meta keys for (const [key, value] of post.meta) { const existing = metaKeys.get(key); if (existing) { existing.count++; if (existing.samples.length < 3 && value && !existing.samples.includes(value)) { existing.samples.push(value.slice(0, 100)); } } else { metaKeys.set(key, { count: 1, samples: value ? [value.slice(0, 100)] : [], isInternal: isInternalMetaKey(key), inferredType: inferMetaType(key, value), }); } } } // Analyze attachments for (const attachment of wxr.attachments) { for (const [key, value] of attachment.meta) { const existing = metaKeys.get(key); if (existing) { existing.count++; } else { metaKeys.set(key, { count: 1, samples: value ? [value.slice(0, 100)] : [], isInternal: isInternalMetaKey(key), inferredType: inferMetaType(key, value), }); } } } return { postTypes, metaKeys, categories: wxr.categories.length, tags: wxr.tags.length, attachments: wxr.attachments.length, authors: wxr.authors.map((a) => a.displayName || a.login || "Unknown"), }; } function isInternalMetaKey(key: string): boolean { // WordPress internal keys if (key.startsWith("_edit_")) return true; if (key.startsWith("_wp_")) return true; if (key === "_edit_last" || key === "_edit_lock") return true; if (key === "_pingme" || key === "_encloseme") return true; // But keep these useful ones if (key === "_thumbnail_id") return false; if (key.startsWith("_yoast_")) return false; if (key.startsWith("_rank_math_")) return false; if (key.startsWith("_aioseop_")) return false; // Other underscore prefixes are usually internal if (key.startsWith("_") && !key.startsWith("_yoast") && !key.startsWith("_thumbnail")) { return true; } return false; } function inferMetaType(key: string, value: string | undefined): MetaKeyInfo["inferredType"] { // Known patterns if (key.endsWith("_id") || key === "_thumbnail_id") return "reference"; if (key.endsWith("_date") || key.endsWith("_time")) return "date"; if (key.endsWith("_count") || key.endsWith("_number") || key === "price") return "number"; // Check value if (!value) return "string"; // Serialized PHP if (value.startsWith("a:") || value.startsWith("O:") || value.startsWith("s:")) { return "json"; } // JSON if ( (value.startsWith("{") && value.endsWith("}")) || (value.startsWith("[") && value.endsWith("]")) ) { return "json"; } // Number if (NUMBER_PATTERN.test(value)) return "number"; // Boolean if (value === "0" || value === "1" || value === "true" || value === "false") { return "boolean"; } return "string"; } function generateMigrationConfig(wxr: WxrData, analysis: ContentAnalysis): MigrationConfig { const collections: Record = {}; const fields: Record = {}; // Map post types to collections for (const [type, count] of analysis.postTypes) { // Skip internal types (see INTERNAL_POST_TYPES in utils.ts) const skip = [ "revision", "nav_menu_item", "custom_css", "customize_changeset", "oembed_cache", "wp_global_styles", "wp_navigation", "wp_template", "wp_template_part", "attachment", // Handled separately as media "wp_block", // Handled separately as sections (reusable blocks) ].includes(type); collections[type] = { collection: mapPostTypeToCollection(type), enabled: !skip, count, }; } // Map meta keys to fields for (const [key, info] of analysis.metaKeys) { fields[key] = { field: mapMetaKeyToField(key), type: info.inferredType, enabled: !info.isInternal, count: info.count, samples: info.samples, }; } return { site: { title: wxr.site.title || "WordPress Site", url: wxr.site.link || "", }, collections, fields, skipPostTypes: [ "revision", "nav_menu_item", "custom_css", "customize_changeset", "oembed_cache", "wp_global_styles", "wp_navigation", "wp_template", "wp_template_part", "attachment", "wp_block", ], skipMetaKeys: ["_edit_last", "_edit_lock", "_pingme", "_encloseme"], }; } function mapPostTypeToCollection(postType: string): string { const mapping: Record = { post: "posts", page: "pages", attachment: "media", product: "products", portfolio: "portfolio", testimonial: "testimonials", team: "team", event: "events", faq: "faqs", }; return mapping[postType] || postType; } function mapMetaKeyToField(key: string): string { // SEO plugins if (key === "_yoast_wpseo_title") return "seo.title"; if (key === "_yoast_wpseo_metadesc") return "seo.description"; if (key === "_yoast_wpseo_focuskw") return "seo.keywords"; if (key === "_rank_math_title") return "seo.title"; if (key === "_rank_math_description") return "seo.description"; if (key === "_aioseop_title") return "seo.title"; if (key === "_aioseop_description") return "seo.description"; // Featured image if (key === "_thumbnail_id") return "featuredImage"; // Remove leading underscore for others if (key.startsWith("_")) { return key.slice(1); } return key; } function generateLiveConfig(config: MigrationConfig, _analysis: ContentAnalysis): string { const lines: string[] = [ "/**", " * Suggested EmDash collections", ` * Generated from: ${config.site.title}`, " *", " * Create these collections in the EmDash admin UI:", " * 1. Go to /_emdash/admin/content-types", " * 2. Click 'New Content Type'", " * 3. Create the collections listed below with their fields", " */", "", ]; // Generate collection suggestions for each enabled post type for (const [type, mapping] of Object.entries(config.collections)) { if (!mapping.enabled) continue; lines.push(`// ${type} → "${mapping.collection}" (${mapping.count} items)`); lines.push(`// Label: "${capitalize(mapping.collection)}"`); lines.push(`// Label Singular: "${capitalize(singularize(mapping.collection))}"`); lines.push("// Suggested fields:"); lines.push("// - title (string)"); lines.push("// - content (portableText)"); lines.push("// - excerpt (string, optional)"); // Add fields for this collection const collectionFields = Object.entries(config.fields) .filter(([_, m]) => m.enabled) .slice(0, 10); // Limit to avoid huge output for (const [key, fieldMapping] of collectionFields) { lines.push( `// - ${sanitizeFieldName(fieldMapping.field)} (${fieldMapping.type}) // from: ${key}`, ); } lines.push(""); } return lines.join("\n"); } function sanitizeFieldName(name: string): string { // Handle nested fields like seo.title → seo: { title } // For now, just flatten with underscore return name.replace(DOT_PATTERN, "_").replace(NON_ALPHANUMERIC_UNDERSCORE_PATTERN, ""); } function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } function singularize(str: string): string { if (str.endsWith("ies")) return str.slice(0, -3) + "y"; if (str.endsWith("s")) return str.slice(0, -1); return str; } // ============================================================================ // Phase 2: Execute // ============================================================================ export async function executeWordPressImport( filePath: string, options: ExecuteOptions, ): Promise { const result: ImportResult = { success: true, phase: "execute", dryRun: options.dryRun, summary: { postsImported: 0, postsSkipped: 0, mediaDownloaded: 0, mediaSkipped: 0, errors: 0, }, files: [], errors: [], nextSteps: [], }; const log = (msg: string) => !options.json && console.log(msg); const progressPath = join(options.outputDir, ".wp-migration-progress.json"); if (options.dryRun) { log(pc.yellow("[DRY RUN] ") + pc.cyan("Importing WordPress content...\n")); } else { log(pc.cyan("Importing WordPress content...\n")); } // Load config let config: MigrationConfig; try { const configContent = await readFile(options.configPath, "utf-8"); config = JSON.parse(configContent); } catch (error) { const msg = `Failed to load migration config: ${options.configPath}`; log(pc.red(msg)); log(pc.dim("Run with --prepare first to generate the config.")); result.success = false; result.errors.push({ message: msg }); if (options.json) console.log(JSON.stringify(result, null, 2)); throw error; } log(pc.dim(`Using config: ${options.configPath}`)); log(pc.dim(`File: ${filePath}`)); if (options.resume) { log(pc.dim(`Resume mode: will skip already-imported items`)); } log(""); // Load or initialize progress tracking let progress: ImportProgress; if (options.resume) { try { const progressContent = await readFile(progressPath, "utf-8"); progress = JSON.parse(progressContent); log( pc.dim( `Resuming from previous run (${progress.stats.importedPosts} posts already imported)`, ), ); } catch { progress = createFreshProgress(filePath, options.configPath); } } else { progress = createFreshProgress(filePath, options.configPath); } const alreadyImported = new Set(progress.importedPosts); const alreadyDownloaded = new Set(progress.downloadedMedia); // Parse WXR const stream = createReadStream(filePath, { encoding: "utf-8" }); const wxr = await parseWxr(stream); // Update totals in progress progress.stats.totalPosts = wxr.posts.length; progress.stats.totalMedia = wxr.attachments.length; // Build media map const mediaMap = new Map(); for (const attachment of wxr.attachments) { if (attachment.id) { mediaMap.set(attachment.id, `media-${attachment.id}`); } } // Stats const stats = { imported: 0, skipped: 0, resumed: 0, errors: 0, byCollection: new Map(), }; const redirects = new Map(); // Process posts for (const post of wxr.posts) { const postType = post.postType || "post"; const mapping = config.collections[postType]; // Skip if not mapped or disabled if (!mapping || !mapping.enabled) { stats.skipped++; continue; } // Skip if already imported (resume mode) if (post.id && alreadyImported.has(post.id)) { stats.resumed++; if (options.verbose) { log(pc.dim(` [skip] ${mapping.collection}/${post.title} (already imported)`)); } continue; } try { const converted = convertPostWithConfig(post, mapping.collection, config, mediaMap); const outputPath = join(options.outputDir, converted.collection, `${converted.slug}.json`); if (options.dryRun) { if (options.verbose) { log(pc.yellow(` [would create] ${outputPath}`)); } result.files.push({ path: outputPath, action: "would_create" }); } else { await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, JSON.stringify(converted.data, null, 2)); result.files.push({ path: outputPath, action: "created" }); // Update progress if (post.id) { progress.importedPosts.push(post.id); progress.stats.importedPosts++; } if (options.verbose) { log(pc.green(` ${mapping.collection}/${converted.slug}`)); } } stats.imported++; stats.byCollection.set( mapping.collection, (stats.byCollection.get(mapping.collection) || 0) + 1, ); // Track redirect if (post.link && converted.slug) { redirects.set(post.link, `/${converted.collection}/${converted.slug}`); } } catch (error) { stats.errors++; const errorMsg = error instanceof Error ? error.message : String(error); result.errors.push({ id: post.id, message: `${post.title}: ${errorMsg}`, }); if (post.id) { progress.errors.push({ id: post.id, type: "post", error: errorMsg }); } if (options.verbose) { log(pc.red(` Failed: ${post.title} - ${errorMsg}`)); } } // Save progress periodically (every 50 items) if (!options.dryRun && stats.imported % 50 === 0) { progress.updatedAt = new Date().toISOString(); await writeFile(progressPath, JSON.stringify(progress, null, 2)); } } // Download media let mediaDownloaded = 0; let mediaSkipped = 0; if (!options.skipMedia && options.mediaDir && wxr.attachments.length > 0) { log(pc.dim("\nDownloading media...")); for (const attachment of wxr.attachments) { // Skip if already downloaded (resume mode) if (attachment.id && alreadyDownloaded.has(attachment.id)) { mediaSkipped++; continue; } try { if (options.dryRun) { if (options.verbose) { log(pc.yellow(` [would download] ${attachment.url}`)); } } else { await downloadMedia(attachment, options.mediaDir); if (attachment.id) { progress.downloadedMedia.push(attachment.id); progress.stats.downloadedMedia++; } if (options.verbose) { log(pc.green(` ${attachment.url}`)); } } mediaDownloaded++; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); result.errors.push({ id: attachment.id, message: `Media ${attachment.url}: ${errorMsg}`, }); if (attachment.id) { progress.errors.push({ id: attachment.id, type: "media", error: errorMsg, }); } if (options.verbose) { log(pc.red(` Failed: ${attachment.url}`)); } } } log(pc.dim(`Downloaded ${mediaDownloaded} media files`)); } // Write redirects const redirectPath = join(options.outputDir, "_redirects.json"); if (redirects.size > 0) { if (options.dryRun) { result.files.push({ path: redirectPath, action: "would_create" }); } else { await writeFile(redirectPath, JSON.stringify(Object.fromEntries(redirects), null, 2)); result.files.push({ path: redirectPath, action: "created" }); } } // Save final progress if (!options.dryRun) { progress.updatedAt = new Date().toISOString(); progress.stats.skippedPosts = stats.skipped; progress.stats.errorCount = stats.errors; await writeFile(progressPath, JSON.stringify(progress, null, 2)); } // Update result summary result.summary.postsImported = stats.imported; result.summary.postsSkipped = stats.skipped + stats.resumed; result.summary.mediaDownloaded = mediaDownloaded; result.summary.mediaSkipped = mediaSkipped; result.summary.errors = stats.errors + result.errors.length; // Summary const prefix = options.dryRun ? "[DRY RUN] " : ""; log(pc.cyan(`\n=== ${prefix}Import ${options.dryRun ? "Preview" : "Complete"} ===\n`)); log(`${options.dryRun ? "Would import" : "Imported"}: ${pc.green(stats.imported.toString())}`); if (stats.resumed > 0) { log(`Resumed (skipped): ${pc.blue(stats.resumed.toString())}`); } log(`Skipped (disabled): ${pc.yellow(stats.skipped.toString())}`); if (stats.errors > 0) { log(`Errors: ${pc.red(stats.errors.toString())}`); } log(pc.bold("\nBy collection:")); for (const [collection, count] of stats.byCollection) { log(` ${collection}: ${count}`); } if (redirects.size > 0 && !options.dryRun) { log(pc.dim(`\nRedirect map written to: ${redirectPath}`)); } // Next steps if (options.dryRun) { result.nextSteps = [ `Run without --dry-run to perform the import`, `emdash import wordpress ${filePath} --execute`, ]; } else if (stats.errors > 0) { result.nextSteps = [ `Fix errors and run with --resume to continue`, `emdash import wordpress ${filePath} --execute --resume`, ]; } else { result.nextSteps = [ `Verify import: emdash migrate:verify --source ${filePath}`, `Progress saved to: ${progressPath}`, ]; } if (!options.dryRun) { log(pc.dim(`\nProgress saved to: ${progressPath}`)); log(pc.dim(`Run with --resume to continue from where you left off.`)); } // JSON output for agents if (options.json) { console.log(JSON.stringify(result, null, 2)); } return result; } function createFreshProgress(sourceFile: string, configFile: string): ImportProgress { return { startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), sourceFile: resolve(sourceFile), configFile: resolve(configFile), importedPosts: [], downloadedMedia: [], errors: [], stats: { totalPosts: 0, totalMedia: 0, importedPosts: 0, downloadedMedia: 0, skippedPosts: 0, errorCount: 0, }, }; } interface ConvertedContent { slug: string; collection: string; data: Record; } function convertPostWithConfig( post: WxrPost, collection: string, config: MigrationConfig, mediaMap: Map, ): ConvertedContent { // Convert content to Portable Text const content = gutenbergToPortableText(post.content || "", { mediaMap }); // Extract slug const slug = extractSlug(post.link) || slugify(post.title || "untitled"); // Build data object const data: Record = { title: post.title, content, status: mapStatus(post.status), publishedAt: post.pubDate ? new Date(post.pubDate).toISOString() : null, createdAt: post.postDate ? new Date(post.postDate).toISOString() : null, author: post.creator, excerpt: post.excerpt, categories: post.categories, tags: post.tags, }; // Map custom fields for (const [wpKey, value] of post.meta) { const fieldConfig = config.fields[wpKey]; if (!fieldConfig || !fieldConfig.enabled) continue; const fieldName = fieldConfig.field; let fieldValue: unknown = value; // Type conversion switch (fieldConfig.type) { case "number": fieldValue = parseFloat(value) || 0; break; case "boolean": fieldValue = value === "1" || value === "true"; break; case "date": fieldValue = new Date(value).toISOString(); break; case "reference": // Map WordPress ID to new reference const wpId = parseInt(value, 10); fieldValue = mediaMap.get(wpId) || value; break; case "json": try { // Try PHP unserialize first if (value.startsWith("a:") || value.startsWith("O:")) { fieldValue = unserializePhp(value); } else { fieldValue = JSON.parse(value); } } catch { fieldValue = value; } break; } // Handle nested fields (e.g., seo.title) if (fieldName.includes(".")) { const parts = fieldName.split("."); let obj: Record = data; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; const nested = obj[part]; if (isRecord(nested)) { obj = nested; } else { const newObj: Record = {}; obj[part] = newObj; obj = newObj; } } obj[parts.at(-1)!] = fieldValue; } else { data[fieldName] = fieldValue; } } // Original WP metadata for reference data._wp = { id: post.id, link: post.link, guid: post.guid, postType: post.postType, }; return { slug, collection, data }; } async function downloadMedia(attachment: WxrAttachment, mediaDir: string): Promise { if (!attachment.url) return; // Validate URL is not targeting internal/private addresses const parsed = validateExternalUrl(attachment.url); const filename = parsed.pathname.split("/").pop() || `media-${attachment.id}`; const filePath = join(mediaDir, filename); await mkdir(dirname(filePath), { recursive: true }); const response = await ssrfSafeFetch(attachment.url); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const buffer = await response.arrayBuffer(); await writeFile(filePath, Buffer.from(buffer)); } function extractSlug(link: string | undefined): string | undefined { if (!link) return undefined; try { const url = new URL(link); const path = url.pathname.replace(TRAILING_SLASH_PATTERN, ""); const segments = path.split("/").filter(Boolean); return segments.pop(); } catch { return undefined; } } function mapStatus(wpStatus: string | undefined): string { switch (wpStatus) { case "publish": return "published"; case "draft": return "draft"; case "pending": return "pending"; case "private": return "private"; case "trash": return "archived"; default: return "draft"; } } /** * Basic PHP unserialize for simple arrays/strings * Not a full implementation, but handles common cases */ function unserializePhp(str: string): unknown { // This is a simplified parser - for production, use a proper library try { if (str.startsWith("a:")) { // Array - extract key/value pairs const result: Record = {}; // Match pattern: s:4:"key";s:5:"value"; const matches = str.matchAll(PHP_ARRAY_PATTERN); for (const match of matches) { const key = match[2]; const strVal = match[4]; const intVal = match[5]; const boolVal = match[6]; if (key) { if (strVal !== undefined) result[key] = strVal; else if (intVal !== undefined) result[key] = parseInt(intVal, 10); else if (boolVal !== undefined) result[key] = boolVal === "1"; } } return result; } if (str.startsWith("s:")) { // Simple string const match = str.match(PHP_STRING_PATTERN); return match?.[1] || str; } return str; } catch { return str; } }