From 55f37f4dfae4b6c4b38c9a3e3f4b6f8a9e8732c1 Mon Sep 17 00:00:00 2001 From: hugo Date: Fri, 13 Feb 2026 15:23:32 +0100 Subject: [PATCH] feat: additional metadata --- src/main/engine/ImportAnalysisEngine.ts | 18 ++ src/main/ipc/handlers.ts | 45 ++- src/main/preload.ts | 6 + .../ImportAnalysisView/ImportAnalysisView.css | 53 +++ .../ImportAnalysisView/ImportAnalysisView.tsx | 304 +++++++++++++----- src/renderer/types/electron.d.ts | 1 + 6 files changed, 351 insertions(+), 76 deletions(-) diff --git a/src/main/engine/ImportAnalysisEngine.ts b/src/main/engine/ImportAnalysisEngine.ts index 157fe06..cd90832 100644 --- a/src/main/engine/ImportAnalysisEngine.ts +++ b/src/main/engine/ImportAnalysisEngine.ts @@ -84,6 +84,9 @@ export interface ImportAnalysisReport { export class ImportAnalysisEngine { private currentProjectId: string = ''; private turndown: TurndownService; + + // Progress callback for reporting analysis steps + onProgress?: (step: string, detail?: string) => void; constructor() { this.turndown = new TurndownService({ @@ -100,6 +103,8 @@ export class ImportAnalysisEngine { async analyzeWxr(wxrData: WxrData, sourceFile: string, uploadsFolder?: string): Promise { const db = getDatabase().getLocal(); + this.onProgress?.('Loading existing posts...'); + // Fetch existing posts for this project const existingPosts = await db .select({ @@ -112,6 +117,8 @@ export class ImportAnalysisEngine { .where(eq(posts.projectId, this.currentProjectId)) .all(); + this.onProgress?.('Loading existing media...', `${existingPosts.length} posts in project`); + // Fetch existing media for this project const existingMedia = await db .select({ @@ -123,6 +130,8 @@ export class ImportAnalysisEngine { .where(eq(media.projectId, this.currentProjectId)) .all(); + this.onProgress?.('Loading existing tags...', `${existingMedia.length} media in project`); + // Fetch existing tags for this project const existingTags = await db .select({ @@ -155,13 +164,22 @@ export class ImportAnalysisEngine { // Build tag set const existingTagNames = new Set(existingTags.map(t => t.name.toLowerCase())); + this.onProgress?.('Analyzing posts...', `${wxrData.posts.length} posts to analyze`); + // Analyze posts const analyzedPosts = this.analyzePostItems(wxrData.posts, slugToPost, checksumToPost); + + this.onProgress?.('Analyzing pages...', `${wxrData.pages.length} pages to analyze`); + const analyzedPages = this.analyzePostItems(wxrData.pages, slugToPost, checksumToPost); + this.onProgress?.('Analyzing media files...', `${wxrData.media.length} media files to analyze`); + // Analyze media const analyzedMedia = await this.analyzeMediaItems(wxrData.media, nameToMedia, checksumToMedia, uploadsFolder); + this.onProgress?.('Processing categories and tags...'); + // Analyze categories const analyzedCategories: AnalyzedCategory[] = wxrData.categories.map(cat => ({ name: cat.name, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index d4d34f8..63fe56c 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -747,7 +747,14 @@ export function registerIpcHandlers(): void { // ============ Import Analysis Handlers ============ + // Helper to emit progress events + const emitImportProgress = (step: string, detail?: string) => { + ipcMain.emit('forward-to-renderer', 'import:progress', { step, detail }); + }; + safeHandle('import:selectAndAnalyze', async (_, uploadsFolder?: string) => { + emitImportProgress('Selecting file...'); + const result = await dialog.showOpenDialog({ title: 'Select WordPress Export File (WXR)', filters: [ @@ -762,12 +769,18 @@ export function registerIpcHandlers(): void { } const filePath = result.filePaths[0]; + const fileName = filePath.split(/[/\\]/).pop() || filePath; + + emitImportProgress('Parsing WXR file...', fileName); + const { WxrParser } = await import('../engine/WxrParser'); const { ImportAnalysisEngine } = await import('../engine/ImportAnalysisEngine'); const parser = new WxrParser(); const wxrData = await parser.parseFile(filePath); + emitImportProgress('Loading project data...', `Found ${wxrData.posts.length} posts, ${wxrData.media.length} media`); + const analysisEngine = new ImportAnalysisEngine(); const projectEngine = getProjectEngine(); const activeProject = await projectEngine.getActiveProject(); @@ -775,16 +788,33 @@ export function registerIpcHandlers(): void { analysisEngine.setProjectContext(activeProject.id); } - return analysisEngine.analyzeWxr(wxrData, filePath, uploadsFolder || undefined); + emitImportProgress('Analyzing posts...', `${wxrData.posts.length} posts`); + + // Add progress callback to engine + analysisEngine.onProgress = (step: string, detail?: string) => { + emitImportProgress(step, detail); + }; + + const report = await analysisEngine.analyzeWxr(wxrData, filePath, uploadsFolder || undefined); + + emitImportProgress('Analysis complete'); + + return report; }); safeHandle('import:analyzeFile', async (_, filePath: string, uploadsFolder?: string) => { + const fileName = filePath.split(/[/\\]/).pop() || filePath; + + emitImportProgress('Parsing WXR file...', fileName); + const { WxrParser } = await import('../engine/WxrParser'); const { ImportAnalysisEngine } = await import('../engine/ImportAnalysisEngine'); const parser = new WxrParser(); const wxrData = await parser.parseFile(filePath); + emitImportProgress('Loading project data...', `Found ${wxrData.posts.length} posts, ${wxrData.media.length} media`); + const analysisEngine = new ImportAnalysisEngine(); const projectEngine = getProjectEngine(); const activeProject = await projectEngine.getActiveProject(); @@ -792,7 +822,18 @@ export function registerIpcHandlers(): void { analysisEngine.setProjectContext(activeProject.id); } - return analysisEngine.analyzeWxr(wxrData, filePath, uploadsFolder || undefined); + emitImportProgress('Analyzing posts...'); + + // Add progress callback to engine + analysisEngine.onProgress = (step: string, detail?: string) => { + emitImportProgress(step, detail); + }; + + const report = await analysisEngine.analyzeWxr(wxrData, filePath, uploadsFolder || undefined); + + emitImportProgress('Analysis complete'); + + return report; }); safeHandle('import:selectUploadsFolder', async () => { diff --git a/src/main/preload.ts b/src/main/preload.ts index 85f3160..bd71f75 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -155,6 +155,11 @@ contextBridge.exposeInMainWorld('electronAPI', { selectAndAnalyze: (uploadsFolder?: string) => ipcRenderer.invoke('import:selectAndAnalyze', uploadsFolder), analyzeFile: (filePath: string, uploadsFolder?: string) => ipcRenderer.invoke('import:analyzeFile', filePath, uploadsFolder), selectUploadsFolder: () => ipcRenderer.invoke('import:selectUploadsFolder'), + onProgress: (callback: (data: { step: string; detail?: string }) => void) => { + const subscription = (_event: Electron.IpcRendererEvent, data: { step: string; detail?: string }) => callback(data); + ipcRenderer.on('import:progress', subscription); + return () => ipcRenderer.removeListener('import:progress', subscription); + }, }, // Import Definition CRUD @@ -340,6 +345,7 @@ export interface ElectronAPI { selectAndAnalyze: (uploadsFolder?: string) => Promise; analyzeFile: (filePath: string, uploadsFolder?: string) => Promise; selectUploadsFolder: () => Promise; + onProgress: (callback: (data: { step: string; detail?: string }) => void) => () => void; }; importDefinitions: { create: (name?: string) => Promise; diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css index 1fc58c0..392c50c 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css @@ -145,6 +145,23 @@ border-top-color: var(--vscode-button-background); border-radius: 50%; animation: spin 0.8s linear infinite; + flex-shrink: 0; +} + +.import-progress { + display: flex; + flex-direction: column; + gap: 2px; +} + +.import-progress-step { + font-size: 13px; + color: var(--vscode-foreground); +} + +.import-progress-detail { + font-size: 11px; + color: var(--vscode-descriptionForeground); } @keyframes spin { @@ -594,6 +611,42 @@ margin-left: 8px; } +/* Post and Media rows with tooltip - enhanced hover state */ +.import-detail-table tr.post-row-with-tooltip, +.import-detail-table tr.media-row-with-tooltip { + cursor: help; + transition: background-color 0.15s ease; +} + +.import-detail-table tr.post-row-with-tooltip:hover, +.import-detail-table tr.media-row-with-tooltip:hover { + background-color: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.04)); +} + +/* Categories column styling */ +.import-detail-table .categories-cell { + font-size: 11px; + color: var(--vscode-descriptionForeground); + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* MIME type column styling */ +.import-detail-table .mime-type-cell { + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-family: var(--vscode-editor-font-family, monospace); +} + +/* Post type column styling */ +.import-detail-table .post-type-cell { + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-family: var(--vscode-editor-font-family, monospace); +} + /* Empty state */ .import-empty-state { display: flex; diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx index 74a2100..d846b6a 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx @@ -33,7 +33,18 @@ interface MediaSection { } interface AnalyzedPostItem { - wxrPost: { wpId: number; title: string; slug: string; status: string }; + wxrPost: { + wpId: number; + title: string; + slug: string; + status: string; + excerpt: string; + pubDate: string | null; + creator: string; + postType: string; + categories: string[]; + tags: string[]; + }; status: string; contentHash: string; markdownPreview: string; @@ -41,7 +52,17 @@ interface AnalyzedPostItem { } interface AnalyzedMediaItem { - wxrMedia: { wpId: number; title: string; filename: string; url: string; relativePath: string }; + wxrMedia: { + wpId: number; + title: string; + filename: string; + url: string; + relativePath: string; + pubDate: string | null; + parentId: number; + mimeType: string; + description: string; + }; status: string; fileHash: string | null; existingMedia?: { id: string; originalName: string }; @@ -66,8 +87,19 @@ export const ImportAnalysisView: React.FC = ({ definiti const [isLoading, setIsLoading] = useState(false); const [isLoadingDefinition, setIsLoadingDefinition] = useState(true); const [expandedSections, setExpandedSections] = useState>({}); + const [progressStep, setProgressStep] = useState(''); + const [progressDetail, setProgressDetail] = useState(''); const nameInputRef = useRef(null); + // Subscribe to progress events + useEffect(() => { + const unsubscribe = window.electronAPI?.import.onProgress(({ step, detail }) => { + setProgressStep(step); + setProgressDetail(detail || ''); + }); + return () => unsubscribe?.(); + }, []); + // Save the current report to the definition const persistReport = useCallback(async (updatedReport: AnalysisReport) => { await window.electronAPI?.importDefinitions.update(definitionId, { @@ -167,6 +199,8 @@ export const ImportAnalysisView: React.FC = ({ definiti const handleSelectAndAnalyze = useCallback(async () => { setIsLoading(true); setReport(null); + setProgressStep(''); + setProgressDetail(''); try { const result = await window.electronAPI?.import.selectAndAnalyze(uploadsFolder || undefined) as AnalysisReport | null; if (result) { @@ -181,6 +215,8 @@ export const ImportAnalysisView: React.FC = ({ definiti console.error('Import analysis failed:', error); } finally { setIsLoading(false); + setProgressStep(''); + setProgressDetail(''); } }, [definitionId, uploadsFolder]); @@ -240,7 +276,10 @@ export const ImportAnalysisView: React.FC = ({ definiti {isLoading && (
- Analyzing WXR file... +
+
{progressStep || 'Analyzing WXR file...'}
+ {progressDetail &&
{progressDetail}
} +
)} @@ -276,12 +315,32 @@ export const ImportAnalysisView: React.FC = ({ definiti /> )} - toggleSection('posts')} - /> + {/* Posts section - only items with postType 'post' */} + {(() => { + const postsOnly = report.posts.items.filter(i => i.wxrPost.postType === 'post'); + return postsOnly.length > 0 && ( + toggleSection('posts')} + /> + ); + })()} + + {/* Other post types section */} + {(() => { + const otherPosts = report.posts.items.filter(i => i.wxrPost.postType !== 'post'); + return otherPosts.length > 0 && ( + toggleSection('other')} + showType + /> + ); + })()} {report.pages.total > 0 && ( ); -const StatCards: React.FC<{ report: AnalysisReport }> = ({ report }) => ( -
-
-

Posts

-
{report.posts.total}
-
- {report.posts.new > 0 && {report.posts.new} new} - {report.posts.updates > 0 && {report.posts.updates} update} - {report.posts.conflicts > 0 && {report.posts.conflicts} conflict} - {report.posts.contentDuplicates > 0 && {report.posts.contentDuplicates} duplicate} -
-
+const StatCards: React.FC<{ report: AnalysisReport }> = ({ report }) => { + // Split posts by type + const postsOnly = report.posts.items.filter(i => i.wxrPost.postType === 'post'); + const otherPosts = report.posts.items.filter(i => i.wxrPost.postType !== 'post'); + + const postsStats = { + total: postsOnly.length, + new: postsOnly.filter(i => i.status === 'new').length, + updates: postsOnly.filter(i => i.status === 'update').length, + conflicts: postsOnly.filter(i => i.status === 'conflict').length, + contentDuplicates: postsOnly.filter(i => i.status === 'content-duplicate').length, + }; + + const otherStats = { + total: otherPosts.length, + new: otherPosts.filter(i => i.status === 'new').length, + updates: otherPosts.filter(i => i.status === 'update').length, + conflicts: otherPosts.filter(i => i.status === 'conflict').length, + contentDuplicates: otherPosts.filter(i => i.status === 'content-duplicate').length, + }; + + // Get unique other post types for display + const otherTypes = [...new Set(otherPosts.map(i => i.wxrPost.postType))].join(', '); -
-

Pages

-
{report.pages.total}
-
- {report.pages.new > 0 && {report.pages.new} new} - {report.pages.updates > 0 && {report.pages.updates} update} - {report.pages.conflicts > 0 && {report.pages.conflicts} conflict} - {report.pages.contentDuplicates > 0 && {report.pages.contentDuplicates} duplicate} + return ( +
+
+

Posts

+
{postsStats.total}
+
+ {postsStats.new > 0 && {postsStats.new} new} + {postsStats.updates > 0 && {postsStats.updates} update} + {postsStats.conflicts > 0 && {postsStats.conflicts} conflict} + {postsStats.contentDuplicates > 0 && {postsStats.contentDuplicates} duplicate} +
-
-
-

Media

-
{report.media.total}
-
- {report.media.new > 0 && {report.media.new} new} - {report.media.updates > 0 && {report.media.updates} update} - {report.media.conflicts > 0 && {report.media.conflicts} conflict} - {report.media.contentDuplicates > 0 && {report.media.contentDuplicates} duplicate} - {report.media.missing > 0 && {report.media.missing} missing} -
-
+ {otherStats.total > 0 && ( +
+

Other

+
{otherStats.total}
+
+ {otherStats.new > 0 && {otherStats.new} new} + {otherStats.updates > 0 && {otherStats.updates} update} + {otherStats.conflicts > 0 && {otherStats.conflicts} conflict} + {otherStats.contentDuplicates > 0 && {otherStats.contentDuplicates} duplicate} +
+
+ )} -
-

Categories

-
{report.categories.length}
-
- {report.categories.filter(c => c.existsInProject).length > 0 && ( - {report.categories.filter(c => c.existsInProject).length} existing - )} - {report.categories.filter(c => !c.existsInProject && c.mappedTo).length > 0 && ( - {report.categories.filter(c => !c.existsInProject && c.mappedTo).length} mapped - )} - {report.categories.filter(c => !c.existsInProject && !c.mappedTo).length > 0 && ( - {report.categories.filter(c => !c.existsInProject && !c.mappedTo).length} new - )} +
+

Pages

+
{report.pages.total}
+
+ {report.pages.new > 0 && {report.pages.new} new} + {report.pages.updates > 0 && {report.pages.updates} update} + {report.pages.conflicts > 0 && {report.pages.conflicts} conflict} + {report.pages.contentDuplicates > 0 && {report.pages.contentDuplicates} duplicate} +
-
-
-

Tags

-
{report.tags.length}
-
- {report.tags.filter(t => t.existsInProject).length > 0 && ( - {report.tags.filter(t => t.existsInProject).length} existing - )} - {report.tags.filter(t => !t.existsInProject && t.mappedTo).length > 0 && ( - {report.tags.filter(t => !t.existsInProject && t.mappedTo).length} mapped - )} - {report.tags.filter(t => !t.existsInProject && !t.mappedTo).length > 0 && ( - {report.tags.filter(t => !t.existsInProject && !t.mappedTo).length} new - )} +
+

Media

+
{report.media.total}
+
+ {report.media.new > 0 && {report.media.new} new} + {report.media.updates > 0 && {report.media.updates} update} + {report.media.conflicts > 0 && {report.media.conflicts} conflict} + {report.media.contentDuplicates > 0 && {report.media.contentDuplicates} duplicate} + {report.media.missing > 0 && {report.media.missing} missing} +
+
+ +
+

Categories

+
{report.categories.length}
+
+ {report.categories.filter(c => c.existsInProject).length > 0 && ( + {report.categories.filter(c => c.existsInProject).length} existing + )} + {report.categories.filter(c => !c.existsInProject && c.mappedTo).length > 0 && ( + {report.categories.filter(c => !c.existsInProject && c.mappedTo).length} mapped + )} + {report.categories.filter(c => !c.existsInProject && !c.mappedTo).length > 0 && ( + {report.categories.filter(c => !c.existsInProject && !c.mappedTo).length} new + )} +
+
+ +
+

Tags

+
{report.tags.length}
+
+ {report.tags.filter(t => t.existsInProject).length > 0 && ( + {report.tags.filter(t => t.existsInProject).length} existing + )} + {report.tags.filter(t => !t.existsInProject && t.mappedTo).length > 0 && ( + {report.tags.filter(t => !t.existsInProject && t.mappedTo).length} mapped + )} + {report.tags.filter(t => !t.existsInProject && !t.mappedTo).length > 0 && ( + {report.tags.filter(t => !t.existsInProject && !t.mappedTo).length} new + )} +
-
-); + ); +}; + +// Helper function to format post metadata for tooltip +function formatPostTooltip(wxrPost: AnalyzedPostItem['wxrPost']): string { + const lines: string[] = []; + lines.push(`WordPress ID: ${wxrPost.wpId}`); + lines.push(`Type: ${wxrPost.postType}`); + lines.push(`Author: ${wxrPost.creator || 'Unknown'}`); + if (wxrPost.pubDate) { + lines.push(`Published: ${new Date(wxrPost.pubDate).toLocaleDateString()}`); + } + if (wxrPost.excerpt) { + const shortExcerpt = wxrPost.excerpt.length > 100 + ? wxrPost.excerpt.substring(0, 100) + '...' + : wxrPost.excerpt; + lines.push(`Excerpt: ${shortExcerpt}`); + } + if (wxrPost.tags.length > 0) { + lines.push(`Tags: ${wxrPost.tags.join(', ')}`); + } + return lines.join('\n'); +} + +// Helper function to format media metadata for tooltip +function formatMediaTooltip(wxrMedia: AnalyzedMediaItem['wxrMedia']): string { + const lines: string[] = []; + lines.push(`WordPress ID: ${wxrMedia.wpId}`); + lines.push(`MIME Type: ${wxrMedia.mimeType || 'Unknown'}`); + if (wxrMedia.pubDate) { + lines.push(`Uploaded: ${new Date(wxrMedia.pubDate).toLocaleDateString()}`); + } + if (wxrMedia.parentId) { + lines.push(`Parent Post ID: ${wxrMedia.parentId}`); + } + lines.push(`URL: ${wxrMedia.url}`); + if (wxrMedia.description) { + const shortDesc = wxrMedia.description.length > 100 + ? wxrMedia.description.substring(0, 100) + '...' + : wxrMedia.description; + lines.push(`Description: ${shortDesc}`); + } + return lines.join('\n'); +} const ConflictsSection: React.FC<{ title: string; @@ -423,14 +562,20 @@ const ConflictsSection: React.FC<{ Slug WXR Title + Categories Existing Title {items.map((item, idx) => ( - + {item.wxrPost.slug} {item.wxrPost.title} + + {item.wxrPost.categories.length > 0 + ? item.wxrPost.categories.join(', ') + : '--'} + {item.existingPost?.title || '--'} ))} @@ -445,7 +590,8 @@ const PostDetailSection: React.FC<{ items: AnalyzedPostItem[]; expanded: boolean; onToggle: () => void; -}> = ({ title, items, expanded, onToggle }) => ( + showType?: boolean; +}> = ({ title, items, expanded, onToggle, showType }) => (

@@ -456,18 +602,26 @@ const PostDetailSection: React.FC<{ Status + {showType && Type} Title Slug + Categories WP Status Existing Match {items.map((item, idx) => ( - + {item.status} + {showType && {item.wxrPost.postType}} {item.wxrPost.title} {item.wxrPost.slug} + + {item.wxrPost.categories.length > 0 + ? item.wxrPost.categories.join(', ') + : '--'} + {item.wxrPost.status} {item.existingPost?.title || '--'} @@ -495,15 +649,17 @@ const MediaDetailSection: React.FC<{ Status Filename + Type Path Existing Match {items.map((item, idx) => ( - + {item.status} {item.wxrMedia.filename} + {item.wxrMedia.mimeType || '--'} {item.wxrMedia.relativePath} {item.existingMedia?.originalName || '--'} diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index c715481..ed84bf7 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -396,6 +396,7 @@ export interface ElectronAPI { selectAndAnalyze: (uploadsFolder?: string) => Promise; analyzeFile: (filePath: string, uploadsFolder?: string) => Promise; selectUploadsFolder: () => Promise; + onProgress: (callback: (data: { step: string; detail?: string }) => void) => () => void; }; importDefinitions: { create: (name?: string) => Promise;