feat: importer starting point

This commit is contained in:
2026-02-13 13:07:44 +01:00
parent deb0f3ae3b
commit d88fb1d9fa
19 changed files with 2666 additions and 10 deletions

View 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');
}
}

View 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;
}
}

View File

@@ -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';

View File

@@ -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

View File

@@ -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 }>;

View File

@@ -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">

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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' : ''}`}>&#9654;</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' : ''}`}>&#9654;</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' : ''}`}>&#9654;</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' : ''}`}>&#9654;</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>
);

View File

@@ -0,0 +1 @@
export { ImportAnalysisView } from './ImportAnalysisView';

View File

@@ -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">

View File

@@ -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';

View File

@@ -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,

View File

@@ -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>;