feat: better conflict resolution management
This commit is contained in:
@@ -11,16 +11,31 @@ import { getMacroConfigMap, type MacroConfig } from '../config/macroConfig';
|
|||||||
export type PostAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate';
|
export type PostAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate';
|
||||||
export type MediaAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate' | 'missing';
|
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 {
|
export interface AnalyzedPost {
|
||||||
wxrPost: WxrPost;
|
wxrPost: WxrPost;
|
||||||
status: PostAnalysisStatus;
|
status: PostAnalysisStatus;
|
||||||
contentHash: string;
|
contentHash: string;
|
||||||
markdownPreview: string;
|
markdownPreview: string;
|
||||||
|
/** How to resolve conflict (only relevant when status is 'conflict'). Default is 'ignore'. */
|
||||||
|
conflictResolution?: ImportConflictResolution;
|
||||||
existingPost?: {
|
existingPost?: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
checksum: string | null;
|
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,
|
slug: posts.slug,
|
||||||
title: posts.title,
|
title: posts.title,
|
||||||
checksum: posts.checksum,
|
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)
|
.from(posts)
|
||||||
.where(eq(posts.projectId, this.currentProjectId))
|
.where(eq(posts.projectId, this.currentProjectId))
|
||||||
@@ -307,8 +329,8 @@ export class ImportAnalysisEngine {
|
|||||||
|
|
||||||
private analyzePostItems(
|
private analyzePostItems(
|
||||||
wxrPosts: WxrPost[],
|
wxrPosts: WxrPost[],
|
||||||
slugToPost: Map<string, { id: string; slug: string; title: string; checksum: string | null }>,
|
slugToPost: Map<string, { id: string; slug: string; title: string; checksum: string | null; excerpt: string | null; author: string | null; publishedAt: Date | null; createdAt: Date; status: string; tags: string | null; categories: string | null }>,
|
||||||
checksumToPost: Map<string, { id: string; slug: string; title: string; checksum: string | null }>,
|
checksumToPost: Map<string, { id: string; slug: string; title: string; checksum: string | null; excerpt: string | null; author: string | null; publishedAt: Date | null; createdAt: Date; status: string; tags: string | null; categories: string | null }>,
|
||||||
): AnalyzedPost[] {
|
): AnalyzedPost[] {
|
||||||
return wxrPosts.map(wxrPost => {
|
return wxrPosts.map(wxrPost => {
|
||||||
const markdown = this.convertToMarkdown(wxrPost.content);
|
const markdown = this.convertToMarkdown(wxrPost.content);
|
||||||
@@ -327,25 +349,44 @@ export class ImportAnalysisEngine {
|
|||||||
} else {
|
} else {
|
||||||
status = 'conflict';
|
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 = {
|
existingPost = {
|
||||||
id: existingBySlug.id,
|
id: existingBySlug.id,
|
||||||
title: existingBySlug.title,
|
title: existingBySlug.title,
|
||||||
slug: existingBySlug.slug,
|
slug: existingBySlug.slug,
|
||||||
checksum: existingBySlug.checksum,
|
checksum: existingBySlug.checksum,
|
||||||
|
pubDate: existingDate ? existingDate.toISOString() : null,
|
||||||
|
excerpt: existingBySlug.excerpt,
|
||||||
|
author: existingBySlug.author,
|
||||||
|
tags: existingTags,
|
||||||
|
categories: existingCategories,
|
||||||
};
|
};
|
||||||
} else if (existingByHash) {
|
} else if (existingByHash) {
|
||||||
status = 'content-duplicate';
|
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 = {
|
existingPost = {
|
||||||
id: existingByHash.id,
|
id: existingByHash.id,
|
||||||
title: existingByHash.title,
|
title: existingByHash.title,
|
||||||
slug: existingByHash.slug,
|
slug: existingByHash.slug,
|
||||||
checksum: existingByHash.checksum,
|
checksum: existingByHash.checksum,
|
||||||
|
pubDate: existingDate ? existingDate.toISOString() : null,
|
||||||
|
excerpt: existingByHash.excerpt,
|
||||||
|
author: existingByHash.author,
|
||||||
|
tags: existingTagsByHash,
|
||||||
|
categories: existingCategoriesByHash,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
status = 'new';
|
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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getDatabase } from '../database';
|
|||||||
import { posts, Post, NewPost, postLinks } from '../database/schema';
|
import { posts, Post, NewPost, postLinks } from '../database/schema';
|
||||||
import { taskManager, Task } from './TaskManager';
|
import { taskManager, Task } from './TaskManager';
|
||||||
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
|
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
|
||||||
|
import { readPostFile as readPostFileShared, type PostFileData } from './postFileUtils';
|
||||||
|
|
||||||
export interface PostData {
|
export interface PostData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -275,38 +276,13 @@ export class PostEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async readPostFile(filePath: string): Promise<PostData | null> {
|
private async readPostFile(filePath: string): Promise<PostData | null> {
|
||||||
try {
|
const data = await readPostFileShared(filePath);
|
||||||
// Check if file exists first to avoid noisy errors
|
if (!data) return null;
|
||||||
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 {
|
return {
|
||||||
id: metadata.id,
|
...data,
|
||||||
projectId: metadata.projectId || this.currentProjectId,
|
projectId: data.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPost(data: Partial<PostData>): Promise<PostData> {
|
async createPost(data: Partial<PostData>): Promise<PostData> {
|
||||||
|
|||||||
@@ -68,8 +68,12 @@ export {
|
|||||||
type AnalyzedTag,
|
type AnalyzedTag,
|
||||||
type PostAnalysisStatus,
|
type PostAnalysisStatus,
|
||||||
type MediaAnalysisStatus,
|
type MediaAnalysisStatus,
|
||||||
|
type ImportConflictResolution,
|
||||||
} from './ImportAnalysisEngine';
|
} from './ImportAnalysisEngine';
|
||||||
export {
|
export {
|
||||||
ImportDefinitionEngine,
|
ImportDefinitionEngine,
|
||||||
type ImportDefinitionData,
|
type ImportDefinitionData,
|
||||||
} from './ImportDefinitionEngine';
|
} from './ImportDefinitionEngine';export {
|
||||||
|
readPostFile,
|
||||||
|
type PostFileData,
|
||||||
|
} from './postFileUtils';
|
||||||
78
src/main/engine/postFileUtils.ts
Normal file
78
src/main/engine/postFileUtils.ts
Normal file
@@ -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<PostFileData | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -871,3 +871,149 @@
|
|||||||
min-width: 24px;
|
min-width: 24px;
|
||||||
text-align: right;
|
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);
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import React, { useState, useCallback, useEffect, useRef } from 'react';
|
|||||||
import type { ChatModel } from '../../types/electron';
|
import type { ChatModel } from '../../types/electron';
|
||||||
import './ImportAnalysisView.css';
|
import './ImportAnalysisView.css';
|
||||||
|
|
||||||
|
/** How to resolve a slug conflict during import */
|
||||||
|
type ImportConflictResolution = 'ignore' | 'overwrite' | 'import';
|
||||||
|
|
||||||
interface AnalysisReport {
|
interface AnalysisReport {
|
||||||
sourceFile: string;
|
sourceFile: string;
|
||||||
site: { title: string; link: string; description: string; language: string };
|
site: { title: string; link: string; description: string; language: string };
|
||||||
@@ -49,7 +52,23 @@ interface AnalyzedPostItem {
|
|||||||
status: string;
|
status: string;
|
||||||
contentHash: string;
|
contentHash: string;
|
||||||
markdownPreview: 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 {
|
interface AnalyzedMediaItem {
|
||||||
@@ -186,6 +205,30 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
|
|||||||
await persistReport(updatedReport);
|
await persistReport(updatedReport);
|
||||||
}, [report, persistReport]);
|
}, [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
|
// Load definition on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -333,6 +376,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
|
|||||||
items={report.posts.items.filter(i => i.status === 'conflict')}
|
items={report.posts.items.filter(i => i.status === 'conflict')}
|
||||||
expanded={expandedSections['post-conflicts'] ?? true}
|
expanded={expandedSections['post-conflicts'] ?? true}
|
||||||
onToggle={() => toggleSection('post-conflicts')}
|
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')}
|
items={report.pages.items.filter(i => i.status === 'conflict')}
|
||||||
expanded={expandedSections['page-conflicts'] ?? true}
|
expanded={expandedSections['page-conflicts'] ?? true}
|
||||||
onToggle={() => toggleSection('page-conflicts')}
|
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 {
|
function formatPostTooltip(wxrPost: AnalyzedPostItem['wxrPost']): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`WordPress ID: ${wxrPost.wpId}`);
|
lines.push(`WordPress ID: ${wxrPost.wpId}`);
|
||||||
@@ -562,6 +607,115 @@ function formatPostTooltip(wxrPost: AnalyzedPostItem['wxrPost']): string {
|
|||||||
return lines.join('\n');
|
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
|
// Helper function to format media metadata for tooltip
|
||||||
function formatMediaTooltip(wxrMedia: AnalyzedMediaItem['wxrMedia']): string {
|
function formatMediaTooltip(wxrMedia: AnalyzedMediaItem['wxrMedia']): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
@@ -588,33 +742,64 @@ const ConflictsSection: React.FC<{
|
|||||||
items: AnalyzedPostItem[];
|
items: AnalyzedPostItem[];
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}> = ({ title, items, expanded, onToggle }) => (
|
onResolutionChange: (slug: string, resolution: ImportConflictResolution) => void;
|
||||||
<div className="import-detail-section">
|
}> = ({ title, items, expanded, onToggle, onResolutionChange }) => (
|
||||||
|
<div className="import-detail-section conflicts-section">
|
||||||
<h3 onClick={onToggle}>
|
<h3 onClick={onToggle}>
|
||||||
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
||||||
{title} ({items.length})
|
{title} ({items.length})
|
||||||
</h3>
|
</h3>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<table className="import-detail-table">
|
<table className="import-detail-table conflicts-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Slug</th>
|
<th>Slug</th>
|
||||||
<th>WXR Title</th>
|
<th>New Entry (WXR)</th>
|
||||||
<th>Categories</th>
|
<th>Existing Entry</th>
|
||||||
<th>Existing Title</th>
|
<th>Resolution</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((item, idx) => (
|
{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 className="slug-cell">{item.wxrPost.slug}</td>
|
||||||
<td>{item.wxrPost.title}</td>
|
<td className="new-entry-cell">
|
||||||
<td className="categories-cell">
|
<PostHoverCard
|
||||||
{item.wxrPost.categories.length > 0
|
className="entry-title tooltip-target"
|
||||||
? item.wxrPost.categories.join(', ')
|
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>
|
||||||
<td className="existing-match">{item.existingPost?.title || '--'}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user