fix: overwrite handling for posts, pages and media

This commit is contained in:
2026-02-15 20:18:43 +01:00
parent e8a768544d
commit 30e3493d9f
4 changed files with 241 additions and 57 deletions

View File

@@ -43,6 +43,8 @@ export interface AnalyzedMedia {
wxrMedia: WxrMedia; wxrMedia: WxrMedia;
status: MediaAnalysisStatus; status: MediaAnalysisStatus;
fileHash: string | null; fileHash: string | null;
/** How to resolve conflict (only relevant when status is 'conflict'). Default is 'ignore'. */
conflictResolution?: ImportConflictResolution;
existingMedia?: { existingMedia?: {
id: string; id: string;
originalName: string; originalName: string;

View File

@@ -462,8 +462,12 @@ export class ImportExecutionEngine extends EventEmitter {
const postEngine = getPostEngine(); const postEngine = getPostEngine();
if (resolution === 'overwrite') { if (resolution === 'overwrite') {
// Create as draft with the same slug (user needs to review and publish) // Update the existing post with new content and set to draft for review
return await this.createImportedPost(analyzed, tagMapping, categoryMapping, result, options, 'draft'); if (!analyzed.existingPost?.id) {
// Fallback: if no existing post ID, create as new draft
return await this.createImportedPost(analyzed, tagMapping, categoryMapping, result, options, 'draft');
}
return await this.updateExistingPost(analyzed, analyzed.existingPost.id, tagMapping, categoryMapping, result, options);
} }
if (resolution === 'import') { if (resolution === 'import') {
@@ -475,6 +479,77 @@ export class ImportExecutionEngine extends EventEmitter {
return false; return false;
} }
/**
* Update an existing post with imported content (for overwrite conflict resolution)
* Sets the post to draft status so user can review before publishing
*/
private async updateExistingPost(
analyzed: AnalyzedPost,
existingPostId: string,
tagMapping: Map<string, { resolved: string; needsCreation: boolean }>,
categoryMapping: Map<string, { resolved: string; needsCreation: boolean }>,
result: ImportExecutionResult,
options: ImportExecutionOptions
): Promise<boolean> {
const wxrPost = analyzed.wxrPost;
const db = getDatabase().getLocal();
const postEngine = getPostEngine();
// Convert Vimeo iframes to [[vimeo]] macros BEFORE markdown conversion
const contentWithVimeo = this.convertVimeoIframes(wxrPost.content);
// Transform WordPress shortcodes [shortcode] to [[shortcode]] BEFORE markdown conversion
const contentWithShortcodes = this.transformShortcodes(contentWithVimeo);
// Convert HTML content to Markdown
let transformedContent = this.convertToMarkdown(contentWithShortcodes);
// Convert absolute media URLs from the site to relative paths
transformedContent = this.convertMediaUrlsToRelative(transformedContent);
// Resolve tags
const resolvedTags = this.resolveTaxonomy(wxrPost.tags, tagMapping);
// Resolve categories
const resolvedCategories = this.resolveTaxonomy(wxrPost.categories, categoryMapping);
// Calculate checksum
const checksum = this.calculateChecksum(transformedContent);
// Update the existing post in the database
// Set to draft status so user can review the imported content
await db.update(posts)
.set({
title: wxrPost.title,
excerpt: wxrPost.excerpt || null,
content: transformedContent, // Store in DB since it's now a draft
status: 'draft',
author: wxrPost.creator || options.defaultAuthor || null,
updatedAt: new Date(),
publishedAt: null, // Clear publishedAt since it's now a draft
checksum,
tags: JSON.stringify(resolvedTags),
categories: JSON.stringify(resolvedCategories),
})
.where(eq(posts.id, existingPostId));
// Update FTS index
await postEngine.updateFTSIndex({
id: existingPostId,
projectId: this.currentProjectId,
title: wxrPost.title,
content: transformedContent,
excerpt: wxrPost.excerpt || undefined,
tags: resolvedTags,
categories: resolvedCategories,
});
// Track wpId to postId mapping (use existing ID)
result.wpIdToPostId.set(wxrPost.wpId, existingPostId);
return true;
}
/** /**
* Create an imported post * Create an imported post
*/ */
@@ -655,11 +730,16 @@ export class ImportExecutionEngine extends EventEmitter {
// Handle conflicts // Handle conflicts
if (analyzed.status === 'conflict') { if (analyzed.status === 'conflict') {
const resolution = (analyzed as any).conflictResolution || 'ignore'; const resolution = analyzed.conflictResolution || 'ignore';
if (resolution === 'ignore') { if (resolution === 'ignore') {
return false; return false;
} }
// For 'overwrite' or 'import', proceed with import
// For 'overwrite', update the existing media entry
if (resolution === 'overwrite' && analyzed.existingMedia?.id) {
return await this.updateExistingMedia(analyzed, analyzed.existingMedia.id, result, options);
}
// For 'import', fall through to create new entry
} }
// Skip updates (same content already exists) // Skip updates (same content already exists)
@@ -718,6 +798,65 @@ export class ImportExecutionEngine extends EventEmitter {
return true; return true;
} }
/**
* Update an existing media entry with imported file (for overwrite conflict resolution)
* Replaces the file on disk and updates metadata in the database
*/
private async updateExistingMedia(
analyzed: AnalyzedMedia,
existingMediaId: string,
result: ImportExecutionResult,
options: ImportExecutionOptions
): Promise<boolean> {
const wxrMedia = analyzed.wxrMedia;
// Build source path
if (!options.uploadsFolder) {
return false;
}
const sourcePath = path.join(options.uploadsFolder, wxrMedia.relativePath);
// Check if file exists
try {
await fs.access(sourcePath);
} catch {
return false;
}
const mediaEngine = getMediaEngine();
// Replace the file on disk and update size/checksum/dimensions in database
await mediaEngine.replaceMediaFile(existingMediaId, sourcePath);
// Update metadata (title, alt, etc.)
await mediaEngine.updateMedia(existingMediaId, {
title: wxrMedia.title || undefined,
alt: wxrMedia.description || undefined,
author: options.defaultAuthor,
});
// Resolve parent post ID for linking
const linkedPostIds: string[] = [];
if (wxrMedia.parentId && wxrMedia.parentId > 0) {
const parentPostId = result.wpIdToPostId.get(wxrMedia.parentId);
if (parentPostId) {
linkedPostIds.push(parentPostId);
}
}
// Link media to posts in the postMedia table if needed
if (linkedPostIds.length > 0) {
const postMediaEngine = getPostMediaEngine();
postMediaEngine.setProjectContext(this.currentProjectId);
for (const postId of linkedPostIds) {
await postMediaEngine.linkMediaToPost(postId, existingMediaId);
}
}
return true;
}
/** /**
* Phase 4: Import pages as posts with "page" category * Phase 4: Import pages as posts with "page" category
*/ */

View File

@@ -49,6 +49,12 @@ const insertedPosts: Array<{
author?: string; author?: string;
}> = []; }> = [];
// Track all database updates
const updatedPosts: Array<{
id: string;
data: any;
}> = [];
const insertedMedia: Array<{ const insertedMedia: Array<{
id: string; id: string;
linkedPostIds: string[]; linkedPostIds: string[];
@@ -63,7 +69,7 @@ const writtenFiles: Array<{
content: string; content: string;
}> = []; }> = [];
// Mock database that tracks inserts // Mock database that tracks inserts and updates
const mockDb = { const mockDb = {
insert: vi.fn().mockImplementation((table: any) => ({ insert: vi.fn().mockImplementation((table: any) => ({
values: vi.fn().mockImplementation(async (data: any) => { values: vi.fn().mockImplementation(async (data: any) => {
@@ -76,6 +82,15 @@ const mockDb = {
return data; return data;
}), }),
})), })),
update: vi.fn().mockImplementation((table: any) => ({
set: vi.fn().mockImplementation((data: any) => ({
where: vi.fn().mockImplementation(async () => {
// Track updates
updatedPosts.push({ id: 'updated-id', data });
return data;
}),
})),
})),
select: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), where: vi.fn().mockResolvedValue([]),
@@ -209,6 +224,7 @@ describe('ImportExecutionEngine E2E Tests', () => {
// Reset all tracking arrays // Reset all tracking arrays
insertedPosts.length = 0; insertedPosts.length = 0;
insertedMedia.length = 0; insertedMedia.length = 0;
updatedPosts.length = 0;
createdTags.length = 0; createdTags.length = 0;
writtenFiles.length = 0; writtenFiles.length = 0;
uuidCounter = 0; uuidCounter = 0;
@@ -881,22 +897,20 @@ describe('ImportExecutionEngine E2E Tests', () => {
const result = await engine.executeImport(report, {}); const result = await engine.executeImport(report, {});
// Post should be IMPORTED // Post should be IMPORTED (via update)
expect(result.posts.imported).toBe(1); expect(result.posts.imported).toBe(1);
expect(result.posts.skipped).toBe(0); expect(result.posts.skipped).toBe(0);
// Should insert exactly one post // Should UPDATE existing post, not insert new one
expect(insertedPosts.length).toBe(1); expect(insertedPosts.length).toBe(0);
expect(updatedPosts.length).toBeGreaterThan(0);
// The inserted post MUST be a DRAFT // The updated post MUST be a DRAFT
expect(insertedPosts[0].status).toBe('draft'); expect(updatedPosts[0].data.status).toBe('draft');
// The slug should be preserved (same as conflict)
expect(insertedPosts[0].slug).toBe('overwrite-me');
// Draft posts store content in DB, not in file // Draft posts store content in DB, not in file
expect(insertedPosts[0].content).not.toBeNull(); expect(updatedPosts[0].data.content).not.toBeNull();
expect(insertedPosts[0].content).toContain('conflict resolution is "overwrite"'); expect(updatedPosts[0].data.content).toContain('conflict resolution is "overwrite"');
// No file should be written (draft = content in DB) // No file should be written (draft = content in DB)
const writtenFile = writtenFiles.find(f => f.path.includes('overwrite-me')); const writtenFile = writtenFiles.find(f => f.path.includes('overwrite-me'));
@@ -966,7 +980,7 @@ describe('ImportExecutionEngine E2E Tests', () => {
expect(writtenFile).toBeDefined(); expect(writtenFile).toBeDefined();
}); });
it('should preserve WordPress dates when importing', async () => { it('should preserve WordPress dates when importing via update (overwrite)', async () => {
// Post 302 has specific dates we want to preserve // Post 302 has specific dates we want to preserve
const post = wxrData.posts.find(p => p.wpId === 302); const post = wxrData.posts.find(p => p.wpId === 302);
expect(post).toBeDefined(); expect(post).toBeDefined();
@@ -1013,19 +1027,14 @@ describe('ImportExecutionEngine E2E Tests', () => {
await engine.executeImport(report, {}); await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1); // Overwrite now updates existing post, not inserts
expect(insertedPosts.length).toBe(0);
expect(updatedPosts.length).toBeGreaterThan(0);
// Dates should come from WXR postDate and postModified // For updates, the updatedAt is set to now (not the WXR date)
const createdAt = insertedPosts[0].createdAt; // since we're updating an existing post
const updatedAt = insertedPosts[0].updatedAt; const updateData = updatedPosts[0].data;
expect(updateData.updatedAt).toBeInstanceOf(Date);
expect(createdAt).toBeInstanceOf(Date);
expect(updatedAt).toBeInstanceOf(Date);
// Created from postDate
expect(createdAt.toISOString()).toContain('2024-01-23');
// Updated from postModified
expect(updatedAt.toISOString()).toContain('2024-01-23T15:30');
}); });
}); });
@@ -1440,15 +1449,23 @@ describe('ImportExecutionEngine E2E Tests', () => {
// Verify result accuracy // Verify result accuracy
expect(result.success).toBe(true); expect(result.success).toBe(true);
// Posts: 1 new imported, 1 ignore skipped, 1 overwrite imported // Posts: 1 new inserted, 1 ignore skipped, 1 overwrite updated (counts as imported)
expect(result.posts.imported).toBe(2); // post1 + post3 expect(result.posts.imported).toBe(2); // post1 (inserted) + post3 (updated)
expect(result.posts.skipped).toBe(1); // post2 (ignore) expect(result.posts.skipped).toBe(1); // post2 (ignore)
expect(result.posts.errors).toBe(0); expect(result.posts.errors).toBe(0);
// Pages: 1 imported // Verify that post1 was inserted and post3 was updated
// Note: insertedPosts may include the page as well (pages are stored as posts)
const postInserts = insertedPosts.filter(p => !JSON.parse(p.categories || '[]').includes('page'));
const pageInserts = insertedPosts.filter(p => JSON.parse(p.categories || '[]').includes('page'));
expect(postInserts.length).toBe(1); // post1 only (new)
expect(updatedPosts.length).toBeGreaterThan(0); // post3 (overwrite)
// Pages: 1 imported (as insert since it's new)
expect(result.pages.imported).toBe(1); expect(result.pages.imported).toBe(1);
expect(result.pages.skipped).toBe(0); expect(result.pages.skipped).toBe(0);
expect(result.pages.errors).toBe(0); expect(result.pages.errors).toBe(0);
expect(pageInserts.length).toBe(1);
// Media: 1 imported // Media: 1 imported
expect(result.media.imported).toBe(1); expect(result.media.imported).toBe(1);

View File

@@ -112,9 +112,10 @@ vi.mock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => mockMediaEngine), getMediaEngine: vi.fn(() => mockMediaEngine),
})); }));
// Mock database - track inserts for verification // Mock database - track inserts and updates for verification
const insertedPosts: any[] = []; const insertedPosts: any[] = [];
const insertedMedia: any[] = []; const insertedMedia: any[] = [];
const updatedPosts: { id: string; data: any }[] = [];
// Create a mock that tracks based on table name property // Create a mock that tracks based on table name property
const createMockInsert = () => ({ const createMockInsert = () => ({
@@ -128,6 +129,24 @@ const createMockInsert = () => ({
}), }),
}); });
// Create a mock that tracks post updates
const createMockUpdate = () => {
let updateData: any = null;
return {
set: vi.fn((data: any) => {
updateData = data;
return {
where: vi.fn((condition: any) => {
// Track the update with the ID being updated
// The condition typically contains the ID
updatedPosts.push({ id: 'updated-post-id', data: updateData });
return Promise.resolve();
}),
};
}),
};
};
const mockDb = { const mockDb = {
select: vi.fn(() => ({ select: vi.fn(() => ({
from: vi.fn(() => ({ from: vi.fn(() => ({
@@ -138,11 +157,7 @@ const mockDb = {
})), })),
})), })),
insert: vi.fn(() => createMockInsert()), insert: vi.fn(() => createMockInsert()),
update: vi.fn(() => ({ update: vi.fn(() => createMockUpdate()),
set: vi.fn(() => ({
where: vi.fn().mockResolvedValue(undefined),
})),
})),
}; };
const mockClient = { const mockClient = {
@@ -259,6 +274,7 @@ describe('ImportExecutionEngine', () => {
vi.clearAllMocks(); vi.clearAllMocks();
insertedPosts.length = 0; insertedPosts.length = 0;
insertedMedia.length = 0; insertedMedia.length = 0;
updatedPosts.length = 0;
engine = new ImportExecutionEngine(); engine = new ImportExecutionEngine();
engine.setProjectContext('test-project', '/mock/project/data'); engine.setProjectContext('test-project', '/mock/project/data');
}); });
@@ -420,7 +436,7 @@ describe('ImportExecutionEngine', () => {
expect(result.posts.skipped).toBe(1); expect(result.posts.skipped).toBe(1);
}); });
it('should create draft for conflict resolution "overwrite"', async () => { it('should update existing post for conflict resolution "overwrite"', async () => {
const wxrPost = createMockWxrPost(); const wxrPost = createMockWxrPost();
const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite'); const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite');
analyzed.existingPost = { analyzed.existingPost = {
@@ -446,11 +462,14 @@ describe('ImportExecutionEngine', () => {
}, },
}); });
await engine.executeImport(report, {}); const result = await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1); // Should update existing post, not insert new one
expect(insertedPosts[0].slug).toBe('test-post'); expect(insertedPosts.length).toBe(0);
expect(insertedPosts[0].status).toBe('draft'); expect(updatedPosts.length).toBeGreaterThan(0);
expect(updatedPosts[0].data.status).toBe('draft');
expect(updatedPosts[0].data.title).toBe(wxrPost.title);
expect(result.posts.imported).toBe(1);
}); });
it('should create new post with new slug for conflict resolution "import"', async () => { it('should create new post with new slug for conflict resolution "import"', async () => {
@@ -644,7 +663,7 @@ describe('ImportExecutionEngine', () => {
expect(insertedPosts[0].publishedAt).toEqual(pubDate); expect(insertedPosts[0].publishedAt).toEqual(pubDate);
}); });
it('should not set publishedAt for draft posts', async () => { it('should not set publishedAt for overwrite draft posts', async () => {
const wxrPost = createMockWxrPost(); const wxrPost = createMockWxrPost();
const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite'); const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite');
analyzed.existingPost = { analyzed.existingPost = {
@@ -672,9 +691,11 @@ describe('ImportExecutionEngine', () => {
await engine.executeImport(report, {}); await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1); // Should update, not insert
expect(insertedPosts[0].status).toBe('draft'); expect(insertedPosts.length).toBe(0);
expect(insertedPosts[0].publishedAt).toBeUndefined(); expect(updatedPosts.length).toBeGreaterThan(0);
expect(updatedPosts[0].data.status).toBe('draft');
expect(updatedPosts[0].data.publishedAt).toBeNull();
}); });
it('should handle post without optional fields', async () => { it('should handle post without optional fields', async () => {
@@ -817,10 +838,11 @@ describe('ImportExecutionEngine', () => {
await engine.executeImport(report, {}); await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1); // Overwrite updates existing post, not inserts
expect(updatedPosts.length).toBeGreaterThan(0);
// Draft posts store content in DB // Draft posts store content in DB
expect(insertedPosts[0].content).toContain('[[gallery ids="1,2"]]'); expect(updatedPosts[0].data.content).toContain('[[gallery ids="1,2"]]');
expect(insertedPosts[0].content).toContain('[[video src="test.mp4"]]'); expect(updatedPosts[0].data.content).toContain('[[video src="test.mp4"]]');
}); });
it('should not escape underscores inside macro names during markdown conversion', async () => { it('should not escape underscores inside macro names during markdown conversion', async () => {
@@ -852,9 +874,10 @@ describe('ImportExecutionEngine', () => {
await engine.executeImport(report, {}); await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1); // Overwrite updates existing post, not inserts
expect(insertedPosts[0].content).toContain('[[photo_archive]]'); expect(updatedPosts.length).toBeGreaterThan(0);
expect(insertedPosts[0].content).not.toContain('photo\\_archive'); expect(updatedPosts[0].data.content).toContain('[[photo_archive]]');
expect(updatedPosts[0].data.content).not.toContain('photo\\_archive');
}); });
it('should not escape underscores in macro attributes during markdown conversion', async () => { it('should not escape underscores in macro attributes during markdown conversion', async () => {
@@ -886,8 +909,9 @@ describe('ImportExecutionEngine', () => {
await engine.executeImport(report, {}); await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1); // Overwrite updates existing post, not inserts
expect(insertedPosts[0].content).toContain('[[my_gallery type="grid_view" size="large_thumb"]]'); expect(updatedPosts.length).toBeGreaterThan(0);
expect(updatedPosts[0].data.content).toContain('[[my_gallery type="grid_view" size="large_thumb"]]');
}); });
it('should map tags based on analysis mappings', async () => { it('should map tags based on analysis mappings', async () => {
@@ -1489,9 +1513,11 @@ describe('ImportExecutionEngine', () => {
await engine.executeImport(report, {}); await engine.executeImport(report, {});
expect(insertedPosts.length).toBe(1); // Should update existing page, not insert new one
expect(insertedPosts[0].status).toBe('draft'); expect(insertedPosts.length).toBe(0);
const categories = JSON.parse(insertedPosts[0].categories); expect(updatedPosts.length).toBeGreaterThan(0);
expect(updatedPosts[0].data.status).toBe('draft');
const categories = JSON.parse(updatedPosts[0].data.categories);
expect(categories).toContain('page'); expect(categories).toContain('page');
}); });