/** * Shared import utilities * * Common constants and functions used across all WordPress import sources. */ import mime from "mime/lite"; import type { ImportFieldDef, CollectionSchemaStatus } from "./types.js"; // ============================================================================= // Constants // ============================================================================= /** Internal WordPress post types that should be excluded from import */ export const INTERNAL_POST_TYPES = [ "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) ]; /** Internal meta key prefixes to filter out */ export const INTERNAL_META_PREFIXES = ["_edit_", "_wp_"]; const NUMERIC_PATTERN = /^-?\d+(\.\d+)?$/; const TRAILING_SLASHES = /\/+$/; const WP_JSON_SUFFIX = /\/wp-json\/?.*$/; /** Specific internal meta keys */ export const INTERNAL_META_KEYS = ["_edit_last", "_edit_lock", "_pingme", "_encloseme"]; /** Base fields required for any WordPress import */ export 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 */ export const FEATURED_IMAGE_FIELD: ImportFieldDef = { slug: "featured_image", label: "Featured Image", type: "image", required: false, }; // ============================================================================= // Type Guards // ============================================================================= /** * Check if a post type is internal/should be excluded */ export function isInternalPostType(type: string): boolean { return INTERNAL_POST_TYPES.includes(type); } /** * Check if a meta key is internal/should be filtered out */ export function isInternalMetaKey(key: string): boolean { // Check specific keys if (INTERNAL_META_KEYS.includes(key)) return true; // Check prefixes for (const prefix of INTERNAL_META_PREFIXES) { if (key.startsWith(prefix)) 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; } // ============================================================================= // Status Mapping // ============================================================================= /** Valid WordPress statuses */ export type WpStatus = "publish" | "draft" | "pending" | "private" | "future"; /** * Map WordPress status to normalized status */ export function mapWpStatus(status: string | undefined): WpStatus { switch (status) { case "publish": return "publish"; case "draft": return "draft"; case "pending": return "pending"; case "private": return "private"; case "future": return "future"; default: return "draft"; } } // ============================================================================= // Collection Mapping // ============================================================================= /** Default mappings from WordPress post types to EmDash collections */ const POST_TYPE_TO_COLLECTION: Record = { post: "posts", page: "pages", attachment: "media", product: "products", portfolio: "portfolio", testimonial: "testimonials", team: "team", event: "events", faq: "faqs", }; /** * Map WordPress post type to EmDash collection name */ export function mapPostTypeToCollection(postType: string): string { return POST_TYPE_TO_COLLECTION[postType] || postType; } // ============================================================================= // Meta Key Mapping // ============================================================================= /** * Map WordPress meta key to EmDash field slug */ export 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; } /** * Infer field type from meta key name and sample value */ export function inferMetaType( key: string, value: string | undefined, ): "string" | "number" | "boolean" | "date" | "json" { if (key.endsWith("_id") || key === "_thumbnail_id") return "string"; 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"; } // ============================================================================= // String Utilities // ============================================================================= export { slugify } from "../utils/slugify.js"; /** * Normalize URL for API requests */ export function normalizeUrl(url: string): string { let normalized = url.trim(); // Add protocol if missing if (!normalized.startsWith("http")) { normalized = `https://${normalized}`; } // Remove trailing slash normalized = normalized.replace(TRAILING_SLASHES, ""); // Remove /wp-json if included normalized = normalized.replace(WP_JSON_SUFFIX, ""); return normalized; } // ============================================================================= // File Utilities // ============================================================================= /** * Extract filename from URL */ export 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 */ export function guessMimeType(filename: string): string | undefined { return mime.getType(filename) ?? undefined; } // ============================================================================= // Attachment Map Builder // ============================================================================= /** * Build a map of attachment IDs to URLs for resolving featured images */ export function buildAttachmentMap( attachments: Array<{ id?: number | string; url?: string }>, ): Map { const map = new Map(); for (const att of attachments) { if (att.id && att.url) { map.set(String(att.id), att.url); } } return map; } // ============================================================================= // Schema Compatibility // ============================================================================= /** * Check if two field types are compatible for import */ export function isTypeCompatible(requiredType: string, existingType: string): boolean { if (requiredType === existingType) return true; 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; } // ============================================================================= // Byline Import Utilities // ============================================================================= import type { BylineRepository } from "../database/repositories/byline.js"; import { slugify as slugifyFn } from "../utils/slugify.js"; const MAX_SLUG_COLLISION_ATTEMPTS = 1000; /** * Find or create a unique byline slug, capped at MAX_SLUG_COLLISION_ATTEMPTS. */ export async function ensureUniqueBylineSlug( bylineRepo: BylineRepository, baseSlug: string, ): Promise { let candidate = baseSlug; let suffix = 2; while (await bylineRepo.findBySlug(candidate)) { if (suffix > MAX_SLUG_COLLISION_ATTEMPTS) { throw new Error( `Byline slug collision limit exceeded for base slug "${baseSlug}". ` + `Tried ${MAX_SLUG_COLLISION_ATTEMPTS} variants.`, ); } candidate = `${baseSlug}-${suffix}`; suffix++; } return candidate; } /** * Resolve (find-or-create) a byline for an imported WordPress author. * Caches results in `cache` keyed by `authorLogin:mappedUserId`. */ export async function resolveImportByline( authorLogin: string | undefined, displayName: string | undefined, mappedUserId: string | undefined, bylineRepo: BylineRepository, cache: Map, ): Promise { if (!authorLogin) return undefined; const cacheKey = `${authorLogin}:${mappedUserId ?? ""}`; const cached = cache.get(cacheKey); if (cached) return cached; if (mappedUserId) { const existingForUser = await bylineRepo.findByUserId(mappedUserId); if (existingForUser) { cache.set(cacheKey, existingForUser.id); return existingForUser.id; } } const name = displayName || authorLogin; const slugBase = slugifyFn(authorLogin); const slug = await ensureUniqueBylineSlug(bylineRepo, slugBase || "author"); const created = await bylineRepo.create({ slug, displayName: name, userId: mappedUserId ?? null, isGuest: !mappedUserId, }); cache.set(cacheKey, created.id); return created.id; } // ============================================================================= // Schema Compatibility // ============================================================================= /** * Check schema compatibility between required fields and existing collection */ export function checkSchemaCompatibility( requiredFields: ImportFieldDef[], existingCollection: { slug: string; fields: Map } | 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, }; } // 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) { fieldStatus[field.slug] = { status: "missing", requiredType: field.type, }; } else if (isTypeCompatible(field.type, existingField.type)) { fieldStatus[field.slug] = { status: "compatible", existingType: existingField.type, requiredType: field.type, }; } else { 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(", ")}`; return { exists: true, fieldStatus, canImport, reason, }; }