feat: importer starting point
This commit is contained in:
26
package-lock.json
generated
26
package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"@milkdown/react": "^7.18.0",
|
||||
"@milkdown/theme-nord": "^7.18.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@xmldom/xmldom": "^0.8.11",
|
||||
"chokidar": "^5.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
@@ -34,6 +35,7 @@
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"sharp": "^0.34.5",
|
||||
"snowball-stemmers": "^0.6.0",
|
||||
"turndown": "^7.2.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
@@ -45,6 +47,7 @@
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
@@ -3844,6 +3847,12 @@
|
||||
"nanoid": "^5.0.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@mixmark-io/domino": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||
@@ -4690,6 +4699,13 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/turndown": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
|
||||
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -5038,7 +5054,6 @@
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -12804,6 +12819,15 @@
|
||||
"@esbuild/win32-x64": "0.27.3"
|
||||
}
|
||||
},
|
||||
"node_modules/turndown": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
|
||||
"integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mixmark-io/domino": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
@@ -66,6 +67,7 @@
|
||||
"@milkdown/react": "^7.18.0",
|
||||
"@milkdown/theme-nord": "^7.18.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@xmldom/xmldom": "^0.8.11",
|
||||
"chokidar": "^5.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
@@ -78,6 +80,7 @@
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"sharp": "^0.34.5",
|
||||
"snowball-stemmers": "^0.6.0",
|
||||
"turndown": "^7.2.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
|
||||
331
src/main/engine/ImportAnalysisEngine.ts
Normal file
331
src/main/engine/ImportAnalysisEngine.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import crypto from 'crypto';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import TurndownService from 'turndown';
|
||||
import { getDatabase } from '../database';
|
||||
import { posts, media, tags } from '../database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { WxrData, WxrPost, WxrMedia, WxrSiteInfo, WxrCategory, WxrTag } from './WxrParser';
|
||||
|
||||
export type PostAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate';
|
||||
export type MediaAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate' | 'missing';
|
||||
|
||||
export interface AnalyzedPost {
|
||||
wxrPost: WxrPost;
|
||||
status: PostAnalysisStatus;
|
||||
contentHash: string;
|
||||
markdownPreview: string;
|
||||
existingPost?: {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
checksum: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnalyzedMedia {
|
||||
wxrMedia: WxrMedia;
|
||||
status: MediaAnalysisStatus;
|
||||
fileHash: string | null;
|
||||
existingMedia?: {
|
||||
id: string;
|
||||
originalName: string;
|
||||
checksum: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnalyzedCategory {
|
||||
name: string;
|
||||
slug: string;
|
||||
existsInProject: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyzedTag {
|
||||
name: string;
|
||||
slug: string;
|
||||
existsInProject: boolean;
|
||||
}
|
||||
|
||||
export interface ImportAnalysisReport {
|
||||
sourceFile: string;
|
||||
site: WxrSiteInfo;
|
||||
analyzedAt: Date;
|
||||
posts: {
|
||||
total: number;
|
||||
new: number;
|
||||
updates: number;
|
||||
conflicts: number;
|
||||
contentDuplicates: number;
|
||||
items: AnalyzedPost[];
|
||||
};
|
||||
pages: {
|
||||
total: number;
|
||||
new: number;
|
||||
updates: number;
|
||||
conflicts: number;
|
||||
contentDuplicates: number;
|
||||
items: AnalyzedPost[];
|
||||
};
|
||||
media: {
|
||||
total: number;
|
||||
new: number;
|
||||
updates: number;
|
||||
conflicts: number;
|
||||
contentDuplicates: number;
|
||||
missing: number;
|
||||
items: AnalyzedMedia[];
|
||||
};
|
||||
categories: AnalyzedCategory[];
|
||||
tags: AnalyzedTag[];
|
||||
}
|
||||
|
||||
export class ImportAnalysisEngine {
|
||||
private currentProjectId: string = '';
|
||||
private turndown: TurndownService;
|
||||
|
||||
constructor() {
|
||||
this.turndown = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
bulletListMarker: '-',
|
||||
});
|
||||
}
|
||||
|
||||
setProjectContext(projectId: string): void {
|
||||
this.currentProjectId = projectId;
|
||||
}
|
||||
|
||||
async analyzeWxr(wxrData: WxrData, sourceFile: string, uploadsFolder?: string): Promise<ImportAnalysisReport> {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
// Fetch existing posts for this project
|
||||
const existingPosts = await db
|
||||
.select({
|
||||
id: posts.id,
|
||||
slug: posts.slug,
|
||||
title: posts.title,
|
||||
checksum: posts.checksum,
|
||||
})
|
||||
.from(posts)
|
||||
.where(eq(posts.projectId, this.currentProjectId))
|
||||
.all();
|
||||
|
||||
// Fetch existing media for this project
|
||||
const existingMedia = await db
|
||||
.select({
|
||||
id: media.id,
|
||||
originalName: media.originalName,
|
||||
checksum: media.checksum,
|
||||
})
|
||||
.from(media)
|
||||
.where(eq(media.projectId, this.currentProjectId))
|
||||
.all();
|
||||
|
||||
// Fetch existing tags for this project
|
||||
const existingTags = await db
|
||||
.select({
|
||||
name: tags.name,
|
||||
})
|
||||
.from(tags)
|
||||
.where(eq(tags.projectId, this.currentProjectId))
|
||||
.all();
|
||||
|
||||
// Build lookup maps for posts
|
||||
const slugToPost = new Map<string, typeof existingPosts[0]>();
|
||||
const checksumToPost = new Map<string, typeof existingPosts[0]>();
|
||||
for (const post of existingPosts) {
|
||||
slugToPost.set(post.slug, post);
|
||||
if (post.checksum) {
|
||||
checksumToPost.set(post.checksum, post);
|
||||
}
|
||||
}
|
||||
|
||||
// Build lookup maps for media
|
||||
const nameToMedia = new Map<string, typeof existingMedia[0]>();
|
||||
const checksumToMedia = new Map<string, typeof existingMedia[0]>();
|
||||
for (const m of existingMedia) {
|
||||
nameToMedia.set(m.originalName.toLowerCase(), m);
|
||||
if (m.checksum) {
|
||||
checksumToMedia.set(m.checksum, m);
|
||||
}
|
||||
}
|
||||
|
||||
// Build tag set
|
||||
const existingTagNames = new Set(existingTags.map(t => t.name.toLowerCase()));
|
||||
|
||||
// Analyze posts
|
||||
const analyzedPosts = this.analyzePostItems(wxrData.posts, slugToPost, checksumToPost);
|
||||
const analyzedPages = this.analyzePostItems(wxrData.pages, slugToPost, checksumToPost);
|
||||
|
||||
// Analyze media
|
||||
const analyzedMedia = await this.analyzeMediaItems(wxrData.media, nameToMedia, checksumToMedia, uploadsFolder);
|
||||
|
||||
// Analyze categories
|
||||
const analyzedCategories: AnalyzedCategory[] = wxrData.categories.map(cat => ({
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
existsInProject: existingTagNames.has(cat.name.toLowerCase()),
|
||||
}));
|
||||
|
||||
// Analyze tags
|
||||
const analyzedTags: AnalyzedTag[] = wxrData.tags.map(tag => ({
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
existsInProject: existingTagNames.has(tag.name.toLowerCase()),
|
||||
}));
|
||||
|
||||
return {
|
||||
sourceFile,
|
||||
site: wxrData.site,
|
||||
analyzedAt: new Date(),
|
||||
posts: this.summarizePostAnalysis(analyzedPosts),
|
||||
pages: this.summarizePostAnalysis(analyzedPages),
|
||||
media: this.summarizeMediaAnalysis(analyzedMedia),
|
||||
categories: analyzedCategories,
|
||||
tags: analyzedTags,
|
||||
};
|
||||
}
|
||||
|
||||
private analyzePostItems(
|
||||
wxrPosts: WxrPost[],
|
||||
slugToPost: Map<string, { id: string; slug: string; title: string; checksum: string | null }>,
|
||||
checksumToPost: Map<string, { id: string; slug: string; title: string; checksum: string | null }>,
|
||||
): AnalyzedPost[] {
|
||||
return wxrPosts.map(wxrPost => {
|
||||
const markdown = this.convertToMarkdown(wxrPost.content);
|
||||
const contentHash = this.calculateChecksum(markdown);
|
||||
const markdownPreview = markdown.substring(0, 200);
|
||||
|
||||
const existingBySlug = slugToPost.get(wxrPost.slug);
|
||||
const existingByHash = checksumToPost.get(contentHash);
|
||||
|
||||
let status: PostAnalysisStatus;
|
||||
let existingPost: AnalyzedPost['existingPost'];
|
||||
|
||||
if (existingBySlug) {
|
||||
if (existingBySlug.checksum === contentHash) {
|
||||
status = 'update';
|
||||
} else {
|
||||
status = 'conflict';
|
||||
}
|
||||
existingPost = {
|
||||
id: existingBySlug.id,
|
||||
title: existingBySlug.title,
|
||||
slug: existingBySlug.slug,
|
||||
checksum: existingBySlug.checksum,
|
||||
};
|
||||
} else if (existingByHash) {
|
||||
status = 'content-duplicate';
|
||||
existingPost = {
|
||||
id: existingByHash.id,
|
||||
title: existingByHash.title,
|
||||
slug: existingByHash.slug,
|
||||
checksum: existingByHash.checksum,
|
||||
};
|
||||
} else {
|
||||
status = 'new';
|
||||
}
|
||||
|
||||
return { wxrPost, status, contentHash, markdownPreview, existingPost };
|
||||
});
|
||||
}
|
||||
|
||||
private async analyzeMediaItems(
|
||||
wxrMediaItems: WxrMedia[],
|
||||
nameToMedia: Map<string, { id: string; originalName: string; checksum: string | null }>,
|
||||
checksumToMedia: Map<string, { id: string; originalName: string; checksum: string | null }>,
|
||||
uploadsFolder?: string,
|
||||
): Promise<AnalyzedMedia[]> {
|
||||
const results: AnalyzedMedia[] = [];
|
||||
|
||||
for (const wxrMedia of wxrMediaItems) {
|
||||
let fileHash: string | null = null;
|
||||
let fileFound = false;
|
||||
|
||||
// Try to read the actual file from the uploads folder
|
||||
if (uploadsFolder) {
|
||||
try {
|
||||
const filePath = path.join(uploadsFolder, wxrMedia.relativePath);
|
||||
const buffer = await fs.readFile(filePath);
|
||||
fileHash = this.calculateChecksum(buffer.toString('binary'));
|
||||
fileFound = true;
|
||||
} catch {
|
||||
// File not found in uploads folder
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileFound) {
|
||||
results.push({
|
||||
wxrMedia,
|
||||
status: 'missing',
|
||||
fileHash: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingByName = nameToMedia.get(wxrMedia.filename.toLowerCase());
|
||||
const existingByHash = fileHash ? checksumToMedia.get(fileHash) : undefined;
|
||||
|
||||
let status: MediaAnalysisStatus;
|
||||
let existingMedia: AnalyzedMedia['existingMedia'];
|
||||
|
||||
if (existingByName) {
|
||||
if (fileHash && existingByName.checksum === fileHash) {
|
||||
status = 'update';
|
||||
} else {
|
||||
status = 'conflict';
|
||||
}
|
||||
existingMedia = {
|
||||
id: existingByName.id,
|
||||
originalName: existingByName.originalName,
|
||||
checksum: existingByName.checksum,
|
||||
};
|
||||
} else if (existingByHash) {
|
||||
status = 'content-duplicate';
|
||||
existingMedia = {
|
||||
id: existingByHash.id,
|
||||
originalName: existingByHash.originalName,
|
||||
checksum: existingByHash.checksum,
|
||||
};
|
||||
} else {
|
||||
status = 'new';
|
||||
}
|
||||
|
||||
results.push({ wxrMedia, status, fileHash, existingMedia });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private summarizePostAnalysis(items: AnalyzedPost[]): ImportAnalysisReport['posts'] {
|
||||
return {
|
||||
total: items.length,
|
||||
new: items.filter(i => i.status === 'new').length,
|
||||
updates: items.filter(i => i.status === 'update').length,
|
||||
conflicts: items.filter(i => i.status === 'conflict').length,
|
||||
contentDuplicates: items.filter(i => i.status === 'content-duplicate').length,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
private summarizeMediaAnalysis(items: AnalyzedMedia[]): ImportAnalysisReport['media'] {
|
||||
return {
|
||||
total: items.length,
|
||||
new: items.filter(i => i.status === 'new').length,
|
||||
updates: items.filter(i => i.status === 'update').length,
|
||||
conflicts: items.filter(i => i.status === 'conflict').length,
|
||||
contentDuplicates: items.filter(i => i.status === 'content-duplicate').length,
|
||||
missing: items.filter(i => i.status === 'missing').length,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
private convertToMarkdown(html: string): string {
|
||||
if (!html || !html.trim()) return '';
|
||||
return this.turndown.turndown(html);
|
||||
}
|
||||
|
||||
private calculateChecksum(content: string): string {
|
||||
return crypto.createHash('md5').update(content).digest('hex');
|
||||
}
|
||||
}
|
||||
307
src/main/engine/WxrParser.ts
Normal file
307
src/main/engine/WxrParser.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { DOMParser } from '@xmldom/xmldom';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
export interface WxrSiteInfo {
|
||||
title: string;
|
||||
link: string;
|
||||
description: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface WxrPost {
|
||||
wpId: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
excerpt: string;
|
||||
pubDate: Date | null;
|
||||
creator: string;
|
||||
status: string;
|
||||
postType: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface WxrMedia {
|
||||
wpId: number;
|
||||
title: string;
|
||||
url: string;
|
||||
filename: string;
|
||||
relativePath: string;
|
||||
pubDate: Date | null;
|
||||
parentId: number;
|
||||
mimeType: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface WxrCategory {
|
||||
name: string;
|
||||
slug: string;
|
||||
parent: string;
|
||||
}
|
||||
|
||||
export interface WxrTag {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface WxrData {
|
||||
site: WxrSiteInfo;
|
||||
posts: WxrPost[];
|
||||
pages: WxrPost[];
|
||||
media: WxrMedia[];
|
||||
categories: WxrCategory[];
|
||||
tags: WxrTag[];
|
||||
}
|
||||
|
||||
// WordPress namespace URIs
|
||||
const NS = {
|
||||
wp: 'http://wordpress.org/export/1.2/',
|
||||
content: 'http://purl.org/rss/1.0/modules/content/',
|
||||
excerpt: 'http://wordpress.org/export/1.2/excerpt/',
|
||||
dc: 'http://purl.org/dc/elements/1.1/',
|
||||
};
|
||||
|
||||
// Common MIME types by file extension
|
||||
const EXT_TO_MIME: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
bmp: 'image/bmp',
|
||||
ico: 'image/x-icon',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
zip: 'application/zip',
|
||||
};
|
||||
|
||||
export class WxrParser {
|
||||
|
||||
async parseFile(filePath: string): Promise<WxrData> {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return this.parseXml(content);
|
||||
}
|
||||
|
||||
parseXml(xmlContent: string): WxrData {
|
||||
const doc = new DOMParser().parseFromString(xmlContent, 'text/xml');
|
||||
const channel = doc.getElementsByTagName('channel')[0];
|
||||
|
||||
if (!channel) {
|
||||
throw new Error('Invalid WXR file: no <channel> element found');
|
||||
}
|
||||
|
||||
const site = this.parseSiteInfo(channel);
|
||||
const categories = this.parseChannelCategories(channel);
|
||||
const tags = this.parseChannelTags(channel);
|
||||
|
||||
const posts: WxrPost[] = [];
|
||||
const pages: WxrPost[] = [];
|
||||
const media: WxrMedia[] = [];
|
||||
|
||||
const items = channel.getElementsByTagName('item');
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const postType = this.getElementText(item, 'post_type', NS.wp);
|
||||
|
||||
if (postType === 'attachment') {
|
||||
media.push(this.parseMediaItem(item));
|
||||
} else if (postType === 'page') {
|
||||
pages.push(this.parsePostItem(item));
|
||||
} else {
|
||||
// 'post' and any other custom post types
|
||||
posts.push(this.parsePostItem(item));
|
||||
}
|
||||
}
|
||||
|
||||
return { site, posts, pages, media, categories, tags };
|
||||
}
|
||||
|
||||
private parseSiteInfo(channel: Element): WxrSiteInfo {
|
||||
return {
|
||||
title: this.getDirectChildText(channel, 'title'),
|
||||
link: this.getDirectChildText(channel, 'link'),
|
||||
description: this.getDirectChildText(channel, 'description'),
|
||||
language: this.getDirectChildText(channel, 'language'),
|
||||
};
|
||||
}
|
||||
|
||||
private parseChannelCategories(channel: Element): WxrCategory[] {
|
||||
const categories: WxrCategory[] = [];
|
||||
const elements = channel.getElementsByTagNameNS(NS.wp, 'category');
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
// Only process direct children of channel (not item-level category elements)
|
||||
if (el.parentNode !== channel) continue;
|
||||
|
||||
categories.push({
|
||||
name: this.getElementText(el, 'cat_name', NS.wp),
|
||||
slug: this.getElementText(el, 'category_nicename', NS.wp),
|
||||
parent: this.getElementText(el, 'category_parent', NS.wp),
|
||||
});
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
private parseChannelTags(channel: Element): WxrTag[] {
|
||||
const tags: WxrTag[] = [];
|
||||
const elements = channel.getElementsByTagNameNS(NS.wp, 'tag');
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
if (el.parentNode !== channel) continue;
|
||||
|
||||
tags.push({
|
||||
name: this.getElementText(el, 'tag_name', NS.wp),
|
||||
slug: this.getElementText(el, 'tag_slug', NS.wp),
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
private parsePostItem(item: Element): WxrPost {
|
||||
const categories: string[] = [];
|
||||
const tags: string[] = [];
|
||||
|
||||
// Item-level <category> elements (no namespace)
|
||||
const catElements = item.getElementsByTagName('category');
|
||||
for (let i = 0; i < catElements.length; i++) {
|
||||
const el = catElements[i];
|
||||
// Only direct children of item
|
||||
if (el.parentNode !== item) continue;
|
||||
const domain = el.getAttribute('domain');
|
||||
const text = this.getTextContent(el);
|
||||
if (domain === 'category' && text) {
|
||||
categories.push(text);
|
||||
} else if (domain === 'post_tag' && text) {
|
||||
tags.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
const pubDateStr = this.getDirectChildText(item, 'pubDate');
|
||||
let pubDate: Date | null = null;
|
||||
if (pubDateStr) {
|
||||
const parsed = new Date(pubDateStr);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
pubDate = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wpId: parseInt(this.getElementText(item, 'post_id', NS.wp) || '0', 10),
|
||||
title: this.getDirectChildText(item, 'title'),
|
||||
slug: this.getElementText(item, 'post_name', NS.wp),
|
||||
content: this.getElementText(item, 'encoded', NS.content),
|
||||
excerpt: this.getElementText(item, 'encoded', NS.excerpt),
|
||||
pubDate,
|
||||
creator: this.getElementText(item, 'creator', NS.dc),
|
||||
status: this.getElementText(item, 'status', NS.wp),
|
||||
postType: this.getElementText(item, 'post_type', NS.wp),
|
||||
categories,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
private parseMediaItem(item: Element): WxrMedia {
|
||||
const url = this.getElementText(item, 'attachment_url', NS.wp);
|
||||
const filename = this.extractFilename(url);
|
||||
const relativePath = this.extractRelativePath(url);
|
||||
|
||||
const pubDateStr = this.getDirectChildText(item, 'pubDate');
|
||||
let pubDate: Date | null = null;
|
||||
if (pubDateStr) {
|
||||
const parsed = new Date(pubDateStr);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
pubDate = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wpId: parseInt(this.getElementText(item, 'post_id', NS.wp) || '0', 10),
|
||||
title: this.getDirectChildText(item, 'title'),
|
||||
url,
|
||||
filename,
|
||||
relativePath,
|
||||
pubDate,
|
||||
parentId: parseInt(this.getElementText(item, 'post_parent', NS.wp) || '0', 10),
|
||||
mimeType: this.inferMimeType(filename),
|
||||
description: this.getElementText(item, 'encoded', NS.content),
|
||||
};
|
||||
}
|
||||
|
||||
private extractFilename(url: string): string {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
return pathname.split('/').pop() || '';
|
||||
} catch {
|
||||
return url.split('/').pop() || '';
|
||||
}
|
||||
}
|
||||
|
||||
private extractRelativePath(url: string): string {
|
||||
if (!url) return '';
|
||||
// Extract path after wp-content/uploads/
|
||||
const marker = 'wp-content/uploads/';
|
||||
const idx = url.indexOf(marker);
|
||||
if (idx !== -1) {
|
||||
return url.substring(idx + marker.length);
|
||||
}
|
||||
// Fallback: return filename only
|
||||
return this.extractFilename(url);
|
||||
}
|
||||
|
||||
private inferMimeType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
return EXT_TO_MIME[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/** Get text content of a namespaced child element */
|
||||
private getElementText(parent: Element, localName: string, nsUri: string): string {
|
||||
const elements = parent.getElementsByTagNameNS(nsUri, localName);
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
// Find first one that is either a direct child or a grandchild (for nested structures)
|
||||
if (el.parentNode === parent || el.parentNode?.parentNode === parent) {
|
||||
return this.getTextContent(el);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Get text content of a direct child element (no namespace) */
|
||||
private getDirectChildText(parent: Element, tagName: string): string {
|
||||
const children = parent.childNodes;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
if (child.nodeType === 1 && (child as Element).localName === tagName) {
|
||||
return this.getTextContent(child as Element);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Safely extract text content, handling CDATA sections */
|
||||
private getTextContent(el: Element): string {
|
||||
let text = '';
|
||||
const children = el.childNodes;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
if (child.nodeType === 3 || child.nodeType === 4) {
|
||||
// Text node or CDATA section
|
||||
text += child.nodeValue || '';
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -50,5 +50,22 @@ export {
|
||||
type SendMessageResult,
|
||||
type ModelInfo,
|
||||
} from './OpenCodeManager';
|
||||
|
||||
|
||||
export {
|
||||
WxrParser,
|
||||
type WxrData,
|
||||
type WxrPost,
|
||||
type WxrMedia,
|
||||
type WxrSiteInfo,
|
||||
type WxrCategory,
|
||||
type WxrTag,
|
||||
} from './WxrParser';
|
||||
export {
|
||||
ImportAnalysisEngine,
|
||||
type ImportAnalysisReport,
|
||||
type AnalyzedPost,
|
||||
type AnalyzedMedia,
|
||||
type AnalyzedCategory,
|
||||
type AnalyzedTag,
|
||||
type PostAnalysisStatus,
|
||||
type MediaAnalysisStatus,
|
||||
} from './ImportAnalysisEngine';
|
||||
|
||||
@@ -745,6 +745,68 @@ export function registerIpcHandlers(): void {
|
||||
return engine.rebuildFromSidecars();
|
||||
});
|
||||
|
||||
// ============ Import Analysis Handlers ============
|
||||
|
||||
safeHandle('import:selectAndAnalyze', async (_, uploadsFolder?: string) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Select WordPress Export File (WXR)',
|
||||
filters: [
|
||||
{ name: 'WordPress Export', extensions: ['xml'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
properties: ['openFile'],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = result.filePaths[0];
|
||||
const { WxrParser } = await import('../engine/WxrParser');
|
||||
const { ImportAnalysisEngine } = await import('../engine/ImportAnalysisEngine');
|
||||
|
||||
const parser = new WxrParser();
|
||||
const wxrData = await parser.parseFile(filePath);
|
||||
|
||||
const analysisEngine = new ImportAnalysisEngine();
|
||||
const projectEngine = getProjectEngine();
|
||||
const activeProject = await projectEngine.getActiveProject();
|
||||
if (activeProject) {
|
||||
analysisEngine.setProjectContext(activeProject.id);
|
||||
}
|
||||
|
||||
return analysisEngine.analyzeWxr(wxrData, filePath, uploadsFolder || undefined);
|
||||
});
|
||||
|
||||
safeHandle('import:analyzeFile', async (_, filePath: string, uploadsFolder?: string) => {
|
||||
const { WxrParser } = await import('../engine/WxrParser');
|
||||
const { ImportAnalysisEngine } = await import('../engine/ImportAnalysisEngine');
|
||||
|
||||
const parser = new WxrParser();
|
||||
const wxrData = await parser.parseFile(filePath);
|
||||
|
||||
const analysisEngine = new ImportAnalysisEngine();
|
||||
const projectEngine = getProjectEngine();
|
||||
const activeProject = await projectEngine.getActiveProject();
|
||||
if (activeProject) {
|
||||
analysisEngine.setProjectContext(activeProject.id);
|
||||
}
|
||||
|
||||
return analysisEngine.analyzeWxr(wxrData, filePath, uploadsFolder || undefined);
|
||||
});
|
||||
|
||||
safeHandle('import:selectUploadsFolder', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Select WordPress Uploads Folder',
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
// ============ Event Forwarding ============
|
||||
|
||||
// Forward engine events to renderer
|
||||
|
||||
@@ -150,6 +150,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
syncFromPosts: () => ipcRenderer.invoke('tags:syncFromPosts'),
|
||||
},
|
||||
|
||||
// Import Analysis
|
||||
import: {
|
||||
selectAndAnalyze: (uploadsFolder?: string) => ipcRenderer.invoke('import:selectAndAnalyze', uploadsFolder),
|
||||
analyzeFile: (filePath: string, uploadsFolder?: string) => ipcRenderer.invoke('import:analyzeFile', filePath, uploadsFolder),
|
||||
selectUploadsFolder: () => ipcRenderer.invoke('import:selectUploadsFolder'),
|
||||
},
|
||||
|
||||
// AI Chat (OpenCode Zen API integration)
|
||||
chat: {
|
||||
// API Key Management
|
||||
@@ -312,6 +319,11 @@ export interface ElectronAPI {
|
||||
getPostsWithTag: (tagId: string) => Promise<unknown[]>;
|
||||
syncFromPosts: () => Promise<void>;
|
||||
};
|
||||
import: {
|
||||
selectAndAnalyze: (uploadsFolder?: string) => Promise<unknown>;
|
||||
analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>;
|
||||
selectUploadsFolder: () => Promise<string | null>;
|
||||
};
|
||||
chat: {
|
||||
// API Key Management
|
||||
checkReady: () => Promise<{ ready: boolean; error?: string; backend?: string }>;
|
||||
|
||||
@@ -37,6 +37,12 @@ const ChatIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ImportIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SyncIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
|
||||
@@ -60,6 +66,9 @@ export const ActivityBar: React.FC = () => {
|
||||
// Check if chat sidebar is active (activeView === 'chat' and sidebar is visible)
|
||||
const isChatActive = activeView === 'chat' && sidebarVisible;
|
||||
|
||||
// Check if import tab is currently active
|
||||
const isImportTabActive = tabs.some(t => t.type === 'import' && t.id === activeTabId);
|
||||
|
||||
// Handle view click - toggle sidebar if clicking on active view, otherwise switch view
|
||||
const handleViewClick = (view: 'posts' | 'media' | 'chat') => {
|
||||
if (activeView === view && sidebarVisible) {
|
||||
@@ -96,6 +105,11 @@ export const ActivityBar: React.FC = () => {
|
||||
openTab({ type: 'tags', id: 'tags', isTransient: false });
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
// Open import as a dedicated (non-transient) tab
|
||||
openTab({ type: 'import', id: 'import', isTransient: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="activity-bar">
|
||||
<div className="activity-bar-top">
|
||||
@@ -127,6 +141,13 @@ export const ActivityBar: React.FC = () => {
|
||||
>
|
||||
<ChatIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${isImportTabActive ? 'active' : ''}`}
|
||||
onClick={handleImportClick}
|
||||
title="Import Analysis"
|
||||
>
|
||||
<ImportIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="activity-bar-bottom">
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SettingsView } from '../SettingsView';
|
||||
import { TagsView } from '../TagsView';
|
||||
import { TagInput } from '../TagInput';
|
||||
import { ChatPanel } from '../ChatPanel';
|
||||
import { ImportAnalysisView } from '../ImportAnalysisView';
|
||||
import { AutoSaveManager } from '../../utils';
|
||||
import { parseMacros, getMacro } from '../../macros/registry';
|
||||
import { PostSearchModal } from '../PostSearchModal';
|
||||
@@ -1531,6 +1532,7 @@ export const Editor: React.FC = () => {
|
||||
const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab);
|
||||
const showTags = activeTab?.type === 'tags' || (activeView === 'tags' && !activeTab);
|
||||
const showChat = activeTab?.type === 'chat';
|
||||
const showImport = activeTab?.type === 'import';
|
||||
|
||||
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
|
||||
useEffect(() => {
|
||||
@@ -1619,6 +1621,17 @@ export const Editor: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Show import analysis if import tab is active
|
||||
if (showImport) {
|
||||
return (
|
||||
<div className="editor">
|
||||
<ImportAnalysisView />
|
||||
{renderErrorModal()}
|
||||
{renderConfirmDeleteModal()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show post editor if a post tab is active
|
||||
if (showPost && activeTabId) {
|
||||
const post = posts.find(p => p.id === activeTabId);
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
.import-analysis {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.import-analysis-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.import-analysis-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.import-analysis-header p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/* File selection area */
|
||||
.import-file-selectors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: var(--vscode-sideBar-background);
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.import-file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.import-file-row label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
min-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.import-file-path {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.import-file-path.placeholder {
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.import-file-row button {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
|
||||
.import-file-row button:hover {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.import-analyze-btn {
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.import-analyze-btn:hover {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.import-analyze-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.import-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.import-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--vscode-descriptionForeground);
|
||||
border-top-color: var(--vscode-button-background);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Site info card */
|
||||
.import-site-info {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
background: var(--vscode-sideBar-background);
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.import-site-info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.import-site-info-item .info-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.import-site-info-item .info-value {
|
||||
font-size: 13px;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
/* Stat cards grid */
|
||||
.import-stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.import-stat-card {
|
||||
background: var(--vscode-sideBar-background);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.import-stat-card h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.import-stat-number {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.import-stat-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.import-stat-breakdown {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.import-stat-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--vscode-badge-background);
|
||||
color: var(--vscode-badge-foreground);
|
||||
}
|
||||
|
||||
.import-stat-tag.stat-new {
|
||||
background: rgba(115, 201, 145, 0.15);
|
||||
color: #73c991;
|
||||
}
|
||||
|
||||
.import-stat-tag.stat-update {
|
||||
background: rgba(117, 190, 255, 0.15);
|
||||
color: #75beff;
|
||||
}
|
||||
|
||||
.import-stat-tag.stat-conflict {
|
||||
background: rgba(244, 135, 113, 0.15);
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.import-stat-tag.stat-duplicate {
|
||||
background: rgba(204, 167, 0, 0.15);
|
||||
color: #cca700;
|
||||
}
|
||||
|
||||
.import-stat-tag.stat-missing {
|
||||
background: rgba(150, 150, 150, 0.15);
|
||||
color: #969696;
|
||||
}
|
||||
|
||||
/* Detail sections */
|
||||
.import-detail-section {
|
||||
background: var(--vscode-sideBar-background);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.import-detail-section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-foreground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.import-detail-section h3 .toggle-icon {
|
||||
font-size: 10px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.import-detail-section h3 .toggle-icon.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Detail tables */
|
||||
.import-detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.import-detail-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
border-bottom: 1px solid var(--vscode-panel-border, rgba(255,255,255,0.1));
|
||||
}
|
||||
|
||||
.import-detail-table td {
|
||||
padding: 6px 10px;
|
||||
color: var(--vscode-foreground);
|
||||
border-bottom: 1px solid var(--vscode-panel-border, rgba(255,255,255,0.06));
|
||||
}
|
||||
|
||||
.import-detail-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.import-detail-table .status-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.import-detail-table .status-badge.new {
|
||||
background: rgba(115, 201, 145, 0.15);
|
||||
color: #73c991;
|
||||
}
|
||||
|
||||
.import-detail-table .status-badge.update {
|
||||
background: rgba(117, 190, 255, 0.15);
|
||||
color: #75beff;
|
||||
}
|
||||
|
||||
.import-detail-table .status-badge.conflict {
|
||||
background: rgba(244, 135, 113, 0.15);
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.import-detail-table .status-badge.content-duplicate {
|
||||
background: rgba(204, 167, 0, 0.15);
|
||||
color: #cca700;
|
||||
}
|
||||
|
||||
.import-detail-table .status-badge.missing {
|
||||
background: rgba(150, 150, 150, 0.15);
|
||||
color: #969696;
|
||||
}
|
||||
|
||||
.import-detail-table .slug-cell {
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.import-detail-table .existing-match {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/* Tag/category pills */
|
||||
.import-taxonomy-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.import-taxonomy-pill {
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--vscode-badge-background);
|
||||
color: var(--vscode-badge-foreground);
|
||||
}
|
||||
|
||||
.import-taxonomy-pill.exists {
|
||||
background: rgba(115, 201, 145, 0.15);
|
||||
color: #73c991;
|
||||
}
|
||||
|
||||
.import-taxonomy-pill.new-tax {
|
||||
background: rgba(117, 190, 255, 0.15);
|
||||
color: #75beff;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.import-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
gap: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.import-empty-state svg {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.import-empty-state p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import './ImportAnalysisView.css';
|
||||
|
||||
interface AnalysisReport {
|
||||
sourceFile: string;
|
||||
site: { title: string; link: string; description: string; language: string };
|
||||
analyzedAt: string;
|
||||
posts: ItemSection;
|
||||
pages: ItemSection;
|
||||
media: MediaSection;
|
||||
categories: TaxonomyItem[];
|
||||
tags: TaxonomyItem[];
|
||||
}
|
||||
|
||||
interface ItemSection {
|
||||
total: number;
|
||||
new: number;
|
||||
updates: number;
|
||||
conflicts: number;
|
||||
contentDuplicates: number;
|
||||
items: AnalyzedPostItem[];
|
||||
}
|
||||
|
||||
interface MediaSection {
|
||||
total: number;
|
||||
new: number;
|
||||
updates: number;
|
||||
conflicts: number;
|
||||
contentDuplicates: number;
|
||||
missing: number;
|
||||
items: AnalyzedMediaItem[];
|
||||
}
|
||||
|
||||
interface AnalyzedPostItem {
|
||||
wxrPost: { wpId: number; title: string; slug: string; status: string };
|
||||
status: string;
|
||||
contentHash: string;
|
||||
markdownPreview: string;
|
||||
existingPost?: { id: string; title: string; slug: string };
|
||||
}
|
||||
|
||||
interface AnalyzedMediaItem {
|
||||
wxrMedia: { wpId: number; title: string; filename: string; url: string; relativePath: string };
|
||||
status: string;
|
||||
fileHash: string | null;
|
||||
existingMedia?: { id: string; originalName: string };
|
||||
}
|
||||
|
||||
interface TaxonomyItem {
|
||||
name: string;
|
||||
slug: string;
|
||||
existsInProject: boolean;
|
||||
}
|
||||
|
||||
export const ImportAnalysisView: React.FC = () => {
|
||||
const { importAnalysis, importAnalysisLoading, setImportAnalysis, setImportAnalysisLoading } = useAppStore();
|
||||
const [uploadsFolder, setUploadsFolder] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
|
||||
const report = importAnalysis as AnalysisReport | null;
|
||||
|
||||
const handleSelectUploadsFolder = useCallback(async () => {
|
||||
const folder = await window.electronAPI?.import.selectUploadsFolder();
|
||||
if (folder) {
|
||||
setUploadsFolder(folder);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectAndAnalyze = useCallback(async () => {
|
||||
setImportAnalysisLoading(true);
|
||||
setImportAnalysis(null);
|
||||
try {
|
||||
const result = await window.electronAPI?.import.selectAndAnalyze(uploadsFolder || undefined);
|
||||
if (result) {
|
||||
setImportAnalysis(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import analysis failed:', error);
|
||||
} finally {
|
||||
setImportAnalysisLoading(false);
|
||||
}
|
||||
}, [uploadsFolder, setImportAnalysis, setImportAnalysisLoading]);
|
||||
|
||||
const toggleSection = useCallback((section: string) => {
|
||||
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="import-analysis">
|
||||
<div className="import-analysis-header">
|
||||
<h2>Import Analysis</h2>
|
||||
<p>Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported.</p>
|
||||
</div>
|
||||
|
||||
<div className="import-file-selectors">
|
||||
<div className="import-file-row">
|
||||
<label>Uploads Folder</label>
|
||||
<div className={`import-file-path ${!uploadsFolder ? 'placeholder' : ''}`}>
|
||||
{uploadsFolder || 'No folder selected'}
|
||||
</div>
|
||||
<button onClick={handleSelectUploadsFolder}>Browse...</button>
|
||||
</div>
|
||||
<div className="import-file-row">
|
||||
<label>WXR File</label>
|
||||
<div className={`import-file-path ${!report ? 'placeholder' : ''}`}>
|
||||
{report?.sourceFile || 'Select a file to analyze'}
|
||||
</div>
|
||||
<button
|
||||
className="import-analyze-btn"
|
||||
onClick={handleSelectAndAnalyze}
|
||||
disabled={importAnalysisLoading}
|
||||
>
|
||||
{importAnalysisLoading ? 'Analyzing...' : 'Select & Analyze'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importAnalysisLoading && (
|
||||
<div className="import-loading">
|
||||
<div className="import-spinner" />
|
||||
Analyzing WXR file...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!report && !importAnalysisLoading && (
|
||||
<div className="import-empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
</svg>
|
||||
<p>Select a WordPress export file to begin analysis.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && !importAnalysisLoading && (
|
||||
<>
|
||||
<SiteInfoCard site={report.site} sourceFile={report.sourceFile} />
|
||||
<StatCards report={report} />
|
||||
|
||||
{report.posts.conflicts > 0 && (
|
||||
<ConflictsSection
|
||||
title="Post Slug Conflicts"
|
||||
items={report.posts.items.filter(i => i.status === 'conflict')}
|
||||
expanded={expandedSections['post-conflicts'] ?? true}
|
||||
onToggle={() => toggleSection('post-conflicts')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{report.pages.conflicts > 0 && (
|
||||
<ConflictsSection
|
||||
title="Page Slug Conflicts"
|
||||
items={report.pages.items.filter(i => i.status === 'conflict')}
|
||||
expanded={expandedSections['page-conflicts'] ?? true}
|
||||
onToggle={() => toggleSection('page-conflicts')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PostDetailSection
|
||||
title={`Posts (${report.posts.total})`}
|
||||
items={report.posts.items}
|
||||
expanded={expandedSections['posts'] ?? false}
|
||||
onToggle={() => toggleSection('posts')}
|
||||
/>
|
||||
|
||||
{report.pages.total > 0 && (
|
||||
<PostDetailSection
|
||||
title={`Pages (${report.pages.total})`}
|
||||
items={report.pages.items}
|
||||
expanded={expandedSections['pages'] ?? false}
|
||||
onToggle={() => toggleSection('pages')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MediaDetailSection
|
||||
title={`Media (${report.media.total})`}
|
||||
items={report.media.items}
|
||||
expanded={expandedSections['media'] ?? false}
|
||||
onToggle={() => toggleSection('media')}
|
||||
/>
|
||||
|
||||
{(report.categories.length > 0 || report.tags.length > 0) && (
|
||||
<TaxonomySection
|
||||
categories={report.categories}
|
||||
tags={report.tags}
|
||||
expanded={expandedSections['taxonomy'] ?? false}
|
||||
onToggle={() => toggleSection('taxonomy')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SiteInfoCard: React.FC<{ site: AnalysisReport['site']; sourceFile: string }> = ({ site, sourceFile }) => (
|
||||
<div className="import-site-info">
|
||||
<div className="import-site-info-item">
|
||||
<span className="info-label">Site</span>
|
||||
<span className="info-value">{site.title || 'Untitled'}</span>
|
||||
</div>
|
||||
<div className="import-site-info-item">
|
||||
<span className="info-label">URL</span>
|
||||
<span className="info-value">{site.link || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="import-site-info-item">
|
||||
<span className="info-label">Language</span>
|
||||
<span className="info-value">{site.language || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="import-site-info-item">
|
||||
<span className="info-label">File</span>
|
||||
<span className="info-value">{sourceFile.split(/[/\\]/).pop()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StatCards: React.FC<{ report: AnalysisReport }> = ({ report }) => (
|
||||
<div className="import-stat-cards">
|
||||
<div className="import-stat-card">
|
||||
<h3>Posts</h3>
|
||||
<div className="import-stat-number">{report.posts.total}</div>
|
||||
<div className="import-stat-breakdown">
|
||||
{report.posts.new > 0 && <span className="import-stat-tag stat-new">{report.posts.new} new</span>}
|
||||
{report.posts.updates > 0 && <span className="import-stat-tag stat-update">{report.posts.updates} update</span>}
|
||||
{report.posts.conflicts > 0 && <span className="import-stat-tag stat-conflict">{report.posts.conflicts} conflict</span>}
|
||||
{report.posts.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{report.posts.contentDuplicates} duplicate</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="import-stat-card">
|
||||
<h3>Pages</h3>
|
||||
<div className="import-stat-number">{report.pages.total}</div>
|
||||
<div className="import-stat-breakdown">
|
||||
{report.pages.new > 0 && <span className="import-stat-tag stat-new">{report.pages.new} new</span>}
|
||||
{report.pages.updates > 0 && <span className="import-stat-tag stat-update">{report.pages.updates} update</span>}
|
||||
{report.pages.conflicts > 0 && <span className="import-stat-tag stat-conflict">{report.pages.conflicts} conflict</span>}
|
||||
{report.pages.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{report.pages.contentDuplicates} duplicate</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="import-stat-card">
|
||||
<h3>Media</h3>
|
||||
<div className="import-stat-number">{report.media.total}</div>
|
||||
<div className="import-stat-breakdown">
|
||||
{report.media.new > 0 && <span className="import-stat-tag stat-new">{report.media.new} new</span>}
|
||||
{report.media.updates > 0 && <span className="import-stat-tag stat-update">{report.media.updates} update</span>}
|
||||
{report.media.conflicts > 0 && <span className="import-stat-tag stat-conflict">{report.media.conflicts} conflict</span>}
|
||||
{report.media.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{report.media.contentDuplicates} duplicate</span>}
|
||||
{report.media.missing > 0 && <span className="import-stat-tag stat-missing">{report.media.missing} missing</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="import-stat-card">
|
||||
<h3>Categories</h3>
|
||||
<div className="import-stat-number">{report.categories.length}</div>
|
||||
<div className="import-stat-breakdown">
|
||||
{report.categories.filter(c => c.existsInProject).length > 0 && (
|
||||
<span className="import-stat-tag stat-update">{report.categories.filter(c => c.existsInProject).length} existing</span>
|
||||
)}
|
||||
{report.categories.filter(c => !c.existsInProject).length > 0 && (
|
||||
<span className="import-stat-tag stat-new">{report.categories.filter(c => !c.existsInProject).length} new</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="import-stat-card">
|
||||
<h3>Tags</h3>
|
||||
<div className="import-stat-number">{report.tags.length}</div>
|
||||
<div className="import-stat-breakdown">
|
||||
{report.tags.filter(t => t.existsInProject).length > 0 && (
|
||||
<span className="import-stat-tag stat-update">{report.tags.filter(t => t.existsInProject).length} existing</span>
|
||||
)}
|
||||
{report.tags.filter(t => !t.existsInProject).length > 0 && (
|
||||
<span className="import-stat-tag stat-new">{report.tags.filter(t => !t.existsInProject).length} new</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ConflictsSection: React.FC<{
|
||||
title: string;
|
||||
items: AnalyzedPostItem[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}> = ({ title, items, expanded, onToggle }) => (
|
||||
<div className="import-detail-section">
|
||||
<h3 onClick={onToggle}>
|
||||
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
||||
{title} ({items.length})
|
||||
</h3>
|
||||
{expanded && (
|
||||
<table className="import-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Slug</th>
|
||||
<th>WXR Title</th>
|
||||
<th>Existing Title</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="slug-cell">{item.wxrPost.slug}</td>
|
||||
<td>{item.wxrPost.title}</td>
|
||||
<td className="existing-match">{item.existingPost?.title || '--'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const PostDetailSection: React.FC<{
|
||||
title: string;
|
||||
items: AnalyzedPostItem[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}> = ({ title, items, expanded, onToggle }) => (
|
||||
<div className="import-detail-section">
|
||||
<h3 onClick={onToggle}>
|
||||
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
||||
{title}
|
||||
</h3>
|
||||
{expanded && (
|
||||
<table className="import-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Title</th>
|
||||
<th>Slug</th>
|
||||
<th>WP Status</th>
|
||||
<th>Existing Match</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td><span className={`status-badge ${item.status}`}>{item.status}</span></td>
|
||||
<td>{item.wxrPost.title}</td>
|
||||
<td className="slug-cell">{item.wxrPost.slug}</td>
|
||||
<td>{item.wxrPost.status}</td>
|
||||
<td className="existing-match">{item.existingPost?.title || '--'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const MediaDetailSection: React.FC<{
|
||||
title: string;
|
||||
items: AnalyzedMediaItem[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}> = ({ title, items, expanded, onToggle }) => (
|
||||
<div className="import-detail-section">
|
||||
<h3 onClick={onToggle}>
|
||||
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
||||
{title}
|
||||
</h3>
|
||||
{expanded && (
|
||||
<table className="import-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Filename</th>
|
||||
<th>Path</th>
|
||||
<th>Existing Match</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td><span className={`status-badge ${item.status}`}>{item.status}</span></td>
|
||||
<td>{item.wxrMedia.filename}</td>
|
||||
<td className="slug-cell">{item.wxrMedia.relativePath}</td>
|
||||
<td className="existing-match">{item.existingMedia?.originalName || '--'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const TaxonomySection: React.FC<{
|
||||
categories: TaxonomyItem[];
|
||||
tags: TaxonomyItem[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}> = ({ categories, tags, expanded, onToggle }) => (
|
||||
<div className="import-detail-section">
|
||||
<h3 onClick={onToggle}>
|
||||
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
||||
Categories & Tags
|
||||
</h3>
|
||||
{expanded && (
|
||||
<>
|
||||
{categories.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
|
||||
Categories
|
||||
</div>
|
||||
<div className="import-taxonomy-list">
|
||||
{categories.map((cat, idx) => (
|
||||
<span key={idx} className={`import-taxonomy-pill ${cat.existsInProject ? 'exists' : 'new-tax'}`}>
|
||||
{cat.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
|
||||
Tags
|
||||
</div>
|
||||
<div className="import-taxonomy-list">
|
||||
{tags.map((tag, idx) => (
|
||||
<span key={idx} className={`import-taxonomy-pill ${tag.existsInProject ? 'exists' : 'new-tax'}`}>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
1
src/renderer/components/ImportAnalysisView/index.ts
Normal file
1
src/renderer/components/ImportAnalysisView/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ImportAnalysisView } from './ImportAnalysisView';
|
||||
@@ -32,13 +32,17 @@ const getTabTitle = (
|
||||
const title = chatTitles.get(tab.id);
|
||||
if (title && title !== 'New Chat') {
|
||||
// Truncate long titles for display
|
||||
return title.length > MAX_CHAT_TITLE_LENGTH
|
||||
return title.length > MAX_CHAT_TITLE_LENGTH
|
||||
? title.substring(0, MAX_CHAT_TITLE_LENGTH) + '…'
|
||||
: title;
|
||||
}
|
||||
return 'New Chat';
|
||||
}
|
||||
|
||||
|
||||
if (tab.type === 'import') {
|
||||
return 'Import Analysis';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
@@ -74,6 +78,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
|
||||
<path d="M14 1H2a1 1 0 00-1 1v10a1 1 0 001 1h3v2.5l4-2.5h5a1 1 0 001-1V2a1 1 0 00-1-1zm0 11H8.5L5 14v-2H2V2h12v10z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'import':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
|
||||
@@ -19,3 +19,4 @@ export { LinkedMediaPanel } from './LinkedMediaPanel';
|
||||
export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
||||
export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal';
|
||||
export { ChatPanel } from './ChatPanel';
|
||||
export { ImportAnalysisView } from './ImportAnalysisView';
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { DeleteReference, ConfirmDeleteDetails } from '../components/Confir
|
||||
const STORAGE_KEY = 'bds-app-state';
|
||||
|
||||
// Tab types
|
||||
export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat';
|
||||
export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
|
||||
|
||||
export interface Tab {
|
||||
type: TabType;
|
||||
@@ -93,7 +93,7 @@ interface AppState {
|
||||
activeTabId: string | null;
|
||||
|
||||
// UI State
|
||||
activeView: 'posts' | 'media' | 'settings' | 'tags' | 'chat';
|
||||
activeView: 'posts' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
|
||||
sidebarVisible: boolean;
|
||||
panelVisible: boolean;
|
||||
selectedPostId: string | null;
|
||||
@@ -126,7 +126,11 @@ interface AppState {
|
||||
// Loading states
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
|
||||
// Import Analysis
|
||||
importAnalysis: unknown | null;
|
||||
importAnalysisLoading: boolean;
|
||||
|
||||
// Project Actions
|
||||
setProjects: (projects: ProjectData[]) => void;
|
||||
setActiveProject: (project: ProjectData | null) => void;
|
||||
@@ -144,7 +148,7 @@ interface AppState {
|
||||
restoreTabState: (state: TabState) => void;
|
||||
|
||||
// Actions
|
||||
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags' | 'chat') => void;
|
||||
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags' | 'chat' | 'import') => void;
|
||||
toggleSidebar: () => void;
|
||||
togglePanel: () => void;
|
||||
setSelectedPost: (id: string | null) => void;
|
||||
@@ -184,6 +188,10 @@ interface AppState {
|
||||
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
|
||||
// Import Analysis Actions
|
||||
setImportAnalysis: (report: unknown | null) => void;
|
||||
setImportAnalysisLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
@@ -231,7 +239,11 @@ export const useAppStore = create<AppState>()(
|
||||
// Initial Loading State
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
|
||||
// Import Analysis State
|
||||
importAnalysis: null,
|
||||
importAnalysisLoading: false,
|
||||
|
||||
// Project Actions
|
||||
setProjects: (projects) => set({ projects }),
|
||||
setActiveProject: (activeProject) => set({ activeProject }),
|
||||
@@ -405,6 +417,10 @@ export const useAppStore = create<AppState>()(
|
||||
// Loading Actions
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
// Import Analysis Actions
|
||||
setImportAnalysis: (importAnalysis) => set({ importAnalysis }),
|
||||
setImportAnalysisLoading: (importAnalysisLoading) => set({ importAnalysisLoading }),
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY,
|
||||
|
||||
5
src/renderer/types/electron.d.ts
vendored
5
src/renderer/types/electron.d.ts
vendored
@@ -381,6 +381,11 @@ export interface ElectronAPI {
|
||||
getPostsWithTag: (tagId: string) => Promise<string[]>;
|
||||
syncFromPosts: () => Promise<SyncTagsResult>;
|
||||
};
|
||||
import: {
|
||||
selectAndAnalyze: (uploadsFolder?: string) => Promise<unknown>;
|
||||
analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>;
|
||||
selectUploadsFolder: () => Promise<string | null>;
|
||||
};
|
||||
chat: {
|
||||
// API Key Management
|
||||
checkReady: () => Promise<ChatReadyStatus>;
|
||||
|
||||
537
tests/engine/ImportAnalysisEngine.test.ts
Normal file
537
tests/engine/ImportAnalysisEngine.test.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* ImportAnalysisEngine Unit Tests
|
||||
*
|
||||
* Tests the REAL ImportAnalysisEngine class with mocked dependencies.
|
||||
* Following TDD: mock database and filesystem, test real analysis logic.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ImportAnalysisEngine } from '../../src/main/engine/ImportAnalysisEngine';
|
||||
import type { ImportAnalysisReport, AnalyzedPost, AnalyzedMedia } from '../../src/main/engine/ImportAnalysisEngine';
|
||||
import type { WxrData, WxrPost, WxrMedia, WxrSiteInfo } from '../../src/main/engine/WxrParser';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Mock data stores
|
||||
const mockPostRows: any[] = [];
|
||||
const mockMediaRows: any[] = [];
|
||||
const mockTagRows: any[] = [];
|
||||
|
||||
function createSelectChain() {
|
||||
return {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
all: vi.fn().mockImplementation(() => {
|
||||
// Return appropriate data based on the table being queried
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
|
||||
};
|
||||
}
|
||||
|
||||
const mockLocalDb = {
|
||||
select: vi.fn(() => {
|
||||
const chain = createSelectChain();
|
||||
// The chain.all will be overridden per test
|
||||
return chain;
|
||||
}),
|
||||
};
|
||||
|
||||
// Mock the database module
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
getLocal: vi.fn(() => mockLocalDb),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock fs/promises for media file reading
|
||||
const mockFileBuffers = new Map<string, Buffer>();
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(async (path: string) => {
|
||||
const buffer = mockFileBuffers.get(path.replace(/\\/g, '/'));
|
||||
if (!buffer) {
|
||||
const error = new Error(`ENOENT: no such file or directory, open '${path}'`);
|
||||
(error as any).code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return buffer;
|
||||
}),
|
||||
stat: vi.fn(async (path: string) => {
|
||||
const buffer = mockFileBuffers.get(path.replace(/\\/g, '/'));
|
||||
if (!buffer) {
|
||||
const error = new Error(`ENOENT: no such file or directory, stat '${path}'`);
|
||||
(error as any).code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return { size: buffer.length };
|
||||
}),
|
||||
access: vi.fn(async (path: string) => {
|
||||
const normalizedPath = path.replace(/\\/g, '/');
|
||||
if (!mockFileBuffers.has(normalizedPath)) {
|
||||
const error = new Error(`ENOENT`);
|
||||
(error as any).code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
// Helper to create a WxrPost
|
||||
function createWxrPost(overrides: Partial<WxrPost> = {}): WxrPost {
|
||||
return {
|
||||
wpId: 1,
|
||||
title: 'Test Post',
|
||||
slug: 'test-post',
|
||||
content: '<p>Test content</p>',
|
||||
excerpt: '',
|
||||
pubDate: new Date('2024-01-15'),
|
||||
creator: 'admin',
|
||||
status: 'publish',
|
||||
postType: 'post',
|
||||
categories: [],
|
||||
tags: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a WxrMedia
|
||||
function createWxrMedia(overrides: Partial<WxrMedia> = {}): 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: null,
|
||||
parentId: 0,
|
||||
mimeType: 'image/jpeg',
|
||||
description: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create WxrData
|
||||
function createWxrData(overrides: Partial<WxrData> = {}): WxrData {
|
||||
return {
|
||||
site: {
|
||||
title: 'Test Blog',
|
||||
link: 'https://example.com',
|
||||
description: 'A test blog',
|
||||
language: 'en',
|
||||
},
|
||||
posts: [],
|
||||
pages: [],
|
||||
media: [],
|
||||
categories: [],
|
||||
tags: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to compute expected MD5 hash (same algo as PostEngine)
|
||||
function md5(content: string): string {
|
||||
return crypto.createHash('md5').update(content).digest('hex');
|
||||
}
|
||||
|
||||
describe('ImportAnalysisEngine', () => {
|
||||
let engine: ImportAnalysisEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPostRows.length = 0;
|
||||
mockMediaRows.length = 0;
|
||||
mockTagRows.length = 0;
|
||||
mockFileBuffers.clear();
|
||||
engine = new ImportAnalysisEngine();
|
||||
engine.setProjectContext('test-project');
|
||||
});
|
||||
|
||||
describe('analyzeWxr - posts', () => {
|
||||
it('should classify a post as new when slug and hash do not exist in DB', async () => {
|
||||
// DB has no existing posts
|
||||
setupDbReturns([], [], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
posts: [createWxrPost({ slug: 'new-post', content: '<p>New content</p>' })],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/path/to/export.xml');
|
||||
|
||||
expect(report.posts.total).toBe(1);
|
||||
expect(report.posts.new).toBe(1);
|
||||
expect(report.posts.items[0].status).toBe('new');
|
||||
});
|
||||
|
||||
it('should classify a post as update when slug AND hash match', async () => {
|
||||
// The engine converts HTML to markdown then hashes it
|
||||
// <p>Existing content</p> -> "Existing content\n" in turndown (approx)
|
||||
// We need to compute what turndown gives us and hash that
|
||||
const markdownContent = 'Existing content';
|
||||
const hash = md5(markdownContent);
|
||||
|
||||
setupDbReturns([
|
||||
{ id: 'existing-1', slug: 'existing-post', title: 'Existing Post', checksum: hash },
|
||||
], [], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
posts: [createWxrPost({ slug: 'existing-post', content: '<p>Existing content</p>' })],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/path/to/export.xml');
|
||||
|
||||
expect(report.posts.total).toBe(1);
|
||||
expect(report.posts.updates).toBe(1);
|
||||
expect(report.posts.items[0].status).toBe('update');
|
||||
expect(report.posts.items[0].existingPost?.id).toBe('existing-1');
|
||||
});
|
||||
|
||||
it('should classify a post as conflict when slug matches but hash differs', async () => {
|
||||
setupDbReturns([
|
||||
{ id: 'existing-1', slug: 'my-post', title: 'My Post', checksum: 'different-hash' },
|
||||
], [], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
posts: [createWxrPost({ slug: 'my-post', content: '<p>Changed content</p>' })],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/path/to/export.xml');
|
||||
|
||||
expect(report.posts.total).toBe(1);
|
||||
expect(report.posts.conflicts).toBe(1);
|
||||
expect(report.posts.items[0].status).toBe('conflict');
|
||||
expect(report.posts.items[0].existingPost?.id).toBe('existing-1');
|
||||
});
|
||||
|
||||
it('should classify a post as content-duplicate when hash matches but slug differs', async () => {
|
||||
const markdownContent = 'Same content here';
|
||||
const hash = md5(markdownContent);
|
||||
|
||||
setupDbReturns([
|
||||
{ id: 'other-post', slug: 'different-slug', title: 'Different Title', checksum: hash },
|
||||
], [], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
posts: [createWxrPost({ slug: 'my-original-slug', content: '<p>Same content here</p>' })],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/path/to/export.xml');
|
||||
|
||||
expect(report.posts.total).toBe(1);
|
||||
expect(report.posts.contentDuplicates).toBe(1);
|
||||
expect(report.posts.items[0].status).toBe('content-duplicate');
|
||||
expect(report.posts.items[0].existingPost?.id).toBe('other-post');
|
||||
});
|
||||
|
||||
it('should analyze multiple posts with mixed statuses', async () => {
|
||||
const existingContent = 'Unchanged content';
|
||||
const existingHash = md5(existingContent);
|
||||
|
||||
setupDbReturns([
|
||||
{ id: 'post-1', slug: 'unchanged', title: 'Unchanged', checksum: existingHash },
|
||||
{ id: 'post-2', slug: 'modified', title: 'Modified', checksum: 'old-hash' },
|
||||
], [], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
posts: [
|
||||
createWxrPost({ slug: 'unchanged', content: '<p>Unchanged content</p>' }),
|
||||
createWxrPost({ slug: 'modified', content: '<p>New modified content</p>' }),
|
||||
createWxrPost({ slug: 'brand-new', content: '<p>Brand new post</p>' }),
|
||||
],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||
|
||||
expect(report.posts.total).toBe(3);
|
||||
expect(report.posts.updates).toBe(1);
|
||||
expect(report.posts.conflicts).toBe(1);
|
||||
expect(report.posts.new).toBe(1);
|
||||
});
|
||||
|
||||
it('should include markdown preview in analyzed posts', async () => {
|
||||
setupDbReturns([], [], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
posts: [createWxrPost({ content: '<p>This is a preview of the <strong>content</strong>.</p>' })],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||
|
||||
const item = report.posts.items[0];
|
||||
expect(item.markdownPreview).toBeTruthy();
|
||||
expect(item.markdownPreview.length).toBeGreaterThan(0);
|
||||
expect(item.markdownPreview.length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
|
||||
it('should compute content hash from markdown conversion of HTML', async () => {
|
||||
setupDbReturns([], [], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
posts: [createWxrPost({ content: '<p>Hello world</p>' })],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||
|
||||
const item = report.posts.items[0];
|
||||
expect(item.contentHash).toBeTruthy();
|
||||
// Hash should be MD5 of the markdown conversion
|
||||
expect(item.contentHash).toMatch(/^[a-f0-9]{32}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeWxr - pages', () => {
|
||||
it('should analyze pages separately from posts', async () => {
|
||||
setupDbReturns([], [], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
posts: [createWxrPost({ slug: 'post-1' })],
|
||||
pages: [createWxrPost({ slug: 'about', postType: 'page' })],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||
|
||||
expect(report.posts.total).toBe(1);
|
||||
expect(report.pages.total).toBe(1);
|
||||
expect(report.pages.items[0].wxrPost.slug).toBe('about');
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeWxr - media', () => {
|
||||
it('should classify media as new when filename not in DB and file exists in uploads', async () => {
|
||||
setupDbReturns([], [], []);
|
||||
const fileBuffer = Buffer.from('fake image data');
|
||||
mockFileBuffers.set('/uploads/2024/01/photo.jpg', fileBuffer);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
media: [createWxrMedia({
|
||||
filename: 'photo.jpg',
|
||||
relativePath: '2024/01/photo.jpg',
|
||||
})],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/test.xml', '/uploads');
|
||||
|
||||
expect(report.media.total).toBe(1);
|
||||
expect(report.media.new).toBe(1);
|
||||
expect(report.media.items[0].status).toBe('new');
|
||||
expect(report.media.items[0].fileHash).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should classify media as update when filename matches AND hash matches', async () => {
|
||||
const fileBuffer = Buffer.from('same file data');
|
||||
const fileHash = md5(fileBuffer.toString('binary'));
|
||||
mockFileBuffers.set('/uploads/2024/01/logo.png', fileBuffer);
|
||||
|
||||
setupDbReturns([], [
|
||||
{ id: 'media-1', originalName: 'logo.png', checksum: fileHash },
|
||||
], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
media: [createWxrMedia({
|
||||
filename: 'logo.png',
|
||||
relativePath: '2024/01/logo.png',
|
||||
})],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/test.xml', '/uploads');
|
||||
|
||||
expect(report.media.total).toBe(1);
|
||||
expect(report.media.updates).toBe(1);
|
||||
expect(report.media.items[0].status).toBe('update');
|
||||
expect(report.media.items[0].existingMedia?.id).toBe('media-1');
|
||||
});
|
||||
|
||||
it('should classify media as conflict when filename matches but hash differs', async () => {
|
||||
const fileBuffer = Buffer.from('new file data');
|
||||
mockFileBuffers.set('/uploads/2024/01/logo.png', fileBuffer);
|
||||
|
||||
setupDbReturns([], [
|
||||
{ id: 'media-1', originalName: 'logo.png', checksum: 'old-hash-value' },
|
||||
], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
media: [createWxrMedia({
|
||||
filename: 'logo.png',
|
||||
relativePath: '2024/01/logo.png',
|
||||
})],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/test.xml', '/uploads');
|
||||
|
||||
expect(report.media.total).toBe(1);
|
||||
expect(report.media.conflicts).toBe(1);
|
||||
expect(report.media.items[0].status).toBe('conflict');
|
||||
});
|
||||
|
||||
it('should classify media as content-duplicate when hash matches but filename differs', async () => {
|
||||
const fileBuffer = Buffer.from('duplicate content');
|
||||
const fileHash = md5(fileBuffer.toString('binary'));
|
||||
mockFileBuffers.set('/uploads/2024/01/new-name.jpg', fileBuffer);
|
||||
|
||||
setupDbReturns([], [
|
||||
{ id: 'media-1', originalName: 'old-name.jpg', checksum: fileHash },
|
||||
], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
media: [createWxrMedia({
|
||||
filename: 'new-name.jpg',
|
||||
relativePath: '2024/01/new-name.jpg',
|
||||
})],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/test.xml', '/uploads');
|
||||
|
||||
expect(report.media.total).toBe(1);
|
||||
expect(report.media.contentDuplicates).toBe(1);
|
||||
expect(report.media.items[0].status).toBe('content-duplicate');
|
||||
});
|
||||
|
||||
it('should mark media as missing when file not found in uploads folder', async () => {
|
||||
setupDbReturns([], [], []);
|
||||
// No file added to mockFileBuffers
|
||||
|
||||
const wxrData = createWxrData({
|
||||
media: [createWxrMedia({
|
||||
filename: 'missing.jpg',
|
||||
relativePath: '2024/01/missing.jpg',
|
||||
})],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/test.xml', '/uploads');
|
||||
|
||||
expect(report.media.total).toBe(1);
|
||||
expect(report.media.missing).toBe(1);
|
||||
expect(report.media.items[0].status).toBe('missing');
|
||||
expect(report.media.items[0].fileHash).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle media analysis without uploads folder (all missing)', async () => {
|
||||
setupDbReturns([], [], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
media: [createWxrMedia({ filename: 'test.jpg' })],
|
||||
});
|
||||
|
||||
// No uploads folder provided
|
||||
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||
|
||||
expect(report.media.total).toBe(1);
|
||||
expect(report.media.missing).toBe(1);
|
||||
expect(report.media.items[0].status).toBe('missing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeWxr - categories and tags', () => {
|
||||
it('should check existing categories against project tags', async () => {
|
||||
setupDbReturns([], [], [
|
||||
{ name: 'Technology' },
|
||||
]);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
categories: [
|
||||
{ name: 'Technology', slug: 'technology', parent: '' },
|
||||
{ name: 'Science', slug: 'science', parent: '' },
|
||||
],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||
|
||||
expect(report.categories).toHaveLength(2);
|
||||
expect(report.categories[0].existsInProject).toBe(true);
|
||||
expect(report.categories[1].existsInProject).toBe(false);
|
||||
});
|
||||
|
||||
it('should check existing tags against project tags', async () => {
|
||||
setupDbReturns([], [], [
|
||||
{ name: 'javascript' },
|
||||
]);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
tags: [
|
||||
{ name: 'javascript', slug: 'javascript' },
|
||||
{ name: 'python', slug: 'python' },
|
||||
],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||
|
||||
expect(report.tags).toHaveLength(2);
|
||||
expect(report.tags[0].existsInProject).toBe(true);
|
||||
expect(report.tags[1].existsInProject).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeWxr - report metadata', () => {
|
||||
it('should include source file and site info in report', async () => {
|
||||
setupDbReturns([], [], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
site: {
|
||||
title: 'My Blog',
|
||||
link: 'https://myblog.com',
|
||||
description: 'A great blog',
|
||||
language: 'de-DE',
|
||||
},
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/exports/myblog.xml');
|
||||
|
||||
expect(report.sourceFile).toBe('/exports/myblog.xml');
|
||||
expect(report.site.title).toBe('My Blog');
|
||||
expect(report.site.link).toBe('https://myblog.com');
|
||||
expect(report.analyzedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should correctly count all post statuses', async () => {
|
||||
const contentA = 'Content A';
|
||||
const hashA = md5(contentA);
|
||||
|
||||
setupDbReturns([
|
||||
{ id: 'p1', slug: 'update-me', title: 'Update Me', checksum: hashA },
|
||||
{ id: 'p2', slug: 'conflict-me', title: 'Conflict Me', checksum: 'old-hash' },
|
||||
], [], []);
|
||||
|
||||
const wxrData = createWxrData({
|
||||
posts: [
|
||||
createWxrPost({ slug: 'update-me', content: '<p>Content A</p>' }),
|
||||
createWxrPost({ slug: 'conflict-me', content: '<p>Different content</p>' }),
|
||||
createWxrPost({ slug: 'new-one', content: '<p>Brand new</p>' }),
|
||||
createWxrPost({ slug: 'another-new', content: '<p>Also new</p>' }),
|
||||
],
|
||||
});
|
||||
|
||||
const report = await engine.analyzeWxr(wxrData, '/test.xml');
|
||||
|
||||
expect(report.posts.total).toBe(4);
|
||||
expect(report.posts.updates).toBe(1);
|
||||
expect(report.posts.conflicts).toBe(1);
|
||||
expect(report.posts.new).toBe(2);
|
||||
expect(report.posts.contentDuplicates).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to set up mock DB return values.
|
||||
* Uses a counter-based approach to return different data for different queries.
|
||||
*/
|
||||
let dbQueryCount = 0;
|
||||
function setupDbReturns(
|
||||
existingPosts: Array<{ id: string; slug: string; title: string; checksum: string }>,
|
||||
existingMedia: Array<{ id: string; originalName: string; checksum: string }>,
|
||||
existingTags: Array<{ name: string }>,
|
||||
) {
|
||||
dbQueryCount = 0;
|
||||
mockLocalDb.select.mockImplementation(() => {
|
||||
const currentQuery = dbQueryCount++;
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
all: vi.fn().mockImplementation(() => {
|
||||
if (currentQuery === 0) return Promise.resolve(existingPosts);
|
||||
if (currentQuery === 1) return Promise.resolve(existingMedia);
|
||||
if (currentQuery === 2) return Promise.resolve(existingTags);
|
||||
return Promise.resolve([]);
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
478
tests/engine/WxrParser.test.ts
Normal file
478
tests/engine/WxrParser.test.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* WxrParser Unit Tests
|
||||
*
|
||||
* Tests the REAL WxrParser class with mocked filesystem.
|
||||
* Following TDD best practices: mock external dependencies, test real implementation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { WxrParser } from '../../src/main/engine/WxrParser';
|
||||
import type { WxrData } from '../../src/main/engine/WxrParser';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Minimal valid WXR XML for testing
|
||||
const MINIMAL_WXR = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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>My Test Blog</title>
|
||||
<link>https://example.com</link>
|
||||
<description>A test blog</description>
|
||||
<language>en-US</language>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
// WXR with categories and tags at channel level
|
||||
const WXR_WITH_TAXONOMIES = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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>My Blog</title>
|
||||
<link>https://example.com</link>
|
||||
<description>Test</description>
|
||||
<language>en</language>
|
||||
<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 Development]]></wp:cat_name>
|
||||
</wp:category>
|
||||
<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>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
// WXR with a single published post
|
||||
const WXR_WITH_POST = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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>My Blog</title>
|
||||
<link>https://example.com</link>
|
||||
<description>Test</description>
|
||||
<language>en</language>
|
||||
<item>
|
||||
<title>Hello World</title>
|
||||
<link>https://example.com/hello-world/</link>
|
||||
<pubDate>Mon, 15 Jan 2024 10:30:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<category domain="category" nicename="uncategorized"><![CDATA[Uncategorized]]></category>
|
||||
<category domain="post_tag" nicename="intro"><![CDATA[Intro]]></category>
|
||||
<category domain="post_tag" nicename="welcome"><![CDATA[Welcome]]></category>
|
||||
<content:encoded><![CDATA[<p>Welcome to my blog. This is my <strong>first</strong> post.</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Welcome to my blog.]]></excerpt:encoded>
|
||||
<wp:post_id>42</wp:post_id>
|
||||
<wp:post_date>2024-01-15 10:30:00</wp:post_date>
|
||||
<wp:post_name>hello-world</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
// WXR with a page
|
||||
const WXR_WITH_PAGE = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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>My Blog</title>
|
||||
<link>https://example.com</link>
|
||||
<description>Test</description>
|
||||
<language>en</language>
|
||||
<item>
|
||||
<title>About Me</title>
|
||||
<content:encoded><![CDATA[<h2>About</h2><p>This is the about page.</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>10</wp:post_id>
|
||||
<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>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
// WXR with a media attachment
|
||||
const WXR_WITH_MEDIA = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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>My Blog</title>
|
||||
<link>https://example.com</link>
|
||||
<description>Test</description>
|
||||
<language>en</language>
|
||||
<item>
|
||||
<title>sunset-photo</title>
|
||||
<content:encoded><![CDATA[A beautiful sunset]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>100</wp:post_id>
|
||||
<wp:post_name>sunset-photo</wp:post_name>
|
||||
<wp:status>inherit</wp:status>
|
||||
<wp:post_type>attachment</wp:post_type>
|
||||
<wp:post_parent>42</wp:post_parent>
|
||||
<wp:attachment_url>https://example.com/wp-content/uploads/2024/01/sunset.jpg</wp:attachment_url>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key>_wp_attached_file</wp:meta_key>
|
||||
<wp:meta_value>2024/01/sunset.jpg</wp:meta_value>
|
||||
</wp:postmeta>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
// WXR with mixed content: posts, pages, and media
|
||||
const WXR_MIXED = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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>Full Blog</title>
|
||||
<link>https://fullblog.com</link>
|
||||
<description>A full blog export</description>
|
||||
<language>de-DE</language>
|
||||
<wp:category>
|
||||
<wp:category_nicename>news</wp:category_nicename>
|
||||
<wp:category_parent></wp:category_parent>
|
||||
<wp:cat_name><![CDATA[News]]></wp:cat_name>
|
||||
</wp:category>
|
||||
<wp:tag>
|
||||
<wp:tag_slug>featured</wp:tag_slug>
|
||||
<wp:tag_name><![CDATA[Featured]]></wp:tag_name>
|
||||
</wp:tag>
|
||||
<item>
|
||||
<title>First Post</title>
|
||||
<pubDate>Tue, 02 Jan 2024 08:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[editor]]></dc:creator>
|
||||
<category domain="category" nicename="news"><![CDATA[News]]></category>
|
||||
<category domain="post_tag" nicename="featured"><![CDATA[Featured]]></category>
|
||||
<content:encoded><![CDATA[<p>First post content.</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[First post]]></excerpt:encoded>
|
||||
<wp:post_id>1</wp:post_id>
|
||||
<wp:post_name>first-post</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
<item>
|
||||
<title>Second Post</title>
|
||||
<pubDate>Wed, 03 Jan 2024 09:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<content:encoded><![CDATA[<p>Second post content.</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>2</wp:post_id>
|
||||
<wp:post_name>second-post</wp:post_name>
|
||||
<wp:status>draft</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
</item>
|
||||
<item>
|
||||
<title>Contact</title>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<content:encoded><![CDATA[<p>Contact us here.</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>3</wp:post_id>
|
||||
<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>
|
||||
<item>
|
||||
<title>logo</title>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<content:encoded><![CDATA[]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>4</wp:post_id>
|
||||
<wp:post_name>logo</wp:post_name>
|
||||
<wp:status>inherit</wp:status>
|
||||
<wp:post_type>attachment</wp:post_type>
|
||||
<wp:post_parent>3</wp:post_parent>
|
||||
<wp:attachment_url>https://fullblog.com/wp-content/uploads/2024/02/logo.png</wp:attachment_url>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
// WXR with draft and trashed posts
|
||||
const WXR_WITH_STATUSES = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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>Blog</title>
|
||||
<link>https://example.com</link>
|
||||
<description></description>
|
||||
<language>en</language>
|
||||
<item>
|
||||
<title>Published Post</title>
|
||||
<content:encoded><![CDATA[<p>Published</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>1</wp:post_id>
|
||||
<wp:post_name>published-post</wp:post_name>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
</item>
|
||||
<item>
|
||||
<title>Draft Post</title>
|
||||
<content:encoded><![CDATA[<p>Draft</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>2</wp:post_id>
|
||||
<wp:post_name>draft-post</wp:post_name>
|
||||
<wp:status>draft</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
</item>
|
||||
<item>
|
||||
<title>Trashed Post</title>
|
||||
<content:encoded><![CDATA[<p>Trash</p>]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>3</wp:post_id>
|
||||
<wp:post_name>__trashed</wp:post_name>
|
||||
<wp:status>trash</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
describe('WxrParser', () => {
|
||||
let parser: WxrParser;
|
||||
|
||||
beforeEach(() => {
|
||||
parser = new WxrParser();
|
||||
});
|
||||
|
||||
describe('parseXml', () => {
|
||||
it('should parse minimal WXR and extract site info', () => {
|
||||
const result = parser.parseXml(MINIMAL_WXR);
|
||||
|
||||
expect(result.site.title).toBe('My Test Blog');
|
||||
expect(result.site.link).toBe('https://example.com');
|
||||
expect(result.site.description).toBe('A test blog');
|
||||
expect(result.site.language).toBe('en-US');
|
||||
});
|
||||
|
||||
it('should return empty arrays when no items exist', () => {
|
||||
const result = parser.parseXml(MINIMAL_WXR);
|
||||
|
||||
expect(result.posts).toEqual([]);
|
||||
expect(result.pages).toEqual([]);
|
||||
expect(result.media).toEqual([]);
|
||||
expect(result.categories).toEqual([]);
|
||||
expect(result.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('should extract channel-level categories with parent relationships', () => {
|
||||
const result = parser.parseXml(WXR_WITH_TAXONOMIES);
|
||||
|
||||
expect(result.categories).toHaveLength(2);
|
||||
expect(result.categories[0]).toEqual({
|
||||
name: 'Technology',
|
||||
slug: 'technology',
|
||||
parent: '',
|
||||
});
|
||||
expect(result.categories[1]).toEqual({
|
||||
name: 'Web Development',
|
||||
slug: 'web-dev',
|
||||
parent: 'technology',
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract channel-level tags', () => {
|
||||
const result = parser.parseXml(WXR_WITH_TAXONOMIES);
|
||||
|
||||
expect(result.tags).toHaveLength(2);
|
||||
expect(result.tags[0]).toEqual({
|
||||
name: 'JavaScript',
|
||||
slug: 'javascript',
|
||||
});
|
||||
expect(result.tags[1]).toEqual({
|
||||
name: 'TypeScript',
|
||||
slug: 'typescript',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse a published post with all fields', () => {
|
||||
const result = parser.parseXml(WXR_WITH_POST);
|
||||
|
||||
expect(result.posts).toHaveLength(1);
|
||||
const post = result.posts[0];
|
||||
expect(post.wpId).toBe(42);
|
||||
expect(post.title).toBe('Hello World');
|
||||
expect(post.slug).toBe('hello-world');
|
||||
expect(post.content).toBe('<p>Welcome to my blog. This is my <strong>first</strong> post.</p>');
|
||||
expect(post.excerpt).toBe('Welcome to my blog.');
|
||||
expect(post.creator).toBe('admin');
|
||||
expect(post.status).toBe('publish');
|
||||
expect(post.postType).toBe('post');
|
||||
expect(post.categories).toEqual(['Uncategorized']);
|
||||
expect(post.tags).toEqual(['Intro', 'Welcome']);
|
||||
expect(post.pubDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should parse a page and put it in pages array', () => {
|
||||
const result = parser.parseXml(WXR_WITH_PAGE);
|
||||
|
||||
expect(result.posts).toHaveLength(0);
|
||||
expect(result.pages).toHaveLength(1);
|
||||
|
||||
const page = result.pages[0];
|
||||
expect(page.wpId).toBe(10);
|
||||
expect(page.title).toBe('About Me');
|
||||
expect(page.slug).toBe('about');
|
||||
expect(page.content).toContain('<h2>About</h2>');
|
||||
expect(page.postType).toBe('page');
|
||||
});
|
||||
|
||||
it('should parse a media attachment with URL and filename', () => {
|
||||
const result = parser.parseXml(WXR_WITH_MEDIA);
|
||||
|
||||
expect(result.posts).toHaveLength(0);
|
||||
expect(result.media).toHaveLength(1);
|
||||
|
||||
const media = result.media[0];
|
||||
expect(media.wpId).toBe(100);
|
||||
expect(media.title).toBe('sunset-photo');
|
||||
expect(media.url).toBe('https://example.com/wp-content/uploads/2024/01/sunset.jpg');
|
||||
expect(media.filename).toBe('sunset.jpg');
|
||||
expect(media.relativePath).toBe('2024/01/sunset.jpg');
|
||||
expect(media.parentId).toBe(42);
|
||||
expect(media.description).toBe('A beautiful sunset');
|
||||
});
|
||||
|
||||
it('should separate posts, pages, and media from mixed content', () => {
|
||||
const result = parser.parseXml(WXR_MIXED);
|
||||
|
||||
expect(result.posts).toHaveLength(2);
|
||||
expect(result.pages).toHaveLength(1);
|
||||
expect(result.media).toHaveLength(1);
|
||||
expect(result.categories).toHaveLength(1);
|
||||
expect(result.tags).toHaveLength(1);
|
||||
|
||||
expect(result.posts[0].title).toBe('First Post');
|
||||
expect(result.posts[1].title).toBe('Second Post');
|
||||
expect(result.pages[0].title).toBe('Contact');
|
||||
expect(result.media[0].title).toBe('logo');
|
||||
});
|
||||
|
||||
it('should extract post categories and tags from item-level category elements', () => {
|
||||
const result = parser.parseXml(WXR_MIXED);
|
||||
|
||||
const firstPost = result.posts[0];
|
||||
expect(firstPost.categories).toEqual(['News']);
|
||||
expect(firstPost.tags).toEqual(['Featured']);
|
||||
|
||||
// Second post has no categories or tags
|
||||
const secondPost = result.posts[1];
|
||||
expect(secondPost.categories).toEqual([]);
|
||||
expect(secondPost.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle different post statuses', () => {
|
||||
const result = parser.parseXml(WXR_WITH_STATUSES);
|
||||
|
||||
expect(result.posts).toHaveLength(3);
|
||||
expect(result.posts[0].status).toBe('publish');
|
||||
expect(result.posts[1].status).toBe('draft');
|
||||
expect(result.posts[2].status).toBe('trash');
|
||||
});
|
||||
|
||||
it('should extract relative path from media URL based on wp-content/uploads', () => {
|
||||
const result = parser.parseXml(WXR_WITH_MEDIA);
|
||||
const media = result.media[0];
|
||||
|
||||
// The path after wp-content/uploads/
|
||||
expect(media.relativePath).toBe('2024/01/sunset.jpg');
|
||||
});
|
||||
|
||||
it('should extract relative path from mixed content media', () => {
|
||||
const result = parser.parseXml(WXR_MIXED);
|
||||
const media = result.media[0];
|
||||
|
||||
expect(media.relativePath).toBe('2024/02/logo.png');
|
||||
expect(media.filename).toBe('logo.png');
|
||||
});
|
||||
|
||||
it('should handle empty content gracefully', () => {
|
||||
const result = parser.parseXml(WXR_WITH_MEDIA);
|
||||
// Media items in WXR often have empty excerpt
|
||||
const media = result.media[0];
|
||||
expect(media).toBeDefined();
|
||||
});
|
||||
|
||||
it('should infer mime type from file extension', () => {
|
||||
const result = parser.parseXml(WXR_WITH_MEDIA);
|
||||
expect(result.media[0].mimeType).toBe('image/jpeg');
|
||||
|
||||
const mixedResult = parser.parseXml(WXR_MIXED);
|
||||
expect(mixedResult.media[0].mimeType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle missing pubDate gracefully', () => {
|
||||
const result = parser.parseXml(WXR_WITH_PAGE);
|
||||
// Page has no pubDate element
|
||||
expect(result.pages[0].pubDate).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFile', () => {
|
||||
it('should read a file and parse its contents', async () => {
|
||||
const fs = await import('fs/promises');
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(WXR_WITH_POST);
|
||||
|
||||
const result = await parser.parseFile('/path/to/export.xml');
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/path/to/export.xml', 'utf-8');
|
||||
expect(result.posts).toHaveLength(1);
|
||||
expect(result.posts[0].title).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should throw an error if the file cannot be read', async () => {
|
||||
const fs = await import('fs/promises');
|
||||
vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));
|
||||
|
||||
await expect(parser.parseFile('/nonexistent.xml')).rejects.toThrow('ENOENT');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -106,6 +106,11 @@ Object.defineProperty(globalThis, 'window', {
|
||||
cancel: vi.fn(),
|
||||
clearCompleted: vi.fn(),
|
||||
},
|
||||
import: {
|
||||
selectAndAnalyze: vi.fn(),
|
||||
analyzeFile: vi.fn(),
|
||||
selectUploadsFolder: vi.fn(),
|
||||
},
|
||||
on: vi.fn(() => () => {}),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user