diff --git a/src/main/database/connection.ts b/src/main/database/connection.ts index d3fc628..09f4b72 100644 --- a/src/main/database/connection.ts +++ b/src/main/database/connection.ts @@ -465,6 +465,21 @@ export class DatabaseConnection { ) `); await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation_id ON chat_messages(conversation_id)'); + + // Create import_definitions table for WXR import configurations + await this.localClient.execute(` + CREATE TABLE IF NOT EXISTS import_definitions ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + wxr_file_path TEXT, + uploads_folder_path TEXT, + last_analysis_result TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_import_definitions_project_id ON import_definitions(project_id)'); } async close(): Promise { diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index b50c2b4..2438ed5 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -143,6 +143,18 @@ export const chatMessages = sqliteTable('chat_messages', { createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), }); +// Import definitions table - stores WXR import configurations +export const importDefinitions = sqliteTable('import_definitions', { + id: text('id').primaryKey(), + projectId: text('project_id').notNull(), + name: text('name').notNull(), + wxrFilePath: text('wxr_file_path'), + uploadsFolderPath: text('uploads_folder_path'), + lastAnalysisResult: text('last_analysis_result'), // JSON text of ImportAnalysisReport + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + // Types for TypeScript export type Project = typeof projects.$inferSelect; export type NewProject = typeof projects.$inferInsert; @@ -164,3 +176,5 @@ export type ChatConversation = typeof chatConversations.$inferSelect; export type NewChatConversation = typeof chatConversations.$inferInsert; export type ChatMessage = typeof chatMessages.$inferSelect; export type NewChatMessage = typeof chatMessages.$inferInsert; +export type ImportDefinition = typeof importDefinitions.$inferSelect; +export type NewImportDefinition = typeof importDefinitions.$inferInsert; diff --git a/src/main/engine/ImportDefinitionEngine.ts b/src/main/engine/ImportDefinitionEngine.ts new file mode 100644 index 0000000..f773218 --- /dev/null +++ b/src/main/engine/ImportDefinitionEngine.ts @@ -0,0 +1,174 @@ +/** + * ImportDefinitionEngine - CRUD for WXR import definitions + * + * Manages persisted import configurations (name, WXR file path, uploads folder, + * last analysis result) stored in the import_definitions table. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { getDatabase } from '../database'; + +export interface ImportDefinitionData { + id: string; + projectId: string; + name: string; + wxrFilePath: string | null; + uploadsFolderPath: string | null; + lastAnalysisResult: unknown | null; + createdAt: string; + updatedAt: string; +} + +export class ImportDefinitionEngine { + private currentProjectId: string = 'default'; + + private getClient() { + const client = getDatabase().getLocalClient(); + if (!client) { + throw new Error('Database not initialized'); + } + return client; + } + + setProjectContext(projectId: string): void { + this.currentProjectId = projectId; + } + + getProjectContext(): string { + return this.currentProjectId; + } + + async createDefinition(name?: string): Promise { + const client = this.getClient(); + const id = `import_${uuidv4()}`; + const now = Date.now(); + const defName = name || 'Untitled Import'; + + await client.execute({ + sql: `INSERT INTO import_definitions (id, project_id, name, wxr_file_path, uploads_folder_path, last_analysis_result, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + args: [id, this.currentProjectId, defName, null, null, null, now, now], + }); + + return { + id, + projectId: this.currentProjectId, + name: defName, + wxrFilePath: null, + uploadsFolderPath: null, + lastAnalysisResult: null, + createdAt: new Date(now).toISOString(), + updatedAt: new Date(now).toISOString(), + }; + } + + async getDefinition(id: string): Promise { + const client = this.getClient(); + const result = await client.execute({ + sql: `SELECT * FROM import_definitions WHERE id = ? AND project_id = ?`, + args: [id, this.currentProjectId], + }); + + if (result.rows.length === 0) return null; + + return this.rowToData(result.rows[0] as any); + } + + async getAllForProject(): Promise { + const client = this.getClient(); + const result = await client.execute({ + sql: `SELECT * FROM import_definitions WHERE project_id = ? ORDER BY updated_at DESC`, + args: [this.currentProjectId], + }); + + return result.rows.map((row: any) => this.rowToData(row)); + } + + async updateDefinition( + id: string, + updates: Partial> + ): Promise { + // Check existence and ownership + const existing = await this.getDefinition(id); + if (!existing) return null; + + const setClauses: string[] = []; + const args: any[] = []; + + if (updates.name !== undefined) { + setClauses.push('name = ?'); + args.push(updates.name); + } + if (updates.wxrFilePath !== undefined) { + setClauses.push('wxr_file_path = ?'); + args.push(updates.wxrFilePath); + } + if (updates.uploadsFolderPath !== undefined) { + setClauses.push('uploads_folder_path = ?'); + args.push(updates.uploadsFolderPath); + } + if (updates.lastAnalysisResult !== undefined) { + setClauses.push('last_analysis_result = ?'); + args.push(typeof updates.lastAnalysisResult === 'string' + ? updates.lastAnalysisResult + : JSON.stringify(updates.lastAnalysisResult)); + } + + if (setClauses.length === 0) return existing; + + const now = Date.now(); + setClauses.push('updated_at = ?'); + args.push(now); + + // WHERE clause args + args.push(id, this.currentProjectId); + + const client = this.getClient(); + await client.execute({ + sql: `UPDATE import_definitions SET ${setClauses.join(', ')} WHERE id = ? AND project_id = ?`, + args, + }); + + return this.getDefinition(id); + } + + async deleteDefinition(id: string): Promise { + // Check existence and ownership + const existing = await this.getDefinition(id); + if (!existing) return false; + + const client = this.getClient(); + await client.execute({ + sql: `DELETE FROM import_definitions WHERE id = ? AND project_id = ?`, + args: [id, this.currentProjectId], + }); + + return true; + } + + private rowToData(row: any): ImportDefinitionData { + let parsedResult: unknown | null = null; + if (row.last_analysis_result) { + try { + parsedResult = JSON.parse(row.last_analysis_result); + } catch { + parsedResult = row.last_analysis_result; + } + } + + return { + id: row.id, + projectId: row.project_id, + name: row.name, + wxrFilePath: row.wxr_file_path ?? null, + uploadsFolderPath: row.uploads_folder_path ?? null, + lastAnalysisResult: parsedResult, + createdAt: typeof row.created_at === 'number' + ? new Date(row.created_at).toISOString() + : row.created_at, + updatedAt: typeof row.updated_at === 'number' + ? new Date(row.updated_at).toISOString() + : row.updated_at, + }; + } +} diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index 0d3c917..17f241e 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -69,3 +69,7 @@ export { type PostAnalysisStatus, type MediaAnalysisStatus, } from './ImportAnalysisEngine'; +export { + ImportDefinitionEngine, + type ImportDefinitionData, +} from './ImportDefinitionEngine'; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 5b83651..126bbae 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -807,6 +807,63 @@ export function registerIpcHandlers(): void { return result.filePaths[0]; }); + // ============ Import Definition CRUD Handlers ============ + + safeHandle('importDefinitions:create', async (_, name?: string) => { + const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); + const engine = new ImportDefinitionEngine(); + const projectEngine = getProjectEngine(); + const activeProject = await projectEngine.getActiveProject(); + if (activeProject) { + engine.setProjectContext(activeProject.id); + } + return engine.createDefinition(name || undefined); + }); + + safeHandle('importDefinitions:get', async (_, id: string) => { + const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); + const engine = new ImportDefinitionEngine(); + const projectEngine = getProjectEngine(); + const activeProject = await projectEngine.getActiveProject(); + if (activeProject) { + engine.setProjectContext(activeProject.id); + } + return engine.getDefinition(id); + }); + + safeHandle('importDefinitions:getAll', async () => { + const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); + const engine = new ImportDefinitionEngine(); + const projectEngine = getProjectEngine(); + const activeProject = await projectEngine.getActiveProject(); + if (activeProject) { + engine.setProjectContext(activeProject.id); + } + return engine.getAllForProject(); + }); + + safeHandle('importDefinitions:update', async (_, id: string, updates: any) => { + const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); + const engine = new ImportDefinitionEngine(); + const projectEngine = getProjectEngine(); + const activeProject = await projectEngine.getActiveProject(); + if (activeProject) { + engine.setProjectContext(activeProject.id); + } + return engine.updateDefinition(id, updates); + }); + + safeHandle('importDefinitions:delete', async (_, id: string) => { + const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine'); + const engine = new ImportDefinitionEngine(); + const projectEngine = getProjectEngine(); + const activeProject = await projectEngine.getActiveProject(); + if (activeProject) { + engine.setProjectContext(activeProject.id); + } + return engine.deleteDefinition(id); + }); + // ============ Event Forwarding ============ // Forward engine events to renderer diff --git a/src/main/preload.ts b/src/main/preload.ts index 04e86c8..f8c52b5 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -157,6 +157,15 @@ contextBridge.exposeInMainWorld('electronAPI', { selectUploadsFolder: () => ipcRenderer.invoke('import:selectUploadsFolder'), }, + // Import Definition CRUD + importDefinitions: { + create: (name?: string) => ipcRenderer.invoke('importDefinitions:create', name), + get: (id: string) => ipcRenderer.invoke('importDefinitions:get', id), + getAll: () => ipcRenderer.invoke('importDefinitions:getAll'), + update: (id: string, updates: unknown) => ipcRenderer.invoke('importDefinitions:update', id, updates), + delete: (id: string) => ipcRenderer.invoke('importDefinitions:delete', id), + }, + // AI Chat (OpenCode Zen API integration) chat: { // API Key Management @@ -324,6 +333,13 @@ export interface ElectronAPI { analyzeFile: (filePath: string, uploadsFolder?: string) => Promise; selectUploadsFolder: () => Promise; }; + importDefinitions: { + create: (name?: string) => Promise; + get: (id: string) => Promise; + getAll: () => Promise; + update: (id: string, updates: unknown) => Promise; + delete: (id: string) => Promise; + }; chat: { // API Key Management checkReady: () => Promise<{ ready: boolean; error?: string; backend?: string }>; diff --git a/src/renderer/components/ActivityBar/ActivityBar.tsx b/src/renderer/components/ActivityBar/ActivityBar.tsx index 516dd61..3b9ef04 100644 --- a/src/renderer/components/ActivityBar/ActivityBar.tsx +++ b/src/renderer/components/ActivityBar/ActivityBar.tsx @@ -66,8 +66,8 @@ 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); + // Check if import sidebar is active + const isImportActive = activeView === 'import' && sidebarVisible; // Handle view click - toggle sidebar if clicking on active view, otherwise switch view const handleViewClick = (view: 'posts' | 'media' | 'chat') => { @@ -106,8 +106,14 @@ export const ActivityBar: React.FC = () => { }; const handleImportClick = () => { - // Open import as a dedicated (non-transient) tab - openTab({ type: 'import', id: 'import', isTransient: false }); + if (activeView === 'import' && sidebarVisible) { + toggleSidebar(); + } else { + setActiveView('import'); + if (!sidebarVisible) { + toggleSidebar(); + } + } }; return ( @@ -142,9 +148,9 @@ export const ActivityBar: React.FC = () => { diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 8486881..6d71a8c 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -1622,10 +1622,10 @@ export const Editor: React.FC = () => { } // Show import analysis if import tab is active - if (showImport) { + if (showImport && activeTabId) { return (
- + {renderErrorModal()} {renderConfirmDeleteModal()}
diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css index 035e360..84f8fc1 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css @@ -20,6 +20,28 @@ color: var(--vscode-foreground); } +.import-definition-name { + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + font-size: 18px; + font-weight: 600; + color: var(--vscode-foreground); + padding: 2px 6px; + margin: -2px -6px; + width: calc(100% + 12px); + outline: none; +} + +.import-definition-name:hover { + border-color: var(--vscode-input-border, #3c3c3c); +} + +.import-definition-name:focus { + border-color: var(--vscode-focusBorder, #007fd4); + background: var(--vscode-input-background, #1e1e1e); +} + .import-analysis-header p { margin: 0; font-size: 12px; diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx index f46cabb..69233a2 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx @@ -1,5 +1,4 @@ -import React, { useState, useCallback } from 'react'; -import { useAppStore } from '../../store'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import './ImportAnalysisView.css'; interface AnalysisReport { @@ -53,43 +52,107 @@ interface TaxonomyItem { existsInProject: boolean; } -export const ImportAnalysisView: React.FC = () => { - const { importAnalysis, importAnalysisLoading, setImportAnalysis, setImportAnalysisLoading } = useAppStore(); - const [uploadsFolder, setUploadsFolder] = useState(null); - const [expandedSections, setExpandedSections] = useState>({}); +interface ImportAnalysisViewProps { + definitionId: string; +} - const report = importAnalysis as AnalysisReport | null; +export const ImportAnalysisView: React.FC = ({ definitionId }) => { + const [name, setName] = useState('Untitled Import'); + const [uploadsFolder, setUploadsFolder] = useState(null); + const [wxrFilePath, setWxrFilePath] = useState(null); + const [report, setReport] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingDefinition, setIsLoadingDefinition] = useState(true); + const [expandedSections, setExpandedSections] = useState>({}); + const nameInputRef = useRef(null); + + // Load definition on mount + useEffect(() => { + const load = async () => { + setIsLoadingDefinition(true); + try { + const def = await window.electronAPI?.importDefinitions.get(definitionId); + if (def) { + setName(def.name); + if (def.uploadsFolderPath) setUploadsFolder(def.uploadsFolderPath); + if (def.wxrFilePath) setWxrFilePath(def.wxrFilePath); + if (def.lastAnalysisResult) { + const parsed = typeof def.lastAnalysisResult === 'string' + ? JSON.parse(def.lastAnalysisResult) + : def.lastAnalysisResult; + setReport(parsed as AnalysisReport); + } + } + } catch (error) { + console.error('Failed to load import definition:', error); + } finally { + setIsLoadingDefinition(false); + } + }; + load(); + }, [definitionId]); + + const handleNameBlur = useCallback(async () => { + const trimmed = name.trim() || 'Untitled Import'; + setName(trimmed); + await window.electronAPI?.importDefinitions.update(definitionId, { name: trimmed }); + }, [definitionId, name]); const handleSelectUploadsFolder = useCallback(async () => { const folder = await window.electronAPI?.import.selectUploadsFolder(); if (folder) { setUploadsFolder(folder); + await window.electronAPI?.importDefinitions.update(definitionId, { uploadsFolderPath: folder }); } - }, []); + }, [definitionId]); const handleSelectAndAnalyze = useCallback(async () => { - setImportAnalysisLoading(true); - setImportAnalysis(null); + setIsLoading(true); + setReport(null); try { - const result = await window.electronAPI?.import.selectAndAnalyze(uploadsFolder || undefined); + const result = await window.electronAPI?.import.selectAndAnalyze(uploadsFolder || undefined) as AnalysisReport | null; if (result) { - setImportAnalysis(result); + setReport(result); + setWxrFilePath(result.sourceFile); + await window.electronAPI?.importDefinitions.update(definitionId, { + lastAnalysisResult: JSON.stringify(result), + wxrFilePath: result.sourceFile, + }); } } catch (error) { console.error('Import analysis failed:', error); } finally { - setImportAnalysisLoading(false); + setIsLoading(false); } - }, [uploadsFolder, setImportAnalysis, setImportAnalysisLoading]); + }, [definitionId, uploadsFolder]); const toggleSection = useCallback((section: string) => { setExpandedSections(prev => ({ ...prev, [section]: !prev[section] })); }, []); + if (isLoadingDefinition) { + return ( +
+
+
+ Loading import definition... +
+
+ ); + } + return (
-

Import Analysis

+ setName(e.target.value)} + onBlur={handleNameBlur} + onKeyDown={(e) => { if (e.key === 'Enter') nameInputRef.current?.blur(); }} + placeholder="Import name..." + />

Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported.

@@ -103,27 +166,27 @@ export const ImportAnalysisView: React.FC = () => {
-
- {report?.sourceFile || 'Select a file to analyze'} +
+ {wxrFilePath || report?.sourceFile || 'Select a file to analyze'}
- {importAnalysisLoading && ( + {isLoading && (
Analyzing WXR file...
)} - {!report && !importAnalysisLoading && ( + {!report && !isLoading && (
@@ -132,7 +195,7 @@ export const ImportAnalysisView: React.FC = () => {
)} - {report && !importAnalysisLoading && ( + {report && !isLoading && ( <> diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 90043bd..ee44bb3 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useAppStore, PostData, MediaData } from '../../store'; import { showToast } from '../Toast'; -import type { ChatConversation } from '../../types/electron'; +import type { ChatConversation, ImportDefinitionData } from '../../types/electron'; import './Sidebar.css'; // Tag data with color information @@ -1268,6 +1268,127 @@ const ChatList: React.FC = () => { ); }; +const ImportList: React.FC = () => { + const { openTab, closeTab } = useAppStore(); + const [definitions, setDefinitions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const loadDefinitions = useCallback(async () => { + try { + const defs = await window.electronAPI?.importDefinitions.getAll(); + if (defs) { + setDefinitions(defs); + } + } catch (error) { + console.error('Failed to load import definitions:', error); + } + }, []); + + useEffect(() => { + const init = async () => { + setIsLoading(true); + await loadDefinitions(); + setIsLoading(false); + }; + init(); + }, [loadDefinitions]); + + const handleNewDefinition = async () => { + try { + const def = await window.electronAPI?.importDefinitions.create(); + if (def) { + setDefinitions(prev => [def, ...prev]); + openTab({ type: 'import', id: def.id, isTransient: false }); + } + } catch (error) { + console.error('Failed to create import definition:', error); + showToast.error('Failed to create import definition'); + } + }; + + const handleOpenDefinition = (definitionId: string) => { + openTab({ type: 'import', id: definitionId, isTransient: false }); + }; + + const handleDeleteDefinition = async (e: React.MouseEvent, definitionId: string) => { + e.stopPropagation(); + try { + await window.electronAPI?.importDefinitions.delete(definitionId); + setDefinitions(prev => prev.filter(d => d.id !== definitionId)); + closeTab(definitionId); + } catch (error) { + console.error('Failed to delete import definition:', error); + showToast.error('Failed to delete import definition'); + } + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); + if (diffDays === 0) { + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); + } else if (diffDays === 1) { + return 'Yesterday'; + } else if (diffDays < 7) { + return date.toLocaleDateString('en-US', { weekday: 'short' }); + } + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }; + + if (isLoading) { + return ( +
+
+ IMPORTS +
+
Loading...
+
+ ); + } + + return ( +
+
+ IMPORTS + +
+
+ {definitions.length === 0 ? ( +
+

No import definitions yet

+ +
+ ) : ( + definitions.map(def => ( +
handleOpenDefinition(def.id)} + > +
+
{def.name}
+
{formatDate(def.updatedAt)}
+
+ +
+ )) + )} +
+
+ ); +}; + export const Sidebar: React.FC = () => { const { activeView, sidebarVisible } = useAppStore(); @@ -1282,6 +1403,7 @@ export const Sidebar: React.FC = () => { {activeView === 'settings' && } {activeView === 'tags' && } {activeView === 'chat' && } + {activeView === 'import' && }
); }; diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index 88be57e..5e7d145 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -8,7 +8,8 @@ const getTabTitle = ( tab: Tab, posts: { id: string; title: string }[], media: { id: string; originalName: string }[], - chatTitles: Map + chatTitles: Map, + importDefTitles: Map ): string => { if (tab.type === 'settings') { return 'Settings'; @@ -40,7 +41,7 @@ const getTabTitle = ( } if (tab.type === 'import') { - return 'Import Analysis'; + return importDefTitles.get(tab.id) || 'Import'; } return 'Unknown'; @@ -129,6 +130,7 @@ export const TabBar: React.FC = () => { const [showLeftArrow, setShowLeftArrow] = useState(false); const [showRightArrow, setShowRightArrow] = useState(false); const [chatTitles, setChatTitles] = useState>(new Map()); + const [importDefTitles, setImportDefTitles] = useState>(new Map()); // Fetch chat titles for chat tabs useEffect(() => { @@ -175,6 +177,33 @@ export const TabBar: React.FC = () => { }; }, []); + // Fetch import definition titles for import tabs + useEffect(() => { + const importTabs = tabs.filter(t => t.type === 'import'); + if (importTabs.length === 0) return; + + const fetchTitles = async () => { + const newTitles = new Map(importDefTitles); + for (const tab of importTabs) { + if (!importDefTitles.has(tab.id)) { + try { + const def = await window.electronAPI?.importDefinitions.get(tab.id); + if (def) { + newTitles.set(tab.id, def.name); + } + } catch (error) { + console.error('Failed to fetch import definition title:', error); + } + } + } + if (newTitles.size !== importDefTitles.size) { + setImportDefTitles(newTitles); + } + }; + + fetchTitles(); + }, [tabs]); // Note: intentionally not including importDefTitles to avoid infinite loops + // Check if arrows are needed based on scroll position const updateArrowVisibility = useCallback(() => { const container = tabsContainerRef.current; @@ -305,7 +334,7 @@ export const TabBar: React.FC = () => { {tabs.map((tab) => { const isActive = tab.id === activeTabId; const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id); - const title = getTabTitle(tab, posts, media, chatTitles); + const title = getTabTitle(tab, posts, media, chatTitles, importDefTitles); const icon = getTabIcon(tab); return ( diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index bdd2643..1939bec 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -127,10 +127,6 @@ interface AppState { isLoading: boolean; error: string | null; - // Import Analysis - importAnalysis: unknown | null; - importAnalysisLoading: boolean; - // Project Actions setProjects: (projects: ProjectData[]) => void; setActiveProject: (project: ProjectData | null) => void; @@ -188,10 +184,6 @@ 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()( @@ -240,10 +232,6 @@ export const useAppStore = create()( isLoading: false, error: null, - // Import Analysis State - importAnalysis: null, - importAnalysisLoading: false, - // Project Actions setProjects: (projects) => set({ projects }), setActiveProject: (activeProject) => set({ activeProject }), @@ -417,10 +405,6 @@ export const useAppStore = create()( // Loading Actions setLoading: (isLoading) => set({ isLoading }), setError: (error) => set({ error }), - - // Import Analysis Actions - setImportAnalysis: (importAnalysis) => set({ importAnalysis }), - setImportAnalysisLoading: (importAnalysisLoading) => set({ importAnalysisLoading }), }), { name: STORAGE_KEY, diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 8a1dc0d..f1f5aa5 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -1,5 +1,16 @@ // Type definitions for the Electron API exposed via preload +export interface ImportDefinitionData { + id: string; + projectId: string; + name: string; + wxrFilePath: string | null; + uploadsFolderPath: string | null; + lastAnalysisResult: unknown | null; + createdAt: string; + updatedAt: string; +} + export interface ProjectMetadata { name: string; description?: string; @@ -386,6 +397,13 @@ export interface ElectronAPI { analyzeFile: (filePath: string, uploadsFolder?: string) => Promise; selectUploadsFolder: () => Promise; }; + importDefinitions: { + create: (name?: string) => Promise; + get: (id: string) => Promise; + getAll: () => Promise; + update: (id: string, updates: Partial>) => Promise; + delete: (id: string) => Promise; + }; chat: { // API Key Management checkReady: () => Promise; diff --git a/tests/engine/ImportDefinitionEngine.test.ts b/tests/engine/ImportDefinitionEngine.test.ts new file mode 100644 index 0000000..f56029c --- /dev/null +++ b/tests/engine/ImportDefinitionEngine.test.ts @@ -0,0 +1,341 @@ +/** + * ImportDefinitionEngine Unit Tests + * + * Tests the REAL ImportDefinitionEngine class with mocked database. + * Following TDD best practices: mock external dependencies, test real implementation. + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +// Store for mock data +const mockDefinitions = new Map(); + +const mockLocalClient = { + execute: vi.fn(async (query: { sql: string; args: any[] }) => { + const sql = query.sql.trim(); + + // INSERT + if (sql.startsWith('INSERT')) { + const row = { + id: query.args[0], + project_id: query.args[1], + name: query.args[2], + wxr_file_path: query.args[3] ?? null, + uploads_folder_path: query.args[4] ?? null, + last_analysis_result: query.args[5] ?? null, + created_at: query.args[6], + updated_at: query.args[7], + }; + mockDefinitions.set(row.id, row); + return { rows: [] }; + } + + // SELECT by id + if (sql.startsWith('SELECT') && sql.includes('WHERE id = ?') && sql.includes('project_id = ?')) { + const id = query.args[0]; + const projectId = query.args[1]; + const def = mockDefinitions.get(id); + if (def && def.project_id === projectId) { + return { rows: [def] }; + } + return { rows: [] }; + } + + // SELECT all for project + if (sql.startsWith('SELECT') && sql.includes('WHERE project_id = ?') && sql.includes('ORDER BY')) { + const projectId = query.args[0]; + const rows = Array.from(mockDefinitions.values()) + .filter(d => d.project_id === projectId) + .sort((a, b) => b.updated_at - a.updated_at); + return { rows }; + } + + // UPDATE + if (sql.startsWith('UPDATE')) { + // Find the id in args (last two args are id and project_id in WHERE) + const id = query.args[query.args.length - 2]; + const projectId = query.args[query.args.length - 1]; + const def = mockDefinitions.get(id); + if (def && def.project_id === projectId) { + // Apply updates based on the SET clause + // Parse set fields from the sql + const setMatch = sql.match(/SET (.+?) WHERE/); + if (setMatch) { + const setParts = setMatch[1].split(', '); + let argIdx = 0; + for (const part of setParts) { + const field = part.split(' = ')[0].trim(); + def[field] = query.args[argIdx]; + argIdx++; + } + } + return { rowsAffected: 1, rows: [] }; + } + return { rowsAffected: 0, rows: [] }; + } + + // DELETE + if (sql.startsWith('DELETE')) { + const id = query.args[0]; + const projectId = query.args[1]; + const def = mockDefinitions.get(id); + if (def && def.project_id === projectId) { + mockDefinitions.delete(id); + return { rowsAffected: 1, rows: [] }; + } + return { rowsAffected: 0, rows: [] }; + } + + return { rows: [] }; + }), +}; + +// Mock the database module +vi.mock('../../src/main/database', () => ({ + getDatabase: vi.fn(() => ({ + getLocal: vi.fn(() => null), + getLocalClient: vi.fn(() => mockLocalClient), + getRemote: vi.fn(() => null), + getDataPaths: vi.fn(() => ({ + database: '/mock/userData/bds.db', + posts: '/mock/userData/posts', + media: '/mock/userData/media', + })), + initializeLocal: vi.fn(), + initializeRemote: vi.fn(), + close: vi.fn(), + })), +})); + +// Mock electron app +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => '/mock/userData'), + }, +})); + +import { ImportDefinitionEngine } from '../../src/main/engine/ImportDefinitionEngine'; + +describe('ImportDefinitionEngine', () => { + let engine: ImportDefinitionEngine; + + beforeEach(() => { + vi.clearAllMocks(); + mockDefinitions.clear(); + engine = new ImportDefinitionEngine(); + engine.setProjectContext('test-project'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should create a new instance', () => { + expect(engine).toBeDefined(); + expect(engine).toBeInstanceOf(ImportDefinitionEngine); + }); + }); + + describe('setProjectContext', () => { + it('should set and return the current project ID', () => { + engine.setProjectContext('project-abc'); + expect(engine.getProjectContext()).toBe('project-abc'); + }); + }); + + describe('createDefinition', () => { + it('should create a definition with default name', async () => { + const def = await engine.createDefinition(); + + expect(def).toBeDefined(); + expect(def.id).toMatch(/^import_/); + expect(def.name).toBe('Untitled Import'); + expect(def.projectId).toBe('test-project'); + expect(def.wxrFilePath).toBeNull(); + expect(def.uploadsFolderPath).toBeNull(); + expect(def.lastAnalysisResult).toBeNull(); + expect(def.createdAt).toBeDefined(); + expect(def.updatedAt).toBeDefined(); + }); + + it('should create a definition with custom name', async () => { + const def = await engine.createDefinition('My WordPress Blog'); + + expect(def.name).toBe('My WordPress Blog'); + }); + + it('should insert into the database', async () => { + await engine.createDefinition('Test Import'); + + expect(mockLocalClient.execute).toHaveBeenCalledTimes(1); + const call = mockLocalClient.execute.mock.calls[0][0]; + expect(call.sql).toContain('INSERT INTO import_definitions'); + expect(call.args[2]).toBe('Test Import'); + }); + }); + + describe('getDefinition', () => { + it('should return a definition by ID', async () => { + const created = await engine.createDefinition('My Import'); + mockLocalClient.execute.mockClear(); + + const def = await engine.getDefinition(created.id); + + expect(def).toBeDefined(); + expect(def!.id).toBe(created.id); + expect(def!.name).toBe('My Import'); + }); + + it('should return null for non-existent ID', async () => { + const def = await engine.getDefinition('non-existent-id'); + + expect(def).toBeNull(); + }); + + it('should not return definitions from other projects', async () => { + const created = await engine.createDefinition('My Import'); + engine.setProjectContext('other-project'); + + const def = await engine.getDefinition(created.id); + + expect(def).toBeNull(); + }); + + it('should parse lastAnalysisResult JSON', async () => { + const created = await engine.createDefinition('My Import'); + // Manually set analysis result in mock store + const storedDef = mockDefinitions.get(created.id); + storedDef.last_analysis_result = JSON.stringify({ posts: { total: 5 } }); + + const def = await engine.getDefinition(created.id); + + expect(def!.lastAnalysisResult).toEqual({ posts: { total: 5 } }); + }); + }); + + describe('getAllForProject', () => { + it('should return empty array when no definitions exist', async () => { + const defs = await engine.getAllForProject(); + + expect(defs).toEqual([]); + }); + + it('should return all definitions for the current project', async () => { + await engine.createDefinition('Import 1'); + await engine.createDefinition('Import 2'); + + const defs = await engine.getAllForProject(); + + expect(defs).toHaveLength(2); + }); + + it('should not include definitions from other projects', async () => { + await engine.createDefinition('Import A'); + engine.setProjectContext('other-project'); + await engine.createDefinition('Import B'); + engine.setProjectContext('test-project'); + + const defs = await engine.getAllForProject(); + + expect(defs).toHaveLength(1); + expect(defs[0].name).toBe('Import A'); + }); + + it('should return definitions ordered by updatedAt DESC', async () => { + await engine.createDefinition('Older'); + // Small delay to ensure different timestamps + await new Promise(resolve => setTimeout(resolve, 10)); + await engine.createDefinition('Newer'); + + const defs = await engine.getAllForProject(); + + expect(defs[0].name).toBe('Newer'); + expect(defs[1].name).toBe('Older'); + }); + }); + + describe('updateDefinition', () => { + it('should update the name', async () => { + const created = await engine.createDefinition('Old Name'); + + const updated = await engine.updateDefinition(created.id, { name: 'New Name' }); + + expect(updated).toBeDefined(); + expect(updated!.name).toBe('New Name'); + }); + + it('should update wxrFilePath', async () => { + const created = await engine.createDefinition('Test'); + + const updated = await engine.updateDefinition(created.id, { wxrFilePath: '/path/to/export.xml' }); + + expect(updated!.wxrFilePath).toBe('/path/to/export.xml'); + }); + + it('should update uploadsFolderPath', async () => { + const created = await engine.createDefinition('Test'); + + const updated = await engine.updateDefinition(created.id, { uploadsFolderPath: '/path/to/uploads' }); + + expect(updated!.uploadsFolderPath).toBe('/path/to/uploads'); + }); + + it('should update lastAnalysisResult as JSON', async () => { + const created = await engine.createDefinition('Test'); + const report = { posts: { total: 10, new: 5 } }; + + const updated = await engine.updateDefinition(created.id, { lastAnalysisResult: JSON.stringify(report) }); + + expect(updated).toBeDefined(); + }); + + it('should return null for non-existent definition', async () => { + const updated = await engine.updateDefinition('non-existent', { name: 'Test' }); + + expect(updated).toBeNull(); + }); + + it('should not update definitions from other projects', async () => { + const created = await engine.createDefinition('Test'); + engine.setProjectContext('other-project'); + + const updated = await engine.updateDefinition(created.id, { name: 'Hacked' }); + + expect(updated).toBeNull(); + }); + }); + + describe('deleteDefinition', () => { + it('should delete an existing definition', async () => { + const created = await engine.createDefinition('To Delete'); + + const result = await engine.deleteDefinition(created.id); + + expect(result).toBe(true); + }); + + it('should return false for non-existent definition', async () => { + const result = await engine.deleteDefinition('non-existent'); + + expect(result).toBe(false); + }); + + it('should not delete definitions from other projects', async () => { + const created = await engine.createDefinition('Test'); + engine.setProjectContext('other-project'); + + const result = await engine.deleteDefinition(created.id); + + expect(result).toBe(false); + }); + + it('should remove the definition from the database', async () => { + const created = await engine.createDefinition('Test'); + await engine.deleteDefinition(created.id); + + const def = await engine.getDefinition(created.id); + expect(def).toBeNull(); + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 8db28f9..fe97b51 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -111,6 +111,13 @@ Object.defineProperty(globalThis, 'window', { analyzeFile: vi.fn(), selectUploadsFolder: vi.fn(), }, + importDefinitions: { + create: vi.fn(), + get: vi.fn(), + getAll: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, on: vi.fn(() => () => {}), }, },