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/react": "^7.18.0",
|
||||||
"@milkdown/theme-nord": "^7.18.0",
|
"@milkdown/theme-nord": "^7.18.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@xmldom/xmldom": "^0.8.11",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"snowball-stemmers": "^0.6.0",
|
"snowball-stemmers": "^0.6.0",
|
||||||
|
"turndown": "^7.2.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"@vitest/ui": "^4.0.18",
|
"@vitest/ui": "^4.0.18",
|
||||||
@@ -3844,6 +3847,12 @@
|
|||||||
"nanoid": "^5.0.9"
|
"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": {
|
"node_modules/@monaco-editor/loader": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||||
@@ -4690,6 +4699,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
@@ -5038,7 +5054,6 @@
|
|||||||
"version": "0.8.11",
|
"version": "0.8.11",
|
||||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
@@ -12804,6 +12819,15 @@
|
|||||||
"@esbuild/win32-x64": "0.27.3"
|
"@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": {
|
"node_modules/type-fest": {
|
||||||
"version": "5.4.4",
|
"version": "5.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"@vitest/ui": "^4.0.18",
|
"@vitest/ui": "^4.0.18",
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
"@milkdown/react": "^7.18.0",
|
"@milkdown/react": "^7.18.0",
|
||||||
"@milkdown/theme-nord": "^7.18.0",
|
"@milkdown/theme-nord": "^7.18.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@xmldom/xmldom": "^0.8.11",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
@@ -78,6 +80,7 @@
|
|||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"snowball-stemmers": "^0.6.0",
|
"snowball-stemmers": "^0.6.0",
|
||||||
|
"turndown": "^7.2.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.11"
|
"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 SendMessageResult,
|
||||||
type ModelInfo,
|
type ModelInfo,
|
||||||
} from './OpenCodeManager';
|
} 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();
|
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 ============
|
// ============ Event Forwarding ============
|
||||||
|
|
||||||
// Forward engine events to renderer
|
// Forward engine events to renderer
|
||||||
|
|||||||
@@ -150,6 +150,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
syncFromPosts: () => ipcRenderer.invoke('tags:syncFromPosts'),
|
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)
|
// AI Chat (OpenCode Zen API integration)
|
||||||
chat: {
|
chat: {
|
||||||
// API Key Management
|
// API Key Management
|
||||||
@@ -312,6 +319,11 @@ export interface ElectronAPI {
|
|||||||
getPostsWithTag: (tagId: string) => Promise<unknown[]>;
|
getPostsWithTag: (tagId: string) => Promise<unknown[]>;
|
||||||
syncFromPosts: () => Promise<void>;
|
syncFromPosts: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
import: {
|
||||||
|
selectAndAnalyze: (uploadsFolder?: string) => Promise<unknown>;
|
||||||
|
analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>;
|
||||||
|
selectUploadsFolder: () => Promise<string | null>;
|
||||||
|
};
|
||||||
chat: {
|
chat: {
|
||||||
// API Key Management
|
// API Key Management
|
||||||
checkReady: () => Promise<{ ready: boolean; error?: string; backend?: string }>;
|
checkReady: () => Promise<{ ready: boolean; error?: string; backend?: string }>;
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ const ChatIcon = () => (
|
|||||||
</svg>
|
</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 = () => (
|
const SyncIcon = () => (
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
<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"/>
|
<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)
|
// Check if chat sidebar is active (activeView === 'chat' and sidebar is visible)
|
||||||
const isChatActive = activeView === 'chat' && sidebarVisible;
|
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
|
// Handle view click - toggle sidebar if clicking on active view, otherwise switch view
|
||||||
const handleViewClick = (view: 'posts' | 'media' | 'chat') => {
|
const handleViewClick = (view: 'posts' | 'media' | 'chat') => {
|
||||||
if (activeView === view && sidebarVisible) {
|
if (activeView === view && sidebarVisible) {
|
||||||
@@ -96,6 +105,11 @@ export const ActivityBar: React.FC = () => {
|
|||||||
openTab({ type: 'tags', id: 'tags', isTransient: false });
|
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 (
|
return (
|
||||||
<div className="activity-bar">
|
<div className="activity-bar">
|
||||||
<div className="activity-bar-top">
|
<div className="activity-bar-top">
|
||||||
@@ -127,6 +141,13 @@ export const ActivityBar: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<ChatIcon />
|
<ChatIcon />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`activity-bar-item ${isImportTabActive ? 'active' : ''}`}
|
||||||
|
onClick={handleImportClick}
|
||||||
|
title="Import Analysis"
|
||||||
|
>
|
||||||
|
<ImportIcon />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="activity-bar-bottom">
|
<div className="activity-bar-bottom">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { SettingsView } from '../SettingsView';
|
|||||||
import { TagsView } from '../TagsView';
|
import { TagsView } from '../TagsView';
|
||||||
import { TagInput } from '../TagInput';
|
import { TagInput } from '../TagInput';
|
||||||
import { ChatPanel } from '../ChatPanel';
|
import { ChatPanel } from '../ChatPanel';
|
||||||
|
import { ImportAnalysisView } from '../ImportAnalysisView';
|
||||||
import { AutoSaveManager } from '../../utils';
|
import { AutoSaveManager } from '../../utils';
|
||||||
import { parseMacros, getMacro } from '../../macros/registry';
|
import { parseMacros, getMacro } from '../../macros/registry';
|
||||||
import { PostSearchModal } from '../PostSearchModal';
|
import { PostSearchModal } from '../PostSearchModal';
|
||||||
@@ -1531,6 +1532,7 @@ export const Editor: React.FC = () => {
|
|||||||
const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab);
|
const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab);
|
||||||
const showTags = activeTab?.type === 'tags' || (activeView === 'tags' && !activeTab);
|
const showTags = activeTab?.type === 'tags' || (activeView === 'tags' && !activeTab);
|
||||||
const showChat = activeTab?.type === 'chat';
|
const showChat = activeTab?.type === 'chat';
|
||||||
|
const showImport = activeTab?.type === 'import';
|
||||||
|
|
||||||
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
|
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
|
||||||
useEffect(() => {
|
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
|
// Show post editor if a post tab is active
|
||||||
if (showPost && activeTabId) {
|
if (showPost && activeTabId) {
|
||||||
const post = posts.find(p => p.id === 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);
|
const title = chatTitles.get(tab.id);
|
||||||
if (title && title !== 'New Chat') {
|
if (title && title !== 'New Chat') {
|
||||||
// Truncate long titles for display
|
// 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.substring(0, MAX_CHAT_TITLE_LENGTH) + '…'
|
||||||
: title;
|
: title;
|
||||||
}
|
}
|
||||||
return 'New Chat';
|
return 'New Chat';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tab.type === 'import') {
|
||||||
|
return 'Import Analysis';
|
||||||
|
}
|
||||||
|
|
||||||
return 'Unknown';
|
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"/>
|
<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>
|
</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:
|
default:
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<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 { ErrorModal, type ErrorDetails } from './ErrorModal';
|
||||||
export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal';
|
export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal';
|
||||||
export { ChatPanel } from './ChatPanel';
|
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';
|
const STORAGE_KEY = 'bds-app-state';
|
||||||
|
|
||||||
// Tab types
|
// Tab types
|
||||||
export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat';
|
export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
type: TabType;
|
type: TabType;
|
||||||
@@ -93,7 +93,7 @@ interface AppState {
|
|||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
activeView: 'posts' | 'media' | 'settings' | 'tags' | 'chat';
|
activeView: 'posts' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
|
||||||
sidebarVisible: boolean;
|
sidebarVisible: boolean;
|
||||||
panelVisible: boolean;
|
panelVisible: boolean;
|
||||||
selectedPostId: string | null;
|
selectedPostId: string | null;
|
||||||
@@ -126,7 +126,11 @@ interface AppState {
|
|||||||
// Loading states
|
// Loading states
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
|
// Import Analysis
|
||||||
|
importAnalysis: unknown | null;
|
||||||
|
importAnalysisLoading: boolean;
|
||||||
|
|
||||||
// Project Actions
|
// Project Actions
|
||||||
setProjects: (projects: ProjectData[]) => void;
|
setProjects: (projects: ProjectData[]) => void;
|
||||||
setActiveProject: (project: ProjectData | null) => void;
|
setActiveProject: (project: ProjectData | null) => void;
|
||||||
@@ -144,7 +148,7 @@ interface AppState {
|
|||||||
restoreTabState: (state: TabState) => void;
|
restoreTabState: (state: TabState) => void;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags' | 'chat') => void;
|
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags' | 'chat' | 'import') => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
togglePanel: () => void;
|
togglePanel: () => void;
|
||||||
setSelectedPost: (id: string | null) => void;
|
setSelectedPost: (id: string | null) => void;
|
||||||
@@ -184,6 +188,10 @@ interface AppState {
|
|||||||
|
|
||||||
setLoading: (loading: boolean) => void;
|
setLoading: (loading: boolean) => void;
|
||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
|
|
||||||
|
// Import Analysis Actions
|
||||||
|
setImportAnalysis: (report: unknown | null) => void;
|
||||||
|
setImportAnalysisLoading: (loading: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppStore = create<AppState>()(
|
export const useAppStore = create<AppState>()(
|
||||||
@@ -231,7 +239,11 @@ export const useAppStore = create<AppState>()(
|
|||||||
// Initial Loading State
|
// Initial Loading State
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
|
// Import Analysis State
|
||||||
|
importAnalysis: null,
|
||||||
|
importAnalysisLoading: false,
|
||||||
|
|
||||||
// Project Actions
|
// Project Actions
|
||||||
setProjects: (projects) => set({ projects }),
|
setProjects: (projects) => set({ projects }),
|
||||||
setActiveProject: (activeProject) => set({ activeProject }),
|
setActiveProject: (activeProject) => set({ activeProject }),
|
||||||
@@ -405,6 +417,10 @@ export const useAppStore = create<AppState>()(
|
|||||||
// Loading Actions
|
// Loading Actions
|
||||||
setLoading: (isLoading) => set({ isLoading }),
|
setLoading: (isLoading) => set({ isLoading }),
|
||||||
setError: (error) => set({ error }),
|
setError: (error) => set({ error }),
|
||||||
|
|
||||||
|
// Import Analysis Actions
|
||||||
|
setImportAnalysis: (importAnalysis) => set({ importAnalysis }),
|
||||||
|
setImportAnalysisLoading: (importAnalysisLoading) => set({ importAnalysisLoading }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: STORAGE_KEY,
|
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[]>;
|
getPostsWithTag: (tagId: string) => Promise<string[]>;
|
||||||
syncFromPosts: () => Promise<SyncTagsResult>;
|
syncFromPosts: () => Promise<SyncTagsResult>;
|
||||||
};
|
};
|
||||||
|
import: {
|
||||||
|
selectAndAnalyze: (uploadsFolder?: string) => Promise<unknown>;
|
||||||
|
analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>;
|
||||||
|
selectUploadsFolder: () => Promise<string | null>;
|
||||||
|
};
|
||||||
chat: {
|
chat: {
|
||||||
// API Key Management
|
// API Key Management
|
||||||
checkReady: () => Promise<ChatReadyStatus>;
|
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(),
|
cancel: vi.fn(),
|
||||||
clearCompleted: vi.fn(),
|
clearCompleted: vi.fn(),
|
||||||
},
|
},
|
||||||
|
import: {
|
||||||
|
selectAndAnalyze: vi.fn(),
|
||||||
|
analyzeFile: vi.fn(),
|
||||||
|
selectUploadsFolder: vi.fn(),
|
||||||
|
},
|
||||||
on: vi.fn(() => () => {}),
|
on: vi.fn(() => () => {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user