From e75a58e3835dccc4314c38bf5cca11328b01c8c5 Mon Sep 17 00:00:00 2001 From: hugo Date: Fri, 13 Feb 2026 21:25:14 +0100 Subject: [PATCH] feat: better conflict resolution management --- src/main/engine/ImportAnalysisEngine.ts | 51 ++++- src/main/engine/PostEngine.ts | 40 +--- src/main/engine/index.ts | 6 +- src/main/engine/postFileUtils.ts | 78 +++++++ .../ImportAnalysisView/ImportAnalysisView.css | 146 ++++++++++++ .../ImportAnalysisView/ImportAnalysisView.tsx | 215 ++++++++++++++++-- 6 files changed, 483 insertions(+), 53 deletions(-) create mode 100644 src/main/engine/postFileUtils.ts diff --git a/src/main/engine/ImportAnalysisEngine.ts b/src/main/engine/ImportAnalysisEngine.ts index 7ca170c..fa11342 100644 --- a/src/main/engine/ImportAnalysisEngine.ts +++ b/src/main/engine/ImportAnalysisEngine.ts @@ -11,16 +11,31 @@ import { getMacroConfigMap, type MacroConfig } from '../config/macroConfig'; export type PostAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate'; export type MediaAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate' | 'missing'; +/** How to resolve a slug conflict during import */ +export type ImportConflictResolution = 'ignore' | 'overwrite' | 'import'; + export interface AnalyzedPost { wxrPost: WxrPost; status: PostAnalysisStatus; contentHash: string; markdownPreview: string; + /** How to resolve conflict (only relevant when status is 'conflict'). Default is 'ignore'. */ + conflictResolution?: ImportConflictResolution; existingPost?: { id: string; title: string; slug: string; checksum: string | null; + /** Date the existing post was created/published */ + pubDate: string | null; + /** Excerpt from existing post */ + excerpt: string | null; + /** Author of the existing post */ + author: string | null; + /** Tags of the existing post */ + tags: string[]; + /** Categories of the existing post */ + categories: string[]; }; } @@ -205,6 +220,13 @@ export class ImportAnalysisEngine { slug: posts.slug, title: posts.title, checksum: posts.checksum, + excerpt: posts.excerpt, + author: posts.author, + publishedAt: posts.publishedAt, + createdAt: posts.createdAt, + status: posts.status, + tags: posts.tags, + categories: posts.categories, }) .from(posts) .where(eq(posts.projectId, this.currentProjectId)) @@ -261,9 +283,9 @@ export class ImportAnalysisEngine { // 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`); @@ -307,8 +329,8 @@ export class ImportAnalysisEngine { private analyzePostItems( wxrPosts: WxrPost[], - slugToPost: Map, - checksumToPost: Map, + slugToPost: Map, + checksumToPost: Map, ): AnalyzedPost[] { return wxrPosts.map(wxrPost => { const markdown = this.convertToMarkdown(wxrPost.content); @@ -327,25 +349,44 @@ export class ImportAnalysisEngine { } else { status = 'conflict'; } + const existingDate = existingBySlug.publishedAt || existingBySlug.createdAt; + const existingTags = existingBySlug.tags ? JSON.parse(existingBySlug.tags) : []; + const existingCategories = existingBySlug.categories ? JSON.parse(existingBySlug.categories) : []; existingPost = { id: existingBySlug.id, title: existingBySlug.title, slug: existingBySlug.slug, checksum: existingBySlug.checksum, + pubDate: existingDate ? existingDate.toISOString() : null, + excerpt: existingBySlug.excerpt, + author: existingBySlug.author, + tags: existingTags, + categories: existingCategories, }; } else if (existingByHash) { status = 'content-duplicate'; + const existingDate = existingByHash.publishedAt || existingByHash.createdAt; + const existingTagsByHash = existingByHash.tags ? JSON.parse(existingByHash.tags) : []; + const existingCategoriesByHash = existingByHash.categories ? JSON.parse(existingByHash.categories) : []; existingPost = { id: existingByHash.id, title: existingByHash.title, slug: existingByHash.slug, checksum: existingByHash.checksum, + pubDate: existingDate ? existingDate.toISOString() : null, + excerpt: existingByHash.excerpt, + author: existingByHash.author, + tags: existingTagsByHash, + categories: existingCategoriesByHash, }; } else { status = 'new'; } - return { wxrPost, status, contentHash, markdownPreview, existingPost }; + // For conflicts, default resolution is 'ignore' + const conflictResolution = status === 'conflict' ? 'ignore' as const : undefined; + + return { wxrPost, status, contentHash, markdownPreview, existingPost, conflictResolution }; }); } diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index f883ebb..d4a5529 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -10,6 +10,7 @@ import { getDatabase } from '../database'; import { posts, Post, NewPost, postLinks } from '../database/schema'; import { taskManager, Task } from './TaskManager'; import { stemText, stemQuery, SupportedLanguage } from './stemmer'; +import { readPostFile as readPostFileShared, type PostFileData } from './postFileUtils'; export interface PostData { id: string; @@ -275,38 +276,13 @@ export class PostEngine extends EventEmitter { } private async readPostFile(filePath: string): Promise { - try { - // Check if file exists first to avoid noisy errors - try { - await fs.access(filePath); - } catch { - // File doesn't exist - this is expected when DB has stale paths - return null; - } - - const content = await fs.readFile(filePath, 'utf-8'); - const { data, content: body } = matter(content); - const metadata = data as PostMetadata; - - return { - id: metadata.id, - projectId: metadata.projectId || this.currentProjectId, - title: metadata.title, - slug: metadata.slug, - excerpt: metadata.excerpt, - content: body, - status: metadata.status, - author: metadata.author, - createdAt: new Date(metadata.createdAt), - updatedAt: new Date(metadata.updatedAt), - publishedAt: metadata.publishedAt ? new Date(metadata.publishedAt) : undefined, - tags: metadata.tags || [], - categories: metadata.categories || [], - }; - } catch (error) { - console.error(`Failed to parse post file: ${filePath}`, error); - return null; - } + const data = await readPostFileShared(filePath); + if (!data) return null; + + return { + ...data, + projectId: data.projectId || this.currentProjectId, + }; } async createPost(data: Partial): Promise { diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index 17f241e..b9c4a36 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -68,8 +68,12 @@ export { type AnalyzedTag, type PostAnalysisStatus, type MediaAnalysisStatus, + type ImportConflictResolution, } from './ImportAnalysisEngine'; export { ImportDefinitionEngine, type ImportDefinitionData, -} from './ImportDefinitionEngine'; +} from './ImportDefinitionEngine';export { + readPostFile, + type PostFileData, +} from './postFileUtils'; \ No newline at end of file diff --git a/src/main/engine/postFileUtils.ts b/src/main/engine/postFileUtils.ts new file mode 100644 index 0000000..d3d7c84 --- /dev/null +++ b/src/main/engine/postFileUtils.ts @@ -0,0 +1,78 @@ +/** + * Shared utilities for reading and parsing post markdown files. + * Used by PostEngine for editing. + */ + +import * as fs from 'fs/promises'; +import matter from 'gray-matter'; + +export interface PostFileData { + id: string; + projectId?: string; + title: string; + slug: string; + excerpt?: string; + content: string; + status: 'draft' | 'published' | 'archived'; + author?: string; + createdAt: Date; + updatedAt: Date; + publishedAt?: Date; + tags: string[]; + categories: string[]; +} + +interface PostFileMetadata { + id: string; + projectId?: string; + title: string; + slug: string; + excerpt?: string; + status: 'draft' | 'published' | 'archived'; + author?: string; + createdAt: string; + updatedAt: string; + publishedAt?: string; + tags?: string[]; + categories?: string[]; +} + +/** + * Read and parse a post markdown file with YAML frontmatter. + * @param filePath Absolute path to the .md file + * @returns Parsed post data or null if file doesn't exist or can't be parsed + */ +export async function readPostFile(filePath: string): Promise { + try { + // Check if file exists first to avoid noisy errors + try { + await fs.access(filePath); + } catch { + // File doesn't exist + return null; + } + + const fileContent = await fs.readFile(filePath, 'utf-8'); + const { data, content: body } = matter(fileContent); + const metadata = data as PostFileMetadata; + + return { + id: metadata.id, + projectId: metadata.projectId, + title: metadata.title, + slug: metadata.slug, + excerpt: metadata.excerpt, + content: body, + status: metadata.status, + author: metadata.author, + createdAt: new Date(metadata.createdAt), + updatedAt: new Date(metadata.updatedAt), + publishedAt: metadata.publishedAt ? new Date(metadata.publishedAt) : undefined, + tags: metadata.tags || [], + categories: metadata.categories || [], + }; + } catch (error) { + console.error(`Failed to parse post file: ${filePath}`, error); + return null; + } +} \ No newline at end of file diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css index ce0b146..c868cfb 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css @@ -871,3 +871,149 @@ min-width: 24px; text-align: right; } +/* Conflict section styles */ +.conflicts-section .conflicts-table { + table-layout: fixed; +} + +.conflicts-table th:nth-child(1), +.conflicts-table td:nth-child(1) { + width: 20%; +} + +.conflicts-table th:nth-child(2), +.conflicts-table td:nth-child(2) { + width: 30%; +} + +.conflicts-table th:nth-child(3), +.conflicts-table td:nth-child(3) { + width: 25%; +} + +.conflicts-table th:nth-child(4), +.conflicts-table td:nth-child(4) { + width: 25%; +} + +.conflict-row .entry-title { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conflict-row .entry-title.tooltip-target { + cursor: help; + text-decoration: underline dotted; + text-decoration-color: var(--vscode-descriptionForeground); + text-underline-offset: 2px; +} + +.conflict-row .entry-title.tooltip-target:hover { + text-decoration-color: var(--vscode-foreground); +} + +/* Post hover card */ +.post-hover-card { + z-index: 1000; + min-width: 300px; + max-width: 420px; + padding: 10px 12px; + background: var(--vscode-editorHoverWidget-background, #2d2d30); + border: 1px solid var(--vscode-editorHoverWidget-border, #454545); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + font-size: 12px; + line-height: 1.5; + white-space: normal; + text-decoration: none; + text-align: left; + color: var(--vscode-editorHoverWidget-foreground, #cccccc); + pointer-events: none; +} + +.post-hover-title { + font-weight: 600; + font-size: 13px; + margin-bottom: 6px; + color: var(--vscode-foreground); +} + +.post-hover-meta { + display: flex; + flex-direction: column; + gap: 2px; + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.post-hover-content { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--vscode-editorHoverWidget-border, #454545); +} + +.post-hover-content-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); + margin-bottom: 4px; +} + +.post-hover-content-text { + font-size: 11px; + line-height: 1.4; + color: var(--vscode-foreground); + word-break: break-word; +} + +.conflict-row .new-entry-cell .entry-title { + color: var(--vscode-charts-blue, #75beff); +} + +.conflict-row .existing-entry-cell .entry-title { + color: var(--vscode-charts-yellow, #cca700); +} + +.conflict-row .entry-categories { + display: block; + font-size: 10px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 2px; +} + +.conflict-row .resolution-cell { + padding: 4px 8px; +} + +.resolution-select { + width: 100%; + padding: 4px 8px; + font-size: 11px; + background: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + border: 1px solid var(--vscode-dropdown-border, var(--vscode-input-border, #3c3c3c)); + border-radius: 4px; + cursor: pointer; + outline: none; +} + +.resolution-select:hover { + border-color: var(--vscode-focusBorder, #007fd4); +} + +.resolution-select:focus { + border-color: var(--vscode-focusBorder, #007fd4); + outline: 1px solid var(--vscode-focusBorder, #007fd4); + outline-offset: -1px; +} + +.resolution-select option { + background: var(--vscode-dropdown-listBackground, var(--vscode-dropdown-background)); + color: var(--vscode-dropdown-foreground); +} \ No newline at end of file diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx index b25effa..4d9afdb 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx @@ -2,6 +2,9 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'; import type { ChatModel } from '../../types/electron'; import './ImportAnalysisView.css'; +/** How to resolve a slug conflict during import */ +type ImportConflictResolution = 'ignore' | 'overwrite' | 'import'; + interface AnalysisReport { sourceFile: string; site: { title: string; link: string; description: string; language: string }; @@ -49,7 +52,23 @@ interface AnalyzedPostItem { status: string; contentHash: string; markdownPreview: string; - existingPost?: { id: string; title: string; slug: string }; + /** How to resolve conflict (only relevant when status is 'conflict'). Default is 'ignore'. */ + conflictResolution?: ImportConflictResolution; + existingPost?: { + id: string; + title: string; + slug: string; + /** Date the existing post was created/published */ + pubDate?: string | null; + /** Excerpt from existing post */ + excerpt?: string | null; + /** Author of the existing post */ + author?: string | null; + /** Tags of the existing post */ + tags?: string[]; + /** Categories of the existing post */ + categories?: string[]; + }; } interface AnalyzedMediaItem { @@ -186,6 +205,30 @@ export const ImportAnalysisView: React.FC = ({ definiti await persistReport(updatedReport); }, [report, persistReport]); + // Handler for updating conflict resolution for a specific post/page + const handleConflictResolutionChange = useCallback(async ( + section: 'posts' | 'pages', + slug: string, + resolution: ImportConflictResolution + ) => { + if (!report) return; + + const updatedReport: AnalysisReport = { + ...report, + [section]: { + ...report[section], + items: report[section].items.map(item => + item.wxrPost.slug === slug && item.status === 'conflict' + ? { ...item, conflictResolution: resolution } + : item + ), + }, + }; + + setReport(updatedReport); + await persistReport(updatedReport); + }, [report, persistReport]); + // Load definition on mount useEffect(() => { const load = async () => { @@ -333,6 +376,7 @@ export const ImportAnalysisView: React.FC = ({ definiti items={report.posts.items.filter(i => i.status === 'conflict')} expanded={expandedSections['post-conflicts'] ?? true} onToggle={() => toggleSection('post-conflicts')} + onResolutionChange={(slug, resolution) => handleConflictResolutionChange('posts', slug, resolution)} /> )} @@ -342,6 +386,7 @@ export const ImportAnalysisView: React.FC = ({ definiti items={report.pages.items.filter(i => i.status === 'conflict')} expanded={expandedSections['page-conflicts'] ?? true} onToggle={() => toggleSection('page-conflicts')} + onResolutionChange={(slug, resolution) => handleConflictResolutionChange('pages', slug, resolution)} /> )} @@ -541,7 +586,7 @@ const StatCards: React.FC<{ report: AnalysisReport }> = ({ report }) => { ); }; -// Helper function to format post metadata for tooltip +// Helper function to format post metadata for tooltip (new post from WXR) function formatPostTooltip(wxrPost: AnalyzedPostItem['wxrPost']): string { const lines: string[] = []; lines.push(`WordPress ID: ${wxrPost.wpId}`); @@ -562,6 +607,115 @@ function formatPostTooltip(wxrPost: AnalyzedPostItem['wxrPost']): string { return lines.join('\n'); } +// Hover card component for post previews (both new WXR entries and existing posts) +function PostHoverCard({ children, className, metadata, contentPreview, onHover }: { + children: React.ReactNode; + className?: string; + metadata: { title: string; author?: string | null; pubDate?: string | null; categories?: string[]; tags?: string[]; excerpt?: string | null }; + contentPreview?: string | null; + onHover?: () => void; +}) { + const [visible, setVisible] = useState(false); + const [pos, setPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); + const triggerRef = useRef(null); + + const handleMouseEnter = useCallback(() => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + const cardWidth = 420; + const cardHeight = 250; + let top = rect.bottom + 4; + let left = rect.left; + // Keep card inside viewport + if (left + cardWidth > window.innerWidth) { + left = window.innerWidth - cardWidth - 8; + } + if (left < 8) left = 8; + if (top + cardHeight > window.innerHeight) { + top = rect.top - cardHeight - 4; + } + if (top < 8) top = 8; + setPos({ top, left }); + } + setVisible(true); + onHover?.(); + }, [onHover]); + + return ( + setVisible(false)} + > + {children} + {visible && ( +
+
{metadata.title}
+
+ {metadata.author && Author: {metadata.author}} + {metadata.pubDate && Published: {new Date(metadata.pubDate).toLocaleDateString()}} + {metadata.categories && metadata.categories.length > 0 && Categories: {metadata.categories.join(', ')}} + {metadata.tags && metadata.tags.length > 0 && Tags: {metadata.tags.join(', ')}} + {metadata.excerpt && Excerpt: {metadata.excerpt.length > 100 ? metadata.excerpt.substring(0, 100) + '...' : metadata.excerpt}} +
+ {contentPreview !== undefined && ( +
+
Content
+
+ {contentPreview ? (contentPreview.substring(0, 200) + (contentPreview.length > 200 ? '...' : '')) : 'Loading...'} +
+
+ )} +
+ )} +
+ ); +} + +// Hover card for existing posts — loads everything from DB on hover +function ExistingPostHoverCard({ children, className, postId }: { + children: React.ReactNode; + className?: string; + postId: string; +}) { + const [postData, setPostData] = useState<{ + title: string; content: string; author?: string; pubDate?: string; + tags: string[]; categories: string[]; excerpt?: string; + } | null>(null); + const [loaded, setLoaded] = useState(false); + + const handleHover = useCallback(async () => { + if (!loaded) { + const post = await window.electronAPI?.posts.get(postId); + if (post) { + const date = post.publishedAt || post.createdAt; + setPostData({ + title: post.title, + content: post.content?.trim().substring(0, 200) || '', + author: post.author, + pubDate: date ? new Date(date).toISOString() : undefined, + tags: post.tags || [], + categories: post.categories || [], + excerpt: post.excerpt, + }); + } + setLoaded(true); + } + }, [postId, loaded]); + + return ( + + {children} + + ); +} + // Helper function to format media metadata for tooltip function formatMediaTooltip(wxrMedia: AnalyzedMediaItem['wxrMedia']): string { const lines: string[] = []; @@ -588,33 +742,64 @@ const ConflictsSection: React.FC<{ items: AnalyzedPostItem[]; expanded: boolean; onToggle: () => void; -}> = ({ title, items, expanded, onToggle }) => ( -
+ onResolutionChange: (slug: string, resolution: ImportConflictResolution) => void; +}> = ({ title, items, expanded, onToggle, onResolutionChange }) => ( +

{title} ({items.length})

{expanded && ( - +
- - - + + + {items.map((item, idx) => ( - + - - + + - ))}
SlugWXR TitleCategoriesExisting TitleNew Entry (WXR)Existing EntryResolution
{item.wxrPost.slug}{item.wxrPost.title} - {item.wxrPost.categories.length > 0 - ? item.wxrPost.categories.join(', ') - : '--'} + + + {item.wxrPost.title} + + {item.wxrPost.categories.length > 0 && ( + + {item.wxrPost.categories.join(', ')} + + )} + + {item.existingPost ? ( + + {item.existingPost.title} + + ) : ( + -- + )} + + {item.existingPost?.title || '--'}