fix: importer bugs and editor bugs
This commit is contained in:
@@ -637,6 +637,10 @@ export class ImportExecutionEngine extends EventEmitter {
|
|||||||
// Unescape double-bracket macros that TurndownService escaped
|
// Unescape double-bracket macros that TurndownService escaped
|
||||||
// \[\[ becomes [[ and \]\] becomes ]]
|
// \[\[ becomes [[ and \]\] becomes ]]
|
||||||
markdown = markdown.replace(/\\\[\\\[/g, '[[').replace(/\\\]\\\]/g, ']]');
|
markdown = markdown.replace(/\\\[\\\[/g, '[[').replace(/\\\]\\\]/g, ']]');
|
||||||
|
// Remove backslash escapes inside [[macro]] blocks (e.g. photo\_archive → photo_archive)
|
||||||
|
markdown = markdown.replace(/\[\[([^\]]*?)\]\]/g, (_match, inner: string) => {
|
||||||
|
return '[[' + inner.replace(/\\(.)/g, '$1') + ']]';
|
||||||
|
});
|
||||||
return markdown;
|
return markdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -839,6 +839,18 @@ export class MediaEngine extends EventEmitter {
|
|||||||
return path.join(this.getMediaDir(), id);
|
return path.join(this.getMediaDir(), id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the relative path for a media item (e.g. media/2025/01/uuid.jpg).
|
||||||
|
* This is the path format used in markdown content for image references.
|
||||||
|
*/
|
||||||
|
async getRelativePath(id: string): Promise<string | null> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
|
||||||
|
if (!dbMedia?.filePath) return null;
|
||||||
|
const dataDir = this.getDataDir();
|
||||||
|
return path.relative(dataDir, dbMedia.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
async rebuildDatabaseFromFiles(): Promise<void> {
|
async rebuildDatabaseFromFiles(): Promise<void> {
|
||||||
const mediaBaseDir = this.getMediaBaseDir();
|
const mediaBaseDir = this.getMediaBaseDir();
|
||||||
console.log(`[MediaEngine] rebuildDatabaseFromFiles: scanning mediaBaseDir=${mediaBaseDir}`);
|
console.log(`[MediaEngine] rebuildDatabaseFromFiles: scanning mediaBaseDir=${mediaBaseDir}`);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as fs from 'fs/promises';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
import { eq, and, desc, gte, lte, like, inArray } from 'drizzle-orm';
|
import { eq, and, desc, gte, lte, like, inArray, ne } from 'drizzle-orm';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { posts, Post, NewPost, postLinks } from '../database/schema';
|
import { posts, Post, NewPost, postLinks } from '../database/schema';
|
||||||
@@ -532,18 +532,73 @@ export class PostEngine extends EventEmitter {
|
|||||||
.all();
|
.all();
|
||||||
const total = countResult.length;
|
const total = countResult.length;
|
||||||
|
|
||||||
|
// Drafts must ALWAYS be included regardless of pagination.
|
||||||
|
// On the first page (offset=0), fetch all drafts and fill remaining slots with non-drafts.
|
||||||
|
// On subsequent pages, only paginate non-draft posts (drafts were already returned).
|
||||||
|
if (offset === 0) {
|
||||||
|
// Fetch ALL drafts (typically few)
|
||||||
|
const draftPosts = await db
|
||||||
|
.select()
|
||||||
|
.from(posts)
|
||||||
|
.where(and(
|
||||||
|
eq(posts.projectId, this.currentProjectId),
|
||||||
|
eq(posts.status, 'draft')
|
||||||
|
))
|
||||||
|
.orderBy(desc(posts.createdAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Fill remaining slots with non-draft posts
|
||||||
|
const remainingSlots = Math.max(0, limit - draftPosts.length);
|
||||||
|
const nonDraftPosts = remainingSlots > 0 ? await db
|
||||||
|
.select()
|
||||||
|
.from(posts)
|
||||||
|
.where(and(
|
||||||
|
eq(posts.projectId, this.currentProjectId),
|
||||||
|
ne(posts.status, 'draft')
|
||||||
|
))
|
||||||
|
.orderBy(desc(posts.createdAt))
|
||||||
|
.limit(remainingSlots)
|
||||||
|
.all() : [];
|
||||||
|
|
||||||
|
const allDbPosts = [...draftPosts, ...nonDraftPosts];
|
||||||
|
const items: PostData[] = allDbPosts.map(dbPost =>
|
||||||
|
this.dbRowToPostData(dbPost, dbPost.content || '')
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
hasMore: allDbPosts.length < total,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent pages: only paginate non-draft posts
|
||||||
|
// Count drafts to calculate correct offset into non-draft posts
|
||||||
|
const draftCount = await db
|
||||||
|
.select({ count: posts.id })
|
||||||
|
.from(posts)
|
||||||
|
.where(and(
|
||||||
|
eq(posts.projectId, this.currentProjectId),
|
||||||
|
eq(posts.status, 'draft')
|
||||||
|
))
|
||||||
|
.all();
|
||||||
|
const numDrafts = draftCount.length;
|
||||||
|
|
||||||
|
// Adjust offset: the first page returned numDrafts + (limit - numDrafts) non-draft posts
|
||||||
|
// So for page 2+, offset into non-draft posts = offset - numDrafts
|
||||||
|
const nonDraftOffset = offset - numDrafts;
|
||||||
const dbPosts = await db
|
const dbPosts = await db
|
||||||
.select()
|
.select()
|
||||||
.from(posts)
|
.from(posts)
|
||||||
.where(eq(posts.projectId, this.currentProjectId))
|
.where(and(
|
||||||
|
eq(posts.projectId, this.currentProjectId),
|
||||||
|
ne(posts.status, 'draft')
|
||||||
|
))
|
||||||
.orderBy(desc(posts.createdAt))
|
.orderBy(desc(posts.createdAt))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(nonDraftOffset)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
// For listing, we don't need to load content from filesystem.
|
|
||||||
// Use DB content for drafts, empty string for published posts.
|
|
||||||
// This avoids expensive filesystem reads for each post.
|
|
||||||
const items: PostData[] = dbPosts.map(dbPost =>
|
const items: PostData[] = dbPosts.map(dbPost =>
|
||||||
this.dbRowToPostData(dbPost, dbPost.content || '')
|
this.dbRowToPostData(dbPost, dbPost.content || '')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -336,8 +336,11 @@ export function registerIpcHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('media:getUrl', async (_, id: string) => {
|
safeHandle('media:getUrl', async (_, id: string) => {
|
||||||
// Returns the bds-media:// protocol URL for a media item
|
// Returns the relative path for a media item (e.g. media/2025/01/uuid.jpg)
|
||||||
return `bds-media://${id}`;
|
// This is the format used in markdown content for image references
|
||||||
|
const engine = getMediaEngine();
|
||||||
|
const relativePath = await engine.getRelativePath(id);
|
||||||
|
return relativePath ?? `media/${id}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('media:getFilePath', async (_, id: string) => {
|
safeHandle('media:getFilePath', async (_, id: string) => {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const autoSaveManager = new AutoSaveManager({
|
|||||||
if (updated) {
|
if (updated) {
|
||||||
useAppStore.getState().updatePost(id, updated as Partial<PostData>);
|
useAppStore.getState().updatePost(id, updated as Partial<PostData>);
|
||||||
useAppStore.getState().markClean(id);
|
useAppStore.getState().markClean(id);
|
||||||
|
// Emit event so PostEditor can update its local state
|
||||||
|
window.dispatchEvent(new CustomEvent('bds:post-auto-saved', { detail: { id, updated } }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSaveComplete: (id) => {
|
onSaveComplete: (id) => {
|
||||||
@@ -670,6 +672,18 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
|
|
||||||
const isDirty = checkIsDirty(postId);
|
const isDirty = checkIsDirty(postId);
|
||||||
|
|
||||||
|
// Listen for auto-save events to keep local post state in sync
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const { id, updated } = (e as CustomEvent).detail;
|
||||||
|
if (id === postId && updated) {
|
||||||
|
setPost(prev => prev ? { ...prev, ...updated as Partial<PostData> } : prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('bds:post-auto-saved', handler);
|
||||||
|
return () => window.removeEventListener('bds:post-auto-saved', handler);
|
||||||
|
}, [postId]);
|
||||||
|
|
||||||
// Check if post has a published version for discard functionality
|
// Check if post has a published version for discard functionality
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.electronAPI?.posts.hasPublishedVersion(postId).then(setHasPublishedVersion);
|
window.electronAPI?.posts.hasPublishedVersion(postId).then(setHasPublishedVersion);
|
||||||
@@ -764,6 +778,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
if (updated) {
|
if (updated) {
|
||||||
useAppStore.getState().updatePost(pending.postId, updated as Partial<PostData>);
|
useAppStore.getState().updatePost(pending.postId, updated as Partial<PostData>);
|
||||||
useAppStore.getState().markClean(pending.postId);
|
useAppStore.getState().markClean(pending.postId);
|
||||||
|
window.dispatchEvent(new CustomEvent('bds:post-auto-saved', { detail: { id: pending.postId, updated } }));
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('Auto-save failed:', error);
|
console.error('Auto-save failed:', error);
|
||||||
@@ -835,6 +850,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
updatePost(postId, updated as Partial<PostData>);
|
updatePost(postId, updated as Partial<PostData>);
|
||||||
|
setPost(prev => prev ? { ...prev, ...updated as Partial<PostData> } : prev);
|
||||||
markClean(postId);
|
markClean(postId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -856,6 +872,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
const updated = await window.electronAPI?.posts.publish(postId);
|
const updated = await window.electronAPI?.posts.publish(postId);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
updatePost(postId, updated as Partial<PostData>);
|
updatePost(postId, updated as Partial<PostData>);
|
||||||
|
setPost(prev => prev ? { ...prev, ...updated as Partial<PostData> } : prev);
|
||||||
showToast.success('Post published');
|
showToast.success('Post published');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -823,6 +823,73 @@ describe('ImportExecutionEngine', () => {
|
|||||||
expect(insertedPosts[0].content).toContain('[[video src="test.mp4"]]');
|
expect(insertedPosts[0].content).toContain('[[video src="test.mp4"]]');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not escape underscores inside macro names during markdown conversion', async () => {
|
||||||
|
const wxrPost = createMockWxrPost({
|
||||||
|
content: '<p>Here is a photo archive: [photo_archive]</p>',
|
||||||
|
});
|
||||||
|
const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite');
|
||||||
|
analyzed.existingPost = {
|
||||||
|
id: 'existing-post-id',
|
||||||
|
title: 'Existing Post',
|
||||||
|
slug: 'test-post',
|
||||||
|
checksum: 'old-checksum',
|
||||||
|
pubDate: null,
|
||||||
|
excerpt: null,
|
||||||
|
author: null,
|
||||||
|
tags: [],
|
||||||
|
categories: [],
|
||||||
|
};
|
||||||
|
const report = createMockAnalysisReport({
|
||||||
|
posts: {
|
||||||
|
total: 1,
|
||||||
|
new: 0,
|
||||||
|
updates: 0,
|
||||||
|
conflicts: 1,
|
||||||
|
contentDuplicates: 0,
|
||||||
|
items: [analyzed],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await engine.executeImport(report, {});
|
||||||
|
|
||||||
|
expect(insertedPosts.length).toBe(1);
|
||||||
|
expect(insertedPosts[0].content).toContain('[[photo_archive]]');
|
||||||
|
expect(insertedPosts[0].content).not.toContain('photo\\_archive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not escape underscores in macro attributes during markdown conversion', async () => {
|
||||||
|
const wxrPost = createMockWxrPost({
|
||||||
|
content: '<p>Show: [my_gallery type="grid_view" size="large_thumb"]</p>',
|
||||||
|
});
|
||||||
|
const analyzed = createMockAnalyzedPost(wxrPost, 'conflict', 'overwrite');
|
||||||
|
analyzed.existingPost = {
|
||||||
|
id: 'existing-post-id',
|
||||||
|
title: 'Existing Post',
|
||||||
|
slug: 'test-post',
|
||||||
|
checksum: 'old-checksum',
|
||||||
|
pubDate: null,
|
||||||
|
excerpt: null,
|
||||||
|
author: null,
|
||||||
|
tags: [],
|
||||||
|
categories: [],
|
||||||
|
};
|
||||||
|
const report = createMockAnalysisReport({
|
||||||
|
posts: {
|
||||||
|
total: 1,
|
||||||
|
new: 0,
|
||||||
|
updates: 0,
|
||||||
|
conflicts: 1,
|
||||||
|
contentDuplicates: 0,
|
||||||
|
items: [analyzed],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await engine.executeImport(report, {});
|
||||||
|
|
||||||
|
expect(insertedPosts.length).toBe(1);
|
||||||
|
expect(insertedPosts[0].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 () => {
|
||||||
const wxrPost = createMockWxrPost({
|
const wxrPost = createMockWxrPost({
|
||||||
tags: ['OldTagName', 'NewTag'],
|
tags: ['OldTagName', 'NewTag'],
|
||||||
|
|||||||
@@ -463,6 +463,47 @@ describe('MediaEngine', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getRelativePath', () => {
|
||||||
|
it('should return relative path from dataDir for a media item', async () => {
|
||||||
|
mediaEngine.setProjectContext('test-project', '/projects/my-blog');
|
||||||
|
|
||||||
|
const selectChain = createSelectChain();
|
||||||
|
selectChain.get.mockResolvedValue({
|
||||||
|
id: 'abc-123',
|
||||||
|
filePath: '/projects/my-blog/media/2025/01/abc-123.jpg',
|
||||||
|
});
|
||||||
|
vi.mocked(mockLocalDb.select).mockReturnValue(selectChain as any);
|
||||||
|
|
||||||
|
const result = await mediaEngine.getRelativePath('abc-123');
|
||||||
|
|
||||||
|
expect(result).toBe('media/2025/01/abc-123.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when media item is not found', async () => {
|
||||||
|
mediaEngine.setProjectContext('test-project', '/projects/my-blog');
|
||||||
|
|
||||||
|
const selectChain = createSelectChain();
|
||||||
|
selectChain.get.mockResolvedValue(undefined);
|
||||||
|
vi.mocked(mockLocalDb.select).mockReturnValue(selectChain as any);
|
||||||
|
|
||||||
|
const result = await mediaEngine.getRelativePath('nonexistent');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when filePath is empty', async () => {
|
||||||
|
mediaEngine.setProjectContext('test-project', '/projects/my-blog');
|
||||||
|
|
||||||
|
const selectChain = createSelectChain();
|
||||||
|
selectChain.get.mockResolvedValue({ id: 'abc-123', filePath: '' });
|
||||||
|
vi.mocked(mockLocalDb.select).mockReturnValue(selectChain as any);
|
||||||
|
|
||||||
|
const result = await mediaEngine.getRelativePath('abc-123');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Multiple Media Import', () => {
|
describe('Multiple Media Import', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFiles.set('/source/image1.jpg', Buffer.from('image1-data'));
|
mockFiles.set('/source/image1.jpg', Buffer.from('image1-data'));
|
||||||
|
|||||||
@@ -2001,17 +2001,24 @@ Published content`);
|
|||||||
it('should return all posts for current project', async () => {
|
it('should return all posts for current project', async () => {
|
||||||
postEngine.setProjectContext('test-project');
|
postEngine.setProjectContext('test-project');
|
||||||
|
|
||||||
|
// getAllPosts now makes 3 queries: count, drafts, non-drafts
|
||||||
|
let selectCallCount = 0;
|
||||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
selectCallCount++;
|
||||||
const chain = createSelectChain();
|
const chain = createSelectChain();
|
||||||
|
const callNum = selectCallCount;
|
||||||
chain.where = vi.fn().mockReturnValue({
|
chain.where = vi.fn().mockReturnValue({
|
||||||
...chain,
|
...chain,
|
||||||
orderBy: vi.fn().mockReturnThis(),
|
orderBy: vi.fn().mockReturnThis(),
|
||||||
limit: vi.fn().mockReturnThis(),
|
limit: vi.fn().mockReturnThis(),
|
||||||
offset: vi.fn().mockReturnThis(),
|
offset: vi.fn().mockReturnThis(),
|
||||||
all: vi.fn().mockResolvedValue([
|
all: vi.fn().mockResolvedValue(
|
||||||
{ id: '1', title: 'Post 1', projectId: 'test-project', tags: '[]', categories: '[]' },
|
callNum === 1
|
||||||
{ id: '2', title: 'Post 2', projectId: 'test-project', tags: '[]', categories: '[]' },
|
? [{ id: '1' }, { id: '2' }] // count query: 2 total
|
||||||
]),
|
: callNum === 2
|
||||||
|
? [{ id: '1', title: 'Draft Post', projectId: 'test-project', status: 'draft', tags: '[]', categories: '[]' }] // drafts
|
||||||
|
: [{ id: '2', title: 'Published Post', projectId: 'test-project', status: 'published', tags: '[]', categories: '[]' }] // non-drafts
|
||||||
|
),
|
||||||
});
|
});
|
||||||
return chain;
|
return chain;
|
||||||
});
|
});
|
||||||
@@ -2021,21 +2028,24 @@ Published content`);
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should parse tags and categories JSON', async () => {
|
it('should parse tags and categories JSON', async () => {
|
||||||
|
// getAllPosts makes 3 queries: count, drafts, non-drafts
|
||||||
|
let selectCallCount = 0;
|
||||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
selectCallCount++;
|
||||||
const chain = createSelectChain();
|
const chain = createSelectChain();
|
||||||
|
const callNum = selectCallCount;
|
||||||
chain.where = vi.fn().mockReturnValue({
|
chain.where = vi.fn().mockReturnValue({
|
||||||
...chain,
|
...chain,
|
||||||
orderBy: vi.fn().mockReturnThis(),
|
orderBy: vi.fn().mockReturnThis(),
|
||||||
limit: vi.fn().mockReturnThis(),
|
limit: vi.fn().mockReturnThis(),
|
||||||
offset: vi.fn().mockReturnThis(),
|
offset: vi.fn().mockReturnThis(),
|
||||||
all: vi.fn().mockResolvedValue([
|
all: vi.fn().mockResolvedValue(
|
||||||
{
|
callNum === 1
|
||||||
id: '1',
|
? [{ id: '1' }] // count
|
||||||
title: 'Tagged Post',
|
: callNum === 2
|
||||||
tags: '["tag1","tag2"]',
|
? [] // no drafts
|
||||||
categories: '["cat1"]'
|
: [{ id: '1', title: 'Tagged Post', tags: '["tag1","tag2"]', categories: '["cat1"]' }] // non-drafts
|
||||||
},
|
),
|
||||||
]),
|
|
||||||
});
|
});
|
||||||
return chain;
|
return chain;
|
||||||
});
|
});
|
||||||
@@ -2044,6 +2054,42 @@ Published content`);
|
|||||||
expect(result.items[0].tags).toEqual(['tag1', 'tag2']);
|
expect(result.items[0].tags).toEqual(['tag1', 'tag2']);
|
||||||
expect(result.items[0].categories).toEqual(['cat1']);
|
expect(result.items[0].categories).toEqual(['cat1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should always include all drafts regardless of pagination limit', async () => {
|
||||||
|
postEngine.setProjectContext('test-project');
|
||||||
|
|
||||||
|
// Simulate: 3 drafts + many published, limit=2
|
||||||
|
let selectCallCount = 0;
|
||||||
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
selectCallCount++;
|
||||||
|
const chain = createSelectChain();
|
||||||
|
const callNum = selectCallCount;
|
||||||
|
chain.where = vi.fn().mockReturnValue({
|
||||||
|
...chain,
|
||||||
|
orderBy: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnThis(),
|
||||||
|
offset: vi.fn().mockReturnThis(),
|
||||||
|
all: vi.fn().mockResolvedValue(
|
||||||
|
callNum === 1
|
||||||
|
? [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }] // count: 5 total
|
||||||
|
: callNum === 2
|
||||||
|
? [ // 3 drafts (ALL of them, regardless of limit)
|
||||||
|
{ id: '1', title: 'Draft 1', status: 'draft', tags: '[]', categories: '[]' },
|
||||||
|
{ id: '2', title: 'Draft 2', status: 'draft', tags: '[]', categories: '[]' },
|
||||||
|
{ id: '3', title: 'Draft 3', status: 'draft', tags: '[]', categories: '[]' },
|
||||||
|
]
|
||||||
|
: [] // no remaining slots for non-drafts (limit=2, 3 drafts > 2)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Even with limit=2, all 3 drafts must be returned
|
||||||
|
const result = await postEngine.getAllPosts({ limit: 2, offset: 0 });
|
||||||
|
expect(result.items).toHaveLength(3);
|
||||||
|
expect(result.items.every(p => p.status === 'draft')).toBe(true);
|
||||||
|
expect(result.hasMore).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getPostsFiltered', () => {
|
describe('getPostsFiltered', () => {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ const mockMediaEngine = {
|
|||||||
reindexText: vi.fn(),
|
reindexText: vi.fn(),
|
||||||
getThumbnailDataUrl: vi.fn(),
|
getThumbnailDataUrl: vi.fn(),
|
||||||
regenerateMissingThumbnails: vi.fn(),
|
regenerateMissingThumbnails: vi.fn(),
|
||||||
|
getRelativePath: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockProjectEngine = {
|
const mockProjectEngine = {
|
||||||
@@ -606,10 +607,21 @@ describe('IPC Handlers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('media:getUrl', () => {
|
describe('media:getUrl', () => {
|
||||||
it('should return bds-media protocol URL', async () => {
|
it('should return relative media path', async () => {
|
||||||
|
mockMediaEngine.getRelativePath.mockResolvedValue('media/2025/01/media-123.jpg');
|
||||||
|
|
||||||
const result = await invokeHandler('media:getUrl', 'media-123');
|
const result = await invokeHandler('media:getUrl', 'media-123');
|
||||||
|
|
||||||
expect(result).toBe('bds-media://media-123');
|
expect(mockMediaEngine.getRelativePath).toHaveBeenCalledWith('media-123');
|
||||||
|
expect(result).toBe('media/2025/01/media-123.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to media/{id} when relative path is not found', async () => {
|
||||||
|
mockMediaEngine.getRelativePath.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await invokeHandler('media:getUrl', 'media-unknown');
|
||||||
|
|
||||||
|
expect(result).toBe('media/media-unknown');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user