feat: proper sidebar and import persistence
This commit is contained in:
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
174
src/main/engine/ImportDefinitionEngine.ts
Normal file
174
src/main/engine/ImportDefinitionEngine.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,3 +69,7 @@ export {
|
|||||||
type PostAnalysisStatus,
|
type PostAnalysisStatus,
|
||||||
type MediaAnalysisStatus,
|
type MediaAnalysisStatus,
|
||||||
} from './ImportAnalysisEngine';
|
} from './ImportAnalysisEngine';
|
||||||
|
export {
|
||||||
|
ImportDefinitionEngine,
|
||||||
|
type ImportDefinitionData,
|
||||||
|
} from './ImportDefinitionEngine';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }>;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
18
src/renderer/types/electron.d.ts
vendored
18
src/renderer/types/electron.d.ts
vendored
@@ -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>;
|
||||||
|
|||||||
341
tests/engine/ImportDefinitionEngine.test.ts
Normal file
341
tests/engine/ImportDefinitionEngine.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(() => () => {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user