feat: proper sidebar and import persistence

This commit is contained in:
2026-02-13 14:09:54 +01:00
parent d88fb1d9fa
commit 9169f2a34c
16 changed files with 922 additions and 50 deletions

View File

@@ -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)'); 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<void> { async close(): Promise<void> {

View File

@@ -143,6 +143,18 @@ export const chatMessages = sqliteTable('chat_messages', {
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), 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 // Types for TypeScript
export type Project = typeof projects.$inferSelect; export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert; export type NewProject = typeof projects.$inferInsert;
@@ -164,3 +176,5 @@ export type ChatConversation = typeof chatConversations.$inferSelect;
export type NewChatConversation = typeof chatConversations.$inferInsert; export type NewChatConversation = typeof chatConversations.$inferInsert;
export type ChatMessage = typeof chatMessages.$inferSelect; export type ChatMessage = typeof chatMessages.$inferSelect;
export type NewChatMessage = typeof chatMessages.$inferInsert; export type NewChatMessage = typeof chatMessages.$inferInsert;
export type ImportDefinition = typeof importDefinitions.$inferSelect;
export type NewImportDefinition = typeof importDefinitions.$inferInsert;

View File

@@ -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<ImportDefinitionData> {
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<ImportDefinitionData | null> {
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<ImportDefinitionData[]> {
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<Pick<ImportDefinitionData, 'name' | 'wxrFilePath' | 'uploadsFolderPath' | 'lastAnalysisResult'>>
): Promise<ImportDefinitionData | null> {
// 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<boolean> {
// 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,
};
}
}

View File

@@ -69,3 +69,7 @@ export {
type PostAnalysisStatus, type PostAnalysisStatus,
type MediaAnalysisStatus, type MediaAnalysisStatus,
} from './ImportAnalysisEngine'; } from './ImportAnalysisEngine';
export {
ImportDefinitionEngine,
type ImportDefinitionData,
} from './ImportDefinitionEngine';

View File

@@ -807,6 +807,63 @@ export function registerIpcHandlers(): void {
return result.filePaths[0]; 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 ============ // ============ Event Forwarding ============
// Forward engine events to renderer // Forward engine events to renderer

View File

@@ -157,6 +157,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
selectUploadsFolder: () => ipcRenderer.invoke('import:selectUploadsFolder'), 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) // AI Chat (OpenCode Zen API integration)
chat: { chat: {
// API Key Management // API Key Management
@@ -324,6 +333,13 @@ export interface ElectronAPI {
analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>; analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>;
selectUploadsFolder: () => Promise<string | null>; selectUploadsFolder: () => Promise<string | null>;
}; };
importDefinitions: {
create: (name?: string) => Promise<unknown>;
get: (id: string) => Promise<unknown>;
getAll: () => Promise<unknown[]>;
update: (id: string, updates: unknown) => Promise<unknown>;
delete: (id: string) => Promise<boolean>;
};
chat: { chat: {
// API Key Management // API Key Management
checkReady: () => Promise<{ ready: boolean; error?: string; backend?: string }>; checkReady: () => Promise<{ ready: boolean; error?: string; backend?: string }>;

View File

@@ -66,8 +66,8 @@ export const ActivityBar: React.FC = () => {
// Check if chat sidebar is active (activeView === 'chat' and sidebar is visible) // Check if chat sidebar is active (activeView === 'chat' and sidebar is visible)
const isChatActive = activeView === 'chat' && sidebarVisible; const isChatActive = activeView === 'chat' && sidebarVisible;
// Check if import tab is currently active // Check if import sidebar is active
const isImportTabActive = tabs.some(t => t.type === 'import' && t.id === activeTabId); const isImportActive = activeView === 'import' && sidebarVisible;
// Handle view click - toggle sidebar if clicking on active view, otherwise switch view // Handle view click - toggle sidebar if clicking on active view, otherwise switch view
const handleViewClick = (view: 'posts' | 'media' | 'chat') => { const handleViewClick = (view: 'posts' | 'media' | 'chat') => {
@@ -106,8 +106,14 @@ export const ActivityBar: React.FC = () => {
}; };
const handleImportClick = () => { const handleImportClick = () => {
// Open import as a dedicated (non-transient) tab if (activeView === 'import' && sidebarVisible) {
openTab({ type: 'import', id: 'import', isTransient: false }); toggleSidebar();
} else {
setActiveView('import');
if (!sidebarVisible) {
toggleSidebar();
}
}
}; };
return ( return (
@@ -142,9 +148,9 @@ export const ActivityBar: React.FC = () => {
<ChatIcon /> <ChatIcon />
</button> </button>
<button <button
className={`activity-bar-item ${isImportTabActive ? 'active' : ''}`} className={`activity-bar-item ${isImportActive ? 'active' : ''}`}
onClick={handleImportClick} onClick={handleImportClick}
title="Import Analysis" title="Import (click again to toggle sidebar)"
> >
<ImportIcon /> <ImportIcon />
</button> </button>

View File

@@ -1622,10 +1622,10 @@ export const Editor: React.FC = () => {
} }
// Show import analysis if import tab is active // Show import analysis if import tab is active
if (showImport) { if (showImport && activeTabId) {
return ( return (
<div className="editor"> <div className="editor">
<ImportAnalysisView /> <ImportAnalysisView key={activeTabId} definitionId={activeTabId} />
{renderErrorModal()} {renderErrorModal()}
{renderConfirmDeleteModal()} {renderConfirmDeleteModal()}
</div> </div>

View File

@@ -20,6 +20,28 @@
color: var(--vscode-foreground); 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 { .import-analysis-header p {
margin: 0; margin: 0;
font-size: 12px; font-size: 12px;

View File

@@ -1,5 +1,4 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useAppStore } from '../../store';
import './ImportAnalysisView.css'; import './ImportAnalysisView.css';
interface AnalysisReport { interface AnalysisReport {
@@ -53,43 +52,107 @@ interface TaxonomyItem {
existsInProject: boolean; existsInProject: boolean;
} }
export const ImportAnalysisView: React.FC = () => { interface ImportAnalysisViewProps {
const { importAnalysis, importAnalysisLoading, setImportAnalysis, setImportAnalysisLoading } = useAppStore(); definitionId: string;
const [uploadsFolder, setUploadsFolder] = useState<string | null>(null); }
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
const report = importAnalysis as AnalysisReport | null; export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definitionId }) => {
const [name, setName] = useState('Untitled Import');
const [uploadsFolder, setUploadsFolder] = useState<string | null>(null);
const [wxrFilePath, setWxrFilePath] = useState<string | null>(null);
const [report, setReport] = useState<AnalysisReport | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingDefinition, setIsLoadingDefinition] = useState(true);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
const nameInputRef = useRef<HTMLInputElement>(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 handleSelectUploadsFolder = useCallback(async () => {
const folder = await window.electronAPI?.import.selectUploadsFolder(); const folder = await window.electronAPI?.import.selectUploadsFolder();
if (folder) { if (folder) {
setUploadsFolder(folder); setUploadsFolder(folder);
await window.electronAPI?.importDefinitions.update(definitionId, { uploadsFolderPath: folder });
} }
}, []); }, [definitionId]);
const handleSelectAndAnalyze = useCallback(async () => { const handleSelectAndAnalyze = useCallback(async () => {
setImportAnalysisLoading(true); setIsLoading(true);
setImportAnalysis(null); setReport(null);
try { try {
const result = await window.electronAPI?.import.selectAndAnalyze(uploadsFolder || undefined); const result = await window.electronAPI?.import.selectAndAnalyze(uploadsFolder || undefined) as AnalysisReport | null;
if (result) { if (result) {
setImportAnalysis(result); setReport(result);
setWxrFilePath(result.sourceFile);
await window.electronAPI?.importDefinitions.update(definitionId, {
lastAnalysisResult: JSON.stringify(result),
wxrFilePath: result.sourceFile,
});
} }
} catch (error) { } catch (error) {
console.error('Import analysis failed:', error); console.error('Import analysis failed:', error);
} finally { } finally {
setImportAnalysisLoading(false); setIsLoading(false);
} }
}, [uploadsFolder, setImportAnalysis, setImportAnalysisLoading]); }, [definitionId, uploadsFolder]);
const toggleSection = useCallback((section: string) => { const toggleSection = useCallback((section: string) => {
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] })); setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
}, []); }, []);
if (isLoadingDefinition) {
return (
<div className="import-analysis">
<div className="import-loading">
<div className="import-spinner" />
Loading import definition...
</div>
</div>
);
}
return ( return (
<div className="import-analysis"> <div className="import-analysis">
<div className="import-analysis-header"> <div className="import-analysis-header">
<h2>Import Analysis</h2> <input
ref={nameInputRef}
className="import-definition-name"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={handleNameBlur}
onKeyDown={(e) => { if (e.key === 'Enter') nameInputRef.current?.blur(); }}
placeholder="Import name..."
/>
<p>Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported.</p> <p>Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported.</p>
</div> </div>
@@ -103,27 +166,27 @@ export const ImportAnalysisView: React.FC = () => {
</div> </div>
<div className="import-file-row"> <div className="import-file-row">
<label>WXR File</label> <label>WXR File</label>
<div className={`import-file-path ${!report ? 'placeholder' : ''}`}> <div className={`import-file-path ${!wxrFilePath ? 'placeholder' : ''}`}>
{report?.sourceFile || 'Select a file to analyze'} {wxrFilePath || report?.sourceFile || 'Select a file to analyze'}
</div> </div>
<button <button
className="import-analyze-btn" className="import-analyze-btn"
onClick={handleSelectAndAnalyze} onClick={handleSelectAndAnalyze}
disabled={importAnalysisLoading} disabled={isLoading}
> >
{importAnalysisLoading ? 'Analyzing...' : 'Select & Analyze'} {isLoading ? 'Analyzing...' : 'Select & Analyze'}
</button> </button>
</div> </div>
</div> </div>
{importAnalysisLoading && ( {isLoading && (
<div className="import-loading"> <div className="import-loading">
<div className="import-spinner" /> <div className="import-spinner" />
Analyzing WXR file... Analyzing WXR file...
</div> </div>
)} )}
{!report && !importAnalysisLoading && ( {!report && !isLoading && (
<div className="import-empty-state"> <div className="import-empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor"> <svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/> <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
@@ -132,7 +195,7 @@ export const ImportAnalysisView: React.FC = () => {
</div> </div>
)} )}
{report && !importAnalysisLoading && ( {report && !isLoading && (
<> <>
<SiteInfoCard site={report.site} sourceFile={report.sourceFile} /> <SiteInfoCard site={report.site} sourceFile={report.sourceFile} />
<StatCards report={report} /> <StatCards report={report} />

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useAppStore, PostData, MediaData } from '../../store'; import { useAppStore, PostData, MediaData } from '../../store';
import { showToast } from '../Toast'; import { showToast } from '../Toast';
import type { ChatConversation } from '../../types/electron'; import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
import './Sidebar.css'; import './Sidebar.css';
// Tag data with color information // 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<ImportDefinitionData[]>([]);
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 (
<div className="chat-list">
<div className="chat-list-header">
<span>IMPORTS</span>
</div>
<div className="chat-loading">Loading...</div>
</div>
);
}
return (
<div className="chat-list">
<div className="chat-list-header">
<span>IMPORTS</span>
<button className="chat-new-button" onClick={handleNewDefinition} title="New Import Definition">
+
</button>
</div>
<div className="chat-list-items">
{definitions.length === 0 ? (
<div className="chat-empty">
<p>No import definitions yet</p>
<button className="chat-start-button" onClick={handleNewDefinition}>
Create an import definition
</button>
</div>
) : (
definitions.map(def => (
<div
key={def.id}
className="chat-list-item"
onClick={() => handleOpenDefinition(def.id)}
>
<div className="chat-item-content">
<div className="chat-item-title">{def.name}</div>
<div className="chat-item-date">{formatDate(def.updatedAt)}</div>
</div>
<button
className="chat-item-delete"
onClick={(e) => handleDeleteDefinition(e, def.id)}
title="Delete import definition"
>
×
</button>
</div>
))
)}
</div>
</div>
);
};
export const Sidebar: React.FC = () => { export const Sidebar: React.FC = () => {
const { activeView, sidebarVisible } = useAppStore(); const { activeView, sidebarVisible } = useAppStore();
@@ -1282,6 +1403,7 @@ export const Sidebar: React.FC = () => {
{activeView === 'settings' && <SettingsNav />} {activeView === 'settings' && <SettingsNav />}
{activeView === 'tags' && <TagsNav />} {activeView === 'tags' && <TagsNav />}
{activeView === 'chat' && <ChatList />} {activeView === 'chat' && <ChatList />}
{activeView === 'import' && <ImportList />}
</div> </div>
); );
}; };

View File

@@ -8,7 +8,8 @@ const getTabTitle = (
tab: Tab, tab: Tab,
posts: { id: string; title: string }[], posts: { id: string; title: string }[],
media: { id: string; originalName: string }[], media: { id: string; originalName: string }[],
chatTitles: Map<string, string> chatTitles: Map<string, string>,
importDefTitles: Map<string, string>
): string => { ): string => {
if (tab.type === 'settings') { if (tab.type === 'settings') {
return 'Settings'; return 'Settings';
@@ -40,7 +41,7 @@ const getTabTitle = (
} }
if (tab.type === 'import') { if (tab.type === 'import') {
return 'Import Analysis'; return importDefTitles.get(tab.id) || 'Import';
} }
return 'Unknown'; return 'Unknown';
@@ -129,6 +130,7 @@ export const TabBar: React.FC = () => {
const [showLeftArrow, setShowLeftArrow] = useState(false); const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false); const [showRightArrow, setShowRightArrow] = useState(false);
const [chatTitles, setChatTitles] = useState<Map<string, string>>(new Map()); const [chatTitles, setChatTitles] = useState<Map<string, string>>(new Map());
const [importDefTitles, setImportDefTitles] = useState<Map<string, string>>(new Map());
// Fetch chat titles for chat tabs // Fetch chat titles for chat tabs
useEffect(() => { 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 // Check if arrows are needed based on scroll position
const updateArrowVisibility = useCallback(() => { const updateArrowVisibility = useCallback(() => {
const container = tabsContainerRef.current; const container = tabsContainerRef.current;
@@ -305,7 +334,7 @@ export const TabBar: React.FC = () => {
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = tab.id === activeTabId; const isActive = tab.id === activeTabId;
const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id); 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); const icon = getTabIcon(tab);
return ( return (

View File

@@ -127,10 +127,6 @@ interface AppState {
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
// Import Analysis
importAnalysis: unknown | null;
importAnalysisLoading: boolean;
// Project Actions // Project Actions
setProjects: (projects: ProjectData[]) => void; setProjects: (projects: ProjectData[]) => void;
setActiveProject: (project: ProjectData | null) => void; setActiveProject: (project: ProjectData | null) => void;
@@ -188,10 +184,6 @@ interface AppState {
setLoading: (loading: boolean) => void; setLoading: (loading: boolean) => void;
setError: (error: string | null) => void; setError: (error: string | null) => void;
// Import Analysis Actions
setImportAnalysis: (report: unknown | null) => void;
setImportAnalysisLoading: (loading: boolean) => void;
} }
export const useAppStore = create<AppState>()( export const useAppStore = create<AppState>()(
@@ -240,10 +232,6 @@ export const useAppStore = create<AppState>()(
isLoading: false, isLoading: false,
error: null, error: null,
// Import Analysis State
importAnalysis: null,
importAnalysisLoading: false,
// Project Actions // Project Actions
setProjects: (projects) => set({ projects }), setProjects: (projects) => set({ projects }),
setActiveProject: (activeProject) => set({ activeProject }), setActiveProject: (activeProject) => set({ activeProject }),
@@ -417,10 +405,6 @@ export const useAppStore = create<AppState>()(
// Loading Actions // Loading Actions
setLoading: (isLoading) => set({ isLoading }), setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }), setError: (error) => set({ error }),
// Import Analysis Actions
setImportAnalysis: (importAnalysis) => set({ importAnalysis }),
setImportAnalysisLoading: (importAnalysisLoading) => set({ importAnalysisLoading }),
}), }),
{ {
name: STORAGE_KEY, name: STORAGE_KEY,

View File

@@ -1,5 +1,16 @@
// Type definitions for the Electron API exposed via preload // 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 { export interface ProjectMetadata {
name: string; name: string;
description?: string; description?: string;
@@ -386,6 +397,13 @@ export interface ElectronAPI {
analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>; analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>;
selectUploadsFolder: () => Promise<string | null>; selectUploadsFolder: () => Promise<string | null>;
}; };
importDefinitions: {
create: (name?: string) => Promise<ImportDefinitionData>;
get: (id: string) => Promise<ImportDefinitionData | null>;
getAll: () => Promise<ImportDefinitionData[]>;
update: (id: string, updates: Partial<Pick<ImportDefinitionData, 'name' | 'wxrFilePath' | 'uploadsFolderPath' | 'lastAnalysisResult'>>) => Promise<ImportDefinitionData | null>;
delete: (id: string) => Promise<boolean>;
};
chat: { chat: {
// API Key Management // API Key Management
checkReady: () => Promise<ChatReadyStatus>; checkReady: () => Promise<ChatReadyStatus>;

View File

@@ -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<string, any>();
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();
});
});
});

View File

@@ -111,6 +111,13 @@ Object.defineProperty(globalThis, 'window', {
analyzeFile: vi.fn(), analyzeFile: vi.fn(),
selectUploadsFolder: vi.fn(), selectUploadsFolder: vi.fn(),
}, },
importDefinitions: {
create: vi.fn(),
get: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
on: vi.fn(() => () => {}), on: vi.fn(() => () => {}),
}, },
}, },