feat: importer starting point
This commit is contained in:
331
src/main/engine/ImportAnalysisEngine.ts
Normal file
331
src/main/engine/ImportAnalysisEngine.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import crypto from 'crypto';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import TurndownService from 'turndown';
|
||||
import { getDatabase } from '../database';
|
||||
import { posts, media, tags } from '../database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { WxrData, WxrPost, WxrMedia, WxrSiteInfo, WxrCategory, WxrTag } from './WxrParser';
|
||||
|
||||
export type PostAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate';
|
||||
export type MediaAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate' | 'missing';
|
||||
|
||||
export interface AnalyzedPost {
|
||||
wxrPost: WxrPost;
|
||||
status: PostAnalysisStatus;
|
||||
contentHash: string;
|
||||
markdownPreview: string;
|
||||
existingPost?: {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
checksum: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnalyzedMedia {
|
||||
wxrMedia: WxrMedia;
|
||||
status: MediaAnalysisStatus;
|
||||
fileHash: string | null;
|
||||
existingMedia?: {
|
||||
id: string;
|
||||
originalName: string;
|
||||
checksum: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnalyzedCategory {
|
||||
name: string;
|
||||
slug: string;
|
||||
existsInProject: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyzedTag {
|
||||
name: string;
|
||||
slug: string;
|
||||
existsInProject: boolean;
|
||||
}
|
||||
|
||||
export interface ImportAnalysisReport {
|
||||
sourceFile: string;
|
||||
site: WxrSiteInfo;
|
||||
analyzedAt: Date;
|
||||
posts: {
|
||||
total: number;
|
||||
new: number;
|
||||
updates: number;
|
||||
conflicts: number;
|
||||
contentDuplicates: number;
|
||||
items: AnalyzedPost[];
|
||||
};
|
||||
pages: {
|
||||
total: number;
|
||||
new: number;
|
||||
updates: number;
|
||||
conflicts: number;
|
||||
contentDuplicates: number;
|
||||
items: AnalyzedPost[];
|
||||
};
|
||||
media: {
|
||||
total: number;
|
||||
new: number;
|
||||
updates: number;
|
||||
conflicts: number;
|
||||
contentDuplicates: number;
|
||||
missing: number;
|
||||
items: AnalyzedMedia[];
|
||||
};
|
||||
categories: AnalyzedCategory[];
|
||||
tags: AnalyzedTag[];
|
||||
}
|
||||
|
||||
export class ImportAnalysisEngine {
|
||||
private currentProjectId: string = '';
|
||||
private turndown: TurndownService;
|
||||
|
||||
constructor() {
|
||||
this.turndown = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
bulletListMarker: '-',
|
||||
});
|
||||
}
|
||||
|
||||
setProjectContext(projectId: string): void {
|
||||
this.currentProjectId = projectId;
|
||||
}
|
||||
|
||||
async analyzeWxr(wxrData: WxrData, sourceFile: string, uploadsFolder?: string): Promise<ImportAnalysisReport> {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
// Fetch existing posts for this project
|
||||
const existingPosts = await db
|
||||
.select({
|
||||
id: posts.id,
|
||||
slug: posts.slug,
|
||||
title: posts.title,
|
||||
checksum: posts.checksum,
|
||||
})
|
||||
.from(posts)
|
||||
.where(eq(posts.projectId, this.currentProjectId))
|
||||
.all();
|
||||
|
||||
// Fetch existing media for this project
|
||||
const existingMedia = await db
|
||||
.select({
|
||||
id: media.id,
|
||||
originalName: media.originalName,
|
||||
checksum: media.checksum,
|
||||
})
|
||||
.from(media)
|
||||
.where(eq(media.projectId, this.currentProjectId))
|
||||
.all();
|
||||
|
||||
// Fetch existing tags for this project
|
||||
const existingTags = await db
|
||||
.select({
|
||||
name: tags.name,
|
||||
})
|
||||
.from(tags)
|
||||
.where(eq(tags.projectId, this.currentProjectId))
|
||||
.all();
|
||||
|
||||
// Build lookup maps for posts
|
||||
const slugToPost = new Map<string, typeof existingPosts[0]>();
|
||||
const checksumToPost = new Map<string, typeof existingPosts[0]>();
|
||||
for (const post of existingPosts) {
|
||||
slugToPost.set(post.slug, post);
|
||||
if (post.checksum) {
|
||||
checksumToPost.set(post.checksum, post);
|
||||
}
|
||||
}
|
||||
|
||||
// Build lookup maps for media
|
||||
const nameToMedia = new Map<string, typeof existingMedia[0]>();
|
||||
const checksumToMedia = new Map<string, typeof existingMedia[0]>();
|
||||
for (const m of existingMedia) {
|
||||
nameToMedia.set(m.originalName.toLowerCase(), m);
|
||||
if (m.checksum) {
|
||||
checksumToMedia.set(m.checksum, m);
|
||||
}
|
||||
}
|
||||
|
||||
// Build tag set
|
||||
const existingTagNames = new Set(existingTags.map(t => t.name.toLowerCase()));
|
||||
|
||||
// Analyze posts
|
||||
const analyzedPosts = this.analyzePostItems(wxrData.posts, slugToPost, checksumToPost);
|
||||
const analyzedPages = this.analyzePostItems(wxrData.pages, slugToPost, checksumToPost);
|
||||
|
||||
// Analyze media
|
||||
const analyzedMedia = await this.analyzeMediaItems(wxrData.media, nameToMedia, checksumToMedia, uploadsFolder);
|
||||
|
||||
// Analyze categories
|
||||
const analyzedCategories: AnalyzedCategory[] = wxrData.categories.map(cat => ({
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
existsInProject: existingTagNames.has(cat.name.toLowerCase()),
|
||||
}));
|
||||
|
||||
// Analyze tags
|
||||
const analyzedTags: AnalyzedTag[] = wxrData.tags.map(tag => ({
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
existsInProject: existingTagNames.has(tag.name.toLowerCase()),
|
||||
}));
|
||||
|
||||
return {
|
||||
sourceFile,
|
||||
site: wxrData.site,
|
||||
analyzedAt: new Date(),
|
||||
posts: this.summarizePostAnalysis(analyzedPosts),
|
||||
pages: this.summarizePostAnalysis(analyzedPages),
|
||||
media: this.summarizeMediaAnalysis(analyzedMedia),
|
||||
categories: analyzedCategories,
|
||||
tags: analyzedTags,
|
||||
};
|
||||
}
|
||||
|
||||
private analyzePostItems(
|
||||
wxrPosts: WxrPost[],
|
||||
slugToPost: Map<string, { id: string; slug: string; title: string; checksum: string | null }>,
|
||||
checksumToPost: Map<string, { id: string; slug: string; title: string; checksum: string | null }>,
|
||||
): AnalyzedPost[] {
|
||||
return wxrPosts.map(wxrPost => {
|
||||
const markdown = this.convertToMarkdown(wxrPost.content);
|
||||
const contentHash = this.calculateChecksum(markdown);
|
||||
const markdownPreview = markdown.substring(0, 200);
|
||||
|
||||
const existingBySlug = slugToPost.get(wxrPost.slug);
|
||||
const existingByHash = checksumToPost.get(contentHash);
|
||||
|
||||
let status: PostAnalysisStatus;
|
||||
let existingPost: AnalyzedPost['existingPost'];
|
||||
|
||||
if (existingBySlug) {
|
||||
if (existingBySlug.checksum === contentHash) {
|
||||
status = 'update';
|
||||
} else {
|
||||
status = 'conflict';
|
||||
}
|
||||
existingPost = {
|
||||
id: existingBySlug.id,
|
||||
title: existingBySlug.title,
|
||||
slug: existingBySlug.slug,
|
||||
checksum: existingBySlug.checksum,
|
||||
};
|
||||
} else if (existingByHash) {
|
||||
status = 'content-duplicate';
|
||||
existingPost = {
|
||||
id: existingByHash.id,
|
||||
title: existingByHash.title,
|
||||
slug: existingByHash.slug,
|
||||
checksum: existingByHash.checksum,
|
||||
};
|
||||
} else {
|
||||
status = 'new';
|
||||
}
|
||||
|
||||
return { wxrPost, status, contentHash, markdownPreview, existingPost };
|
||||
});
|
||||
}
|
||||
|
||||
private async analyzeMediaItems(
|
||||
wxrMediaItems: WxrMedia[],
|
||||
nameToMedia: Map<string, { id: string; originalName: string; checksum: string | null }>,
|
||||
checksumToMedia: Map<string, { id: string; originalName: string; checksum: string | null }>,
|
||||
uploadsFolder?: string,
|
||||
): Promise<AnalyzedMedia[]> {
|
||||
const results: AnalyzedMedia[] = [];
|
||||
|
||||
for (const wxrMedia of wxrMediaItems) {
|
||||
let fileHash: string | null = null;
|
||||
let fileFound = false;
|
||||
|
||||
// Try to read the actual file from the uploads folder
|
||||
if (uploadsFolder) {
|
||||
try {
|
||||
const filePath = path.join(uploadsFolder, wxrMedia.relativePath);
|
||||
const buffer = await fs.readFile(filePath);
|
||||
fileHash = this.calculateChecksum(buffer.toString('binary'));
|
||||
fileFound = true;
|
||||
} catch {
|
||||
// File not found in uploads folder
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileFound) {
|
||||
results.push({
|
||||
wxrMedia,
|
||||
status: 'missing',
|
||||
fileHash: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingByName = nameToMedia.get(wxrMedia.filename.toLowerCase());
|
||||
const existingByHash = fileHash ? checksumToMedia.get(fileHash) : undefined;
|
||||
|
||||
let status: MediaAnalysisStatus;
|
||||
let existingMedia: AnalyzedMedia['existingMedia'];
|
||||
|
||||
if (existingByName) {
|
||||
if (fileHash && existingByName.checksum === fileHash) {
|
||||
status = 'update';
|
||||
} else {
|
||||
status = 'conflict';
|
||||
}
|
||||
existingMedia = {
|
||||
id: existingByName.id,
|
||||
originalName: existingByName.originalName,
|
||||
checksum: existingByName.checksum,
|
||||
};
|
||||
} else if (existingByHash) {
|
||||
status = 'content-duplicate';
|
||||
existingMedia = {
|
||||
id: existingByHash.id,
|
||||
originalName: existingByHash.originalName,
|
||||
checksum: existingByHash.checksum,
|
||||
};
|
||||
} else {
|
||||
status = 'new';
|
||||
}
|
||||
|
||||
results.push({ wxrMedia, status, fileHash, existingMedia });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private summarizePostAnalysis(items: AnalyzedPost[]): ImportAnalysisReport['posts'] {
|
||||
return {
|
||||
total: items.length,
|
||||
new: items.filter(i => i.status === 'new').length,
|
||||
updates: items.filter(i => i.status === 'update').length,
|
||||
conflicts: items.filter(i => i.status === 'conflict').length,
|
||||
contentDuplicates: items.filter(i => i.status === 'content-duplicate').length,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
private summarizeMediaAnalysis(items: AnalyzedMedia[]): ImportAnalysisReport['media'] {
|
||||
return {
|
||||
total: items.length,
|
||||
new: items.filter(i => i.status === 'new').length,
|
||||
updates: items.filter(i => i.status === 'update').length,
|
||||
conflicts: items.filter(i => i.status === 'conflict').length,
|
||||
contentDuplicates: items.filter(i => i.status === 'content-duplicate').length,
|
||||
missing: items.filter(i => i.status === 'missing').length,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
private convertToMarkdown(html: string): string {
|
||||
if (!html || !html.trim()) return '';
|
||||
return this.turndown.turndown(html);
|
||||
}
|
||||
|
||||
private calculateChecksum(content: string): string {
|
||||
return crypto.createHash('md5').update(content).digest('hex');
|
||||
}
|
||||
}
|
||||
307
src/main/engine/WxrParser.ts
Normal file
307
src/main/engine/WxrParser.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { DOMParser } from '@xmldom/xmldom';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
export interface WxrSiteInfo {
|
||||
title: string;
|
||||
link: string;
|
||||
description: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface WxrPost {
|
||||
wpId: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
excerpt: string;
|
||||
pubDate: Date | null;
|
||||
creator: string;
|
||||
status: string;
|
||||
postType: string;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface WxrMedia {
|
||||
wpId: number;
|
||||
title: string;
|
||||
url: string;
|
||||
filename: string;
|
||||
relativePath: string;
|
||||
pubDate: Date | null;
|
||||
parentId: number;
|
||||
mimeType: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface WxrCategory {
|
||||
name: string;
|
||||
slug: string;
|
||||
parent: string;
|
||||
}
|
||||
|
||||
export interface WxrTag {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface WxrData {
|
||||
site: WxrSiteInfo;
|
||||
posts: WxrPost[];
|
||||
pages: WxrPost[];
|
||||
media: WxrMedia[];
|
||||
categories: WxrCategory[];
|
||||
tags: WxrTag[];
|
||||
}
|
||||
|
||||
// WordPress namespace URIs
|
||||
const NS = {
|
||||
wp: 'http://wordpress.org/export/1.2/',
|
||||
content: 'http://purl.org/rss/1.0/modules/content/',
|
||||
excerpt: 'http://wordpress.org/export/1.2/excerpt/',
|
||||
dc: 'http://purl.org/dc/elements/1.1/',
|
||||
};
|
||||
|
||||
// Common MIME types by file extension
|
||||
const EXT_TO_MIME: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
bmp: 'image/bmp',
|
||||
ico: 'image/x-icon',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
zip: 'application/zip',
|
||||
};
|
||||
|
||||
export class WxrParser {
|
||||
|
||||
async parseFile(filePath: string): Promise<WxrData> {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return this.parseXml(content);
|
||||
}
|
||||
|
||||
parseXml(xmlContent: string): WxrData {
|
||||
const doc = new DOMParser().parseFromString(xmlContent, 'text/xml');
|
||||
const channel = doc.getElementsByTagName('channel')[0];
|
||||
|
||||
if (!channel) {
|
||||
throw new Error('Invalid WXR file: no <channel> element found');
|
||||
}
|
||||
|
||||
const site = this.parseSiteInfo(channel);
|
||||
const categories = this.parseChannelCategories(channel);
|
||||
const tags = this.parseChannelTags(channel);
|
||||
|
||||
const posts: WxrPost[] = [];
|
||||
const pages: WxrPost[] = [];
|
||||
const media: WxrMedia[] = [];
|
||||
|
||||
const items = channel.getElementsByTagName('item');
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const postType = this.getElementText(item, 'post_type', NS.wp);
|
||||
|
||||
if (postType === 'attachment') {
|
||||
media.push(this.parseMediaItem(item));
|
||||
} else if (postType === 'page') {
|
||||
pages.push(this.parsePostItem(item));
|
||||
} else {
|
||||
// 'post' and any other custom post types
|
||||
posts.push(this.parsePostItem(item));
|
||||
}
|
||||
}
|
||||
|
||||
return { site, posts, pages, media, categories, tags };
|
||||
}
|
||||
|
||||
private parseSiteInfo(channel: Element): WxrSiteInfo {
|
||||
return {
|
||||
title: this.getDirectChildText(channel, 'title'),
|
||||
link: this.getDirectChildText(channel, 'link'),
|
||||
description: this.getDirectChildText(channel, 'description'),
|
||||
language: this.getDirectChildText(channel, 'language'),
|
||||
};
|
||||
}
|
||||
|
||||
private parseChannelCategories(channel: Element): WxrCategory[] {
|
||||
const categories: WxrCategory[] = [];
|
||||
const elements = channel.getElementsByTagNameNS(NS.wp, 'category');
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
// Only process direct children of channel (not item-level category elements)
|
||||
if (el.parentNode !== channel) continue;
|
||||
|
||||
categories.push({
|
||||
name: this.getElementText(el, 'cat_name', NS.wp),
|
||||
slug: this.getElementText(el, 'category_nicename', NS.wp),
|
||||
parent: this.getElementText(el, 'category_parent', NS.wp),
|
||||
});
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
private parseChannelTags(channel: Element): WxrTag[] {
|
||||
const tags: WxrTag[] = [];
|
||||
const elements = channel.getElementsByTagNameNS(NS.wp, 'tag');
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
if (el.parentNode !== channel) continue;
|
||||
|
||||
tags.push({
|
||||
name: this.getElementText(el, 'tag_name', NS.wp),
|
||||
slug: this.getElementText(el, 'tag_slug', NS.wp),
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
private parsePostItem(item: Element): WxrPost {
|
||||
const categories: string[] = [];
|
||||
const tags: string[] = [];
|
||||
|
||||
// Item-level <category> elements (no namespace)
|
||||
const catElements = item.getElementsByTagName('category');
|
||||
for (let i = 0; i < catElements.length; i++) {
|
||||
const el = catElements[i];
|
||||
// Only direct children of item
|
||||
if (el.parentNode !== item) continue;
|
||||
const domain = el.getAttribute('domain');
|
||||
const text = this.getTextContent(el);
|
||||
if (domain === 'category' && text) {
|
||||
categories.push(text);
|
||||
} else if (domain === 'post_tag' && text) {
|
||||
tags.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
const pubDateStr = this.getDirectChildText(item, 'pubDate');
|
||||
let pubDate: Date | null = null;
|
||||
if (pubDateStr) {
|
||||
const parsed = new Date(pubDateStr);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
pubDate = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wpId: parseInt(this.getElementText(item, 'post_id', NS.wp) || '0', 10),
|
||||
title: this.getDirectChildText(item, 'title'),
|
||||
slug: this.getElementText(item, 'post_name', NS.wp),
|
||||
content: this.getElementText(item, 'encoded', NS.content),
|
||||
excerpt: this.getElementText(item, 'encoded', NS.excerpt),
|
||||
pubDate,
|
||||
creator: this.getElementText(item, 'creator', NS.dc),
|
||||
status: this.getElementText(item, 'status', NS.wp),
|
||||
postType: this.getElementText(item, 'post_type', NS.wp),
|
||||
categories,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
private parseMediaItem(item: Element): WxrMedia {
|
||||
const url = this.getElementText(item, 'attachment_url', NS.wp);
|
||||
const filename = this.extractFilename(url);
|
||||
const relativePath = this.extractRelativePath(url);
|
||||
|
||||
const pubDateStr = this.getDirectChildText(item, 'pubDate');
|
||||
let pubDate: Date | null = null;
|
||||
if (pubDateStr) {
|
||||
const parsed = new Date(pubDateStr);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
pubDate = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wpId: parseInt(this.getElementText(item, 'post_id', NS.wp) || '0', 10),
|
||||
title: this.getDirectChildText(item, 'title'),
|
||||
url,
|
||||
filename,
|
||||
relativePath,
|
||||
pubDate,
|
||||
parentId: parseInt(this.getElementText(item, 'post_parent', NS.wp) || '0', 10),
|
||||
mimeType: this.inferMimeType(filename),
|
||||
description: this.getElementText(item, 'encoded', NS.content),
|
||||
};
|
||||
}
|
||||
|
||||
private extractFilename(url: string): string {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
return pathname.split('/').pop() || '';
|
||||
} catch {
|
||||
return url.split('/').pop() || '';
|
||||
}
|
||||
}
|
||||
|
||||
private extractRelativePath(url: string): string {
|
||||
if (!url) return '';
|
||||
// Extract path after wp-content/uploads/
|
||||
const marker = 'wp-content/uploads/';
|
||||
const idx = url.indexOf(marker);
|
||||
if (idx !== -1) {
|
||||
return url.substring(idx + marker.length);
|
||||
}
|
||||
// Fallback: return filename only
|
||||
return this.extractFilename(url);
|
||||
}
|
||||
|
||||
private inferMimeType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
return EXT_TO_MIME[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/** Get text content of a namespaced child element */
|
||||
private getElementText(parent: Element, localName: string, nsUri: string): string {
|
||||
const elements = parent.getElementsByTagNameNS(nsUri, localName);
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
// Find first one that is either a direct child or a grandchild (for nested structures)
|
||||
if (el.parentNode === parent || el.parentNode?.parentNode === parent) {
|
||||
return this.getTextContent(el);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Get text content of a direct child element (no namespace) */
|
||||
private getDirectChildText(parent: Element, tagName: string): string {
|
||||
const children = parent.childNodes;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
if (child.nodeType === 1 && (child as Element).localName === tagName) {
|
||||
return this.getTextContent(child as Element);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Safely extract text content, handling CDATA sections */
|
||||
private getTextContent(el: Element): string {
|
||||
let text = '';
|
||||
const children = el.childNodes;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
if (child.nodeType === 3 || child.nodeType === 4) {
|
||||
// Text node or CDATA section
|
||||
text += child.nodeValue || '';
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -50,5 +50,22 @@ export {
|
||||
type SendMessageResult,
|
||||
type ModelInfo,
|
||||
} from './OpenCodeManager';
|
||||
|
||||
|
||||
export {
|
||||
WxrParser,
|
||||
type WxrData,
|
||||
type WxrPost,
|
||||
type WxrMedia,
|
||||
type WxrSiteInfo,
|
||||
type WxrCategory,
|
||||
type WxrTag,
|
||||
} from './WxrParser';
|
||||
export {
|
||||
ImportAnalysisEngine,
|
||||
type ImportAnalysisReport,
|
||||
type AnalyzedPost,
|
||||
type AnalyzedMedia,
|
||||
type AnalyzedCategory,
|
||||
type AnalyzedTag,
|
||||
type PostAnalysisStatus,
|
||||
type MediaAnalysisStatus,
|
||||
} from './ImportAnalysisEngine';
|
||||
|
||||
@@ -745,6 +745,68 @@ export function registerIpcHandlers(): void {
|
||||
return engine.rebuildFromSidecars();
|
||||
});
|
||||
|
||||
// ============ Import Analysis Handlers ============
|
||||
|
||||
safeHandle('import:selectAndAnalyze', async (_, uploadsFolder?: string) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Select WordPress Export File (WXR)',
|
||||
filters: [
|
||||
{ name: 'WordPress Export', extensions: ['xml'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
properties: ['openFile'],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = result.filePaths[0];
|
||||
const { WxrParser } = await import('../engine/WxrParser');
|
||||
const { ImportAnalysisEngine } = await import('../engine/ImportAnalysisEngine');
|
||||
|
||||
const parser = new WxrParser();
|
||||
const wxrData = await parser.parseFile(filePath);
|
||||
|
||||
const analysisEngine = new ImportAnalysisEngine();
|
||||
const projectEngine = getProjectEngine();
|
||||
const activeProject = await projectEngine.getActiveProject();
|
||||
if (activeProject) {
|
||||
analysisEngine.setProjectContext(activeProject.id);
|
||||
}
|
||||
|
||||
return analysisEngine.analyzeWxr(wxrData, filePath, uploadsFolder || undefined);
|
||||
});
|
||||
|
||||
safeHandle('import:analyzeFile', async (_, filePath: string, uploadsFolder?: string) => {
|
||||
const { WxrParser } = await import('../engine/WxrParser');
|
||||
const { ImportAnalysisEngine } = await import('../engine/ImportAnalysisEngine');
|
||||
|
||||
const parser = new WxrParser();
|
||||
const wxrData = await parser.parseFile(filePath);
|
||||
|
||||
const analysisEngine = new ImportAnalysisEngine();
|
||||
const projectEngine = getProjectEngine();
|
||||
const activeProject = await projectEngine.getActiveProject();
|
||||
if (activeProject) {
|
||||
analysisEngine.setProjectContext(activeProject.id);
|
||||
}
|
||||
|
||||
return analysisEngine.analyzeWxr(wxrData, filePath, uploadsFolder || undefined);
|
||||
});
|
||||
|
||||
safeHandle('import:selectUploadsFolder', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Select WordPress Uploads Folder',
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
// ============ Event Forwarding ============
|
||||
|
||||
// Forward engine events to renderer
|
||||
|
||||
@@ -150,6 +150,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
syncFromPosts: () => ipcRenderer.invoke('tags:syncFromPosts'),
|
||||
},
|
||||
|
||||
// Import Analysis
|
||||
import: {
|
||||
selectAndAnalyze: (uploadsFolder?: string) => ipcRenderer.invoke('import:selectAndAnalyze', uploadsFolder),
|
||||
analyzeFile: (filePath: string, uploadsFolder?: string) => ipcRenderer.invoke('import:analyzeFile', filePath, uploadsFolder),
|
||||
selectUploadsFolder: () => ipcRenderer.invoke('import:selectUploadsFolder'),
|
||||
},
|
||||
|
||||
// AI Chat (OpenCode Zen API integration)
|
||||
chat: {
|
||||
// API Key Management
|
||||
@@ -312,6 +319,11 @@ export interface ElectronAPI {
|
||||
getPostsWithTag: (tagId: string) => Promise<unknown[]>;
|
||||
syncFromPosts: () => Promise<void>;
|
||||
};
|
||||
import: {
|
||||
selectAndAnalyze: (uploadsFolder?: string) => Promise<unknown>;
|
||||
analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>;
|
||||
selectUploadsFolder: () => Promise<string | null>;
|
||||
};
|
||||
chat: {
|
||||
// API Key Management
|
||||
checkReady: () => Promise<{ ready: boolean; error?: string; backend?: string }>;
|
||||
|
||||
@@ -37,6 +37,12 @@ const ChatIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ImportIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SyncIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
|
||||
@@ -60,6 +66,9 @@ export const ActivityBar: React.FC = () => {
|
||||
// Check if chat sidebar is active (activeView === 'chat' and sidebar is visible)
|
||||
const isChatActive = activeView === 'chat' && sidebarVisible;
|
||||
|
||||
// Check if import tab is currently active
|
||||
const isImportTabActive = tabs.some(t => t.type === 'import' && t.id === activeTabId);
|
||||
|
||||
// Handle view click - toggle sidebar if clicking on active view, otherwise switch view
|
||||
const handleViewClick = (view: 'posts' | 'media' | 'chat') => {
|
||||
if (activeView === view && sidebarVisible) {
|
||||
@@ -96,6 +105,11 @@ export const ActivityBar: React.FC = () => {
|
||||
openTab({ type: 'tags', id: 'tags', isTransient: false });
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
// Open import as a dedicated (non-transient) tab
|
||||
openTab({ type: 'import', id: 'import', isTransient: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="activity-bar">
|
||||
<div className="activity-bar-top">
|
||||
@@ -127,6 +141,13 @@ export const ActivityBar: React.FC = () => {
|
||||
>
|
||||
<ChatIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${isImportTabActive ? 'active' : ''}`}
|
||||
onClick={handleImportClick}
|
||||
title="Import Analysis"
|
||||
>
|
||||
<ImportIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="activity-bar-bottom">
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SettingsView } from '../SettingsView';
|
||||
import { TagsView } from '../TagsView';
|
||||
import { TagInput } from '../TagInput';
|
||||
import { ChatPanel } from '../ChatPanel';
|
||||
import { ImportAnalysisView } from '../ImportAnalysisView';
|
||||
import { AutoSaveManager } from '../../utils';
|
||||
import { parseMacros, getMacro } from '../../macros/registry';
|
||||
import { PostSearchModal } from '../PostSearchModal';
|
||||
@@ -1531,6 +1532,7 @@ export const Editor: React.FC = () => {
|
||||
const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab);
|
||||
const showTags = activeTab?.type === 'tags' || (activeView === 'tags' && !activeTab);
|
||||
const showChat = activeTab?.type === 'chat';
|
||||
const showImport = activeTab?.type === 'import';
|
||||
|
||||
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
|
||||
useEffect(() => {
|
||||
@@ -1619,6 +1621,17 @@ export const Editor: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Show import analysis if import tab is active
|
||||
if (showImport) {
|
||||
return (
|
||||
<div className="editor">
|
||||
<ImportAnalysisView />
|
||||
{renderErrorModal()}
|
||||
{renderConfirmDeleteModal()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show post editor if a post tab is active
|
||||
if (showPost && activeTabId) {
|
||||
const post = posts.find(p => p.id === activeTabId);
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
.import-analysis {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.import-analysis-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.import-analysis-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.import-analysis-header p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/* File selection area */
|
||||
.import-file-selectors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: var(--vscode-sideBar-background);
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.import-file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.import-file-row label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
min-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.import-file-path {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.import-file-path.placeholder {
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.import-file-row button {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
|
||||
.import-file-row button:hover {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.import-analyze-btn {
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.import-analyze-btn:hover {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.import-analyze-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.import-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.import-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--vscode-descriptionForeground);
|
||||
border-top-color: var(--vscode-button-background);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Site info card */
|
||||
.import-site-info {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
background: var(--vscode-sideBar-background);
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.import-site-info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.import-site-info-item .info-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.import-site-info-item .info-value {
|
||||
font-size: 13px;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
/* Stat cards grid */
|
||||
.import-stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.import-stat-card {
|
||||
background: var(--vscode-sideBar-background);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.import-stat-card h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.import-stat-number {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.import-stat-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.import-stat-breakdown {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.import-stat-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--vscode-badge-background);
|
||||
color: var(--vscode-badge-foreground);
|
||||
}
|
||||
|
||||
.import-stat-tag.stat-new {
|
||||
background: rgba(115, 201, 145, 0.15);
|
||||
color: #73c991;
|
||||
}
|
||||
|
||||
.import-stat-tag.stat-update {
|
||||
background: rgba(117, 190, 255, 0.15);
|
||||
color: #75beff;
|
||||
}
|
||||
|
||||
.import-stat-tag.stat-conflict {
|
||||
background: rgba(244, 135, 113, 0.15);
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.import-stat-tag.stat-duplicate {
|
||||
background: rgba(204, 167, 0, 0.15);
|
||||
color: #cca700;
|
||||
}
|
||||
|
||||
.import-stat-tag.stat-missing {
|
||||
background: rgba(150, 150, 150, 0.15);
|
||||
color: #969696;
|
||||
}
|
||||
|
||||
/* Detail sections */
|
||||
.import-detail-section {
|
||||
background: var(--vscode-sideBar-background);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.import-detail-section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-foreground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.import-detail-section h3 .toggle-icon {
|
||||
font-size: 10px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.import-detail-section h3 .toggle-icon.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Detail tables */
|
||||
.import-detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.import-detail-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
border-bottom: 1px solid var(--vscode-panel-border, rgba(255,255,255,0.1));
|
||||
}
|
||||
|
||||
.import-detail-table td {
|
||||
padding: 6px 10px;
|
||||
color: var(--vscode-foreground);
|
||||
border-bottom: 1px solid var(--vscode-panel-border, rgba(255,255,255,0.06));
|
||||
}
|
||||
|
||||
.import-detail-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.import-detail-table .status-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.import-detail-table .status-badge.new {
|
||||
background: rgba(115, 201, 145, 0.15);
|
||||
color: #73c991;
|
||||
}
|
||||
|
||||
.import-detail-table .status-badge.update {
|
||||
background: rgba(117, 190, 255, 0.15);
|
||||
color: #75beff;
|
||||
}
|
||||
|
||||
.import-detail-table .status-badge.conflict {
|
||||
background: rgba(244, 135, 113, 0.15);
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.import-detail-table .status-badge.content-duplicate {
|
||||
background: rgba(204, 167, 0, 0.15);
|
||||
color: #cca700;
|
||||
}
|
||||
|
||||
.import-detail-table .status-badge.missing {
|
||||
background: rgba(150, 150, 150, 0.15);
|
||||
color: #969696;
|
||||
}
|
||||
|
||||
.import-detail-table .slug-cell {
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.import-detail-table .existing-match {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/* Tag/category pills */
|
||||
.import-taxonomy-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.import-taxonomy-pill {
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--vscode-badge-background);
|
||||
color: var(--vscode-badge-foreground);
|
||||
}
|
||||
|
||||
.import-taxonomy-pill.exists {
|
||||
background: rgba(115, 201, 145, 0.15);
|
||||
color: #73c991;
|
||||
}
|
||||
|
||||
.import-taxonomy-pill.new-tax {
|
||||
background: rgba(117, 190, 255, 0.15);
|
||||
color: #75beff;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.import-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
gap: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.import-empty-state svg {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.import-empty-state p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import './ImportAnalysisView.css';
|
||||
|
||||
interface AnalysisReport {
|
||||
sourceFile: string;
|
||||
site: { title: string; link: string; description: string; language: string };
|
||||
analyzedAt: string;
|
||||
posts: ItemSection;
|
||||
pages: ItemSection;
|
||||
media: MediaSection;
|
||||
categories: TaxonomyItem[];
|
||||
tags: TaxonomyItem[];
|
||||
}
|
||||
|
||||
interface ItemSection {
|
||||
total: number;
|
||||
new: number;
|
||||
updates: number;
|
||||
conflicts: number;
|
||||
contentDuplicates: number;
|
||||
items: AnalyzedPostItem[];
|
||||
}
|
||||
|
||||
interface MediaSection {
|
||||
total: number;
|
||||
new: number;
|
||||
updates: number;
|
||||
conflicts: number;
|
||||
contentDuplicates: number;
|
||||
missing: number;
|
||||
items: AnalyzedMediaItem[];
|
||||
}
|
||||
|
||||
interface AnalyzedPostItem {
|
||||
wxrPost: { wpId: number; title: string; slug: string; status: string };
|
||||
status: string;
|
||||
contentHash: string;
|
||||
markdownPreview: string;
|
||||
existingPost?: { id: string; title: string; slug: string };
|
||||
}
|
||||
|
||||
interface AnalyzedMediaItem {
|
||||
wxrMedia: { wpId: number; title: string; filename: string; url: string; relativePath: string };
|
||||
status: string;
|
||||
fileHash: string | null;
|
||||
existingMedia?: { id: string; originalName: string };
|
||||
}
|
||||
|
||||
interface TaxonomyItem {
|
||||
name: string;
|
||||
slug: string;
|
||||
existsInProject: boolean;
|
||||
}
|
||||
|
||||
export const ImportAnalysisView: React.FC = () => {
|
||||
const { importAnalysis, importAnalysisLoading, setImportAnalysis, setImportAnalysisLoading } = useAppStore();
|
||||
const [uploadsFolder, setUploadsFolder] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
|
||||
const report = importAnalysis as AnalysisReport | null;
|
||||
|
||||
const handleSelectUploadsFolder = useCallback(async () => {
|
||||
const folder = await window.electronAPI?.import.selectUploadsFolder();
|
||||
if (folder) {
|
||||
setUploadsFolder(folder);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectAndAnalyze = useCallback(async () => {
|
||||
setImportAnalysisLoading(true);
|
||||
setImportAnalysis(null);
|
||||
try {
|
||||
const result = await window.electronAPI?.import.selectAndAnalyze(uploadsFolder || undefined);
|
||||
if (result) {
|
||||
setImportAnalysis(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import analysis failed:', error);
|
||||
} finally {
|
||||
setImportAnalysisLoading(false);
|
||||
}
|
||||
}, [uploadsFolder, setImportAnalysis, setImportAnalysisLoading]);
|
||||
|
||||
const toggleSection = useCallback((section: string) => {
|
||||
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="import-analysis">
|
||||
<div className="import-analysis-header">
|
||||
<h2>Import Analysis</h2>
|
||||
<p>Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported.</p>
|
||||
</div>
|
||||
|
||||
<div className="import-file-selectors">
|
||||
<div className="import-file-row">
|
||||
<label>Uploads Folder</label>
|
||||
<div className={`import-file-path ${!uploadsFolder ? 'placeholder' : ''}`}>
|
||||
{uploadsFolder || 'No folder selected'}
|
||||
</div>
|
||||
<button onClick={handleSelectUploadsFolder}>Browse...</button>
|
||||
</div>
|
||||
<div className="import-file-row">
|
||||
<label>WXR File</label>
|
||||
<div className={`import-file-path ${!report ? 'placeholder' : ''}`}>
|
||||
{report?.sourceFile || 'Select a file to analyze'}
|
||||
</div>
|
||||
<button
|
||||
className="import-analyze-btn"
|
||||
onClick={handleSelectAndAnalyze}
|
||||
disabled={importAnalysisLoading}
|
||||
>
|
||||
{importAnalysisLoading ? 'Analyzing...' : 'Select & Analyze'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importAnalysisLoading && (
|
||||
<div className="import-loading">
|
||||
<div className="import-spinner" />
|
||||
Analyzing WXR file...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!report && !importAnalysisLoading && (
|
||||
<div className="import-empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
</svg>
|
||||
<p>Select a WordPress export file to begin analysis.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && !importAnalysisLoading && (
|
||||
<>
|
||||
<SiteInfoCard site={report.site} sourceFile={report.sourceFile} />
|
||||
<StatCards report={report} />
|
||||
|
||||
{report.posts.conflicts > 0 && (
|
||||
<ConflictsSection
|
||||
title="Post Slug Conflicts"
|
||||
items={report.posts.items.filter(i => i.status === 'conflict')}
|
||||
expanded={expandedSections['post-conflicts'] ?? true}
|
||||
onToggle={() => toggleSection('post-conflicts')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{report.pages.conflicts > 0 && (
|
||||
<ConflictsSection
|
||||
title="Page Slug Conflicts"
|
||||
items={report.pages.items.filter(i => i.status === 'conflict')}
|
||||
expanded={expandedSections['page-conflicts'] ?? true}
|
||||
onToggle={() => toggleSection('page-conflicts')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PostDetailSection
|
||||
title={`Posts (${report.posts.total})`}
|
||||
items={report.posts.items}
|
||||
expanded={expandedSections['posts'] ?? false}
|
||||
onToggle={() => toggleSection('posts')}
|
||||
/>
|
||||
|
||||
{report.pages.total > 0 && (
|
||||
<PostDetailSection
|
||||
title={`Pages (${report.pages.total})`}
|
||||
items={report.pages.items}
|
||||
expanded={expandedSections['pages'] ?? false}
|
||||
onToggle={() => toggleSection('pages')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MediaDetailSection
|
||||
title={`Media (${report.media.total})`}
|
||||
items={report.media.items}
|
||||
expanded={expandedSections['media'] ?? false}
|
||||
onToggle={() => toggleSection('media')}
|
||||
/>
|
||||
|
||||
{(report.categories.length > 0 || report.tags.length > 0) && (
|
||||
<TaxonomySection
|
||||
categories={report.categories}
|
||||
tags={report.tags}
|
||||
expanded={expandedSections['taxonomy'] ?? false}
|
||||
onToggle={() => toggleSection('taxonomy')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SiteInfoCard: React.FC<{ site: AnalysisReport['site']; sourceFile: string }> = ({ site, sourceFile }) => (
|
||||
<div className="import-site-info">
|
||||
<div className="import-site-info-item">
|
||||
<span className="info-label">Site</span>
|
||||
<span className="info-value">{site.title || 'Untitled'}</span>
|
||||
</div>
|
||||
<div className="import-site-info-item">
|
||||
<span className="info-label">URL</span>
|
||||
<span className="info-value">{site.link || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="import-site-info-item">
|
||||
<span className="info-label">Language</span>
|
||||
<span className="info-value">{site.language || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="import-site-info-item">
|
||||
<span className="info-label">File</span>
|
||||
<span className="info-value">{sourceFile.split(/[/\\]/).pop()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StatCards: React.FC<{ report: AnalysisReport }> = ({ report }) => (
|
||||
<div className="import-stat-cards">
|
||||
<div className="import-stat-card">
|
||||
<h3>Posts</h3>
|
||||
<div className="import-stat-number">{report.posts.total}</div>
|
||||
<div className="import-stat-breakdown">
|
||||
{report.posts.new > 0 && <span className="import-stat-tag stat-new">{report.posts.new} new</span>}
|
||||
{report.posts.updates > 0 && <span className="import-stat-tag stat-update">{report.posts.updates} update</span>}
|
||||
{report.posts.conflicts > 0 && <span className="import-stat-tag stat-conflict">{report.posts.conflicts} conflict</span>}
|
||||
{report.posts.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{report.posts.contentDuplicates} duplicate</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="import-stat-card">
|
||||
<h3>Pages</h3>
|
||||
<div className="import-stat-number">{report.pages.total}</div>
|
||||
<div className="import-stat-breakdown">
|
||||
{report.pages.new > 0 && <span className="import-stat-tag stat-new">{report.pages.new} new</span>}
|
||||
{report.pages.updates > 0 && <span className="import-stat-tag stat-update">{report.pages.updates} update</span>}
|
||||
{report.pages.conflicts > 0 && <span className="import-stat-tag stat-conflict">{report.pages.conflicts} conflict</span>}
|
||||
{report.pages.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{report.pages.contentDuplicates} duplicate</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="import-stat-card">
|
||||
<h3>Media</h3>
|
||||
<div className="import-stat-number">{report.media.total}</div>
|
||||
<div className="import-stat-breakdown">
|
||||
{report.media.new > 0 && <span className="import-stat-tag stat-new">{report.media.new} new</span>}
|
||||
{report.media.updates > 0 && <span className="import-stat-tag stat-update">{report.media.updates} update</span>}
|
||||
{report.media.conflicts > 0 && <span className="import-stat-tag stat-conflict">{report.media.conflicts} conflict</span>}
|
||||
{report.media.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{report.media.contentDuplicates} duplicate</span>}
|
||||
{report.media.missing > 0 && <span className="import-stat-tag stat-missing">{report.media.missing} missing</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="import-stat-card">
|
||||
<h3>Categories</h3>
|
||||
<div className="import-stat-number">{report.categories.length}</div>
|
||||
<div className="import-stat-breakdown">
|
||||
{report.categories.filter(c => c.existsInProject).length > 0 && (
|
||||
<span className="import-stat-tag stat-update">{report.categories.filter(c => c.existsInProject).length} existing</span>
|
||||
)}
|
||||
{report.categories.filter(c => !c.existsInProject).length > 0 && (
|
||||
<span className="import-stat-tag stat-new">{report.categories.filter(c => !c.existsInProject).length} new</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="import-stat-card">
|
||||
<h3>Tags</h3>
|
||||
<div className="import-stat-number">{report.tags.length}</div>
|
||||
<div className="import-stat-breakdown">
|
||||
{report.tags.filter(t => t.existsInProject).length > 0 && (
|
||||
<span className="import-stat-tag stat-update">{report.tags.filter(t => t.existsInProject).length} existing</span>
|
||||
)}
|
||||
{report.tags.filter(t => !t.existsInProject).length > 0 && (
|
||||
<span className="import-stat-tag stat-new">{report.tags.filter(t => !t.existsInProject).length} new</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ConflictsSection: React.FC<{
|
||||
title: string;
|
||||
items: AnalyzedPostItem[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}> = ({ title, items, expanded, onToggle }) => (
|
||||
<div className="import-detail-section">
|
||||
<h3 onClick={onToggle}>
|
||||
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
||||
{title} ({items.length})
|
||||
</h3>
|
||||
{expanded && (
|
||||
<table className="import-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Slug</th>
|
||||
<th>WXR Title</th>
|
||||
<th>Existing Title</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="slug-cell">{item.wxrPost.slug}</td>
|
||||
<td>{item.wxrPost.title}</td>
|
||||
<td className="existing-match">{item.existingPost?.title || '--'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const PostDetailSection: React.FC<{
|
||||
title: string;
|
||||
items: AnalyzedPostItem[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}> = ({ title, items, expanded, onToggle }) => (
|
||||
<div className="import-detail-section">
|
||||
<h3 onClick={onToggle}>
|
||||
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
||||
{title}
|
||||
</h3>
|
||||
{expanded && (
|
||||
<table className="import-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Title</th>
|
||||
<th>Slug</th>
|
||||
<th>WP Status</th>
|
||||
<th>Existing Match</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td><span className={`status-badge ${item.status}`}>{item.status}</span></td>
|
||||
<td>{item.wxrPost.title}</td>
|
||||
<td className="slug-cell">{item.wxrPost.slug}</td>
|
||||
<td>{item.wxrPost.status}</td>
|
||||
<td className="existing-match">{item.existingPost?.title || '--'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const MediaDetailSection: React.FC<{
|
||||
title: string;
|
||||
items: AnalyzedMediaItem[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}> = ({ title, items, expanded, onToggle }) => (
|
||||
<div className="import-detail-section">
|
||||
<h3 onClick={onToggle}>
|
||||
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
||||
{title}
|
||||
</h3>
|
||||
{expanded && (
|
||||
<table className="import-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Filename</th>
|
||||
<th>Path</th>
|
||||
<th>Existing Match</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td><span className={`status-badge ${item.status}`}>{item.status}</span></td>
|
||||
<td>{item.wxrMedia.filename}</td>
|
||||
<td className="slug-cell">{item.wxrMedia.relativePath}</td>
|
||||
<td className="existing-match">{item.existingMedia?.originalName || '--'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const TaxonomySection: React.FC<{
|
||||
categories: TaxonomyItem[];
|
||||
tags: TaxonomyItem[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}> = ({ categories, tags, expanded, onToggle }) => (
|
||||
<div className="import-detail-section">
|
||||
<h3 onClick={onToggle}>
|
||||
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
||||
Categories & Tags
|
||||
</h3>
|
||||
{expanded && (
|
||||
<>
|
||||
{categories.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
|
||||
Categories
|
||||
</div>
|
||||
<div className="import-taxonomy-list">
|
||||
{categories.map((cat, idx) => (
|
||||
<span key={idx} className={`import-taxonomy-pill ${cat.existsInProject ? 'exists' : 'new-tax'}`}>
|
||||
{cat.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
|
||||
Tags
|
||||
</div>
|
||||
<div className="import-taxonomy-list">
|
||||
{tags.map((tag, idx) => (
|
||||
<span key={idx} className={`import-taxonomy-pill ${tag.existsInProject ? 'exists' : 'new-tax'}`}>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
1
src/renderer/components/ImportAnalysisView/index.ts
Normal file
1
src/renderer/components/ImportAnalysisView/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ImportAnalysisView } from './ImportAnalysisView';
|
||||
@@ -32,13 +32,17 @@ const getTabTitle = (
|
||||
const title = chatTitles.get(tab.id);
|
||||
if (title && title !== 'New Chat') {
|
||||
// Truncate long titles for display
|
||||
return title.length > MAX_CHAT_TITLE_LENGTH
|
||||
return title.length > MAX_CHAT_TITLE_LENGTH
|
||||
? title.substring(0, MAX_CHAT_TITLE_LENGTH) + '…'
|
||||
: title;
|
||||
}
|
||||
return 'New Chat';
|
||||
}
|
||||
|
||||
|
||||
if (tab.type === 'import') {
|
||||
return 'Import Analysis';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
@@ -74,6 +78,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
|
||||
<path d="M14 1H2a1 1 0 00-1 1v10a1 1 0 001 1h3v2.5l4-2.5h5a1 1 0 001-1V2a1 1 0 00-1-1zm0 11H8.5L5 14v-2H2V2h12v10z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'import':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
|
||||
@@ -19,3 +19,4 @@ export { LinkedMediaPanel } from './LinkedMediaPanel';
|
||||
export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
||||
export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal';
|
||||
export { ChatPanel } from './ChatPanel';
|
||||
export { ImportAnalysisView } from './ImportAnalysisView';
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { DeleteReference, ConfirmDeleteDetails } from '../components/Confir
|
||||
const STORAGE_KEY = 'bds-app-state';
|
||||
|
||||
// Tab types
|
||||
export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat';
|
||||
export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
|
||||
|
||||
export interface Tab {
|
||||
type: TabType;
|
||||
@@ -93,7 +93,7 @@ interface AppState {
|
||||
activeTabId: string | null;
|
||||
|
||||
// UI State
|
||||
activeView: 'posts' | 'media' | 'settings' | 'tags' | 'chat';
|
||||
activeView: 'posts' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
|
||||
sidebarVisible: boolean;
|
||||
panelVisible: boolean;
|
||||
selectedPostId: string | null;
|
||||
@@ -126,7 +126,11 @@ interface AppState {
|
||||
// Loading states
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
|
||||
// Import Analysis
|
||||
importAnalysis: unknown | null;
|
||||
importAnalysisLoading: boolean;
|
||||
|
||||
// Project Actions
|
||||
setProjects: (projects: ProjectData[]) => void;
|
||||
setActiveProject: (project: ProjectData | null) => void;
|
||||
@@ -144,7 +148,7 @@ interface AppState {
|
||||
restoreTabState: (state: TabState) => void;
|
||||
|
||||
// Actions
|
||||
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags' | 'chat') => void;
|
||||
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags' | 'chat' | 'import') => void;
|
||||
toggleSidebar: () => void;
|
||||
togglePanel: () => void;
|
||||
setSelectedPost: (id: string | null) => void;
|
||||
@@ -184,6 +188,10 @@ interface AppState {
|
||||
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
|
||||
// Import Analysis Actions
|
||||
setImportAnalysis: (report: unknown | null) => void;
|
||||
setImportAnalysisLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
@@ -231,7 +239,11 @@ export const useAppStore = create<AppState>()(
|
||||
// Initial Loading State
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
|
||||
// Import Analysis State
|
||||
importAnalysis: null,
|
||||
importAnalysisLoading: false,
|
||||
|
||||
// Project Actions
|
||||
setProjects: (projects) => set({ projects }),
|
||||
setActiveProject: (activeProject) => set({ activeProject }),
|
||||
@@ -405,6 +417,10 @@ export const useAppStore = create<AppState>()(
|
||||
// Loading Actions
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
// Import Analysis Actions
|
||||
setImportAnalysis: (importAnalysis) => set({ importAnalysis }),
|
||||
setImportAnalysisLoading: (importAnalysisLoading) => set({ importAnalysisLoading }),
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY,
|
||||
|
||||
5
src/renderer/types/electron.d.ts
vendored
5
src/renderer/types/electron.d.ts
vendored
@@ -381,6 +381,11 @@ export interface ElectronAPI {
|
||||
getPostsWithTag: (tagId: string) => Promise<string[]>;
|
||||
syncFromPosts: () => Promise<SyncTagsResult>;
|
||||
};
|
||||
import: {
|
||||
selectAndAnalyze: (uploadsFolder?: string) => Promise<unknown>;
|
||||
analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>;
|
||||
selectUploadsFolder: () => Promise<string | null>;
|
||||
};
|
||||
chat: {
|
||||
// API Key Management
|
||||
checkReady: () => Promise<ChatReadyStatus>;
|
||||
|
||||
Reference in New Issue
Block a user