/** * WordPress WXR analyze endpoint * * POST /_emdash/api/import/wordpress/analyze * * Accepts a WXR file upload and returns analysis of its contents, * including post types, counts, custom fields, and schema compatibility. */ import type { APIRoute } from "astro"; import { parseWxrString, SchemaRegistry, type WxrData } from "@premium-cms/emdash"; import mime from "mime/lite"; import { requirePerm } from "#api/authorize.js"; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { RESERVED_COLLECTION_SLUGS } from "#schema/types.js"; import type { EmDashHandlers } from "#types"; export const prerender = false; const NUMERIC_PATTERN = /^-?\d+(\.\d+)?$/; const INVALID_SLUG_CHARS = /[^a-z0-9_]/g; const LEADING_NON_ALPHA = /^[^a-z]+/; /** Field compatibility status */ export type FieldCompatibility = | "compatible" // Field exists with compatible type | "type_mismatch" // Field exists but type differs | "missing"; // Field doesn't exist /** Single field definition for import */ export interface ImportFieldDef { slug: string; label: string; type: string; required: boolean; searchable?: boolean; } /** Schema status for a collection */ export interface CollectionSchemaStatus { /** Whether collection exists */ exists: boolean; /** If exists, per-field compatibility */ fieldStatus: Record< string, { status: FieldCompatibility; existingType?: string; requiredType: string; } >; /** Can we safely import to this collection? */ canImport: boolean; /** Human-readable reason if canImport is false */ reason?: string; } /** Post type with full schema info */ export interface PostTypeAnalysis { /** WordPress post type name */ name: string; /** Number of items to import */ count: number; /** Suggested collection slug */ suggestedCollection: string; /** Fields we need to create */ requiredFields: ImportFieldDef[]; /** Schema compatibility status */ schemaStatus: CollectionSchemaStatus; } /** Individual attachment info for media import */ export interface AttachmentInfo { id?: number; title?: string; url?: string; filename?: string; mimeType?: string; } /** Author info from WordPress */ export interface WpAuthorInfo { id?: number; login?: string; email?: string; displayName?: string; postCount: number; } export interface WxrAnalysis { site: { title: string; url: string; }; postTypes: PostTypeAnalysis[]; attachments: { count: number; items: AttachmentInfo[]; }; categories: number; tags: number; authors: WpAuthorInfo[]; customFields: Array<{ key: string; count: number; samples: string[]; suggestedField: string; suggestedType: "string" | "number" | "boolean" | "date" | "json"; isInternal: boolean; }>; } export const POST: APIRoute = async ({ request, locals }) => { const { emdash, user } = locals; const denied = requirePerm(user, "import:execute"); if (denied) return denied; try { const formData = await request.formData(); const fileEntry = formData.get("file"); const file = fileEntry instanceof File ? fileEntry : null; if (!file) { return apiError("VALIDATION_ERROR", "No file provided", 400); } // Parse WXR const text = await file.text(); const wxr = await parseWxrString(text); // Fetch existing collections from schema registry const existingCollections = await fetchExistingCollections(emdash?.db); // Analyze content with schema compatibility const analysis = analyzeWxr(wxr, existingCollections); return apiSuccess(analysis); } catch (error) { return handleError(error, "Failed to analyze file", "WXR_ANALYZE_ERROR"); } }; /** Existing collection info from schema registry */ interface ExistingCollection { slug: string; fields: Map; } /** Fetch collections and their fields from schema registry */ async function fetchExistingCollections( db: EmDashHandlers["db"] | undefined, ): Promise> { const result = new Map(); if (!db) return result; try { const registry = new SchemaRegistry(db); const collections = await registry.listCollections(); for (const collection of collections) { const fields = await registry.listFields(collection.id); const fieldMap = new Map(); for (const field of fields) { fieldMap.set(field.slug, { type: field.type, columnType: field.columnType, }); } result.set(collection.slug, { slug: collection.slug, fields: fieldMap, }); } } catch (error) { console.warn("Could not fetch schema registry:", error); } return result; } /** Base fields required for any WordPress import */ const BASE_REQUIRED_FIELDS: ImportFieldDef[] = [ { slug: "title", label: "Title", type: "string", required: true, searchable: true }, { slug: "content", label: "Content", type: "portableText", required: false, searchable: true }, { slug: "excerpt", label: "Excerpt", type: "text", required: false }, ]; /** Featured image field - only added to post types that have _thumbnail_id */ const FEATURED_IMAGE_FIELD: ImportFieldDef = { slug: "featured_image", label: "Featured Image", type: "image", required: false, }; function analyzeWxr( wxr: WxrData, existingCollections: Map, ): WxrAnalysis { // Count post types and track which have featured images const postTypeCounts = new Map(); const postTypesWithThumbnails = new Set(); const metaKeys = new Map(); const authorPostCounts = new Map(); for (const post of wxr.posts) { const type = post.postType || "post"; postTypeCounts.set(type, (postTypeCounts.get(type) || 0) + 1); // Count posts per author (by login) if (post.creator) { authorPostCounts.set(post.creator, (authorPostCounts.get(post.creator) || 0) + 1); } // Track if this post type has featured images if (post.meta.has("_thumbnail_id")) { postTypesWithThumbnails.add(type); } // 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.push(value.slice(0, 100)); } } else { metaKeys.set(key, { count: 1, samples: value ? [value.slice(0, 100)] : [], isInternal: isInternalMetaKey(key), }); } } } // Map meta keys to fields (for custom fields analysis) const customFields = [...metaKeys.entries()] .filter(([_, info]) => !info.isInternal) .map(([key, info]) => ({ key, count: info.count, samples: info.samples, suggestedField: mapMetaKeyToField(key), suggestedType: inferMetaType(key, info.samples[0]), isInternal: info.isInternal, })) .toSorted((a, b) => b.count - a.count); // Build post type analysis with schema compatibility const seenSlugs = new Map(); const postTypes: PostTypeAnalysis[] = [...postTypeCounts.entries()] .filter(([type]) => !isInternalPostType(type)) .map(([name, count]) => { let suggestedCollection = mapPostTypeToCollection(name); // Deduplicate: if multiple post types produce the same slug, append a suffix const seen = seenSlugs.get(suggestedCollection) ?? 0; seenSlugs.set(suggestedCollection, seen + 1); if (seen > 0) { suggestedCollection = `${suggestedCollection}_${seen}`; } const existingCollection = existingCollections.get(suggestedCollection); // Build required fields - add featured_image only if posts have thumbnails const requiredFields = [...BASE_REQUIRED_FIELDS]; if (postTypesWithThumbnails.has(name)) { requiredFields.push(FEATURED_IMAGE_FIELD); } const schemaStatus = checkSchemaCompatibility(requiredFields, existingCollection); return { name, count, suggestedCollection, requiredFields, schemaStatus, }; }) .toSorted((a, b) => b.count - a.count); // Build attachment info list const attachmentItems: AttachmentInfo[] = wxr.attachments.map((att) => { const filename = att.url ? getFilenameFromUrl(att.url) : undefined; const mimeType = filename ? guessMimeType(filename) : undefined; return { id: att.id, title: att.title, url: att.url, filename, mimeType, }; }); return { site: { title: wxr.site.title || "WordPress Site", url: wxr.site.link || "", }, postTypes, attachments: { count: wxr.attachments.length, items: attachmentItems, }, categories: wxr.categories.length, tags: wxr.tags.length, authors: wxr.authors.map((a) => ({ id: a.id, login: a.login, email: a.email, displayName: a.displayName || a.login || "Unknown", postCount: a.login ? authorPostCounts.get(a.login) || 0 : 0, })), customFields, }; } /** Extract filename from URL */ function getFilenameFromUrl(url: string): string | undefined { try { const parsed = new URL(url); const segments = parsed.pathname.split("/").filter(Boolean); return segments.pop(); } catch { return undefined; } } /** Guess MIME type from filename extension */ function guessMimeType(filename: string): string | undefined { return mime.getType(filename) ?? undefined; } /** Check if a collection schema is compatible with import requirements */ function checkSchemaCompatibility( requiredFields: ImportFieldDef[], existingCollection: ExistingCollection | undefined, ): CollectionSchemaStatus { if (!existingCollection) { // Collection doesn't exist - will need to create it const fieldStatus: CollectionSchemaStatus["fieldStatus"] = {}; for (const field of requiredFields) { fieldStatus[field.slug] = { status: "missing", requiredType: field.type, }; } return { exists: false, fieldStatus, canImport: true, // We can create it }; } // Collection exists - check field compatibility const fieldStatus: CollectionSchemaStatus["fieldStatus"] = {}; const incompatibleFields: string[] = []; for (const field of requiredFields) { const existingField = existingCollection.fields.get(field.slug); if (!existingField) { // Field missing - we can add it fieldStatus[field.slug] = { status: "missing", requiredType: field.type, }; } else if (isTypeCompatible(field.type, existingField.type)) { // Field exists and is compatible fieldStatus[field.slug] = { status: "compatible", existingType: existingField.type, requiredType: field.type, }; } else { // Field exists but type doesn't match fieldStatus[field.slug] = { status: "type_mismatch", existingType: existingField.type, requiredType: field.type, }; incompatibleFields.push(field.slug); } } const canImport = incompatibleFields.length === 0; const reason = canImport ? undefined : `Incompatible field types: ${incompatibleFields.join(", ")}. ` + `Existing fields have different types than required for import.`; return { exists: true, fieldStatus, canImport, reason, }; } /** Check if two field types are compatible for import */ function isTypeCompatible(requiredType: string, existingType: string): boolean { // Exact match if (requiredType === existingType) return true; // Compatible mappings const compatibleTypes: Record = { string: ["string", "text", "slug"], text: ["string", "text"], portableText: ["portableText", "json"], number: ["number", "integer"], integer: ["number", "integer"], }; const compatible = compatibleTypes[requiredType]; return compatible?.includes(existingType) ?? false; } function isInternalPostType(type: string): boolean { return [ "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); } function isInternalMetaKey(key: string): boolean { 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; // Keep these useful ones if (key === "_thumbnail_id") return false; if (key.startsWith("_yoast_")) return false; if (key.startsWith("_rank_math_")) return false; // Other underscore prefixes are usually internal if (key.startsWith("_")) return true; return false; } function sanitizeSlug(slug: string): string { const sanitized = slug .toLowerCase() .replace(INVALID_SLUG_CHARS, "_") .replace(LEADING_NON_ALPHA, ""); if (!sanitized) return "imported"; if (RESERVED_COLLECTION_SLUGS.includes(sanitized)) return `wp_${sanitized}`; return sanitized; } 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] || sanitizeSlug(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 === "_rank_math_title") return "seo_title"; if (key === "_rank_math_description") return "seo_description"; if (key === "_thumbnail_id") return "featured_image"; // Remove leading underscore if (key.startsWith("_")) return key.slice(1); return key; } function inferMetaType( key: string, value: string | undefined, ): "string" | "number" | "boolean" | "date" | "json" { if (key.endsWith("_id") || key === "_thumbnail_id") return "string"; // reference if (key.endsWith("_date") || key.endsWith("_time")) return "date"; if (key.endsWith("_count") || key.endsWith("_number")) return "number"; if (!value) return "string"; // Serialized PHP or JSON if (value.startsWith("a:") || value.startsWith("{") || value.startsWith("[")) return "json"; // Number if (NUMERIC_PATTERN.test(value)) return "number"; // Boolean if (["0", "1", "true", "false"].includes(value)) return "boolean"; return "string"; } 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; } // Export helpers for use in prepare endpoint export { capitalize, sanitizeSlug, singularize, mapPostTypeToCollection };