feat: first cut at the import execution
This commit is contained in:
696
src/main/engine/ImportExecutionEngine.ts
Normal file
696
src/main/engine/ImportExecutionEngine.ts
Normal file
@@ -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<number, string>;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// Regex to match WordPress shortcodes: [macroname ...] but NOT [[macroname ...]]
|
||||
const WP_SHORTCODE_REGEX = /(?<!\[)\[(\w+)([^\]]*?)(?:\s*\/)?\](?!\])/g;
|
||||
|
||||
export class ImportExecutionEngine extends EventEmitter {
|
||||
private currentProjectId: string = 'default';
|
||||
private dataDir: string | null = null;
|
||||
private turndown: TurndownService;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.turndown = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
bulletListMarker: '-',
|
||||
});
|
||||
}
|
||||
|
||||
setProjectContext(projectId: string, dataDir?: string): void {
|
||||
this.currentProjectId = projectId;
|
||||
this.dataDir = dataDir || null;
|
||||
}
|
||||
|
||||
getProjectContext(): string {
|
||||
return this.currentProjectId;
|
||||
}
|
||||
|
||||
private getBaseDir(): string {
|
||||
if (this.dataDir) return this.dataDir;
|
||||
const userDataPath = app.getPath('userData');
|
||||
return path.join(userDataPath, 'projects', this.currentProjectId);
|
||||
}
|
||||
|
||||
private getPostsBaseDir(): string {
|
||||
return path.join(this.getBaseDir(), 'posts');
|
||||
}
|
||||
|
||||
private getMediaBaseDir(): string {
|
||||
return path.join(this.getBaseDir(), 'media');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the date-based directory for posts (posts/YYYY/MM/)
|
||||
*/
|
||||
private getPostsDirForDate(date: Date): string {
|
||||
const baseDir = this.getPostsBaseDir();
|
||||
const year = date.getFullYear().toString();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
return path.join(baseDir, year, month);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the date-based directory for media (media/YYYY/MM/)
|
||||
*/
|
||||
private getMediaDirForDate(date: Date): string {
|
||||
const baseDir = this.getMediaBaseDir();
|
||||
const year = date.getFullYear().toString();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
return path.join(baseDir, year, month);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the full import process
|
||||
*/
|
||||
async executeImport(
|
||||
report: ImportAnalysisReport,
|
||||
options: ImportExecutionOptions
|
||||
): Promise<ImportExecutionResult> {
|
||||
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<string, { resolved: string; needsCreation: boolean }> {
|
||||
const mapping = new Map<string, { resolved: string; needsCreation: boolean }>();
|
||||
|
||||
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<string, { resolved: string; needsCreation: boolean }>,
|
||||
categoryMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
result: ImportExecutionResult,
|
||||
progress: (phase: string, current: number, total: number, detail?: string) => void
|
||||
): Promise<void> {
|
||||
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<string, { resolved: string; needsCreation: boolean }>,
|
||||
categoryMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions,
|
||||
progress: (phase: string, current: number, total: number, detail?: string) => void
|
||||
): Promise<void> {
|
||||
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<string, { resolved: string; needsCreation: boolean }>,
|
||||
categoryMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions
|
||||
): Promise<boolean> {
|
||||
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<string, { resolved: string; needsCreation: boolean }>,
|
||||
categoryMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions
|
||||
): Promise<boolean> {
|
||||
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<string, { resolved: string; needsCreation: boolean }>,
|
||||
categoryMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions,
|
||||
status: 'draft' | 'published',
|
||||
overrideSlug?: string
|
||||
): Promise<boolean> {
|
||||
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<string> {
|
||||
const metadata: Record<string, unknown> = {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<string, { resolved: string; needsCreation: boolean }>,
|
||||
categoryMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions,
|
||||
progress: (phase: string, current: number, total: number, detail?: string) => void
|
||||
): Promise<void> {
|
||||
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, { resolved: string; needsCreation: boolean }>
|
||||
): 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;
|
||||
}
|
||||
@@ -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 || [],
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<unknown>;
|
||||
analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>;
|
||||
selectUploadsFolder: () => Promise<string | null>;
|
||||
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<unknown>;
|
||||
|
||||
@@ -1017,3 +1017,258 @@
|
||||
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;
|
||||
}
|
||||
@@ -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<ImportAnalysisViewProps> = ({ definitionId }) => {
|
||||
const [name, setName] = useState('Untitled Import');
|
||||
const [uploadsFolder, setUploadsFolder] = useState<string | null>(null);
|
||||
@@ -140,6 +164,17 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
const [progressStep, setProgressStep] = useState<string>('');
|
||||
const [progressDetail, setProgressDetail] = useState<string>('');
|
||||
const [executionState, setExecutionState] = useState<ImportExecutionState>({
|
||||
isExecuting: false,
|
||||
taskId: null,
|
||||
phase: '',
|
||||
current: 0,
|
||||
total: 0,
|
||||
detail: '',
|
||||
eta: null,
|
||||
completed: false,
|
||||
error: null,
|
||||
});
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Subscribe to progress events
|
||||
@@ -151,6 +186,46 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ 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<ImportAnalysisViewProps> = ({ 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<number, number> = {};
|
||||
const mediaDistrib: Record<number, number> = {};
|
||||
|
||||
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<ImportAnalysisViewProps> = ({ definiti
|
||||
<>
|
||||
<SiteInfoCard site={report.site} sourceFile={report.sourceFile} />
|
||||
<StatCards report={report} />
|
||||
<DateDistributionCard distribution={getDateDistribution()} />
|
||||
|
||||
{/* Execution Progress */}
|
||||
{executionState.isExecuting && (
|
||||
<div className="import-execution-progress">
|
||||
<div className="import-execution-header">
|
||||
<h3>Importing...</h3>
|
||||
{executionState.eta !== null && executionState.eta > 0 && (
|
||||
<span className="import-eta">{formatEta(executionState.eta)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="import-progress-bar">
|
||||
<div
|
||||
className="import-progress-fill"
|
||||
style={{ width: `${executionState.total > 0 ? (executionState.current / executionState.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="import-progress-info">
|
||||
<span className="import-phase">{executionState.phase}</span>
|
||||
{executionState.detail && <span className="import-detail">{executionState.detail}</span>}
|
||||
<span className="import-counter">{executionState.current} / {executionState.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution Complete */}
|
||||
{executionState.completed && (
|
||||
<div className="import-execution-complete">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
<span>Import completed successfully!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution Error */}
|
||||
{executionState.error && (
|
||||
<div className="import-execution-error">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
<span>Import failed: {executionState.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execute Button */}
|
||||
{!executionState.isExecuting && !executionState.completed && (
|
||||
(() => {
|
||||
const counts = getImportableCount();
|
||||
const totalImportable = counts.posts + counts.media + counts.pages + counts.tags;
|
||||
return (
|
||||
<div className="import-execute-section">
|
||||
<div className="import-execute-summary">
|
||||
Ready to import:
|
||||
{counts.tags > 0 && <span className="import-count-tag">{counts.tags} tags/categories</span>}
|
||||
{counts.posts > 0 && <span className="import-count-tag">{counts.posts} posts</span>}
|
||||
{counts.media > 0 && <span className="import-count-tag">{counts.media} media</span>}
|
||||
{counts.pages > 0 && <span className="import-count-tag">{counts.pages} pages</span>}
|
||||
</div>
|
||||
<button
|
||||
className="import-execute-btn"
|
||||
onClick={handleExecuteImport}
|
||||
disabled={totalImportable === 0}
|
||||
>
|
||||
{totalImportable === 0 ? 'Nothing to Import' : `Import ${totalImportable} Items`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
{report.posts.conflicts > 0 && (
|
||||
<ConflictsSection
|
||||
@@ -588,6 +831,82 @@ const StatCards: React.FC<{ report: AnalysisReport }> = ({ report }) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface DateDistribution {
|
||||
posts: Record<number, number>;
|
||||
media: Record<number, number>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="import-date-distribution">
|
||||
<h3>Date Distribution</h3>
|
||||
<div className="distribution-grid">
|
||||
<div className="distribution-column">
|
||||
<div className="distribution-header">
|
||||
<span className="distribution-label">Posts/Pages</span>
|
||||
<span className="distribution-total">{totalPosts} total</span>
|
||||
</div>
|
||||
<div className="distribution-bars">
|
||||
{allYears.map(year => {
|
||||
const count = distribution.posts[year] || 0;
|
||||
const percentage = (count / maxPostCount) * 100;
|
||||
return (
|
||||
<div key={`post-${year}`} className="distribution-row">
|
||||
<span className="distribution-year">{year}</span>
|
||||
<div className="distribution-bar-container">
|
||||
<div
|
||||
className="distribution-bar distribution-bar-posts"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="distribution-count">{count || '-'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="distribution-column">
|
||||
<div className="distribution-header">
|
||||
<span className="distribution-label">Media</span>
|
||||
<span className="distribution-total">{totalMedia} total</span>
|
||||
</div>
|
||||
<div className="distribution-bars">
|
||||
{allYears.map(year => {
|
||||
const count = distribution.media[year] || 0;
|
||||
const percentage = (count / maxMediaCount) * 100;
|
||||
return (
|
||||
<div key={`media-${year}`} className="distribution-row">
|
||||
<span className="distribution-year">{year}</span>
|
||||
<div className="distribution-bar-container">
|
||||
<div
|
||||
className="distribution-bar distribution-bar-media"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="distribution-count">{count || '-'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to format post metadata for tooltip (new post from WXR)
|
||||
function formatPostTooltip(wxrPost: AnalyzedPostItem['wxrPost']): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
16
src/renderer/types/electron.d.ts
vendored
16
src/renderer/types/electron.d.ts
vendored
@@ -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<unknown>;
|
||||
analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>;
|
||||
selectUploadsFolder: () => Promise<string | null>;
|
||||
execute: (reportJson: string, uploadsFolder?: string) => Promise<ImportExecuteResult>;
|
||||
onProgress: (callback: (data: { step: string; detail?: string }) => void) => () => void;
|
||||
onExecutionProgress: (callback: (data: ImportExecutionProgress) => void) => () => void;
|
||||
};
|
||||
importDefinitions: {
|
||||
create: (name?: string) => Promise<ImportDefinitionData>;
|
||||
|
||||
608
tests/assets/import-test-cases.wxr
Normal file
608
tests/assets/import-test-cases.wxr
Normal file
@@ -0,0 +1,608 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
IMPORT TEST CASES WXR FILE
|
||||
==========================
|
||||
|
||||
This file contains WordPress export data designed to test all aspects of the
|
||||
ImportExecutionEngine. Each element is documented with its test purpose.
|
||||
|
||||
TEST CATEGORIES:
|
||||
================
|
||||
|
||||
1. HTML TO MARKDOWN CONVERSION
|
||||
- Post ID 101: Basic text formatting (bold, italic, underline, strikethrough)
|
||||
- Post ID 102: Headings (h1-h6)
|
||||
- Post ID 103: Lists (ordered, unordered, nested)
|
||||
- Post ID 104: Links and images
|
||||
- Post ID 105: Code blocks (inline and fenced)
|
||||
- Post ID 106: Blockquotes
|
||||
- Post ID 107: Tables
|
||||
|
||||
2. WORDPRESS SHORTCODE/MACRO CONVERSION
|
||||
- Post ID 201: [gallery] shortcode → [[gallery]] macro
|
||||
- Post ID 202: [video] shortcode with attributes → [[video]] macro
|
||||
- Post ID 203: Multiple shortcodes in one post
|
||||
- Post ID 204: Self-closing shortcodes [shortcode /]
|
||||
- Post ID 205: Nested content should NOT be double-converted (already [[ ]])
|
||||
|
||||
3. TAG AND CATEGORY MAPPING
|
||||
- Categories: "Technology", "Web Dev", "Programming"
|
||||
- Tags: "JavaScript", "TypeScript", "React", "nodejs"
|
||||
- Test: Map "Web Dev" to existing "web-development"
|
||||
- Test: Map "nodejs" to existing "node"
|
||||
- Test: Create new tag "react" (doesn't exist)
|
||||
|
||||
4. CONFLICT RESOLUTION - POSTS
|
||||
- Post ID 301: slug "existing-post" → exists → "ignore" (skip import)
|
||||
- Post ID 302: slug "overwrite-me" → exists → "overwrite" (import as draft)
|
||||
- Post ID 303: slug "duplicate-slug" → exists → "import" (new slug)
|
||||
|
||||
5. MEDIA IMPORT
|
||||
- Media ID 401: New image, parent is Post 201
|
||||
- Media ID 402: New image, no parent (standalone)
|
||||
- Media ID 403: conflict "ignore" (existing file)
|
||||
|
||||
6. PAGE IMPORT
|
||||
- Page ID 501: Standard page → becomes post with "page" category
|
||||
- Page ID 502: Page with HTML content for conversion test
|
||||
-->
|
||||
<rss version="2.0"
|
||||
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<title>Test Blog for Import</title>
|
||||
<link>https://testblog.example.com</link>
|
||||
<description>A comprehensive test blog for WXR import testing</description>
|
||||
<language>en-US</language>
|
||||
|
||||
<!-- ======================================== -->
|
||||
<!-- CATEGORIES (defined at channel level) -->
|
||||
<!-- ======================================== -->
|
||||
|
||||
<wp:category>
|
||||
<wp:term_id>1</wp:term_id>
|
||||
<wp:category_nicename>technology</wp:category_nicename>
|
||||
<wp:category_parent></wp:category_parent>
|
||||
<wp:cat_name><![CDATA[Technology]]></wp:cat_name>
|
||||
</wp:category>
|
||||
|
||||
<wp:category>
|
||||
<wp:term_id>2</wp:term_id>
|
||||
<wp:category_nicename>web-dev</wp:category_nicename>
|
||||
<wp:category_parent>technology</wp:category_parent>
|
||||
<wp:cat_name><![CDATA[Web Dev]]></wp:cat_name>
|
||||
</wp:category>
|
||||
|
||||
<wp:category>
|
||||
<wp:term_id>3</wp:term_id>
|
||||
<wp:category_nicename>programming</wp:category_nicename>
|
||||
<wp:category_parent></wp:category_parent>
|
||||
<wp:cat_name><![CDATA[Programming]]></wp:cat_name>
|
||||
</wp:category>
|
||||
|
||||
<!-- ======================================== -->
|
||||
<!-- TAGS (defined at channel level) -->
|
||||
<!-- ======================================== -->
|
||||
|
||||
<wp:tag>
|
||||
<wp:term_id>10</wp:term_id>
|
||||
<wp:tag_slug>javascript</wp:tag_slug>
|
||||
<wp:tag_name><![CDATA[JavaScript]]></wp:tag_name>
|
||||
</wp:tag>
|
||||
|
||||
<wp:tag>
|
||||
<wp:term_id>11</wp:term_id>
|
||||
<wp:tag_slug>typescript</wp:tag_slug>
|
||||
<wp:tag_name><![CDATA[TypeScript]]></wp:tag_name>
|
||||
</wp:tag>
|
||||
|
||||
<wp:tag>
|
||||
<wp:term_id>12</wp:term_id>
|
||||
<wp:tag_slug>react</wp:tag_slug>
|
||||
<wp:tag_name><![CDATA[React]]></wp:tag_name>
|
||||
</wp:tag>
|
||||
|
||||
<wp:tag>
|
||||
<wp:term_id>13</wp:term_id>
|
||||
<wp:tag_slug>nodejs</wp:tag_slug>
|
||||
<wp:tag_name><![CDATA[nodejs]]></wp:tag_name>
|
||||
</wp:tag>
|
||||
|
||||
<!-- ======================================== -->
|
||||
<!-- SECTION 1: HTML TO MARKDOWN CONVERSION -->
|
||||
<!-- ======================================== -->
|
||||
|
||||
<!-- Post 101: Basic Text Formatting -->
|
||||
<item>
|
||||
<title>HTML Formatting Test: Basic Text Styles</title>
|
||||
<link>https://testblog.example.com/html-formatting-basic/</link>
|
||||
<pubDate>Mon, 01 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="technology"><![CDATA[Technology]]></category>
|
||||
<content:encoded><![CDATA[<p>This paragraph has <strong>bold text</strong> and <em>italic text</em>.</p>
|
||||
<p>Here is <b>another bold</b> using b tag and <i>italic using i tag</i>.</p>
|
||||
<p>Combined: <strong><em>bold and italic together</em></strong>.</p>
|
||||
<p>Some <del>strikethrough text</del> and <s>also this</s>.</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing basic HTML text formatting]]></excerpt:encoded>
|
||||
<wp:post_id>101</wp:post_id>
|
||||
<wp:post_date>2024-01-01 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-01 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-02 12:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-02 12:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>html-formatting-basic</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- Post 102: Headings -->
|
||||
<item>
|
||||
<title>HTML Formatting Test: Headings</title>
|
||||
<link>https://testblog.example.com/html-formatting-headings/</link>
|
||||
<pubDate>Tue, 02 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="technology"><![CDATA[Technology]]></category>
|
||||
<content:encoded><![CDATA[<h1>Heading Level 1</h1>
|
||||
<p>Paragraph after h1.</p>
|
||||
<h2>Heading Level 2</h2>
|
||||
<p>Paragraph after h2.</p>
|
||||
<h3>Heading Level 3</h3>
|
||||
<p>Paragraph after h3.</p>
|
||||
<h4>Heading Level 4</h4>
|
||||
<h5>Heading Level 5</h5>
|
||||
<h6>Heading Level 6</h6>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing heading levels]]></excerpt:encoded>
|
||||
<wp:post_id>102</wp:post_id>
|
||||
<wp:post_date>2024-01-02 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-02 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-02 10:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-02 10:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>html-formatting-headings</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- Post 103: Lists -->
|
||||
<item>
|
||||
<title>HTML Formatting Test: Lists</title>
|
||||
<link>https://testblog.example.com/html-formatting-lists/</link>
|
||||
<pubDate>Wed, 03 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="programming"><![CDATA[Programming]]></category>
|
||||
<content:encoded><![CDATA[<p>Unordered list:</p>
|
||||
<ul>
|
||||
<li>First item</li>
|
||||
<li>Second item</li>
|
||||
<li>Third item</li>
|
||||
</ul>
|
||||
<p>Ordered list:</p>
|
||||
<ol>
|
||||
<li>Step one</li>
|
||||
<li>Step two</li>
|
||||
<li>Step three</li>
|
||||
</ol>
|
||||
<p>Nested list:</p>
|
||||
<ul>
|
||||
<li>Parent item
|
||||
<ul>
|
||||
<li>Child item 1</li>
|
||||
<li>Child item 2</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Another parent</li>
|
||||
</ul>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing list formatting]]></excerpt:encoded>
|
||||
<wp:post_id>103</wp:post_id>
|
||||
<wp:post_date>2024-01-03 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-03 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-03 10:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-03 10:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>html-formatting-lists</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- Post 104: Links and Images -->
|
||||
<item>
|
||||
<title>HTML Formatting Test: Links and Images</title>
|
||||
<link>https://testblog.example.com/html-formatting-links/</link>
|
||||
<pubDate>Thu, 04 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="web-dev"><![CDATA[Web Dev]]></category>
|
||||
<category domain="post_tag" nicename="javascript"><![CDATA[JavaScript]]></category>
|
||||
<content:encoded><![CDATA[<p>Here is a <a href="https://example.com">simple link</a>.</p>
|
||||
<p>Link with title: <a href="https://example.com" title="Example Site">titled link</a>.</p>
|
||||
<p>Image: <img src="https://example.com/image.jpg" alt="Test image" /></p>
|
||||
<p>Image with title: <img src="https://example.com/photo.png" alt="Photo" title="My Photo" /></p>
|
||||
<p>Linked image: <a href="https://example.com"><img src="https://example.com/banner.jpg" alt="Banner" /></a></p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing links and images]]></excerpt:encoded>
|
||||
<wp:post_id>104</wp:post_id>
|
||||
<wp:post_date>2024-01-04 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-04 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-04 10:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-04 10:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>html-formatting-links</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- Post 105: Code Blocks -->
|
||||
<item>
|
||||
<title>HTML Formatting Test: Code Blocks</title>
|
||||
<link>https://testblog.example.com/html-formatting-code/</link>
|
||||
<pubDate>Fri, 05 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="programming"><![CDATA[Programming]]></category>
|
||||
<category domain="post_tag" nicename="javascript"><![CDATA[JavaScript]]></category>
|
||||
<category domain="post_tag" nicename="typescript"><![CDATA[TypeScript]]></category>
|
||||
<content:encoded><![CDATA[<p>Inline code: Use <code>const x = 10;</code> to declare a constant.</p>
|
||||
<p>Code block:</p>
|
||||
<pre><code>function hello() {
|
||||
console.log("Hello World");
|
||||
}
|
||||
hello();</code></pre>
|
||||
<p>Another block with pre only:</p>
|
||||
<pre>Plain preformatted text
|
||||
with multiple lines</pre>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing code formatting]]></excerpt:encoded>
|
||||
<wp:post_id>105</wp:post_id>
|
||||
<wp:post_date>2024-01-05 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-05 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-06 09:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-06 09:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>html-formatting-code</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- Post 106: Blockquotes -->
|
||||
<item>
|
||||
<title>HTML Formatting Test: Blockquotes</title>
|
||||
<link>https://testblog.example.com/html-formatting-quotes/</link>
|
||||
<pubDate>Sat, 06 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="technology"><![CDATA[Technology]]></category>
|
||||
<content:encoded><![CDATA[<p>A famous quote:</p>
|
||||
<blockquote>
|
||||
<p>The only way to do great work is to love what you do.</p>
|
||||
<p>- Steve Jobs</p>
|
||||
</blockquote>
|
||||
<p>Nested blockquote:</p>
|
||||
<blockquote>
|
||||
<p>Outer quote</p>
|
||||
<blockquote>
|
||||
<p>Inner quote</p>
|
||||
</blockquote>
|
||||
</blockquote>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing blockquote formatting]]></excerpt:encoded>
|
||||
<wp:post_id>106</wp:post_id>
|
||||
<wp:post_date>2024-01-06 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-06 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-06 10:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-06 10:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>html-formatting-quotes</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- ======================================== -->
|
||||
<!-- SECTION 2: SHORTCODE/MACRO CONVERSION -->
|
||||
<!-- ======================================== -->
|
||||
|
||||
<!-- Post 201: Gallery Shortcode -->
|
||||
<item>
|
||||
<title>Shortcode Test: Gallery</title>
|
||||
<link>https://testblog.example.com/shortcode-gallery/</link>
|
||||
<pubDate>Mon, 15 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="technology"><![CDATA[Technology]]></category>
|
||||
<category domain="post_tag" nicename="react"><![CDATA[React]]></category>
|
||||
<content:encoded><![CDATA[<p>Check out this gallery:</p>
|
||||
[gallery ids="1,2,3" columns="3"]
|
||||
<p>Pretty nice, right?</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing gallery shortcode conversion]]></excerpt:encoded>
|
||||
<wp:post_id>201</wp:post_id>
|
||||
<wp:post_date>2024-01-15 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-15 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-15 10:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-15 10:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>shortcode-gallery</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- Post 202: Video Shortcode -->
|
||||
<item>
|
||||
<title>Shortcode Test: Video</title>
|
||||
<link>https://testblog.example.com/shortcode-video/</link>
|
||||
<pubDate>Tue, 16 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="technology"><![CDATA[Technology]]></category>
|
||||
<content:encoded><![CDATA[<p>Watch this video:</p>
|
||||
[video src="https://example.com/video.mp4" width="640" height="360"]
|
||||
<p>Hope you enjoyed it!</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing video shortcode conversion]]></excerpt:encoded>
|
||||
<wp:post_id>202</wp:post_id>
|
||||
<wp:post_date>2024-01-16 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-16 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-16 10:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-16 10:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>shortcode-video</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- Post 203: Multiple Shortcodes -->
|
||||
<item>
|
||||
<title>Shortcode Test: Multiple Macros</title>
|
||||
<link>https://testblog.example.com/shortcode-multiple/</link>
|
||||
<pubDate>Wed, 17 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="web-dev"><![CDATA[Web Dev]]></category>
|
||||
<category domain="post_tag" nicename="javascript"><![CDATA[JavaScript]]></category>
|
||||
<category domain="post_tag" nicename="nodejs"><![CDATA[nodejs]]></category>
|
||||
<content:encoded><![CDATA[<p>Multiple shortcodes in one post:</p>
|
||||
[audio src="https://example.com/podcast.mp3"]
|
||||
<p>And here's a gallery:</p>
|
||||
[gallery ids="10,20,30"]
|
||||
<p>With an embed:</p>
|
||||
[embed]https://youtube.com/watch?v=abc123[/embed]
|
||||
<p>End of post.</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing multiple shortcodes]]></excerpt:encoded>
|
||||
<wp:post_id>203</wp:post_id>
|
||||
<wp:post_date>2024-01-17 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-17 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-17 10:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-17 10:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>shortcode-multiple</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- Post 204: Self-Closing Shortcodes -->
|
||||
<item>
|
||||
<title>Shortcode Test: Self-Closing</title>
|
||||
<link>https://testblog.example.com/shortcode-selfclose/</link>
|
||||
<pubDate>Thu, 18 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="programming"><![CDATA[Programming]]></category>
|
||||
<content:encoded><![CDATA[<p>Self-closing shortcodes:</p>
|
||||
[divider /]
|
||||
<p>Another one:</p>
|
||||
[spacer height="20" /]
|
||||
<p>Done.</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing self-closing shortcodes]]></excerpt:encoded>
|
||||
<wp:post_id>204</wp:post_id>
|
||||
<wp:post_date>2024-01-18 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-18 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-18 10:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-18 10:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>shortcode-selfclose</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- Post 205: Already Double-Bracketed (should NOT convert) -->
|
||||
<item>
|
||||
<title>Shortcode Test: Already Converted</title>
|
||||
<link>https://testblog.example.com/shortcode-already/</link>
|
||||
<pubDate>Fri, 19 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="technology"><![CDATA[Technology]]></category>
|
||||
<content:encoded><![CDATA[<p>This is already in our format:</p>
|
||||
[[gallery ids="1,2,3"]]
|
||||
<p>Mixed content:</p>
|
||||
[video src="new.mp4"]
|
||||
<p>And already converted:</p>
|
||||
[[audio src="podcast.mp3"]]]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing already-converted macros]]></excerpt:encoded>
|
||||
<wp:post_id>205</wp:post_id>
|
||||
<wp:post_date>2024-01-19 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-19 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-19 10:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-19 10:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>shortcode-already</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- ======================================== -->
|
||||
<!-- SECTION 3: CONFLICT RESOLUTION - POSTS -->
|
||||
<!-- ======================================== -->
|
||||
|
||||
<!-- Post 301: Conflict → Ignore -->
|
||||
<item>
|
||||
<title>Conflict Test: Ignore Resolution</title>
|
||||
<link>https://testblog.example.com/existing-post/</link>
|
||||
<pubDate>Mon, 22 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="technology"><![CDATA[Technology]]></category>
|
||||
<content:encoded><![CDATA[<p>This post has slug "existing-post" which already exists in the project.</p>
|
||||
<p>The conflict resolution is set to "ignore" so this should be skipped.</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing ignore resolution]]></excerpt:encoded>
|
||||
<wp:post_id>301</wp:post_id>
|
||||
<wp:post_date>2024-01-22 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-22 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-22 10:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-22 10:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>existing-post</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- Post 302: Conflict → Overwrite -->
|
||||
<item>
|
||||
<title>Conflict Test: Overwrite Resolution</title>
|
||||
<link>https://testblog.example.com/overwrite-me/</link>
|
||||
<pubDate>Tue, 23 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="programming"><![CDATA[Programming]]></category>
|
||||
<category domain="post_tag" nicename="typescript"><![CDATA[TypeScript]]></category>
|
||||
<content:encoded><![CDATA[<p>This post has slug "overwrite-me" which exists.</p>
|
||||
<p>The conflict resolution is "overwrite" so it should be imported as a <strong>draft</strong>.</p>
|
||||
<p>The draft will have the same slug for review.</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing overwrite resolution]]></excerpt:encoded>
|
||||
<wp:post_id>302</wp:post_id>
|
||||
<wp:post_date>2024-01-23 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-23 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-23 15:30:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-23 15:30:00</wp:post_modified_gmt>
|
||||
<wp:post_name>overwrite-me</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- Post 303: Conflict → Import (new slug) -->
|
||||
<item>
|
||||
<title>Conflict Test: Import as New</title>
|
||||
<link>https://testblog.example.com/duplicate-slug/</link>
|
||||
<pubDate>Wed, 24 Jan 2024 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<category domain="category" nicename="web-dev"><![CDATA[Web Dev]]></category>
|
||||
<content:encoded><![CDATA[<p>This post has slug "duplicate-slug" which exists.</p>
|
||||
<p>The conflict resolution is "import" so it should get a <strong>new unique slug</strong>.</p>
|
||||
<p>The new slug will be generated from the title.</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Testing import as new resolution]]></excerpt:encoded>
|
||||
<wp:post_id>303</wp:post_id>
|
||||
<wp:post_date>2024-01-24 10:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-24 10:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-24 10:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-24 10:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>duplicate-slug</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- ======================================== -->
|
||||
<!-- SECTION 4: MEDIA IMPORT -->
|
||||
<!-- ======================================== -->
|
||||
|
||||
<!-- Media 401: New image with parent post -->
|
||||
<item>
|
||||
<title>gallery-image-1</title>
|
||||
<link>https://testblog.example.com/gallery-image-1/</link>
|
||||
<pubDate>Mon, 15 Jan 2024 09:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<content:encoded><![CDATA[A beautiful sunset over the mountains]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>401</wp:post_id>
|
||||
<wp:post_date>2024-01-15 09:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-15 09:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-15 09:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-15 09:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>gallery-image-1</wp:post_name>
|
||||
<wp:status>inherit</wp:status>
|
||||
<wp:post_type>attachment</wp:post_type>
|
||||
<wp:post_parent>201</wp:post_parent>
|
||||
<wp:attachment_url>https://testblog.example.com/wp-content/uploads/2024/01/sunset.jpg</wp:attachment_url>
|
||||
</item>
|
||||
|
||||
<!-- Media 402: Standalone image (no parent) -->
|
||||
<item>
|
||||
<title>standalone-logo</title>
|
||||
<link>https://testblog.example.com/standalone-logo/</link>
|
||||
<pubDate>Tue, 02 Jan 2024 08:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<content:encoded><![CDATA[Site logo image]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>402</wp:post_id>
|
||||
<wp:post_date>2024-01-02 08:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-02 08:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-02 08:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-02 08:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>standalone-logo</wp:post_name>
|
||||
<wp:status>inherit</wp:status>
|
||||
<wp:post_type>attachment</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
<wp:attachment_url>https://testblog.example.com/wp-content/uploads/2024/01/logo.png</wp:attachment_url>
|
||||
</item>
|
||||
|
||||
<!-- Media 403: Conflict ignore -->
|
||||
<item>
|
||||
<title>existing-image</title>
|
||||
<link>https://testblog.example.com/existing-image/</link>
|
||||
<pubDate>Wed, 03 Jan 2024 08:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<content:encoded><![CDATA[Image that already exists]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>403</wp:post_id>
|
||||
<wp:post_date>2024-01-03 08:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-03 08:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-03 08:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-03 08:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>existing-image</wp:post_name>
|
||||
<wp:status>inherit</wp:status>
|
||||
<wp:post_type>attachment</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
<wp:attachment_url>https://testblog.example.com/wp-content/uploads/2024/01/existing.jpg</wp:attachment_url>
|
||||
</item>
|
||||
|
||||
<!-- ======================================== -->
|
||||
<!-- SECTION 5: PAGE IMPORT -->
|
||||
<!-- ======================================== -->
|
||||
|
||||
<!-- Page 501: Standard page -->
|
||||
<item>
|
||||
<title>About This Blog</title>
|
||||
<link>https://testblog.example.com/about/</link>
|
||||
<pubDate>Sun, 01 Jan 2024 08:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[testauthor]]></dc:creator>
|
||||
<content:encoded><![CDATA[<h2>About</h2>
|
||||
<p>Welcome to my blog. This is a page, not a post.</p>
|
||||
<p>Pages should be imported as posts with the "page" category.</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[About page]]></excerpt:encoded>
|
||||
<wp:post_id>501</wp:post_id>
|
||||
<wp:post_date>2024-01-01 08:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-01 08:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-10 12:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-10 12:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>about</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>page</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
<!-- Page 502: Page with complex HTML -->
|
||||
<item>
|
||||
<title>Contact Information</title>
|
||||
<link>https://testblog.example.com/contact/</link>
|
||||
<pubDate>Sun, 01 Jan 2024 09:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<content:encoded><![CDATA[<h2>Contact Us</h2>
|
||||
<p>Reach out through the following channels:</p>
|
||||
<ul>
|
||||
<li>Email: <a href="mailto:test@example.com">test@example.com</a></li>
|
||||
<li>Twitter: <a href="https://twitter.com/test">@test</a></li>
|
||||
</ul>
|
||||
<h3>Office Hours</h3>
|
||||
<p>Monday to Friday, 9am-5pm.</p>
|
||||
[contact_form id="1"]]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Contact us]]></excerpt:encoded>
|
||||
<wp:post_id>502</wp:post_id>
|
||||
<wp:post_date>2024-01-01 09:00:00</wp:post_date>
|
||||
<wp:post_date_gmt>2024-01-01 09:00:00</wp:post_date_gmt>
|
||||
<wp:post_modified>2024-01-15 16:00:00</wp:post_modified>
|
||||
<wp:post_modified_gmt>2024-01-15 16:00:00</wp:post_modified_gmt>
|
||||
<wp:post_name>contact</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>page</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
|
||||
</channel>
|
||||
</rss>
|
||||
1378
tests/engine/ImportExecutionEngine.e2e.test.ts
Normal file
1378
tests/engine/ImportExecutionEngine.e2e.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1549
tests/engine/ImportExecutionEngine.test.ts
Normal file
1549
tests/engine/ImportExecutionEngine.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -393,6 +393,168 @@ export function createMockDropboxConflict(overrides?: Partial<DropboxConflict>):
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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>): 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>): WxrPost {
|
||||
const id = wxrPostIdCounter++;
|
||||
return {
|
||||
wpId: id,
|
||||
title: `Test Post ${id}`,
|
||||
slug: `test-post-${id}`,
|
||||
status: 'publish',
|
||||
content: `<p>Test content for post ${id}</p>`,
|
||||
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>): 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<AnalyzedPost>,
|
||||
wxrOverrides?: Partial<WxrPost>
|
||||
): 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<AnalyzedMedia>,
|
||||
wxrOverrides?: Partial<WxrMedia>
|
||||
): AnalyzedMedia {
|
||||
const wxrMedia = createMockWxrMedia(wxrOverrides);
|
||||
return {
|
||||
wxrMedia,
|
||||
status: 'new' as MediaAnalysisStatus,
|
||||
fileHash: `filehash-${wxrMedia.wpId}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockAnalyzedCategory(overrides?: Partial<AnalyzedCategory>): AnalyzedCategory {
|
||||
return {
|
||||
name: 'Test Category',
|
||||
slug: 'test-category',
|
||||
existsInProject: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockAnalyzedTag(overrides?: Partial<AnalyzedTag>): AnalyzedTag {
|
||||
return {
|
||||
name: 'test-tag',
|
||||
slug: 'test-tag',
|
||||
existsInProject: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockImportAnalysisReport(
|
||||
overrides?: Partial<ImportAnalysisReport>
|
||||
): 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;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
||||
Reference in New Issue
Block a user