feat: better conflict resolution management

This commit is contained in:
2026-02-13 21:25:14 +01:00
parent 9f16443577
commit e75a58e383
6 changed files with 483 additions and 53 deletions

View File

@@ -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);
}

View File

@@ -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<ImportAnalysisViewProps> = ({ 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<ImportAnalysisViewProps> = ({ 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<ImportAnalysisViewProps> = ({ 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<HTMLSpanElement>(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 (
<span
ref={triggerRef}
className={className}
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setVisible(false)}
>
{children}
{visible && (
<div className="post-hover-card" style={{ position: 'fixed', top: pos.top, left: pos.left }}>
<div className="post-hover-title">{metadata.title}</div>
<div className="post-hover-meta">
{metadata.author && <span>Author: {metadata.author}</span>}
{metadata.pubDate && <span>Published: {new Date(metadata.pubDate).toLocaleDateString()}</span>}
{metadata.categories && metadata.categories.length > 0 && <span>Categories: {metadata.categories.join(', ')}</span>}
{metadata.tags && metadata.tags.length > 0 && <span>Tags: {metadata.tags.join(', ')}</span>}
{metadata.excerpt && <span>Excerpt: {metadata.excerpt.length > 100 ? metadata.excerpt.substring(0, 100) + '...' : metadata.excerpt}</span>}
</div>
{contentPreview !== undefined && (
<div className="post-hover-content">
<div className="post-hover-content-label">Content</div>
<div className="post-hover-content-text">
{contentPreview ? (contentPreview.substring(0, 200) + (contentPreview.length > 200 ? '...' : '')) : 'Loading...'}
</div>
</div>
)}
</div>
)}
</span>
);
}
// 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 (
<PostHoverCard
className={className}
metadata={postData || { title: 'Loading...' }}
contentPreview={loaded ? (postData?.content || null) : undefined}
onHover={handleHover}
>
{children}
</PostHoverCard>
);
}
// 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 }) => (
<div className="import-detail-section">
onResolutionChange: (slug: string, resolution: ImportConflictResolution) => void;
}> = ({ title, items, expanded, onToggle, onResolutionChange }) => (
<div className="import-detail-section conflicts-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
{title} ({items.length})
</h3>
{expanded && (
<table className="import-detail-table">
<table className="import-detail-table conflicts-table">
<thead>
<tr>
<th>Slug</th>
<th>WXR Title</th>
<th>Categories</th>
<th>Existing Title</th>
<th>New Entry (WXR)</th>
<th>Existing Entry</th>
<th>Resolution</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx} className="post-row-with-tooltip" title={formatPostTooltip(item.wxrPost)}>
<tr key={idx} className="conflict-row">
<td className="slug-cell">{item.wxrPost.slug}</td>
<td>{item.wxrPost.title}</td>
<td className="categories-cell">
{item.wxrPost.categories.length > 0
? item.wxrPost.categories.join(', ')
: '--'}
<td className="new-entry-cell">
<PostHoverCard
className="entry-title tooltip-target"
metadata={{ title: item.wxrPost.title, author: item.wxrPost.creator, pubDate: item.wxrPost.pubDate, categories: item.wxrPost.categories, tags: item.wxrPost.tags }}
contentPreview={item.markdownPreview}
>
{item.wxrPost.title}
</PostHoverCard>
{item.wxrPost.categories.length > 0 && (
<span className="entry-categories">
{item.wxrPost.categories.join(', ')}
</span>
)}
</td>
<td className="existing-entry-cell">
{item.existingPost ? (
<ExistingPostHoverCard
className="entry-title tooltip-target"
postId={item.existingPost.id}
>
{item.existingPost.title}
</ExistingPostHoverCard>
) : (
<span className="entry-title">--</span>
)}
</td>
<td className="resolution-cell">
<select
className="resolution-select"
value={item.conflictResolution || 'ignore'}
onChange={(e) => onResolutionChange(item.wxrPost.slug, e.target.value as ImportConflictResolution)}
>
<option value="ignore">Ignore</option>
<option value="overwrite">Overwrite</option>
<option value="import">Import (new slug)</option>
</select>
</td>
<td className="existing-match">{item.existingPost?.title || '--'}</td>
</tr>
))}
</tbody>