From b036cf3c465da362f53910831befece89ff0e7f2 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 14 Feb 2026 20:49:43 +0100 Subject: [PATCH] feat: first cut at the import execution --- src/main/engine/ImportExecutionEngine.ts | 696 ++++++++ src/main/engine/MediaEngine.ts | 12 +- src/main/engine/PostEngine.ts | 3 +- src/main/ipc/handlers.ts | 95 + src/main/preload.ts | 29 + .../ImportAnalysisView/ImportAnalysisView.css | 255 +++ .../ImportAnalysisView/ImportAnalysisView.tsx | 319 ++++ src/renderer/types/electron.d.ts | 16 + tests/assets/import-test-cases.wxr | 608 +++++++ .../engine/ImportExecutionEngine.e2e.test.ts | 1378 +++++++++++++++ tests/engine/ImportExecutionEngine.test.ts | 1549 +++++++++++++++++ tests/engine/MediaEngine.test.ts | 11 + tests/utils/factories.ts | 164 ++ 13 files changed, 5130 insertions(+), 5 deletions(-) create mode 100644 src/main/engine/ImportExecutionEngine.ts create mode 100644 tests/assets/import-test-cases.wxr create mode 100644 tests/engine/ImportExecutionEngine.e2e.test.ts create mode 100644 tests/engine/ImportExecutionEngine.test.ts diff --git a/src/main/engine/ImportExecutionEngine.ts b/src/main/engine/ImportExecutionEngine.ts new file mode 100644 index 0000000..655f422 --- /dev/null +++ b/src/main/engine/ImportExecutionEngine.ts @@ -0,0 +1,696 @@ +/** + * ImportExecutionEngine - Executes WXR import based on analysis results + * + * Handles the 4-phase import process: + * 1. Create new tags/categories + * 2. Import posts (handling conflicts correctly) + * 3. Import media (with post linkage) + * 4. Import pages (as posts with "page" category) + */ + +import { EventEmitter } from 'events'; +import { v4 as uuidv4 } from 'uuid'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import matter from 'gray-matter'; +import { app } from 'electron'; +import TurndownService from 'turndown'; +import { getDatabase } from '../database'; +import { posts, media, NewPost, NewMedia } from '../database/schema'; +import { eq } from 'drizzle-orm'; +import { getTagEngine } from './TagEngine'; +import { getPostEngine, PostData } from './PostEngine'; +import { getMediaEngine, MediaData } from './MediaEngine'; +import type { + ImportAnalysisReport, + AnalyzedPost, + AnalyzedMedia, + AnalyzedCategory, + AnalyzedTag, + ImportConflictResolution, +} from './ImportAnalysisEngine'; +import type { WxrPost, WxrMedia } from './WxrParser'; + +export interface ImportExecutionOptions { + /** Path to the WordPress uploads folder for media files */ + uploadsFolder?: string; + /** Progress callback */ + onProgress?: (phase: string, current: number, total: number, detail?: string) => void; +} + +export interface ImportExecutionResult { + success: boolean; + tags: { + created: number; + skipped: number; + }; + posts: { + imported: number; + skipped: number; + errors: number; + }; + media: { + imported: number; + skipped: number; + errors: number; + }; + pages: { + imported: number; + skipped: number; + errors: number; + }; + /** Mapping from WordPress post ID to our post GUID */ + wpIdToPostId: Map; + errors: string[]; +} + +// Regex to match WordPress shortcodes: [macroname ...] but NOT [[macroname ...]] +const WP_SHORTCODE_REGEX = /(? { + const result: ImportExecutionResult = { + success: true, + tags: { created: 0, skipped: 0 }, + posts: { imported: 0, skipped: 0, errors: 0 }, + media: { imported: 0, skipped: 0, errors: 0 }, + pages: { imported: 0, skipped: 0, errors: 0 }, + wpIdToPostId: new Map(), + errors: [], + }; + + const progress = options.onProgress || (() => {}); + + try { + // Build tag/category mappings + const tagMapping = this.buildTaxonomyMapping(report.tags); + const categoryMapping = this.buildTaxonomyMapping(report.categories); + + // Phase 1: Create new tags + progress('tags', 0, report.tags.length + report.categories.length, 'Creating tags...'); + await this.executePhase1Tags(report, tagMapping, categoryMapping, result, progress); + + // Phase 2: Import posts + progress('posts', 0, report.posts.items.length, 'Importing posts...'); + await this.executePhase2Posts(report, tagMapping, categoryMapping, result, options, progress); + + // Phase 3: Import media + progress('media', 0, report.media.items.length, 'Importing media...'); + await this.executePhase3Media(report, result, options, progress); + + // Phase 4: Import pages + progress('pages', 0, report.pages.items.length, 'Importing pages...'); + await this.executePhase4Pages(report, tagMapping, categoryMapping, result, options, progress); + + progress('complete', 1, 1, 'Import complete'); + } catch (error) { + result.success = false; + result.errors.push(error instanceof Error ? error.message : String(error)); + } + + return result; + } + + /** + * Build a mapping from original taxonomy name to resolved name + * - If existsInProject: use the name as-is (lowercase) + * - If mappedTo: use the mappedTo value (lowercase) + * - Otherwise: use the name and mark for creation + */ + private buildTaxonomyMapping( + items: Array<{ name: string; existsInProject: boolean; mappedTo?: string }> + ): Map { + const mapping = new Map(); + + for (const item of items) { + const key = item.name.toLowerCase(); + if (item.mappedTo) { + // Mapped to existing tag + mapping.set(key, { resolved: item.mappedTo.toLowerCase(), needsCreation: false }); + } else if (item.existsInProject) { + // Already exists + mapping.set(key, { resolved: key, needsCreation: false }); + } else { + // New tag to create + mapping.set(key, { resolved: key, needsCreation: true }); + } + } + + return mapping; + } + + /** + * Phase 1: Create new tags and categories + */ + private async executePhase1Tags( + report: ImportAnalysisReport, + tagMapping: Map, + categoryMapping: Map, + result: ImportExecutionResult, + progress: (phase: string, current: number, total: number, detail?: string) => void + ): Promise { + const tagEngine = getTagEngine(); + tagEngine.setProjectContext(this.currentProjectId); + + let current = 0; + const total = report.tags.length + report.categories.length; + + // Create new tags + for (const tag of report.tags) { + current++; + const mapping = tagMapping.get(tag.name.toLowerCase()); + + if (mapping?.needsCreation) { + try { + await tagEngine.createTag({ name: mapping.resolved }); + result.tags.created++; + progress('tags', current, total, `Created tag: ${mapping.resolved}`); + } catch (error) { + // Tag might already exist (race condition or duplicate in list) + result.tags.skipped++; + } + } else { + result.tags.skipped++; + } + } + + // Create new categories (as tags) + for (const category of report.categories) { + current++; + const mapping = categoryMapping.get(category.name.toLowerCase()); + + if (mapping?.needsCreation) { + try { + await tagEngine.createTag({ name: mapping.resolved }); + result.tags.created++; + progress('tags', current, total, `Created category tag: ${mapping.resolved}`); + } catch (error) { + result.tags.skipped++; + } + } else { + result.tags.skipped++; + } + } + } + + /** + * Phase 2: Import posts + */ + private async executePhase2Posts( + report: ImportAnalysisReport, + tagMapping: Map, + categoryMapping: Map, + result: ImportExecutionResult, + options: ImportExecutionOptions, + progress: (phase: string, current: number, total: number, detail?: string) => void + ): Promise { + const total = report.posts.items.length; + + for (let i = 0; i < report.posts.items.length; i++) { + const analyzed = report.posts.items[i]; + progress('posts', i + 1, total, `Processing: ${analyzed.wxrPost.title}`); + + try { + const imported = await this.importPost(analyzed, tagMapping, categoryMapping, result, options); + if (imported) { + result.posts.imported++; + } else { + result.posts.skipped++; + } + } catch (error) { + result.posts.errors++; + result.errors.push(`Failed to import post "${analyzed.wxrPost.title}": ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + /** + * Import a single post + */ + private async importPost( + analyzed: AnalyzedPost, + tagMapping: Map, + categoryMapping: Map, + result: ImportExecutionResult, + options: ImportExecutionOptions + ): Promise { + const wxrPost = analyzed.wxrPost; + + // Handle different analysis statuses + if (analyzed.status === 'content-duplicate') { + // Skip content duplicates + return false; + } + + if (analyzed.status === 'update') { + // Skip updates (same content already exists) + return false; + } + + if (analyzed.status === 'conflict') { + const resolution = analyzed.conflictResolution || 'ignore'; + + if (resolution === 'ignore') { + return false; + } + + // Handle overwrite and import + return await this.importPostWithConflict(analyzed, resolution, tagMapping, categoryMapping, result, options); + } + + // New post - import it + return await this.createImportedPost(analyzed, tagMapping, categoryMapping, result, options, 'published'); + } + + /** + * Import a post that has a conflict + */ + private async importPostWithConflict( + analyzed: AnalyzedPost, + resolution: ImportConflictResolution, + tagMapping: Map, + categoryMapping: Map, + result: ImportExecutionResult, + options: ImportExecutionOptions + ): Promise { + const postEngine = getPostEngine(); + + if (resolution === 'overwrite') { + // Create as draft with the same slug (user needs to review and publish) + return await this.createImportedPost(analyzed, tagMapping, categoryMapping, result, options, 'draft'); + } + + if (resolution === 'import') { + // Create with a new unique slug + const newSlug = await postEngine.generateUniqueSlug(analyzed.wxrPost.title); + return await this.createImportedPost(analyzed, tagMapping, categoryMapping, result, options, 'published', newSlug); + } + + return false; + } + + /** + * Create an imported post + */ + private async createImportedPost( + analyzed: AnalyzedPost, + tagMapping: Map, + categoryMapping: Map, + result: ImportExecutionResult, + options: ImportExecutionOptions, + status: 'draft' | 'published', + overrideSlug?: string + ): Promise { + const wxrPost = analyzed.wxrPost; + const db = getDatabase().getLocal(); + + // Transform WordPress shortcodes [shortcode] to [[shortcode]] BEFORE markdown conversion + // (TurndownService escapes brackets, so we must transform first) + const contentWithShortcodes = this.transformShortcodes(wxrPost.content); + + // Convert HTML content to Markdown + const transformedContent = this.convertToMarkdown(contentWithShortcodes); + + // Resolve tags + const resolvedTags = this.resolveTaxonomy(wxrPost.tags, tagMapping); + + // Resolve categories + const resolvedCategories = this.resolveTaxonomy(wxrPost.categories, categoryMapping); + + // Determine dates (dates may be strings after JSON serialization through IPC) + const createdAt = this.toDate(wxrPost.postDate) || this.toDate(wxrPost.pubDate) || new Date(); + const updatedAt = this.toDate(wxrPost.postModified) || createdAt; + const publishedAt = status === 'published' ? (this.toDate(wxrPost.pubDate) || createdAt) : undefined; + + // Generate post ID + const postId = uuidv4(); + + // Build post data + const postData: PostData = { + id: postId, + projectId: this.currentProjectId, + title: wxrPost.title, + slug: overrideSlug || wxrPost.slug, + excerpt: wxrPost.excerpt || undefined, + content: transformedContent, + status, + author: wxrPost.creator || undefined, + createdAt, + updatedAt, + publishedAt, + tags: resolvedTags, + categories: resolvedCategories, + }; + + // Write to filesystem first (for published posts) + let filePath = ''; + if (status === 'published') { + filePath = await this.writePostFile(postData); + } + + // Calculate checksum + const checksum = this.calculateChecksum(transformedContent); + + // Insert into database + const dbPost: NewPost = { + id: postData.id, + projectId: postData.projectId, + title: postData.title, + slug: postData.slug, + excerpt: postData.excerpt, + content: status === 'draft' ? postData.content : null, // Draft content in DB, published in file + status: postData.status, + author: postData.author, + createdAt: postData.createdAt, + updatedAt: postData.updatedAt, + publishedAt: postData.publishedAt, + filePath, + checksum, + tags: JSON.stringify(postData.tags), + categories: JSON.stringify(postData.categories), + }; + + await db.insert(posts).values(dbPost); + + // Update FTS index + const postEngine = getPostEngine(); + await postEngine.updateFTSIndex(postData); + + // Track wpId to postId mapping + result.wpIdToPostId.set(wxrPost.wpId, postId); + + return true; + } + + /** + * Write a post file to the filesystem + */ + private async writePostFile(post: PostData): Promise { + const metadata: Record = { + id: post.id, + projectId: post.projectId, + title: post.title, + slug: post.slug, + status: post.status, + createdAt: post.createdAt.toISOString(), + updatedAt: post.updatedAt.toISOString(), + tags: post.tags, + categories: post.categories, + }; + + if (post.excerpt) metadata.excerpt = post.excerpt; + if (post.author) metadata.author = post.author; + if (post.publishedAt) metadata.publishedAt = post.publishedAt.toISOString(); + + const postsDir = this.getPostsDirForDate(post.createdAt); + await fs.mkdir(postsDir, { recursive: true }); + + const fileContent = matter.stringify(post.content, metadata); + const filePath = path.join(postsDir, `${post.slug}.md`); + + await fs.writeFile(filePath, fileContent, 'utf-8'); + return filePath; + } + + /** + * Phase 3: Import media files + */ + private async executePhase3Media( + report: ImportAnalysisReport, + result: ImportExecutionResult, + options: ImportExecutionOptions, + progress: (phase: string, current: number, total: number, detail?: string) => void + ): Promise { + const total = report.media.items.length; + + for (let i = 0; i < report.media.items.length; i++) { + const analyzed = report.media.items[i]; + progress('media', i + 1, total, `Processing: ${analyzed.wxrMedia.filename}`); + + try { + const imported = await this.importMediaFile(analyzed, result, options); + if (imported) { + result.media.imported++; + } else { + result.media.skipped++; + } + } catch (error) { + result.media.errors++; + result.errors.push(`Failed to import media "${analyzed.wxrMedia.filename}": ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + /** + * Import a single media file + */ + private async importMediaFile( + analyzed: AnalyzedMedia, + result: ImportExecutionResult, + options: ImportExecutionOptions + ): Promise { + const wxrMedia = analyzed.wxrMedia; + + // Skip missing files + if (analyzed.status === 'missing') { + return false; + } + + // Skip content duplicates + if (analyzed.status === 'content-duplicate') { + return false; + } + + // Handle conflicts + if (analyzed.status === 'conflict') { + const resolution = (analyzed as any).conflictResolution || 'ignore'; + if (resolution === 'ignore') { + return false; + } + // For 'overwrite' or 'import', proceed with import + } + + // Skip updates (same content already exists) + if (analyzed.status === 'update') { + return false; + } + + // Build source path + if (!options.uploadsFolder) { + return false; + } + + const sourcePath = path.join(options.uploadsFolder, wxrMedia.relativePath); + + // Check if file exists + try { + await fs.access(sourcePath); + } catch { + return false; + } + + // Resolve parent post ID + const linkedPostIds: string[] = []; + if (wxrMedia.parentId && wxrMedia.parentId > 0) { + const parentPostId = result.wpIdToPostId.get(wxrMedia.parentId); + if (parentPostId) { + linkedPostIds.push(parentPostId); + } + } + + // Determine creation date from WXR (may be string after JSON serialization) + const createdAt = this.toDate(wxrMedia.pubDate) || new Date(); + + // Import the media file + const mediaEngine = getMediaEngine(); + await mediaEngine.importMedia(sourcePath, { + caption: wxrMedia.title || undefined, + alt: wxrMedia.description || undefined, + mimeType: wxrMedia.mimeType, + tags: [], + linkedPostIds, + createdAt, + updatedAt: createdAt, + }); + + return true; + } + + /** + * Phase 4: Import pages as posts with "page" category + */ + private async executePhase4Pages( + report: ImportAnalysisReport, + tagMapping: Map, + categoryMapping: Map, + result: ImportExecutionResult, + options: ImportExecutionOptions, + progress: (phase: string, current: number, total: number, detail?: string) => void + ): Promise { + const total = report.pages.items.length; + + // Ensure "page" category exists in mapping + if (!categoryMapping.has('page')) { + categoryMapping.set('page', { resolved: 'page', needsCreation: false }); + } + + for (let i = 0; i < report.pages.items.length; i++) { + const analyzed = report.pages.items[i]; + const wxrPage = analyzed.wxrPost; + + // Add "page" to categories + const modifiedWxrPost: WxrPost = { + ...wxrPage, + categories: [...wxrPage.categories, 'page'], + }; + + const modifiedAnalyzed: AnalyzedPost = { + ...analyzed, + wxrPost: modifiedWxrPost, + }; + + progress('pages', i + 1, total, `Processing: ${wxrPage.title}`); + + try { + const imported = await this.importPost(modifiedAnalyzed, tagMapping, categoryMapping, result, options); + if (imported) { + result.pages.imported++; + } else { + result.pages.skipped++; + } + } catch (error) { + result.pages.errors++; + result.errors.push(`Failed to import page "${wxrPage.title}": ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + /** + * Convert HTML to Markdown using Turndown + */ + private convertToMarkdown(html: string): string { + if (!html || !html.trim()) return ''; + let markdown = this.turndown.turndown(html); + // Unescape double-bracket macros that TurndownService escaped + // \[\[ becomes [[ and \]\] becomes ]] + markdown = markdown.replace(/\\\[\\\[/g, '[[').replace(/\\\]\\\]/g, ']]'); + return markdown; + } + + /** + * Transform WordPress shortcodes [shortcode] to [[shortcode]] + */ + private transformShortcodes(content: string): string { + return content.replace(WP_SHORTCODE_REGEX, '[[$1$2]]'); + } + + /** + * Resolve taxonomy items using the mapping + */ + private resolveTaxonomy( + items: string[], + mapping: Map + ): string[] { + return items.map(item => { + const key = item.toLowerCase(); + const mapped = mapping.get(key); + return mapped ? mapped.resolved : key; + }); + } + + /** + * Safely convert a value to a Date object. + * Handles Date objects, ISO strings (from JSON serialization), and null/undefined. + */ + private toDate(value: Date | string | null | undefined): Date | null { + if (!value) return null; + if (value instanceof Date) { + return isNaN(value.getTime()) ? null : value; + } + if (typeof value === 'string') { + const parsed = new Date(value); + return isNaN(parsed.getTime()) ? null : parsed; + } + return null; + } + + /** + * Calculate MD5 checksum of content + */ + private calculateChecksum(content: string): string { + return crypto.createHash('md5').update(content).digest('hex'); + } +} + +// Singleton instance +let importExecutionEngineInstance: ImportExecutionEngine | null = null; + +export function getImportExecutionEngine(): ImportExecutionEngine { + if (!importExecutionEngineInstance) { + importExecutionEngineInstance = new ImportExecutionEngine(); + } + return importExecutionEngineInstance; +} diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index 657c991..5ccff37 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -451,13 +451,17 @@ export class MediaEngine extends EventEmitter { const id = uuidv4(); const now = new Date(); + // Use provided createdAt date or current date + const createdAt = metadata?.createdAt ?? now; + const updatedAt = metadata?.updatedAt ?? now; + const sourceBuffer = await fs.readFile(sourcePath); const originalName = path.basename(sourcePath); const ext = path.extname(originalName); const filename = `${id}${ext}`; - // Use date-based directory structure (media/YYYY/MM/) - const mediaDir = this.getMediaDirForDate(now); + // Use date-based directory structure (media/YYYY/MM/) based on createdAt + const mediaDir = this.getMediaDirForDate(createdAt); await fs.mkdir(mediaDir, { recursive: true }); const destPath = path.join(mediaDir, filename); @@ -490,8 +494,8 @@ export class MediaEngine extends EventEmitter { height, alt: metadata?.alt, caption: metadata?.caption, - createdAt: now, - updatedAt: now, + createdAt, + updatedAt, tags: metadata?.tags || [], }; diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 44c3808..57c5751 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -100,8 +100,9 @@ export class PostEngine extends EventEmitter { * Stores the stemmed content (combining title, excerpt, content, tags, categories). * Includes project_id for project-scoped search. * Only the post ID is returned from searches - actual post data comes from DB/files. + * Public to allow ImportExecutionEngine to index imported posts directly. */ - private async updateFTSIndex(post: { + async updateFTSIndex(post: { id: string; projectId: string; title: string; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 194dcfe..5b5d73c 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -775,6 +775,101 @@ export function registerIpcHandlers(): void { return result.filePaths[0]; }); + // Helper to emit import execution progress events + const emitImportExecutionProgress = ( + taskId: string, + phase: string, + current: number, + total: number, + detail?: string, + eta?: number + ) => { + ipcMain.emit('forward-to-renderer', 'import:executionProgress', { + taskId, + phase, + current, + total, + detail, + eta, + }); + }; + + safeHandle('import:execute', async (_, reportJson: string, uploadsFolder?: string) => { + const { ImportExecutionEngine } = await import('../engine/ImportExecutionEngine'); + + // Parse the report + const report = JSON.parse(reportJson) as import('../engine/ImportAnalysisEngine').ImportAnalysisReport; + + // Set up project context + const projectEngine = getProjectEngine(); + const activeProject = await projectEngine.getActiveProject(); + + // Calculate total items for ETA + // Note: 'update' and 'content-duplicate' statuses are SKIPPED during import, only 'new' and resolved conflicts are imported + const totalItems = + report.tags.filter(t => !t.existsInProject).length + + report.categories.filter(c => !c.existsInProject).length + + report.posts.items.filter(p => p.status === 'new' || (p.status === 'conflict' && p.conflictResolution && p.conflictResolution !== 'ignore')).length + + report.media.items.filter(m => m.status === 'new').length + + report.pages.items.filter(p => p.status === 'new' || (p.status === 'conflict' && p.conflictResolution && p.conflictResolution !== 'ignore')).length; + + // Create a task for the import + const taskId = `import-${Date.now()}`; + let processedItems = 0; + let startTime = Date.now(); + + const task = { + id: taskId, + name: `Import from ${report.site.title || 'WordPress'}`, + execute: async (onProgress: (progress: number, message: string) => void) => { + const executionEngine = new ImportExecutionEngine(); + + if (activeProject) { + executionEngine.setProjectContext(activeProject.id, activeProject.dataPath); + } + + const result = await executionEngine.executeImport(report, { + uploadsFolder, + onProgress: (phase, current, total, detail) => { + // Update processed items count based on phase progress + processedItems++; + + // Calculate ETA + const elapsed = Date.now() - startTime; + const itemsPerMs = processedItems / elapsed; + const remainingItems = totalItems - processedItems; + const etaMs = itemsPerMs > 0 ? remainingItems / itemsPerMs : 0; + + // Calculate overall progress percentage + const overallProgress = totalItems > 0 + ? Math.round((processedItems / totalItems) * 100) + : 0; + + // Report to TaskManager + onProgress(overallProgress, `${phase}: ${detail || `${current}/${total}`}`); + + // Also emit detailed progress for UI + emitImportExecutionProgress(taskId, phase, current, total, detail, etaMs); + }, + }); + + // Convert Map to plain object for serialization + const serializedResult = { + ...result, + wpIdToPostId: Object.fromEntries(result.wpIdToPostId), + }; + + return serializedResult; + }, + }; + + // Run the task - this returns immediately with a promise + const resultPromise = taskManager.runTask(task); + + // Return task ID so UI can track it + return { taskId, totalItems }; + }); + // ============ Import Definition CRUD Handlers ============ safeHandle('importDefinitions:create', async (_, name?: string) => { diff --git a/src/main/preload.ts b/src/main/preload.ts index e281bd0..47780cc 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -130,11 +130,31 @@ contextBridge.exposeInMainWorld('electronAPI', { selectAndAnalyze: (uploadsFolder?: string) => ipcRenderer.invoke('import:selectAndAnalyze', uploadsFolder), analyzeFile: (filePath: string, uploadsFolder?: string) => ipcRenderer.invoke('import:analyzeFile', filePath, uploadsFolder), selectUploadsFolder: () => ipcRenderer.invoke('import:selectUploadsFolder'), + execute: (reportJson: string, uploadsFolder?: string) => ipcRenderer.invoke('import:execute', reportJson, uploadsFolder), onProgress: (callback: (data: { step: string; detail?: string }) => void) => { const subscription = (_event: Electron.IpcRendererEvent, data: { step: string; detail?: string }) => callback(data); ipcRenderer.on('import:progress', subscription); return () => ipcRenderer.removeListener('import:progress', subscription); }, + onExecutionProgress: (callback: (data: { + taskId: string; + phase: string; + current: number; + total: number; + detail?: string; + eta?: number; + }) => void) => { + const subscription = (_event: Electron.IpcRendererEvent, data: { + taskId: string; + phase: string; + current: number; + total: number; + detail?: string; + eta?: number; + }) => callback(data); + ipcRenderer.on('import:executionProgress', subscription); + return () => ipcRenderer.removeListener('import:executionProgress', subscription); + }, }, // Import Definition CRUD @@ -314,7 +334,16 @@ export interface ElectronAPI { selectAndAnalyze: (uploadsFolder?: string) => Promise; analyzeFile: (filePath: string, uploadsFolder?: string) => Promise; selectUploadsFolder: () => Promise; + execute: (reportJson: string, uploadsFolder?: string) => Promise<{ taskId: string; totalItems: number }>; onProgress: (callback: (data: { step: string; detail?: string }) => void) => () => void; + onExecutionProgress: (callback: (data: { + taskId: string; + phase: string; + current: number; + total: number; + detail?: string; + eta?: number; + }) => void) => () => void; }; importDefinitions: { create: (name?: string) => Promise; diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css index c868cfb..2b878a3 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css @@ -1016,4 +1016,259 @@ .resolution-select option { background: var(--vscode-dropdown-listBackground, var(--vscode-dropdown-background)); color: var(--vscode-dropdown-foreground); +} + +/* Import Execution Section */ +.import-execute-section { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px; + background: var(--vscode-sideBar-background); + border-radius: 6px; + border: 1px solid var(--vscode-editorWidget-border, transparent); +} + +.import-execute-summary { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--vscode-descriptionForeground); +} + +.import-count-tag { + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; +} + +.import-execute-btn { + padding: 10px 24px; + font-size: 14px; + font-weight: 600; + border: none; + border-radius: 4px; + cursor: pointer; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + white-space: nowrap; +} + +.import-execute-btn:hover { + background: var(--vscode-button-hoverBackground); +} + +.import-execute-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Import Execution Progress */ +.import-execution-progress { + padding: 16px; + background: var(--vscode-sideBar-background); + border-radius: 6px; + border: 1px solid var(--vscode-editorWidget-border, transparent); +} + +.import-execution-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.import-execution-header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.import-eta { + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.import-progress-bar { + height: 6px; + background: var(--vscode-progressBar-background, #0e639c); + border-radius: 3px; + overflow: hidden; + margin-bottom: 10px; + opacity: 0.3; +} + +.import-progress-fill { + height: 100%; + background: var(--vscode-button-background); + border-radius: 3px; + transition: width 0.3s ease; +} + +.import-progress-info { + display: flex; + align-items: center; + gap: 12px; + font-size: 12px; +} + +.import-phase { + font-weight: 600; + color: var(--vscode-foreground); +} + +.import-detail { + flex: 1; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.import-counter { + color: var(--vscode-descriptionForeground); + font-variant-numeric: tabular-nums; +} + +/* Execution Complete */ +.import-execution-complete { + display: flex; + align-items: center; + gap: 10px; + padding: 16px; + background: var(--vscode-inputValidation-infoBackground, rgba(0, 127, 212, 0.1)); + border: 1px solid var(--vscode-inputValidation-infoBorder, #007fd4); + border-radius: 6px; + color: var(--vscode-foreground); + font-size: 13px; + font-weight: 500; +} + +.import-execution-complete svg { + fill: var(--vscode-charts-green, #89d185); + flex-shrink: 0; +} + +/* Execution Error */ +.import-execution-error { + display: flex; + align-items: center; + gap: 10px; + padding: 16px; + background: var(--vscode-inputValidation-errorBackground, rgba(243, 70, 70, 0.1)); + border: 1px solid var(--vscode-inputValidation-errorBorder, #f34646); + border-radius: 6px; + color: var(--vscode-foreground); + font-size: 13px; +} + +.import-execution-error svg { + fill: var(--vscode-errorForeground, #f34646); + flex-shrink: 0; +} + +/* Date Distribution Card */ +.import-date-distribution { + background: var(--vscode-sideBar-background); + border-radius: 6px; + padding: 16px; + border: 1px solid var(--vscode-editorWidget-border, transparent); +} + +.import-date-distribution h3 { + margin: 0 0 12px 0; + font-size: 13px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.distribution-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +.distribution-column { + display: flex; + flex-direction: column; + gap: 8px; +} + +.distribution-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 6px; + border-bottom: 1px solid var(--vscode-editorWidget-border, #3c3c3c); +} + +.distribution-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); +} + +.distribution-total { + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.distribution-bars { + display: flex; + flex-direction: column; + gap: 4px; +} + +.distribution-row { + display: flex; + align-items: center; + gap: 8px; + height: 20px; +} + +.distribution-year { + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--vscode-foreground); + min-width: 36px; + text-align: right; +} + +.distribution-bar-container { + flex: 1; + height: 12px; + background: var(--vscode-input-background); + border-radius: 2px; + overflow: hidden; +} + +.distribution-bar { + height: 100%; + border-radius: 2px; + min-width: 2px; + transition: width 0.3s ease; +} + +.distribution-bar-posts { + background: var(--vscode-charts-blue, #75beff); +} + +.distribution-bar-media { + background: var(--vscode-charts-green, #89d185); +} + +.distribution-count { + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--vscode-descriptionForeground); + min-width: 32px; + text-align: right; } \ No newline at end of file diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx index aea8d5a..3113e94 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx @@ -130,6 +130,30 @@ interface ImportAnalysisViewProps { definitionId: string; } +interface ImportExecutionState { + isExecuting: boolean; + taskId: string | null; + phase: string; + current: number; + total: number; + detail: string; + eta: number | null; + completed: boolean; + error: string | null; +} + +const formatEta = (etaMs: number): string => { + if (etaMs <= 0) return ''; + const seconds = Math.ceil(etaMs / 1000); + if (seconds < 60) return `~${seconds}s remaining`; + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + if (minutes < 60) return `~${minutes}m ${secs}s remaining`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `~${hours}h ${mins}m remaining`; +}; + export const ImportAnalysisView: React.FC = ({ definitionId }) => { const [name, setName] = useState('Untitled Import'); const [uploadsFolder, setUploadsFolder] = useState(null); @@ -140,6 +164,17 @@ export const ImportAnalysisView: React.FC = ({ definiti const [expandedSections, setExpandedSections] = useState>({}); const [progressStep, setProgressStep] = useState(''); const [progressDetail, setProgressDetail] = useState(''); + const [executionState, setExecutionState] = useState({ + isExecuting: false, + taskId: null, + phase: '', + current: 0, + total: 0, + detail: '', + eta: null, + completed: false, + error: null, + }); const nameInputRef = useRef(null); // Subscribe to progress events @@ -151,6 +186,46 @@ export const ImportAnalysisView: React.FC = ({ definiti return () => unsubscribe?.(); }, []); + // Subscribe to execution progress events + useEffect(() => { + const unsubscribe = window.electronAPI?.import.onExecutionProgress(({ taskId, phase, current, total, detail, eta }) => { + setExecutionState(prev => { + if (prev.taskId !== taskId) return prev; + return { + ...prev, + phase, + current, + total, + detail: detail || '', + eta: eta ?? null, + }; + }); + }); + return () => unsubscribe?.(); + }, []); + + // Subscribe to task completion events + useEffect(() => { + const unsubscribe = window.electronAPI?.on('task:completed', (task: { taskId: string }) => { + setExecutionState(prev => { + if (prev.taskId !== task.taskId) return prev; + return { ...prev, isExecuting: false, completed: true }; + }); + }); + return () => unsubscribe?.(); + }, []); + + // Subscribe to task failure events + useEffect(() => { + const unsubscribe = window.electronAPI?.on('task:failed', (task: { taskId: string; error: string }) => { + setExecutionState(prev => { + if (prev.taskId !== task.taskId) return prev; + return { ...prev, isExecuting: false, error: task.error }; + }); + }); + return () => unsubscribe?.(); + }, []); + // Save the current report to the definition const persistReport = useCallback(async (updatedReport: AnalysisReport) => { await window.electronAPI?.importDefinitions.update(definitionId, { @@ -295,6 +370,104 @@ export const ImportAnalysisView: React.FC = ({ definiti } }, [definitionId, uploadsFolder]); + const handleExecuteImport = useCallback(async () => { + if (!report) return; + + // Reset execution state + setExecutionState({ + isExecuting: true, + taskId: null, + phase: 'Starting...', + current: 0, + total: 0, + detail: '', + eta: null, + completed: false, + error: null, + }); + + try { + const result = await window.electronAPI?.import.execute( + JSON.stringify(report), + uploadsFolder || undefined + ); + + if (result) { + setExecutionState(prev => ({ + ...prev, + taskId: result.taskId, + total: result.totalItems, + })); + } + } catch (error) { + console.error('Import execution failed:', error); + setExecutionState(prev => ({ + ...prev, + isExecuting: false, + error: error instanceof Error ? error.message : 'Unknown error', + })); + } + }, [report, uploadsFolder]); + + // Calculate how many items will be imported + // Note: 'update' and 'content-duplicate' are SKIPPED - only 'new' and resolved conflicts are imported + const getImportableCount = useCallback(() => { + if (!report) return { posts: 0, media: 0, pages: 0, tags: 0 }; + + const postsToImport = report.posts.items.filter(p => + p.status === 'new' || + (p.status === 'conflict' && p.conflictResolution && p.conflictResolution !== 'ignore') + ).length; + + const mediaToImport = report.media.items.filter(m => + m.status === 'new' + ).length; + + const pagesToImport = report.pages.items.filter(p => + p.status === 'new' || + (p.status === 'conflict' && p.conflictResolution && p.conflictResolution !== 'ignore') + ).length; + + const tagsToImport = report.tags.filter(t => !t.existsInProject).length + + report.categories.filter(c => !c.existsInProject).length; + + return { posts: postsToImport, media: mediaToImport, pages: pagesToImport, tags: tagsToImport }; + }, [report]); + + // Calculate date distribution for posts and media + const getDateDistribution = useCallback(() => { + if (!report) return { posts: {}, media: {} }; + + const postsDistrib: Record = {}; + const mediaDistrib: Record = {}; + + for (const item of report.posts.items) { + const date = item.wxrPost.postDate || item.wxrPost.pubDate; + if (date) { + const year = new Date(date).getFullYear(); + postsDistrib[year] = (postsDistrib[year] || 0) + 1; + } + } + + for (const item of report.pages.items) { + const date = item.wxrPost.postDate || item.wxrPost.pubDate; + if (date) { + const year = new Date(date).getFullYear(); + postsDistrib[year] = (postsDistrib[year] || 0) + 1; + } + } + + for (const item of report.media.items) { + const date = item.wxrMedia.pubDate; + if (date) { + const year = new Date(date).getFullYear(); + mediaDistrib[year] = (mediaDistrib[year] || 0) + 1; + } + } + + return { posts: postsDistrib, media: mediaDistrib }; + }, [report]); + const toggleSection = useCallback((section: string) => { setExpandedSections(prev => ({ ...prev, [section]: !prev[section] })); }, []); @@ -371,6 +544,76 @@ export const ImportAnalysisView: React.FC = ({ definiti <> + + + {/* Execution Progress */} + {executionState.isExecuting && ( +
+
+

Importing...

+ {executionState.eta !== null && executionState.eta > 0 && ( + {formatEta(executionState.eta)} + )} +
+
+
0 ? (executionState.current / executionState.total) * 100 : 0}%` }} + /> +
+
+ {executionState.phase} + {executionState.detail && {executionState.detail}} + {executionState.current} / {executionState.total} +
+
+ )} + + {/* Execution Complete */} + {executionState.completed && ( +
+ + + + Import completed successfully! +
+ )} + + {/* Execution Error */} + {executionState.error && ( +
+ + + + Import failed: {executionState.error} +
+ )} + + {/* Execute Button */} + {!executionState.isExecuting && !executionState.completed && ( + (() => { + const counts = getImportableCount(); + const totalImportable = counts.posts + counts.media + counts.pages + counts.tags; + return ( +
+
+ Ready to import: + {counts.tags > 0 && {counts.tags} tags/categories} + {counts.posts > 0 && {counts.posts} posts} + {counts.media > 0 && {counts.media} media} + {counts.pages > 0 && {counts.pages} pages} +
+ +
+ ); + })() + )} {report.posts.conflicts > 0 && ( = ({ report }) => { ); }; +interface DateDistribution { + posts: Record; + media: Record; +} + +const DateDistributionCard: React.FC<{ distribution: DateDistribution }> = ({ distribution }) => { + const postYears = Object.keys(distribution.posts).map(Number).sort(); + const mediaYears = Object.keys(distribution.media).map(Number).sort(); + const allYears = [...new Set([...postYears, ...mediaYears])].sort(); + + if (allYears.length === 0) { + return null; + } + + const maxPostCount = Math.max(...Object.values(distribution.posts), 1); + const maxMediaCount = Math.max(...Object.values(distribution.media), 1); + const totalPosts = Object.values(distribution.posts).reduce((a, b) => a + b, 0); + const totalMedia = Object.values(distribution.media).reduce((a, b) => a + b, 0); + + return ( +
+

Date Distribution

+
+
+
+ Posts/Pages + {totalPosts} total +
+
+ {allYears.map(year => { + const count = distribution.posts[year] || 0; + const percentage = (count / maxPostCount) * 100; + return ( +
+ {year} +
+
+
+ {count || '-'} +
+ ); + })} +
+
+
+
+ Media + {totalMedia} total +
+
+ {allYears.map(year => { + const count = distribution.media[year] || 0; + const percentage = (count / maxMediaCount) * 100; + return ( +
+ {year} +
+
+
+ {count || '-'} +
+ ); + })} +
+
+
+
+ ); +}; + // Helper function to format post metadata for tooltip (new post from WXR) function formatPostTooltip(wxrPost: AnalyzedPostItem['wxrPost']): string { const lines: string[] = []; diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index e5e6b03..97746f7 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -1,5 +1,19 @@ // Type definitions for the Electron API exposed via preload +export interface ImportExecuteResult { + taskId: string; + totalItems: number; +} + +export interface ImportExecutionProgress { + taskId: string; + phase: string; + current: number; + total: number; + detail?: string; + eta?: number; +} + export interface ImportDefinitionData { id: string; projectId: string; @@ -365,7 +379,9 @@ export interface ElectronAPI { selectAndAnalyze: (uploadsFolder?: string) => Promise; analyzeFile: (filePath: string, uploadsFolder?: string) => Promise; selectUploadsFolder: () => Promise; + execute: (reportJson: string, uploadsFolder?: string) => Promise; onProgress: (callback: (data: { step: string; detail?: string }) => void) => () => void; + onExecutionProgress: (callback: (data: ImportExecutionProgress) => void) => () => void; }; importDefinitions: { create: (name?: string) => Promise; diff --git a/tests/assets/import-test-cases.wxr b/tests/assets/import-test-cases.wxr new file mode 100644 index 0000000..96424fb --- /dev/null +++ b/tests/assets/import-test-cases.wxr @@ -0,0 +1,608 @@ + + + + + Test Blog for Import + https://testblog.example.com + A comprehensive test blog for WXR import testing + en-US + + + + + + + 1 + technology + + + + + + 2 + web-dev + technology + + + + + 3 + programming + + + + + + + + + + 10 + javascript + + + + + 11 + typescript + + + + + 12 + react + + + + + 13 + nodejs + + + + + + + + + + HTML Formatting Test: Basic Text Styles + https://testblog.example.com/html-formatting-basic/ + Mon, 01 Jan 2024 10:00:00 +0000 + + + This paragraph has bold text and italic text.

+

Here is another bold using b tag and italic using i tag.

+

Combined: bold and italic together.

+

Some strikethrough text and also this.

]]>
+ + 101 + 2024-01-01 10:00:00 + 2024-01-01 10:00:00 + 2024-01-02 12:00:00 + 2024-01-02 12:00:00 + html-formatting-basic + publish + post + 0 +
+ + + + HTML Formatting Test: Headings + https://testblog.example.com/html-formatting-headings/ + Tue, 02 Jan 2024 10:00:00 +0000 + + + Heading Level 1 +

Paragraph after h1.

+

Heading Level 2

+

Paragraph after h2.

+

Heading Level 3

+

Paragraph after h3.

+

Heading Level 4

+
Heading Level 5
+
Heading Level 6
]]>
+ + 102 + 2024-01-02 10:00:00 + 2024-01-02 10:00:00 + 2024-01-02 10:00:00 + 2024-01-02 10:00:00 + html-formatting-headings + publish + post + 0 +
+ + + + HTML Formatting Test: Lists + https://testblog.example.com/html-formatting-lists/ + Wed, 03 Jan 2024 10:00:00 +0000 + + + Unordered list:

+
    +
  • First item
  • +
  • Second item
  • +
  • Third item
  • +
+

Ordered list:

+
    +
  1. Step one
  2. +
  3. Step two
  4. +
  5. Step three
  6. +
+

Nested list:

+
    +
  • Parent item +
      +
    • Child item 1
    • +
    • Child item 2
    • +
    +
  • +
  • Another parent
  • +
]]>
+ + 103 + 2024-01-03 10:00:00 + 2024-01-03 10:00:00 + 2024-01-03 10:00:00 + 2024-01-03 10:00:00 + html-formatting-lists + publish + post + 0 +
+ + + + HTML Formatting Test: Links and Images + https://testblog.example.com/html-formatting-links/ + Thu, 04 Jan 2024 10:00:00 +0000 + + + + Here is a simple link.

+

Link with title: titled link.

+

Image: Test image

+

Image with title: Photo

+

Linked image: Banner

]]>
+ + 104 + 2024-01-04 10:00:00 + 2024-01-04 10:00:00 + 2024-01-04 10:00:00 + 2024-01-04 10:00:00 + html-formatting-links + publish + post + 0 +
+ + + + HTML Formatting Test: Code Blocks + https://testblog.example.com/html-formatting-code/ + Fri, 05 Jan 2024 10:00:00 +0000 + + + + + Inline code: Use const x = 10; to declare a constant.

+

Code block:

+
function hello() {
+  console.log("Hello World");
+}
+hello();
+

Another block with pre only:

+
Plain preformatted text
+with multiple lines
]]>
+ + 105 + 2024-01-05 10:00:00 + 2024-01-05 10:00:00 + 2024-01-06 09:00:00 + 2024-01-06 09:00:00 + html-formatting-code + publish + post + 0 +
+ + + + HTML Formatting Test: Blockquotes + https://testblog.example.com/html-formatting-quotes/ + Sat, 06 Jan 2024 10:00:00 +0000 + + + A famous quote:

+
+

The only way to do great work is to love what you do.

+

- Steve Jobs

+
+

Nested blockquote:

+
+

Outer quote

+
+

Inner quote

+
+
]]>
+ + 106 + 2024-01-06 10:00:00 + 2024-01-06 10:00:00 + 2024-01-06 10:00:00 + 2024-01-06 10:00:00 + html-formatting-quotes + publish + post + 0 +
+ + + + + + + + Shortcode Test: Gallery + https://testblog.example.com/shortcode-gallery/ + Mon, 15 Jan 2024 10:00:00 +0000 + + + + Check out this gallery:

+[gallery ids="1,2,3" columns="3"] +

Pretty nice, right?

]]>
+ + 201 + 2024-01-15 10:00:00 + 2024-01-15 10:00:00 + 2024-01-15 10:00:00 + 2024-01-15 10:00:00 + shortcode-gallery + publish + post + 0 +
+ + + + Shortcode Test: Video + https://testblog.example.com/shortcode-video/ + Tue, 16 Jan 2024 10:00:00 +0000 + + + Watch this video:

+[video src="https://example.com/video.mp4" width="640" height="360"] +

Hope you enjoyed it!

]]>
+ + 202 + 2024-01-16 10:00:00 + 2024-01-16 10:00:00 + 2024-01-16 10:00:00 + 2024-01-16 10:00:00 + shortcode-video + publish + post + 0 +
+ + + + Shortcode Test: Multiple Macros + https://testblog.example.com/shortcode-multiple/ + Wed, 17 Jan 2024 10:00:00 +0000 + + + + + Multiple shortcodes in one post:

+[audio src="https://example.com/podcast.mp3"] +

And here's a gallery:

+[gallery ids="10,20,30"] +

With an embed:

+[embed]https://youtube.com/watch?v=abc123[/embed] +

End of post.

]]>
+ + 203 + 2024-01-17 10:00:00 + 2024-01-17 10:00:00 + 2024-01-17 10:00:00 + 2024-01-17 10:00:00 + shortcode-multiple + publish + post + 0 +
+ + + + Shortcode Test: Self-Closing + https://testblog.example.com/shortcode-selfclose/ + Thu, 18 Jan 2024 10:00:00 +0000 + + + Self-closing shortcodes:

+[divider /] +

Another one:

+[spacer height="20" /] +

Done.

]]>
+ + 204 + 2024-01-18 10:00:00 + 2024-01-18 10:00:00 + 2024-01-18 10:00:00 + 2024-01-18 10:00:00 + shortcode-selfclose + publish + post + 0 +
+ + + + Shortcode Test: Already Converted + https://testblog.example.com/shortcode-already/ + Fri, 19 Jan 2024 10:00:00 +0000 + + + This is already in our format:

+[[gallery ids="1,2,3"]] +

Mixed content:

+[video src="new.mp4"] +

And already converted:

+[[audio src="podcast.mp3"]]]]>
+ + 205 + 2024-01-19 10:00:00 + 2024-01-19 10:00:00 + 2024-01-19 10:00:00 + 2024-01-19 10:00:00 + shortcode-already + publish + post + 0 +
+ + + + + + + + Conflict Test: Ignore Resolution + https://testblog.example.com/existing-post/ + Mon, 22 Jan 2024 10:00:00 +0000 + + + This post has slug "existing-post" which already exists in the project.

+

The conflict resolution is set to "ignore" so this should be skipped.

]]>
+ + 301 + 2024-01-22 10:00:00 + 2024-01-22 10:00:00 + 2024-01-22 10:00:00 + 2024-01-22 10:00:00 + existing-post + publish + post + 0 +
+ + + + Conflict Test: Overwrite Resolution + https://testblog.example.com/overwrite-me/ + Tue, 23 Jan 2024 10:00:00 +0000 + + + + This post has slug "overwrite-me" which exists.

+

The conflict resolution is "overwrite" so it should be imported as a draft.

+

The draft will have the same slug for review.

]]>
+ + 302 + 2024-01-23 10:00:00 + 2024-01-23 10:00:00 + 2024-01-23 15:30:00 + 2024-01-23 15:30:00 + overwrite-me + publish + post + 0 +
+ + + + Conflict Test: Import as New + https://testblog.example.com/duplicate-slug/ + Wed, 24 Jan 2024 10:00:00 +0000 + + + This post has slug "duplicate-slug" which exists.

+

The conflict resolution is "import" so it should get a new unique slug.

+

The new slug will be generated from the title.

]]>
+ + 303 + 2024-01-24 10:00:00 + 2024-01-24 10:00:00 + 2024-01-24 10:00:00 + 2024-01-24 10:00:00 + duplicate-slug + publish + post + 0 +
+ + + + + + + + gallery-image-1 + https://testblog.example.com/gallery-image-1/ + Mon, 15 Jan 2024 09:00:00 +0000 + + + + 401 + 2024-01-15 09:00:00 + 2024-01-15 09:00:00 + 2024-01-15 09:00:00 + 2024-01-15 09:00:00 + gallery-image-1 + inherit + attachment + 201 + https://testblog.example.com/wp-content/uploads/2024/01/sunset.jpg + + + + + standalone-logo + https://testblog.example.com/standalone-logo/ + Tue, 02 Jan 2024 08:00:00 +0000 + + + + 402 + 2024-01-02 08:00:00 + 2024-01-02 08:00:00 + 2024-01-02 08:00:00 + 2024-01-02 08:00:00 + standalone-logo + inherit + attachment + 0 + https://testblog.example.com/wp-content/uploads/2024/01/logo.png + + + + + existing-image + https://testblog.example.com/existing-image/ + Wed, 03 Jan 2024 08:00:00 +0000 + + + + 403 + 2024-01-03 08:00:00 + 2024-01-03 08:00:00 + 2024-01-03 08:00:00 + 2024-01-03 08:00:00 + existing-image + inherit + attachment + 0 + https://testblog.example.com/wp-content/uploads/2024/01/existing.jpg + + + + + + + + + About This Blog + https://testblog.example.com/about/ + Sun, 01 Jan 2024 08:00:00 +0000 + + About +

Welcome to my blog. This is a page, not a post.

+

Pages should be imported as posts with the "page" category.

]]>
+ + 501 + 2024-01-01 08:00:00 + 2024-01-01 08:00:00 + 2024-01-10 12:00:00 + 2024-01-10 12:00:00 + about + publish + page + 0 +
+ + + + Contact Information + https://testblog.example.com/contact/ + Sun, 01 Jan 2024 09:00:00 +0000 + + Contact Us +

Reach out through the following channels:

+ +

Office Hours

+

Monday to Friday, 9am-5pm.

+[contact_form id="1"]]]>
+ + 502 + 2024-01-01 09:00:00 + 2024-01-01 09:00:00 + 2024-01-15 16:00:00 + 2024-01-15 16:00:00 + contact + publish + page + 0 +
+ +
+
diff --git a/tests/engine/ImportExecutionEngine.e2e.test.ts b/tests/engine/ImportExecutionEngine.e2e.test.ts new file mode 100644 index 0000000..81dccb3 --- /dev/null +++ b/tests/engine/ImportExecutionEngine.e2e.test.ts @@ -0,0 +1,1378 @@ +/** + * ImportExecutionEngine End-to-End Tests + * + * Comprehensive tests that parse a real WXR file and verify the complete import process. + * Uses tests/assets/import-test-cases.wxr as the source test data. + * + * Test Categories: + * 1. HTML to Markdown Conversion - verifies proper transformation of all HTML elements + * 2. Shortcode/Macro Conversion - verifies [shortcode] → [[shortcode]] transformation + * 3. Tag/Category Mapping - verifies taxonomy resolution and creation + * 4. Conflict Resolution - verifies ignore/overwrite/import behaviors + * 5. Media Import - verifies media file handling with post linkage + * 6. Page Import - verifies pages become posts with "page" category + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import * as path from 'path'; +import * as fs from 'fs'; +import { WxrParser, type WxrData } from '../../src/main/engine/WxrParser'; +import type { + ImportAnalysisReport, + AnalyzedPost, + AnalyzedMedia, + AnalyzedCategory, + AnalyzedTag, + PostAnalysisStatus, + MediaAnalysisStatus, +} from '../../src/main/engine/ImportAnalysisEngine'; +import type { WxrPost, WxrMedia } from '../../src/main/engine/WxrParser'; + +// Read the WXR file SYNCHRONOUSLY at module load time (before mocks apply) +const wxrFilePath = path.join(__dirname, '../assets/import-test-cases.wxr'); +const wxrFileContent = fs.readFileSync(wxrFilePath, 'utf-8'); + +// Track all database inserts +const insertedPosts: Array<{ + id: string; + projectId: string; + title: string; + slug: string; + content: string | null; + status: string; + tags: string; + categories: string; + createdAt: Date; + updatedAt: Date; + publishedAt?: Date; + author?: string; +}> = []; + +const insertedMedia: Array<{ + id: string; + linkedPostIds: string[]; + caption?: string; +}> = []; + +const createdTags: string[] = []; + +// Track files written +const writtenFiles: Array<{ + path: string; + content: string; +}> = []; + +// Mock database that tracks inserts +const mockDb = { + insert: vi.fn().mockImplementation((table: any) => ({ + values: vi.fn().mockImplementation(async (data: any) => { + // Track based on data structure + if (data && typeof data === 'object') { + if ('slug' in data && 'title' in data) { + insertedPosts.push(data); + } + } + return data; + }), + })), + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), +}; + +const mockClient = { + execute: vi.fn().mockResolvedValue({ rows: [] }), +}; + +// Mock modules +vi.mock('../../src/main/database', () => ({ + getDatabase: vi.fn(() => ({ + getLocal: vi.fn(() => mockDb), + getLocalClient: vi.fn(() => mockClient), + })), +})); + +vi.mock('fs/promises', () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockImplementation(async (filePath: string, content: string) => { + writtenFiles.push({ path: filePath, content }); + }), + copyFile: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue({ size: 1024 }), + readFile: vi.fn().mockImplementation(async (filePath: string) => { + // Return the pre-loaded WXR content for the test file + if (filePath.endsWith('import-test-cases.wxr')) { + return wxrFileContent; + } + return Buffer.from('test data'); + }), +})); + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => '/mock/user/data'), + }, +})); + +let uuidCounter = 0; +vi.mock('uuid', () => ({ + v4: vi.fn(() => `test-uuid-${++uuidCounter}`), +})); + +// Mock TagEngine +const mockTagEngine = { + setProjectContext: vi.fn(), + createTag: vi.fn().mockImplementation(async (input: { name: string }) => { + createdTags.push(input.name.toLowerCase()); + return { + id: `tag-${input.name}`, + projectId: 'test-project', + name: input.name.toLowerCase(), + createdAt: new Date(), + updatedAt: new Date(), + }; + }), + getAllTags: vi.fn().mockResolvedValue([]), +}; + +vi.mock('../../src/main/engine/TagEngine', () => ({ + getTagEngine: vi.fn(() => mockTagEngine), +})); + +// Mock PostEngine +const mockPostEngine = { + setProjectContext: vi.fn(), + createPost: vi.fn(), + publishPost: vi.fn(), + isSlugAvailable: vi.fn().mockResolvedValue(true), + generateUniqueSlug: vi.fn().mockImplementation(async (title: string) => { + return `${title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}-new`; + }), + updateFTSIndex: vi.fn().mockResolvedValue(undefined), +}; + +vi.mock('../../src/main/engine/PostEngine', () => ({ + getPostEngine: vi.fn(() => mockPostEngine), +})); + +// Mock MediaEngine +const mockMediaEngine = { + setProjectContext: vi.fn(), + importMedia: vi.fn().mockImplementation(async (sourcePath: string, metadata?: any) => { + const result = { + id: `media-${Math.random().toString(36).substr(2, 9)}`, + filename: path.basename(sourcePath), + originalName: metadata?.originalName || path.basename(sourcePath), + caption: metadata?.caption, + linkedPostIds: metadata?.linkedPostIds || [], + }; + insertedMedia.push(result); + return result; + }), +}; + +vi.mock('../../src/main/engine/MediaEngine', () => ({ + getMediaEngine: vi.fn(() => mockMediaEngine), +})); + +// Import after mocks are set up +import { ImportExecutionEngine } from '../../src/main/engine/ImportExecutionEngine'; + +describe('ImportExecutionEngine E2E Tests', () => { + let engine: ImportExecutionEngine; + let wxrData: WxrData; + + beforeEach(async () => { + // Reset all tracking arrays + insertedPosts.length = 0; + insertedMedia.length = 0; + createdTags.length = 0; + writtenFiles.length = 0; + uuidCounter = 0; + + // Clear all mocks + vi.clearAllMocks(); + + // Create engine instance + engine = new ImportExecutionEngine(); + engine.setProjectContext('test-project', '/mock/test/data'); + + // Parse the WXR content (mocked readFile will return our pre-loaded content) + const parser = new WxrParser(); + wxrData = await parser.parseFile(wxrFilePath); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ========================================================================== + // SECTION 1: HTML TO MARKDOWN CONVERSION + // ========================================================================== + + describe('HTML to Markdown Conversion', () => { + /** + * Creates a minimal analysis report for a single post for testing conversion + */ + function createSinglePostReport(wxrPost: WxrPost): ImportAnalysisReport { + return { + wxrData: wxrData, + posts: { + total: 1, + new: 1, + update: 0, + conflict: 0, + items: [{ + wxrPost, + status: 'new' as PostAnalysisStatus, + contentHash: 'test-hash', + markdownPreview: '', + }], + }, + pages: { total: 0, new: 0, update: 0, conflict: 0, items: [] }, + media: { total: 0, new: 0, update: 0, conflict: 0, missing: 0, items: [] }, + tags: [], + categories: [], + site: wxrData.site, + macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] }, + }; + } + + it('should convert basic text formatting (bold, italic, strikethrough)', async () => { + // Post 101: Basic Text Formatting + const post = wxrData.posts.find(p => p.wpId === 101); + expect(post).toBeDefined(); + + const report = createSinglePostReport(post!); + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + + // Find the written file for this post + const writtenFile = writtenFiles.find(f => f.path.includes('html-formatting-basic')); + expect(writtenFile).toBeDefined(); + + const content = writtenFile!.content; + + // Verify bold conversion: and → **text** + expect(content).toContain('**bold text**'); + expect(content).toContain('**another bold**'); + + // Verify italic conversion: and → _text_ or *text* + expect(content).toMatch(/_italic text_|\*italic text\*/); + expect(content).toMatch(/_italic using i tag_|\*italic using i tag\*/); + + // Verify combined bold+italic (TurndownService outputs **_text_** or ***text***) + expect(content).toMatch(/\*\*_bold and italic together_\*\*|\*\*\*bold and italic together\*\*\*/); + + // Note: TurndownService does NOT convert and to ~~ by default + // The strikethrough text will appear as plain text + expect(content).toContain('strikethrough text'); + expect(content).toContain('also this'); + }); + + it('should convert headings (h1-h6) to ATX style', async () => { + // Post 102: Headings + const post = wxrData.posts.find(p => p.wpId === 102); + expect(post).toBeDefined(); + + const report = createSinglePostReport(post!); + await engine.executeImport(report, {}); + + const writtenFile = writtenFiles.find(f => f.path.includes('html-formatting-headings')); + expect(writtenFile).toBeDefined(); + + const content = writtenFile!.content; + + // Verify ATX-style headings + expect(content).toContain('# Heading Level 1'); + expect(content).toContain('## Heading Level 2'); + expect(content).toContain('### Heading Level 3'); + expect(content).toContain('#### Heading Level 4'); + expect(content).toContain('##### Heading Level 5'); + expect(content).toContain('###### Heading Level 6'); + + // Verify paragraphs between headings + expect(content).toContain('Paragraph after h1'); + expect(content).toContain('Paragraph after h2'); + }); + + it('should convert lists (ordered, unordered, nested)', async () => { + // Post 103: Lists + const post = wxrData.posts.find(p => p.wpId === 103); + expect(post).toBeDefined(); + + const report = createSinglePostReport(post!); + await engine.executeImport(report, {}); + + const writtenFile = writtenFiles.find(f => f.path.includes('html-formatting-lists')); + expect(writtenFile).toBeDefined(); + + const content = writtenFile!.content; + + // Verify unordered list items (- marker with possible spaces) + expect(content).toMatch(/-\s+First item/); + expect(content).toMatch(/-\s+Second item/); + expect(content).toMatch(/-\s+Third item/); + + // Verify ordered list items + expect(content).toMatch(/1\.\s+Step one/); + expect(content).toMatch(/2\.\s+Step two/); + expect(content).toMatch(/3\.\s+Step three/); + + // Verify nested list structure (indent varies) + expect(content).toMatch(/-\s+Parent item/); + expect(content).toMatch(/-\s+Another parent/); + // Nested items should contain Child items somewhere in content + expect(content).toContain('Child item 1'); + expect(content).toContain('Child item 2'); + }); + + it('should convert links and images', async () => { + // Post 104: Links and Images + const post = wxrData.posts.find(p => p.wpId === 104); + expect(post).toBeDefined(); + + const report = createSinglePostReport(post!); + await engine.executeImport(report, {}); + + const writtenFile = writtenFiles.find(f => f.path.includes('html-formatting-links')); + expect(writtenFile).toBeDefined(); + + const content = writtenFile!.content; + + // Verify link conversion + expect(content).toContain('[simple link](https://example.com)'); + expect(content).toMatch(/\[titled link\]\(https:\/\/example\.com.*\)/); + + // Verify image conversion + expect(content).toContain('![Test image](https://example.com/image.jpg)'); + expect(content).toContain('![Photo](https://example.com/photo.png'); + + // Verify linked image + expect(content).toContain('[![Banner](https://example.com/banner.jpg)](https://example.com)'); + }); + + it('should convert code blocks (inline and fenced)', async () => { + // Post 105: Code Blocks + const post = wxrData.posts.find(p => p.wpId === 105); + expect(post).toBeDefined(); + + const report = createSinglePostReport(post!); + await engine.executeImport(report, {}); + + const writtenFile = writtenFiles.find(f => f.path.includes('html-formatting-code')); + expect(writtenFile).toBeDefined(); + + const content = writtenFile!.content; + + // Verify inline code + expect(content).toContain('`const x = 10;`'); + + // Verify fenced code block + expect(content).toContain('```'); + expect(content).toContain('function hello()'); + expect(content).toContain('console.log("Hello World")'); + + // Verify
 only block
+      expect(content).toContain('Plain preformatted text');
+    });
+
+    it('should convert blockquotes', async () => {
+      // Post 106: Blockquotes
+      const post = wxrData.posts.find(p => p.wpId === 106);
+      expect(post).toBeDefined();
+
+      const report = createSinglePostReport(post!);
+      await engine.executeImport(report, {});
+
+      const writtenFile = writtenFiles.find(f => f.path.includes('html-formatting-quotes'));
+      expect(writtenFile).toBeDefined();
+
+      const content = writtenFile!.content;
+
+      // Verify blockquote conversion
+      expect(content).toContain('> The only way to do great work is to love what you do.');
+      // Note: TurndownService escapes the dash, so it becomes \- or just text
+      expect(content).toContain('Steve Jobs');
+
+      // Verify nested blockquote (should have > > for inner quote)
+      expect(content).toContain('> Outer quote');
+      expect(content).toContain('> > Inner quote');
+    });
+  });
+
+  // ==========================================================================
+  // SECTION 2: SHORTCODE/MACRO CONVERSION
+  // ==========================================================================
+
+  describe('Shortcode to Macro Conversion', () => {
+    function createSinglePostReport(wxrPost: WxrPost): ImportAnalysisReport {
+      return {
+        wxrData: wxrData,
+        posts: {
+          total: 1,
+          new: 1,
+          update: 0,
+          conflict: 0,
+          items: [{
+            wxrPost,
+            status: 'new' as PostAnalysisStatus,
+            contentHash: 'test-hash',
+            markdownPreview: '',
+          }],
+        },
+        pages: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        media: { total: 0, new: 0, update: 0, conflict: 0, missing: 0, items: [] },
+        tags: [],
+        categories: [],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+    }
+
+    it('should convert [gallery] shortcode to [[gallery]] macro', async () => {
+      // Post 201: Gallery Shortcode
+      const post = wxrData.posts.find(p => p.wpId === 201);
+      expect(post).toBeDefined();
+      expect(post!.content).toContain('[gallery ids="1,2,3" columns="3"]');
+
+      const report = createSinglePostReport(post!);
+      await engine.executeImport(report, {});
+
+      const writtenFile = writtenFiles.find(f => f.path.includes('shortcode-gallery'));
+      expect(writtenFile).toBeDefined();
+
+      const content = writtenFile!.content;
+
+      // MUST be converted to double brackets
+      expect(content).toContain('[[gallery ids="1,2,3" columns="3"]]');
+      // MUST NOT contain single bracket version
+      expect(content).not.toMatch(/(? {
+      // Post 202: Video Shortcode
+      const post = wxrData.posts.find(p => p.wpId === 202);
+      expect(post).toBeDefined();
+      expect(post!.content).toContain('[video src="https://example.com/video.mp4"');
+
+      const report = createSinglePostReport(post!);
+      await engine.executeImport(report, {});
+
+      const writtenFile = writtenFiles.find(f => f.path.includes('shortcode-video'));
+      expect(writtenFile).toBeDefined();
+
+      const content = writtenFile!.content;
+
+      // MUST preserve all attributes in double-bracket format
+      expect(content).toContain('[[video src="https://example.com/video.mp4" width="640" height="360"]]');
+    });
+
+    it('should convert multiple shortcodes in a single post', async () => {
+      // Post 203: Multiple Shortcodes
+      const post = wxrData.posts.find(p => p.wpId === 203);
+      expect(post).toBeDefined();
+
+      const report = createSinglePostReport(post!);
+      await engine.executeImport(report, {});
+
+      const writtenFile = writtenFiles.find(f => f.path.includes('shortcode-multiple'));
+      expect(writtenFile).toBeDefined();
+
+      const content = writtenFile!.content;
+
+      // All shortcodes must be converted
+      expect(content).toContain('[[audio src="https://example.com/podcast.mp3"]]');
+      expect(content).toContain('[[gallery ids="10,20,30"]]');
+      expect(content).toContain('[[embed]]');
+
+      // None should remain as single brackets
+      expect(content).not.toMatch(/(? {
+      // Post 204: Self-Closing Shortcodes
+      const post = wxrData.posts.find(p => p.wpId === 204);
+      expect(post).toBeDefined();
+      expect(post!.content).toContain('[divider /]');
+      expect(post!.content).toContain('[spacer height="20" /]');
+
+      const report = createSinglePostReport(post!);
+      await engine.executeImport(report, {});
+
+      const writtenFile = writtenFiles.find(f => f.path.includes('shortcode-selfclose'));
+      expect(writtenFile).toBeDefined();
+
+      const content = writtenFile!.content;
+
+      // Self-closing shortcodes should be converted (removing the /)
+      expect(content).toContain('[[divider]]');
+      expect(content).toContain('[[spacer height="20"]]');
+    });
+
+    it('should NOT double-convert already bracketed [[macro]] content', async () => {
+      // Post 205: Already Double-Bracketed
+      const post = wxrData.posts.find(p => p.wpId === 205);
+      expect(post).toBeDefined();
+      // Original content has both formats
+      expect(post!.content).toContain('[[gallery ids="1,2,3"]]');
+      expect(post!.content).toContain('[video src="new.mp4"]');
+      expect(post!.content).toContain('[[audio src="podcast.mp3"]]');
+
+      const report = createSinglePostReport(post!);
+      await engine.executeImport(report, {});
+
+      const writtenFile = writtenFiles.find(f => f.path.includes('shortcode-already'));
+      expect(writtenFile).toBeDefined();
+
+      const content = writtenFile!.content;
+
+      // Already-converted macros MUST remain as double brackets (not become [[[[...)
+      expect(content).toContain('[[gallery ids="1,2,3"]]');
+      expect(content).not.toContain('[[[');
+      expect(content).not.toContain(']]]');
+
+      // Single bracket shortcode MUST be converted
+      expect(content).toContain('[[video src="new.mp4"]]');
+
+      // Pre-existing double bracket MUST remain unchanged
+      expect(content).toContain('[[audio src="podcast.mp3"]]');
+    });
+  });
+
+  // ==========================================================================
+  // SECTION 3: TAG AND CATEGORY MAPPING
+  // ==========================================================================
+
+  describe('Tag and Category Mapping', () => {
+    it('should create new tags that do not exist in the project', async () => {
+      // Post 201 has tag "React" which we'll mark as new
+      const post = wxrData.posts.find(p => p.wpId === 201);
+      expect(post).toBeDefined();
+      expect(post!.tags).toContain('React');
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: {
+          total: 1,
+          new: 1,
+          update: 0,
+          conflict: 0,
+          items: [{
+            wxrPost: post!,
+            status: 'new' as PostAnalysisStatus,
+            contentHash: 'test-hash',
+            markdownPreview: '',
+          }],
+        },
+        pages: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        media: { total: 0, new: 0, update: 0, conflict: 0, missing: 0, items: [] },
+        tags: [
+          { name: 'React', slug: 'react', existsInProject: false },  // New tag
+        ],
+        categories: [
+          { name: 'Technology', slug: 'technology', existsInProject: true },  // Existing
+        ],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      const result = await engine.executeImport(report, {});
+
+      // Tag "react" should have been created
+      expect(createdTags).toContain('react');
+      expect(result.tags.created).toBe(1);
+
+      // The imported post should have the tag
+      expect(insertedPosts.length).toBe(1);
+      const postTags = JSON.parse(insertedPosts[0].tags);
+      expect(postTags).toContain('react');
+    });
+
+    it('should map tags to existing project tags when mappedTo is set', async () => {
+      // Post 203 has tag "nodejs" which we'll map to existing "node"
+      const post = wxrData.posts.find(p => p.wpId === 203);
+      expect(post).toBeDefined();
+      expect(post!.tags).toContain('nodejs');
+      expect(post!.tags).toContain('JavaScript');
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: {
+          total: 1,
+          new: 1,
+          update: 0,
+          conflict: 0,
+          items: [{
+            wxrPost: post!,
+            status: 'new' as PostAnalysisStatus,
+            contentHash: 'test-hash',
+            markdownPreview: '',
+          }],
+        },
+        pages: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        media: { total: 0, new: 0, update: 0, conflict: 0, missing: 0, items: [] },
+        tags: [
+          { name: 'nodejs', slug: 'nodejs', existsInProject: false, mappedTo: 'node' },  // Mapped
+          { name: 'JavaScript', slug: 'javascript', existsInProject: true },  // Existing
+        ],
+        categories: [
+          { name: 'Web Dev', slug: 'web-dev', existsInProject: false, mappedTo: 'web-development' },  // Mapped
+        ],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      const result = await engine.executeImport(report, {});
+
+      // Should NOT create "nodejs" tag (it's mapped)
+      expect(createdTags).not.toContain('nodejs');
+      // Should NOT create "node" either (it exists)
+      expect(createdTags).not.toContain('node');
+
+      // The imported post should use the mapped tag name
+      expect(insertedPosts.length).toBe(1);
+      const postTags = JSON.parse(insertedPosts[0].tags);
+      expect(postTags).toContain('node');  // Mapped from "nodejs"
+      expect(postTags).toContain('javascript');  // Existing
+
+      // Category should also be mapped
+      const postCategories = JSON.parse(insertedPosts[0].categories);
+      expect(postCategories).toContain('web-development');  // Mapped from "Web Dev"
+    });
+
+    it('should skip existing tags and not try to create them', async () => {
+      const post = wxrData.posts.find(p => p.wpId === 105);
+      expect(post).toBeDefined();
+      expect(post!.tags).toContain('JavaScript');
+      expect(post!.tags).toContain('TypeScript');
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: {
+          total: 1,
+          new: 1,
+          update: 0,
+          conflict: 0,
+          items: [{
+            wxrPost: post!,
+            status: 'new' as PostAnalysisStatus,
+            contentHash: 'test-hash',
+            markdownPreview: '',
+          }],
+        },
+        pages: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        media: { total: 0, new: 0, update: 0, conflict: 0, missing: 0, items: [] },
+        tags: [
+          { name: 'JavaScript', slug: 'javascript', existsInProject: true },
+          { name: 'TypeScript', slug: 'typescript', existsInProject: true },
+        ],
+        categories: [
+          { name: 'Programming', slug: 'programming', existsInProject: true },
+        ],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      const result = await engine.executeImport(report, {});
+
+      // No tags should be created (all exist)
+      expect(createdTags.length).toBe(0);
+      expect(result.tags.created).toBe(0);
+      expect(result.tags.skipped).toBe(3);  // 2 tags + 1 category
+
+      // Post should still have the tags
+      const postTags = JSON.parse(insertedPosts[0].tags);
+      expect(postTags).toContain('javascript');
+      expect(postTags).toContain('typescript');
+    });
+  });
+
+  // ==========================================================================
+  // SECTION 4: CONFLICT RESOLUTION
+  // ==========================================================================
+
+  describe('Conflict Resolution', () => {
+    it('should SKIP import when conflict resolution is "ignore"', async () => {
+      // Post 301: Conflict → Ignore
+      const post = wxrData.posts.find(p => p.wpId === 301);
+      expect(post).toBeDefined();
+      expect(post!.slug).toBe('existing-post');
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: {
+          total: 1,
+          new: 0,
+          update: 0,
+          conflict: 1,
+          items: [{
+            wxrPost: post!,
+            status: 'conflict' as PostAnalysisStatus,
+            contentHash: 'test-hash',
+            markdownPreview: '',
+            conflictResolution: 'ignore',
+            existingPost: {
+              id: 'existing-post-id',
+              title: 'Existing Post',
+              slug: 'existing-post',
+              checksum: 'old-hash',
+              pubDate: '2023-01-01T00:00:00Z',
+              excerpt: null,
+              author: null,
+              tags: [],
+              categories: [],
+            },
+          }],
+        },
+        pages: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        media: { total: 0, new: 0, update: 0, conflict: 0, missing: 0, items: [] },
+        tags: [],
+        categories: [],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      const result = await engine.executeImport(report, {});
+
+      // Post should be SKIPPED
+      expect(result.posts.skipped).toBe(1);
+      expect(result.posts.imported).toBe(0);
+
+      // No post should be inserted
+      expect(insertedPosts.length).toBe(0);
+
+      // No file should be written for this post
+      const writtenFile = writtenFiles.find(f => f.path.includes('existing-post'));
+      expect(writtenFile).toBeUndefined();
+    });
+
+    it('should import as DRAFT when conflict resolution is "overwrite"', async () => {
+      // Post 302: Conflict → Overwrite
+      const post = wxrData.posts.find(p => p.wpId === 302);
+      expect(post).toBeDefined();
+      expect(post!.slug).toBe('overwrite-me');
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: {
+          total: 1,
+          new: 0,
+          update: 0,
+          conflict: 1,
+          items: [{
+            wxrPost: post!,
+            status: 'conflict' as PostAnalysisStatus,
+            contentHash: 'test-hash',
+            markdownPreview: '',
+            conflictResolution: 'overwrite',
+            existingPost: {
+              id: 'existing-overwrite-id',
+              title: 'Original Post',
+              slug: 'overwrite-me',
+              checksum: 'old-hash',
+              pubDate: '2023-01-01T00:00:00Z',
+              excerpt: null,
+              author: null,
+              tags: [],
+              categories: [],
+            },
+          }],
+        },
+        pages: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        media: { total: 0, new: 0, update: 0, conflict: 0, missing: 0, items: [] },
+        tags: [
+          { name: 'TypeScript', slug: 'typescript', existsInProject: true },
+        ],
+        categories: [
+          { name: 'Programming', slug: 'programming', existsInProject: true },
+        ],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      const result = await engine.executeImport(report, {});
+
+      // Post should be IMPORTED
+      expect(result.posts.imported).toBe(1);
+      expect(result.posts.skipped).toBe(0);
+
+      // Should insert exactly one post
+      expect(insertedPosts.length).toBe(1);
+
+      // The inserted post MUST be a DRAFT
+      expect(insertedPosts[0].status).toBe('draft');
+
+      // The slug should be preserved (same as conflict)
+      expect(insertedPosts[0].slug).toBe('overwrite-me');
+
+      // Draft posts store content in DB, not in file
+      expect(insertedPosts[0].content).not.toBeNull();
+      expect(insertedPosts[0].content).toContain('conflict resolution is "overwrite"');
+
+      // No file should be written (draft = content in DB)
+      const writtenFile = writtenFiles.find(f => f.path.includes('overwrite-me'));
+      expect(writtenFile).toBeUndefined();
+    });
+
+    it('should import with NEW SLUG when conflict resolution is "import"', async () => {
+      // Post 303: Conflict → Import (new slug)
+      const post = wxrData.posts.find(p => p.wpId === 303);
+      expect(post).toBeDefined();
+      expect(post!.slug).toBe('duplicate-slug');
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: {
+          total: 1,
+          new: 0,
+          update: 0,
+          conflict: 1,
+          items: [{
+            wxrPost: post!,
+            status: 'conflict' as PostAnalysisStatus,
+            contentHash: 'test-hash',
+            markdownPreview: '',
+            conflictResolution: 'import',
+            existingPost: {
+              id: 'existing-duplicate-id',
+              title: 'Duplicate Post',
+              slug: 'duplicate-slug',
+              checksum: 'old-hash',
+              pubDate: '2023-01-01T00:00:00Z',
+              excerpt: null,
+              author: null,
+              tags: [],
+              categories: [],
+            },
+          }],
+        },
+        pages: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        media: { total: 0, new: 0, update: 0, conflict: 0, missing: 0, items: [] },
+        tags: [],
+        categories: [
+          { name: 'Web Dev', slug: 'web-dev', existsInProject: true },
+        ],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      const result = await engine.executeImport(report, {});
+
+      // Post should be IMPORTED
+      expect(result.posts.imported).toBe(1);
+
+      // Should insert exactly one post
+      expect(insertedPosts.length).toBe(1);
+
+      // The inserted post should be PUBLISHED (not draft)
+      expect(insertedPosts[0].status).toBe('published');
+
+      // The slug must be DIFFERENT from the original (new unique slug)
+      expect(insertedPosts[0].slug).not.toBe('duplicate-slug');
+      // The mock generates slug from title with "-new" suffix
+      expect(insertedPosts[0].slug).toBe('conflict-test-import-as-new-new');
+
+      // Published post should have file written
+      const writtenFile = writtenFiles.find(f => f.path.includes('conflict-test-import-as-new-new'));
+      expect(writtenFile).toBeDefined();
+    });
+
+    it('should preserve WordPress dates when importing', async () => {
+      // Post 302 has specific dates we want to preserve
+      const post = wxrData.posts.find(p => p.wpId === 302);
+      expect(post).toBeDefined();
+
+      // Verify the WXR dates are parsed correctly
+      expect(post!.postDate).toBeInstanceOf(Date);
+      expect(post!.postModified).toBeInstanceOf(Date);
+      expect(post!.postDate!.toISOString()).toContain('2024-01-23');
+      expect(post!.postModified!.toISOString()).toContain('2024-01-23');
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: {
+          total: 1,
+          new: 0,
+          update: 0,
+          conflict: 1,
+          items: [{
+            wxrPost: post!,
+            status: 'conflict' as PostAnalysisStatus,
+            contentHash: 'test-hash',
+            markdownPreview: '',
+            conflictResolution: 'overwrite',
+            existingPost: {
+              id: 'existing-id',
+              title: 'Original',
+              slug: 'overwrite-me',
+              checksum: 'old',
+              pubDate: null,
+              excerpt: null,
+              author: null,
+              tags: [],
+              categories: [],
+            },
+          }],
+        },
+        pages: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        media: { total: 0, new: 0, update: 0, conflict: 0, missing: 0, items: [] },
+        tags: [],
+        categories: [],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      await engine.executeImport(report, {});
+
+      expect(insertedPosts.length).toBe(1);
+
+      // Dates should come from WXR postDate and postModified
+      const createdAt = insertedPosts[0].createdAt;
+      const updatedAt = insertedPosts[0].updatedAt;
+
+      expect(createdAt).toBeInstanceOf(Date);
+      expect(updatedAt).toBeInstanceOf(Date);
+
+      // Created from postDate
+      expect(createdAt.toISOString()).toContain('2024-01-23');
+      // Updated from postModified
+      expect(updatedAt.toISOString()).toContain('2024-01-23T15:30');
+    });
+  });
+
+  // ==========================================================================
+  // SECTION 5: MEDIA IMPORT
+  // ==========================================================================
+
+  describe('Media Import', () => {
+    it('should import media and link to parent post via wpId mapping', async () => {
+      // First import Post 201 (the parent of Media 401)
+      const post = wxrData.posts.find(p => p.wpId === 201);
+      const media = wxrData.media.find(m => m.wpId === 401);
+
+      expect(post).toBeDefined();
+      expect(media).toBeDefined();
+      expect(media!.parentId).toBe(201);  // Media is attached to post 201
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: {
+          total: 1,
+          new: 1,
+          update: 0,
+          conflict: 0,
+          items: [{
+            wxrPost: post!,
+            status: 'new' as PostAnalysisStatus,
+            contentHash: 'test-hash',
+            markdownPreview: '',
+          }],
+        },
+        pages: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        media: {
+          total: 1,
+          new: 1,
+          update: 0,
+          conflict: 0,
+          missing: 0,
+          items: [{
+            wxrMedia: media!,
+            status: 'new' as MediaAnalysisStatus,
+            fileHash: 'media-hash',
+          }],
+        },
+        tags: [],
+        categories: [],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      const result = await engine.executeImport(report, { uploadsFolder: '/mock/wp-content/uploads' });
+
+      // Post should be imported
+      expect(result.posts.imported).toBe(1);
+
+      // Media should be imported
+      expect(result.media.imported).toBe(1);
+
+      // Verify wpId to postId mapping was created
+      expect(result.wpIdToPostId.has(201)).toBe(true);
+
+      // Media should be linked to the imported post
+      expect(insertedMedia.length).toBe(1);
+      expect(insertedMedia[0].linkedPostIds.length).toBe(1);
+      expect(insertedMedia[0].linkedPostIds[0]).toBe(result.wpIdToPostId.get(201));
+    });
+
+    it('should import standalone media without parent link', async () => {
+      // Media 402 has no parent (parentId = 0)
+      const media = wxrData.media.find(m => m.wpId === 402);
+      expect(media).toBeDefined();
+      expect(media!.parentId).toBe(0);
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        pages: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        media: {
+          total: 1,
+          new: 1,
+          update: 0,
+          conflict: 0,
+          missing: 0,
+          items: [{
+            wxrMedia: media!,
+            status: 'new' as MediaAnalysisStatus,
+            fileHash: 'media-hash',
+          }],
+        },
+        tags: [],
+        categories: [],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      const result = await engine.executeImport(report, { uploadsFolder: '/mock/wp-content/uploads' });
+
+      expect(result.media.imported).toBe(1);
+
+      // Should be imported with caption from WXR title
+      expect(insertedMedia.length).toBe(1);
+      expect(insertedMedia[0].caption).toBe('standalone-logo');
+
+      // No linked posts (standalone)
+      expect(insertedMedia[0].linkedPostIds.length).toBe(0);
+    });
+
+    it('should skip media when conflict resolution is "ignore"', async () => {
+      // Media 403: Conflict ignore
+      const media = wxrData.media.find(m => m.wpId === 403);
+      expect(media).toBeDefined();
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        pages: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        media: {
+          total: 1,
+          new: 0,
+          update: 0,
+          conflict: 1,
+          missing: 0,
+          items: [{
+            wxrMedia: media!,
+            status: 'conflict' as MediaAnalysisStatus,
+            fileHash: 'media-hash',
+            conflictResolution: 'ignore',
+            existingMedia: {
+              id: 'existing-media-id',
+              originalName: 'existing.jpg',
+              checksum: 'existing-hash',
+            },
+          } as any],
+        },
+        tags: [],
+        categories: [],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      const result = await engine.executeImport(report, { uploadsFolder: '/mock/wp-content/uploads' });
+
+      // Media should be SKIPPED
+      expect(result.media.skipped).toBe(1);
+      expect(result.media.imported).toBe(0);
+
+      // No media should be imported
+      expect(insertedMedia.length).toBe(0);
+    });
+
+    it('should skip media when file is missing in uploads folder', async () => {
+      // Media with status 'missing'
+      const media = wxrData.media.find(m => m.wpId === 401);
+      expect(media).toBeDefined();
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        pages: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        media: {
+          total: 1,
+          new: 0,
+          update: 0,
+          conflict: 0,
+          missing: 1,
+          items: [{
+            wxrMedia: media!,
+            status: 'missing' as MediaAnalysisStatus,
+            fileHash: null,
+          }],
+        },
+        tags: [],
+        categories: [],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      const result = await engine.executeImport(report, { uploadsFolder: '/mock/wp-content/uploads' });
+
+      // Media should be SKIPPED (missing)
+      expect(result.media.skipped).toBe(1);
+      expect(result.media.imported).toBe(0);
+    });
+  });
+
+  // ==========================================================================
+  // SECTION 6: PAGE IMPORT
+  // ==========================================================================
+
+  describe('Page Import', () => {
+    it('should import pages as posts with "page" category', async () => {
+      // Page 501: Standard page
+      const page = wxrData.pages.find(p => p.wpId === 501);
+      expect(page).toBeDefined();
+      expect(page!.postType).toBe('page');
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        pages: {
+          total: 1,
+          new: 1,
+          update: 0,
+          conflict: 0,
+          items: [{
+            wxrPost: page!,
+            status: 'new' as PostAnalysisStatus,
+            contentHash: 'test-hash',
+            markdownPreview: '',
+          }],
+        },
+        media: { total: 0, new: 0, update: 0, conflict: 0, missing: 0, items: [] },
+        tags: [],
+        categories: [],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      const result = await engine.executeImport(report, {});
+
+      expect(result.pages.imported).toBe(1);
+
+      // Page should be inserted as a post
+      expect(insertedPosts.length).toBe(1);
+      expect(insertedPosts[0].title).toBe('About This Blog');
+
+      // MUST have "page" category
+      const categories = JSON.parse(insertedPosts[0].categories);
+      expect(categories).toContain('page');
+
+      // Verify content is converted to Markdown
+      const writtenFile = writtenFiles.find(f => f.path.includes('about'));
+      expect(writtenFile).toBeDefined();
+      expect(writtenFile!.content).toContain('## About');
+      expect(writtenFile!.content).toContain('Welcome to my blog. This is a page, not a post.');
+    });
+
+    it('should convert page HTML content and shortcodes', async () => {
+      // Page 502: Page with complex HTML and shortcode
+      const page = wxrData.pages.find(p => p.wpId === 502);
+      expect(page).toBeDefined();
+      expect(page!.content).toContain('[contact_form id="1"]');
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        pages: {
+          total: 1,
+          new: 1,
+          update: 0,
+          conflict: 0,
+          items: [{
+            wxrPost: page!,
+            status: 'new' as PostAnalysisStatus,
+            contentHash: 'test-hash',
+            markdownPreview: '',
+          }],
+        },
+        media: { total: 0, new: 0, update: 0, conflict: 0, missing: 0, items: [] },
+        tags: [],
+        categories: [],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      await engine.executeImport(report, {});
+
+      const writtenFile = writtenFiles.find(f => f.path.includes('contact'));
+      expect(writtenFile).toBeDefined();
+
+      const content = writtenFile!.content;
+
+      // HTML should be converted to Markdown
+      expect(content).toContain('## Contact Us');
+      expect(content).toContain('### Office Hours');
+
+      // Links should be converted
+      expect(content).toContain('[test@example.com](mailto:test@example.com)');
+      expect(content).toContain('[@test](https://twitter.com/test)');
+
+      // Lists should be converted (TurndownService uses multiple spaces after -)
+      expect(content).toMatch(/-\s+Email:/);
+      expect(content).toMatch(/-\s+Twitter:/);
+
+      // Shortcode should be converted to macro (underscore may be escaped by TurndownService)
+      expect(content).toMatch(/\[\[contact_?\\?_?form id="1"\]\]/);
+    });
+
+    it('should preserve page author information', async () => {
+      // Page 502 has author "admin"
+      const page = wxrData.pages.find(p => p.wpId === 502);
+      expect(page).toBeDefined();
+      expect(page!.creator).toBe('admin');
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: { total: 0, new: 0, update: 0, conflict: 0, items: [] },
+        pages: {
+          total: 1,
+          new: 1,
+          update: 0,
+          conflict: 0,
+          items: [{
+            wxrPost: page!,
+            status: 'new' as PostAnalysisStatus,
+            contentHash: 'test-hash',
+            markdownPreview: '',
+          }],
+        },
+        media: { total: 0, new: 0, update: 0, conflict: 0, missing: 0, items: [] },
+        tags: [],
+        categories: [],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      await engine.executeImport(report, {});
+
+      expect(insertedPosts.length).toBe(1);
+      expect(insertedPosts[0].author).toBe('admin');
+    });
+  });
+
+  // ==========================================================================
+  // SECTION 7: RESULT SUMMARY VERIFICATION
+  // ==========================================================================
+
+  describe('Result Summary Accuracy', () => {
+    it('should return accurate counts for a mixed import', async () => {
+      // Import multiple posts with different statuses
+      const post1 = wxrData.posts.find(p => p.wpId === 101);  // New
+      const post2 = wxrData.posts.find(p => p.wpId === 301);  // Conflict-ignore
+      const post3 = wxrData.posts.find(p => p.wpId === 302);  // Conflict-overwrite
+      const page = wxrData.pages.find(p => p.wpId === 501);   // New page
+      const media = wxrData.media.find(m => m.wpId === 402);  // New media
+
+      const report: ImportAnalysisReport = {
+        wxrData: wxrData,
+        posts: {
+          total: 3,
+          new: 1,
+          update: 0,
+          conflict: 2,
+          items: [
+            {
+              wxrPost: post1!,
+              status: 'new' as PostAnalysisStatus,
+              contentHash: 'hash1',
+              markdownPreview: '',
+            },
+            {
+              wxrPost: post2!,
+              status: 'conflict' as PostAnalysisStatus,
+              contentHash: 'hash2',
+              markdownPreview: '',
+              conflictResolution: 'ignore',
+              existingPost: { id: 'e1', title: 'E1', slug: 'existing-post', checksum: null, pubDate: null, excerpt: null, author: null, tags: [], categories: [] },
+            },
+            {
+              wxrPost: post3!,
+              status: 'conflict' as PostAnalysisStatus,
+              contentHash: 'hash3',
+              markdownPreview: '',
+              conflictResolution: 'overwrite',
+              existingPost: { id: 'e2', title: 'E2', slug: 'overwrite-me', checksum: null, pubDate: null, excerpt: null, author: null, tags: [], categories: [] },
+            },
+          ],
+        },
+        pages: {
+          total: 1,
+          new: 1,
+          update: 0,
+          conflict: 0,
+          items: [{
+            wxrPost: page!,
+            status: 'new' as PostAnalysisStatus,
+            contentHash: 'hash4',
+            markdownPreview: '',
+          }],
+        },
+        media: {
+          total: 1,
+          new: 1,
+          update: 0,
+          conflict: 0,
+          missing: 0,
+          items: [{
+            wxrMedia: media!,
+            status: 'new' as MediaAnalysisStatus,
+            fileHash: 'media-hash',
+          }],
+        },
+        tags: [
+          { name: 'NewTag', slug: 'newtag', existsInProject: false },
+          { name: 'ExistingTag', slug: 'existingtag', existsInProject: true },
+        ],
+        categories: [
+          { name: 'Technology', slug: 'technology', existsInProject: true },
+        ],
+        site: wxrData.site,
+        macros: { totalUniqueMacros: 0, totalMacroUsages: 0, allMapped: true, macros: [] },
+      };
+
+      const result = await engine.executeImport(report, { uploadsFolder: '/mock/wp-content/uploads' });
+
+      // Verify result accuracy
+      expect(result.success).toBe(true);
+
+      // Posts: 1 new imported, 1 ignore skipped, 1 overwrite imported
+      expect(result.posts.imported).toBe(2);  // post1 + post3
+      expect(result.posts.skipped).toBe(1);   // post2 (ignore)
+      expect(result.posts.errors).toBe(0);
+
+      // Pages: 1 imported
+      expect(result.pages.imported).toBe(1);
+      expect(result.pages.skipped).toBe(0);
+      expect(result.pages.errors).toBe(0);
+
+      // Media: 1 imported
+      expect(result.media.imported).toBe(1);
+      expect(result.media.skipped).toBe(0);
+      expect(result.media.errors).toBe(0);
+
+      // Tags: 1 created (NewTag), 2 skipped (ExistingTag + Technology category)
+      expect(result.tags.created).toBe(1);
+      expect(result.tags.skipped).toBe(2);
+
+      // WpId mapping should have entries for imported posts
+      expect(result.wpIdToPostId.size).toBeGreaterThanOrEqual(2);  // post1, post3
+    });
+  });
+});
diff --git a/tests/engine/ImportExecutionEngine.test.ts b/tests/engine/ImportExecutionEngine.test.ts
new file mode 100644
index 0000000..3d7b45d
--- /dev/null
+++ b/tests/engine/ImportExecutionEngine.test.ts
@@ -0,0 +1,1549 @@
+/**
+ * ImportExecutionEngine Unit Tests
+ *
+ * Tests the ImportExecutionEngine which handles the actual import of WXR data.
+ * Following TDD best practices: mock external dependencies, test real implementation.
+ */
+
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import type {
+  ImportAnalysisReport,
+  AnalyzedPost,
+  AnalyzedMedia,
+  AnalyzedCategory,
+  AnalyzedTag,
+} from '../../src/main/engine/ImportAnalysisEngine';
+import type { WxrPost, WxrMedia, WxrSiteInfo } from '../../src/main/engine/WxrParser';
+
+// Mock modules before importing the engine
+vi.mock('../../src/main/database', () => ({
+  getDatabase: vi.fn(() => ({
+    getLocal: vi.fn(() => mockDb),
+    getLocalClient: vi.fn(() => mockClient),
+  })),
+}));
+
+vi.mock('fs/promises', () => ({
+  mkdir: vi.fn().mockResolvedValue(undefined),
+  writeFile: vi.fn().mockResolvedValue(undefined),
+  copyFile: vi.fn().mockResolvedValue(undefined),
+  readFile: vi.fn().mockResolvedValue(Buffer.from('test image data')),
+  access: vi.fn().mockResolvedValue(undefined),
+  stat: vi.fn().mockResolvedValue({ size: 1024 }),
+}));
+
+vi.mock('electron', () => ({
+  app: {
+    getPath: vi.fn(() => '/mock/user/data'),
+  },
+}));
+
+vi.mock('uuid', () => ({
+  v4: vi.fn(() => 'mock-uuid-' + Math.random().toString(36).substr(2, 9)),
+}));
+
+// Mock the TagEngine
+const mockTagEngine = {
+  setProjectContext: vi.fn(),
+  createTag: vi.fn().mockImplementation(async (input: { name: string }) => ({
+    id: `tag-${input.name}`,
+    projectId: 'test-project',
+    name: input.name.toLowerCase(),
+    createdAt: new Date(),
+    updatedAt: new Date(),
+  })),
+  getAllTags: vi.fn().mockResolvedValue([]),
+};
+
+vi.mock('../../src/main/engine/TagEngine', () => ({
+  getTagEngine: vi.fn(() => mockTagEngine),
+}));
+
+// Mock the PostEngine
+const mockPostEngine = {
+  setProjectContext: vi.fn(),
+  createPost: vi.fn().mockImplementation(async (data: any) => ({
+    id: data.id || 'mock-post-id',
+    projectId: data.projectId || 'test-project',
+    title: data.title,
+    slug: data.slug,
+    content: data.content,
+    excerpt: data.excerpt,
+    status: data.status,
+    author: data.author,
+    createdAt: data.createdAt || new Date(),
+    updatedAt: data.updatedAt || new Date(),
+    publishedAt: data.publishedAt,
+    tags: data.tags || [],
+    categories: data.categories || [],
+  })),
+  publishPost: vi.fn().mockImplementation(async (id: string) => ({ id, status: 'published' })),
+  isSlugAvailable: vi.fn().mockResolvedValue(true),
+  generateUniqueSlug: vi.fn().mockImplementation(async (title: string) => `${title.toLowerCase().replace(/\s+/g, '-')}-new`),
+  updateFTSIndex: vi.fn().mockResolvedValue(undefined),
+};
+
+vi.mock('../../src/main/engine/PostEngine', () => ({
+  getPostEngine: vi.fn(() => mockPostEngine),
+}));
+
+// Mock the MediaEngine
+const mockMediaEngine = {
+  setProjectContext: vi.fn(),
+  importMedia: vi.fn().mockImplementation(async (sourcePath: string, metadata?: any) => ({
+    id: 'mock-media-id',
+    filename: 'test.jpg',
+    originalName: metadata?.originalName || 'test.jpg',
+    mimeType: metadata?.mimeType || 'image/jpeg',
+    size: 1024,
+    width: 800,
+    height: 600,
+    alt: metadata?.alt,
+    caption: metadata?.caption,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+    tags: metadata?.tags || [],
+    linkedPostIds: metadata?.linkedPostIds || [],
+  })),
+  updateMedia: vi.fn().mockResolvedValue({}),
+};
+
+vi.mock('../../src/main/engine/MediaEngine', () => ({
+  getMediaEngine: vi.fn(() => mockMediaEngine),
+}));
+
+// Mock database - track inserts for verification
+const insertedPosts: any[] = [];
+const insertedMedia: any[] = [];
+
+// Create a mock that tracks based on table name property
+const createMockInsert = () => ({
+  values: vi.fn((data: any) => {
+    // The insert will be called for posts or media tables
+    // We determine which based on the data structure
+    if (data.slug !== undefined && data.projectId !== undefined) {
+      insertedPosts.push(data);
+    }
+    return Promise.resolve();
+  }),
+});
+
+const mockDb = {
+  select: vi.fn(() => ({
+    from: vi.fn(() => ({
+      where: vi.fn(() => ({
+        get: vi.fn().mockResolvedValue(null),
+        all: vi.fn().mockResolvedValue([]),
+      })),
+    })),
+  })),
+  insert: vi.fn(() => createMockInsert()),
+  update: vi.fn(() => ({
+    set: vi.fn(() => ({
+      where: vi.fn().mockResolvedValue(undefined),
+    })),
+  })),
+};
+
+const mockClient = {
+  execute: vi.fn().mockResolvedValue({ rows: [] }),
+};
+
+// Import engine after mocks
+import { ImportExecutionEngine, ImportExecutionResult, ImportExecutionOptions } from '../../src/main/engine/ImportExecutionEngine';
+
+// Test fixtures
+function createMockWxrPost(overrides?: Partial): WxrPost {
+  return {
+    wpId: 1,
+    title: 'Test Post',
+    slug: 'test-post',
+    content: '

Test content with [gallery ids="1,2,3"]

', + excerpt: 'Test excerpt', + pubDate: new Date('2024-01-15T10:00:00Z'), + postDate: new Date('2024-01-15T10:00:00Z'), + postModified: new Date('2024-01-20T15:00:00Z'), + creator: 'admin', + status: 'publish', + postType: 'post', + categories: ['Technology'], + tags: ['JavaScript', 'TypeScript'], + ...overrides, + }; +} + +function createMockWxrMedia(overrides?: Partial): WxrMedia { + return { + wpId: 100, + title: 'Test Image', + url: 'https://example.com/wp-content/uploads/2024/01/test.jpg', + filename: 'test.jpg', + relativePath: '2024/01/test.jpg', + pubDate: new Date('2024-01-15T10:00:00Z'), + parentId: 1, + mimeType: 'image/jpeg', + description: 'A test image description', + ...overrides, + }; +} + +function createMockAnalyzedPost(wxrPost: WxrPost, status: 'new' | 'update' | 'conflict' | 'content-duplicate' = 'new', conflictResolution?: 'ignore' | 'overwrite' | 'import'): AnalyzedPost { + return { + wxrPost, + status, + contentHash: 'abc123', + markdownPreview: 'Test content preview', + conflictResolution, + }; +} + +function createMockAnalyzedMedia(wxrMedia: WxrMedia, status: 'new' | 'update' | 'conflict' | 'content-duplicate' | 'missing' = 'new'): AnalyzedMedia { + return { + wxrMedia, + status, + fileHash: 'def456', + }; +} + +function createMockAnalysisReport(overrides?: Partial): ImportAnalysisReport { + return { + sourceFile: '/path/to/export.xml', + site: { + title: 'Test Blog', + link: 'https://example.com', + description: 'A test blog', + language: 'en-US', + }, + analyzedAt: new Date(), + posts: { + total: 0, + new: 0, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [], + }, + pages: { + total: 0, + new: 0, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [], + }, + media: { + total: 0, + new: 0, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + missing: 0, + items: [], + }, + categories: [], + tags: [], + macros: { + total: 0, + mappedCount: 0, + unmappedCount: 0, + discovered: [], + }, + ...overrides, + }; +} + +describe('ImportExecutionEngine', () => { + let engine: ImportExecutionEngine; + + beforeEach(() => { + vi.clearAllMocks(); + insertedPosts.length = 0; + insertedMedia.length = 0; + engine = new ImportExecutionEngine(); + engine.setProjectContext('test-project', '/mock/project/data'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('setProjectContext', () => { + it('should set the project context', () => { + engine.setProjectContext('new-project', '/new/data/path'); + expect(engine.getProjectContext()).toBe('new-project'); + }); + + it('should handle project context without dataDir', () => { + engine.setProjectContext('another-project'); + expect(engine.getProjectContext()).toBe('another-project'); + }); + }); + + describe('Error Handling', () => { + it('should handle top-level executeImport errors gracefully', async () => { + // Make tagEngine.setProjectContext throw to simulate catastrophic failure + mockTagEngine.setProjectContext.mockImplementationOnce(() => { + throw new Error('Catastrophic failure'); + }); + + const report = createMockAnalysisReport({}); + + const result = await engine.executeImport(report, {}); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0]).toContain('Catastrophic failure'); + }); + }); + + describe('Phase 1: Tag Creation', () => { + it('should create new tags from analysis report', async () => { + const report = createMockAnalysisReport({ + tags: [ + { name: 'NewTag', slug: 'newtag', existsInProject: false }, + { name: 'ExistingTag', slug: 'existingtag', existsInProject: true }, + ], + categories: [ + { name: 'NewCategory', slug: 'newcategory', existsInProject: false }, + ], + }); + + const result = await engine.executeImport(report, {}); + + // Should create only new tags (not existing ones) + expect(mockTagEngine.createTag).toHaveBeenCalledWith({ name: 'newtag' }); + expect(mockTagEngine.createTag).toHaveBeenCalledWith({ name: 'newcategory' }); + expect(mockTagEngine.createTag).toHaveBeenCalledTimes(2); + }); + + it('should not create tags that already exist', async () => { + const report = createMockAnalysisReport({ + tags: [ + { name: 'ExistingTag', slug: 'existingtag', existsInProject: true }, + ], + }); + + await engine.executeImport(report, {}); + + expect(mockTagEngine.createTag).not.toHaveBeenCalled(); + }); + + it('should use mapped tag names when creating tags', async () => { + const report = createMockAnalysisReport({ + tags: [ + { name: 'OldName', slug: 'oldname', existsInProject: false, mappedTo: 'NewName' }, + ], + }); + + await engine.executeImport(report, {}); + + // Should NOT create the tag if it's mapped to an existing one + // The mappedTo value means it should use an existing tag + expect(mockTagEngine.createTag).not.toHaveBeenCalled(); + }); + + it('should count skipped when tag creation fails (duplicate/race condition)', async () => { + mockTagEngine.createTag.mockRejectedValueOnce(new Error('Tag already exists')); + + const report = createMockAnalysisReport({ + tags: [ + { name: 'NewTag', slug: 'newtag', existsInProject: false }, + ], + }); + + const result = await engine.executeImport(report, {}); + + // Tag creation failed, should be counted as skipped not created + expect(result.tags.skipped).toBe(1); + expect(result.tags.created).toBe(0); + }); + + it('should count skipped when category creation fails', async () => { + mockTagEngine.createTag.mockRejectedValueOnce(new Error('Category already exists')); + + const report = createMockAnalysisReport({ + categories: [ + { name: 'NewCategory', slug: 'newcategory', existsInProject: false }, + ], + }); + + const result = await engine.executeImport(report, {}); + + expect(result.tags.skipped).toBe(1); + expect(result.tags.created).toBe(0); + }); + }); + + describe('Phase 2: Post Import', () => { + it('should import new posts with correct dates from WXR', async () => { + const wxrPost = createMockWxrPost({ + postDate: new Date('2024-01-15T10:00:00Z'), + postModified: new Date('2024-01-20T15:00:00Z'), + }); + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + }); + + await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + // Check that post was inserted into DB with correct dates + expect(insertedPosts.length).toBe(1); + expect(insertedPosts[0].title).toBe('Test Post'); + expect(insertedPosts[0].slug).toBe('test-post'); + expect(insertedPosts[0].createdAt).toEqual(new Date('2024-01-15T10:00:00Z')); + expect(insertedPosts[0].updatedAt).toEqual(new Date('2024-01-20T15:00:00Z')); + }); + + it('should skip posts with conflict resolution "ignore"', async () => { + const wxrPost = createMockWxrPost(); + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 0, + updates: 0, + conflicts: 1, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'conflict', 'ignore')], + }, + }); + + const result = await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(0); + expect(result.posts.skipped).toBe(1); + }); + + it('should create draft for conflict resolution "overwrite"', async () => { + const wxrPost = createMockWxrPost(); + const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite'); + analyzed.existingPost = { + id: 'existing-post-id', + title: 'Existing Post', + slug: 'test-post', + checksum: 'old-checksum', + pubDate: null, + excerpt: null, + author: null, + tags: [], + categories: [], + }; + + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 0, + updates: 0, + conflicts: 1, + contentDuplicates: 0, + items: [analyzed], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + expect(insertedPosts[0].slug).toBe('test-post'); + expect(insertedPosts[0].status).toBe('draft'); + }); + + it('should create new post with new slug for conflict resolution "import"', async () => { + const wxrPost = createMockWxrPost(); + const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'import'); + analyzed.existingPost = { + id: 'existing-post-id', + title: 'Existing Post', + slug: 'test-post', + checksum: 'old-checksum', + pubDate: null, + excerpt: null, + author: null, + tags: [], + categories: [], + }; + + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 0, + updates: 0, + conflicts: 1, + contentDuplicates: 0, + items: [analyzed], + }, + }); + + await engine.executeImport(report, {}); + + expect(mockPostEngine.generateUniqueSlug).toHaveBeenCalled(); + expect(insertedPosts.length).toBe(1); + }); + + it('should skip posts with status "content-duplicate"', async () => { + const wxrPost = createMockWxrPost(); + const analyzed = createMockAnalyzedPost(wxrPost, 'content-duplicate'); + + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 0, + updates: 0, + conflicts: 0, + contentDuplicates: 1, + items: [analyzed], + }, + }); + + const result = await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(0); + expect(result.posts.skipped).toBe(1); + expect(result.posts.imported).toBe(0); + }); + + it('should skip posts with status "update"', async () => { + const wxrPost = createMockWxrPost(); + const analyzed = createMockAnalyzedPost(wxrPost, 'update'); + analyzed.existingPost = { + id: 'existing-post-id', + title: 'Existing Post', + slug: 'test-post', + checksum: 'same-content-checksum', + pubDate: null, + excerpt: null, + author: null, + tags: [], + categories: [], + }; + + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 0, + updates: 1, + conflicts: 0, + contentDuplicates: 0, + items: [analyzed], + }, + }); + + const result = await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(0); + expect(result.posts.skipped).toBe(1); + }); + + it('should handle empty content gracefully', async () => { + const wxrPost = createMockWxrPost({ + content: '', + }); + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + // Empty content should be preserved as empty string + }); + + it('should handle whitespace-only content gracefully', async () => { + const wxrPost = createMockWxrPost({ + content: ' \n\t ', + }); + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + }); + + it('should include excerpt in post metadata when present', async () => { + const wxrPost = createMockWxrPost({ + excerpt: 'This is a post excerpt', + }); + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + expect(insertedPosts[0].excerpt).toBe('This is a post excerpt'); + }); + + it('should include author in post metadata when present', async () => { + const wxrPost = createMockWxrPost({ + creator: 'johndoe', + }); + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + expect(insertedPosts[0].author).toBe('johndoe'); + }); + + it('should include publishedAt for published posts', async () => { + const pubDate = new Date('2024-01-15T10:00:00Z'); + const wxrPost = createMockWxrPost({ + pubDate, + }); + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + expect(insertedPosts[0].publishedAt).toEqual(pubDate); + }); + + it('should not set publishedAt for draft posts', async () => { + const wxrPost = createMockWxrPost(); + const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite'); + analyzed.existingPost = { + id: 'existing-post-id', + title: 'Existing Post', + slug: 'test-post', + checksum: 'old-checksum', + pubDate: null, + excerpt: null, + author: null, + tags: [], + categories: [], + }; + + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 0, + updates: 0, + conflicts: 1, + contentDuplicates: 0, + items: [analyzed], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + expect(insertedPosts[0].status).toBe('draft'); + expect(insertedPosts[0].publishedAt).toBeUndefined(); + }); + + it('should handle post without optional fields', async () => { + const wxrPost = createMockWxrPost({ + excerpt: '', + creator: '', + pubDate: undefined, + postDate: undefined, + postModified: undefined, + }); + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + // Should use current date as fallback + expect(insertedPosts[0].createdAt).toBeInstanceOf(Date); + }); + + it('should handle dates that are ISO strings (from JSON serialization through IPC)', async () => { + // After JSON.parse, dates become strings like "2024-01-15T10:00:00.000Z" + const wxrPost = createMockWxrPost({ + postDate: '2023-06-20T08:30:00.000Z' as any, + postModified: '2023-07-01T12:00:00.000Z' as any, + pubDate: '2023-06-20T08:30:00.000Z' as any, + status: 'publish', + }); + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + expect(insertedPosts[0].createdAt).toBeInstanceOf(Date); + expect(insertedPosts[0].createdAt).toEqual(new Date('2023-06-20T08:30:00.000Z')); + expect(insertedPosts[0].updatedAt).toBeInstanceOf(Date); + expect(insertedPosts[0].updatedAt).toEqual(new Date('2023-07-01T12:00:00.000Z')); + expect(insertedPosts[0].publishedAt).toBeInstanceOf(Date); + expect(insertedPosts[0].publishedAt).toEqual(new Date('2023-06-20T08:30:00.000Z')); + }); + + it('should handle invalid date strings gracefully', async () => { + const wxrPost = createMockWxrPost({ + postDate: 'not-a-date' as any, + postModified: '' as any, + pubDate: undefined, + }); + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + // Invalid dates should fall back to current date + expect(insertedPosts[0].createdAt).toBeInstanceOf(Date); + }); + + it('should record error when post import fails', async () => { + const wxrPost = createMockWxrPost(); + + // Make the database insert throw an error + const originalInsert = mockDb.insert; + mockDb.insert = vi.fn(() => ({ + values: vi.fn().mockRejectedValue(new Error('Database error')), + })); + + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + }); + + const result = await engine.executeImport(report, {}); + + expect(result.posts.errors).toBe(1); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0]).toContain('Failed to import post'); + + // Restore + mockDb.insert = originalInsert; + }); + + it('should transform WordPress shortcodes to double bracket format', async () => { + const wxrPost = createMockWxrPost({ + content: '

Check out [gallery ids="1,2"] and [video src="test.mp4"]

', + }); + // Create as conflict with overwrite resolution to get a draft (content in DB) + const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite'); + analyzed.existingPost = { + id: 'existing-post-id', + title: 'Existing Post', + slug: 'test-post', + checksum: 'old-checksum', + pubDate: null, + excerpt: null, + author: null, + tags: [], + categories: [], + }; + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 0, + updates: 0, + conflicts: 1, + contentDuplicates: 0, + items: [analyzed], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + // Draft posts store content in DB + expect(insertedPosts[0].content).toContain('[[gallery ids="1,2"]]'); + expect(insertedPosts[0].content).toContain('[[video src="test.mp4"]]'); + }); + + it('should map tags based on analysis mappings', async () => { + const wxrPost = createMockWxrPost({ + tags: ['OldTagName', 'NewTag'], + categories: ['MappedCategory'], + }); + + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + tags: [ + { name: 'OldTagName', slug: 'oldtagname', existsInProject: false, mappedTo: 'ExistingTag' }, + { name: 'NewTag', slug: 'newtag', existsInProject: false }, + ], + categories: [ + { name: 'MappedCategory', slug: 'mappedcategory', existsInProject: false, mappedTo: 'TargetCategory' }, + ], + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + const tags = JSON.parse(insertedPosts[0].tags); + const categories = JSON.parse(insertedPosts[0].categories); + expect(tags).toContain('existingtag'); + expect(tags).toContain('newtag'); + expect(categories).toContain('targetcategory'); + }); + + it('should build wpId to postId mapping for media phase', async () => { + const wxrPost = createMockWxrPost({ wpId: 42 }); + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + }); + + const result = await engine.executeImport(report, {}); + + expect(result.wpIdToPostId.get(42)).toBeDefined(); + }); + }); + + describe('Phase 3: Media Import', () => { + it('should import new media files', async () => { + const wxrMedia = createMockWxrMedia(); + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + missing: 0, + items: [createMockAnalyzedMedia(wxrMedia, 'new')], + }, + }); + + await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).toHaveBeenCalled(); + }); + + it('should pass createdAt and updatedAt from WXR pubDate to MediaEngine', async () => { + const pubDate = new Date('2020-03-15T14:30:00Z'); + const wxrMedia = createMockWxrMedia({ pubDate }); + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + missing: 0, + items: [createMockAnalyzedMedia(wxrMedia, 'new')], + }, + }); + + await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + createdAt: pubDate, + updatedAt: pubDate, + }) + ); + }); + + it('should handle media pubDate as string (from JSON serialization)', async () => { + const wxrMedia = createMockWxrMedia({ + pubDate: '2019-11-25T09:00:00.000Z' as any, + }); + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + missing: 0, + items: [createMockAnalyzedMedia(wxrMedia, 'new')], + }, + }); + + await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + createdAt: new Date('2019-11-25T09:00:00.000Z'), + updatedAt: new Date('2019-11-25T09:00:00.000Z'), + }) + ); + }); + + it('should set caption from WXR title', async () => { + const wxrMedia = createMockWxrMedia({ title: 'Beautiful Sunset' }); + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + missing: 0, + items: [createMockAnalyzedMedia(wxrMedia, 'new')], + }, + }); + + await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + caption: 'Beautiful Sunset', + }) + ); + }); + + it('should link media to parent post using wpId mapping', async () => { + // First import a post + const wxrPost = createMockWxrPost({ wpId: 42 }); + const wxrMedia = createMockWxrMedia({ parentId: 42 }); + + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + media: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + missing: 0, + items: [createMockAnalyzedMedia(wxrMedia, 'new')], + }, + }); + + await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + linkedPostIds: expect.any(Array), + }) + ); + }); + + it('should skip missing media files', async () => { + const wxrMedia = createMockWxrMedia(); + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 0, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + missing: 1, + items: [createMockAnalyzedMedia(wxrMedia, 'missing')], + }, + }); + + const result = await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).not.toHaveBeenCalled(); + expect(result.media.skipped).toBe(1); + }); + + it('should skip media with conflict resolution "ignore"', async () => { + const wxrMedia = createMockWxrMedia(); + const analyzed = createMockAnalyzedMedia(wxrMedia, 'conflict'); + (analyzed as any).conflictResolution = 'ignore'; + + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 0, + updates: 0, + conflicts: 1, + contentDuplicates: 0, + missing: 0, + items: [analyzed], + }, + }); + + const result = await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).not.toHaveBeenCalled(); + expect(result.media.skipped).toBe(1); + }); + + it('should skip media with status "content-duplicate"', async () => { + const wxrMedia = createMockWxrMedia(); + const analyzed = createMockAnalyzedMedia(wxrMedia, 'content-duplicate'); + + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 0, + updates: 0, + conflicts: 0, + contentDuplicates: 1, + missing: 0, + items: [analyzed], + }, + }); + + const result = await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).not.toHaveBeenCalled(); + expect(result.media.skipped).toBe(1); + }); + + it('should skip media with status "update"', async () => { + const wxrMedia = createMockWxrMedia(); + const analyzed = createMockAnalyzedMedia(wxrMedia, 'update'); + + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 0, + updates: 1, + conflicts: 0, + contentDuplicates: 0, + missing: 0, + items: [analyzed], + }, + }); + + const result = await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).not.toHaveBeenCalled(); + expect(result.media.skipped).toBe(1); + }); + + it('should skip media when uploadsFolder is not provided', async () => { + const wxrMedia = createMockWxrMedia(); + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + missing: 0, + items: [createMockAnalyzedMedia(wxrMedia, 'new')], + }, + }); + + const result = await engine.executeImport(report, {}); // No uploadsFolder + + expect(mockMediaEngine.importMedia).not.toHaveBeenCalled(); + expect(result.media.skipped).toBe(1); + }); + + it('should skip media when source file does not exist', async () => { + const { access } = await import('fs/promises'); + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + + const wxrMedia = createMockWxrMedia(); + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + missing: 0, + items: [createMockAnalyzedMedia(wxrMedia, 'new')], + }, + }); + + const result = await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).not.toHaveBeenCalled(); + expect(result.media.skipped).toBe(1); + }); + + it('should import media with conflict resolution "overwrite"', async () => { + const wxrMedia = createMockWxrMedia(); + const analyzed = createMockAnalyzedMedia(wxrMedia, 'conflict'); + (analyzed as any).conflictResolution = 'overwrite'; + + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 0, + updates: 0, + conflicts: 1, + contentDuplicates: 0, + missing: 0, + items: [analyzed], + }, + }); + + await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).toHaveBeenCalled(); + }); + + it('should import media with conflict resolution "import"', async () => { + const wxrMedia = createMockWxrMedia(); + const analyzed = createMockAnalyzedMedia(wxrMedia, 'conflict'); + (analyzed as any).conflictResolution = 'import'; + + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 0, + updates: 0, + conflicts: 1, + contentDuplicates: 0, + missing: 0, + items: [analyzed], + }, + }); + + await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).toHaveBeenCalled(); + }); + + it('should set alt text from WXR description', async () => { + const wxrMedia = createMockWxrMedia({ description: 'Alt text for image' }); + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + missing: 0, + items: [createMockAnalyzedMedia(wxrMedia, 'new')], + }, + }); + + await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + alt: 'Alt text for image', + }) + ); + }); + + it('should handle media with no parent post', async () => { + const wxrMedia = createMockWxrMedia({ parentId: 0 }); + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + missing: 0, + items: [createMockAnalyzedMedia(wxrMedia, 'new')], + }, + }); + + await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(mockMediaEngine.importMedia).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + linkedPostIds: [], + }) + ); + }); + + it('should record error when media import fails', async () => { + mockMediaEngine.importMedia.mockRejectedValueOnce(new Error('Media import failed')); + + const wxrMedia = createMockWxrMedia(); + const report = createMockAnalysisReport({ + media: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + missing: 0, + items: [createMockAnalyzedMedia(wxrMedia, 'new')], + }, + }); + + const result = await engine.executeImport(report, { uploadsFolder: '/uploads' }); + + expect(result.media.errors).toBe(1); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0]).toContain('Failed to import media'); + }); + }); + + describe('Phase 4: Page Import', () => { + it('should import pages with "page" category', async () => { + const wxrPage = createMockWxrPost({ + postType: 'page', + categories: [], + }); + const report = createMockAnalysisReport({ + pages: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPage, 'new')], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + const categories = JSON.parse(insertedPosts[0].categories); + expect(categories).toContain('page'); + }); + + it('should preserve existing page categories and add "page"', async () => { + const wxrPage = createMockWxrPost({ + postType: 'page', + categories: ['Documentation', 'Help'], + }); + const report = createMockAnalysisReport({ + pages: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPage, 'new')], + }, + categories: [ + { name: 'Documentation', slug: 'documentation', existsInProject: false }, + { name: 'Help', slug: 'help', existsInProject: false }, + ], + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + const categories = JSON.parse(insertedPosts[0].categories); + expect(categories).toContain('page'); + expect(categories).toContain('documentation'); + expect(categories).toContain('help'); + }); + + it('should use existing page category mapping when defined in report', async () => { + const wxrPage = createMockWxrPost({ + postType: 'page', + categories: [], + }); + const report = createMockAnalysisReport({ + pages: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPage, 'new')], + }, + categories: [ + // Page is already defined in the category mapping + { name: 'page', slug: 'page', existsInProject: true }, + ], + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + const categories = JSON.parse(insertedPosts[0].categories); + expect(categories).toContain('page'); + }); + + it('should skip pages with status "content-duplicate"', async () => { + const wxrPage = createMockWxrPost({ + postType: 'page', + categories: [], + }); + const report = createMockAnalysisReport({ + pages: { + total: 1, + new: 0, + updates: 0, + conflicts: 0, + contentDuplicates: 1, + items: [createMockAnalyzedPost(wxrPage, 'content-duplicate')], + }, + }); + + const result = await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(0); + expect(result.pages.skipped).toBe(1); + }); + + it('should skip pages with status "update"', async () => { + const wxrPage = createMockWxrPost({ + postType: 'page', + categories: [], + }); + const analyzed = createMockAnalyzedPost(wxrPage, 'update'); + analyzed.existingPost = { + id: 'existing-page-id', + title: 'Existing Page', + slug: 'test-page', + checksum: 'same-content-checksum', + pubDate: null, + excerpt: null, + author: null, + tags: [], + categories: [], + }; + + const report = createMockAnalysisReport({ + pages: { + total: 1, + new: 0, + updates: 1, + conflicts: 0, + contentDuplicates: 0, + items: [analyzed], + }, + }); + + const result = await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(0); + expect(result.pages.skipped).toBe(1); + }); + + it('should handle page conflict with "overwrite" resolution', async () => { + const wxrPage = createMockWxrPost({ + postType: 'page', + categories: [], + }); + const analyzed = createMockAnalyzedPost(wxrPage, 'conflict', 'overwrite'); + analyzed.existingPost = { + id: 'existing-page-id', + title: 'Existing Page', + slug: 'test-post', + checksum: 'old-checksum', + pubDate: null, + excerpt: null, + author: null, + tags: [], + categories: [], + }; + + const report = createMockAnalysisReport({ + pages: { + total: 1, + new: 0, + updates: 0, + conflicts: 1, + contentDuplicates: 0, + items: [analyzed], + }, + }); + + await engine.executeImport(report, {}); + + expect(insertedPosts.length).toBe(1); + expect(insertedPosts[0].status).toBe('draft'); + const categories = JSON.parse(insertedPosts[0].categories); + expect(categories).toContain('page'); + }); + + it('should handle page conflict with "import" resolution', async () => { + const wxrPage = createMockWxrPost({ + postType: 'page', + categories: [], + }); + const analyzed = createMockAnalyzedPost(wxrPage, 'conflict', 'import'); + analyzed.existingPost = { + id: 'existing-page-id', + title: 'Existing Page', + slug: 'test-post', + checksum: 'old-checksum', + pubDate: null, + excerpt: null, + author: null, + tags: [], + categories: [], + }; + + const report = createMockAnalysisReport({ + pages: { + total: 1, + new: 0, + updates: 0, + conflicts: 1, + contentDuplicates: 0, + items: [analyzed], + }, + }); + + await engine.executeImport(report, {}); + + expect(mockPostEngine.generateUniqueSlug).toHaveBeenCalled(); + expect(insertedPosts.length).toBe(1); + const categories = JSON.parse(insertedPosts[0].categories); + expect(categories).toContain('page'); + }); + + it('should record error when page import fails', async () => { + const wxrPage = createMockWxrPost({ + postType: 'page', + categories: [], + }); + + // Make the database insert throw an error + const originalInsert = mockDb.insert; + mockDb.insert = vi.fn(() => ({ + values: vi.fn().mockRejectedValue(new Error('Database error')), + })); + + const report = createMockAnalysisReport({ + pages: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPage, 'new')], + }, + }); + + const result = await engine.executeImport(report, {}); + + expect(result.pages.errors).toBe(1); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0]).toContain('Failed to import page'); + + // Restore + mockDb.insert = originalInsert; + }); + }); + + describe('Progress Reporting', () => { + it('should call progress callback during import', async () => { + const progressCallback = vi.fn(); + const wxrPost = createMockWxrPost(); + const report = createMockAnalysisReport({ + posts: { + total: 1, + new: 1, + updates: 0, + conflicts: 0, + contentDuplicates: 0, + items: [createMockAnalyzedPost(wxrPost, 'new')], + }, + }); + + await engine.executeImport(report, { onProgress: progressCallback }); + + expect(progressCallback).toHaveBeenCalled(); + }); + }); + + describe('Result Summary', () => { + it('should return accurate counts in result', async () => { + const report = createMockAnalysisReport({ + tags: [ + { name: 'NewTag', slug: 'newtag', existsInProject: false }, + ], + posts: { + total: 2, + new: 1, + updates: 0, + conflicts: 1, + contentDuplicates: 0, + items: [ + createMockAnalyzedPost(createMockWxrPost({ wpId: 1 }), 'new'), + createMockAnalyzedPost(createMockWxrPost({ wpId: 2 }), 'conflict', 'ignore'), + ], + }, + }); + + const result = await engine.executeImport(report, {}); + + expect(result.tags.created).toBe(1); + expect(result.posts.imported).toBe(1); + expect(result.posts.skipped).toBe(1); + }); + }); +}); diff --git a/tests/engine/MediaEngine.test.ts b/tests/engine/MediaEngine.test.ts index 0760b89..4f253b7 100644 --- a/tests/engine/MediaEngine.test.ts +++ b/tests/engine/MediaEngine.test.ts @@ -284,6 +284,17 @@ describe('MediaEngine', () => { expect(media.updatedAt.getTime()).toBeLessThanOrEqual(after.getTime()); }); + it('should use provided createdAt date when specified', async () => { + const historicalDate = new Date('2018-05-15T10:30:00Z'); + const media = await mediaEngine.importMedia('/source/image.jpg', { + createdAt: historicalDate, + updatedAt: historicalDate, + }); + + expect(media.createdAt.getTime()).toBe(historicalDate.getTime()); + expect(media.updatedAt.getTime()).toBe(historicalDate.getTime()); + }); + it('should emit mediaImported event', async () => { const handler = vi.fn(); mediaEngine.on('mediaImported', handler); diff --git a/tests/utils/factories.ts b/tests/utils/factories.ts index c46802f..10a268d 100644 --- a/tests/utils/factories.ts +++ b/tests/utils/factories.ts @@ -393,6 +393,168 @@ export function createMockDropboxConflict(overrides?: Partial): }; } +// ============================================ +// Import Analysis Report Mock Factory +// ============================================ + +import type { + ImportAnalysisReport, + AnalyzedPost, + AnalyzedMedia, + AnalyzedCategory, + AnalyzedTag, + PostAnalysisStatus, + MediaAnalysisStatus, +} from '../../src/main/engine/ImportAnalysisEngine'; +import type { WxrPost, WxrMedia, WxrSiteInfo } from '../../src/main/engine/WxrParser'; + +let wxrPostIdCounter = 1; +let wxrMediaIdCounter = 1; + +export function createMockWxrSiteInfo(overrides?: Partial): WxrSiteInfo { + return { + title: 'Test WordPress Site', + link: 'https://example.com', + description: 'A test WordPress site', + language: 'en-US', + ...overrides, + }; +} + +export function createMockWxrPost(overrides?: Partial): WxrPost { + const id = wxrPostIdCounter++; + return { + wpId: id, + title: `Test Post ${id}`, + slug: `test-post-${id}`, + status: 'publish', + content: `

Test content for post ${id}

`, + excerpt: `Excerpt for post ${id}`, + pubDate: '2024-01-15T10:00:00Z', + postDate: '2024-01-15T10:00:00Z', + postModified: '2024-01-16T12:00:00Z', + creator: 'testauthor', + postType: 'post', + categories: ['Test Category'], + tags: ['test-tag'], + ...overrides, + }; +} + +export function createMockWxrMedia(overrides?: Partial): WxrMedia { + const id = wxrMediaIdCounter++; + return { + wpId: id, + title: `Test Media ${id}`, + filename: `test-image-${id}.jpg`, + url: `https://example.com/wp-content/uploads/2024/01/test-image-${id}.jpg`, + relativePath: `2024/01/test-image-${id}.jpg`, + pubDate: '2024-01-15T10:00:00Z', + parentId: 0, + mimeType: 'image/jpeg', + description: `Description for media ${id}`, + ...overrides, + }; +} + +export function createMockAnalyzedPost( + overrides?: Partial, + wxrOverrides?: Partial +): AnalyzedPost { + const wxrPost = createMockWxrPost(wxrOverrides); + return { + wxrPost, + status: 'new' as PostAnalysisStatus, + contentHash: `hash-${wxrPost.wpId}`, + markdownPreview: `# ${wxrPost.title}\n\nTest content for post ${wxrPost.wpId}`, + ...overrides, + }; +} + +export function createMockAnalyzedMedia( + overrides?: Partial, + wxrOverrides?: Partial +): AnalyzedMedia { + const wxrMedia = createMockWxrMedia(wxrOverrides); + return { + wxrMedia, + status: 'new' as MediaAnalysisStatus, + fileHash: `filehash-${wxrMedia.wpId}`, + ...overrides, + }; +} + +export function createMockAnalyzedCategory(overrides?: Partial): AnalyzedCategory { + return { + name: 'Test Category', + slug: 'test-category', + existsInProject: false, + ...overrides, + }; +} + +export function createMockAnalyzedTag(overrides?: Partial): AnalyzedTag { + return { + name: 'test-tag', + slug: 'test-tag', + existsInProject: false, + ...overrides, + }; +} + +export function createMockImportAnalysisReport( + overrides?: Partial +): ImportAnalysisReport { + const posts = overrides?.posts?.items || [createMockAnalyzedPost()]; + const pages = overrides?.pages?.items || []; + const mediaItems = overrides?.media?.items || [createMockAnalyzedMedia()]; + const categories = overrides?.categories || [createMockAnalyzedCategory()]; + const tags = overrides?.tags || [createMockAnalyzedTag()]; + + return { + sourceFile: '/path/to/export.xml', + site: createMockWxrSiteInfo(), + analyzedAt: new Date('2024-01-15T12:00:00Z'), + posts: { + total: posts.length, + new: posts.filter(p => p.status === 'new').length, + updates: posts.filter(p => p.status === 'update').length, + conflicts: posts.filter(p => p.status === 'conflict').length, + contentDuplicates: posts.filter(p => p.status === 'content-duplicate').length, + items: posts, + ...overrides?.posts, + }, + pages: { + total: pages.length, + new: pages.filter(p => p.status === 'new').length, + updates: pages.filter(p => p.status === 'update').length, + conflicts: pages.filter(p => p.status === 'conflict').length, + contentDuplicates: pages.filter(p => p.status === 'content-duplicate').length, + items: pages, + ...overrides?.pages, + }, + media: { + total: mediaItems.length, + new: mediaItems.filter(m => m.status === 'new').length, + updates: mediaItems.filter(m => m.status === 'update').length, + conflicts: mediaItems.filter(m => m.status === 'conflict').length, + contentDuplicates: mediaItems.filter(m => m.status === 'content-duplicate').length, + missing: mediaItems.filter(m => m.status === 'missing').length, + items: mediaItems, + ...overrides?.media, + }, + categories, + tags, + macros: overrides?.macros || { + total: 0, + mappedCount: 0, + unmappedCount: 0, + discovered: [], + }, + ...overrides, + }; +} + // ============================================ // Reset Utilities // ============================================ @@ -403,6 +565,8 @@ export function resetMockCounters(): void { projectIdCounter = 1; taskIdCounter = 1; dropboxConflictIdCounter = 1; + wxrPostIdCounter = 1; + wxrMediaIdCounter = 1; } // ============================================