Feat/language detection (#31)

* feat: implementation of language detection

* run utility scripts in tasks

* fix: addiitonal fixes for background utilities

* feat: toast() also for utility scripts

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-03 14:36:15 +01:00
committed by GitHub
parent 5747925503
commit 32b66e1677
37 changed files with 2616 additions and 55 deletions

View File

@@ -20,6 +20,7 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
content: overrides.content ?? `# ${title}\n\nBody`,
status: overrides.status ?? 'published',
author: overrides.author,
language: overrides.language,
createdAt,
updatedAt,
publishedAt: overrides.publishedAt,
@@ -155,4 +156,31 @@ describe('GenerationSitemapFeedService', () => {
expect(result.rssXml).toBe('');
expect(result.atomXml).toBe('');
});
it('includes per-post language in RSS dc:language and Atom xml:lang', () => {
const publishedPosts = [
makePost({ id: '1', slug: 'post-en', title: 'English', language: 'en' }),
makePost({ id: '2', slug: 'post-de', title: 'German', language: 'de' }),
makePost({ id: '3', slug: 'post-no-lang', title: 'Default' }),
];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Test Blog',
maxPostsPerPage: 10,
publishedPosts,
publishedListPosts: publishedPosts,
postIndex: buildIndex(publishedPosts),
includeFeeds: true,
});
// RSS should have dc:language per item
expect(result.rssXml).toContain('xmlns:dc=');
expect(result.rssXml).toContain('<dc:language>en</dc:language>');
expect(result.rssXml).toContain('<dc:language>de</dc:language>');
// Atom should have xml:lang on entries with language
expect(result.atomXml).toContain('xml:lang="en"');
expect(result.atomXml).toContain('xml:lang="de"');
});
});

View File

@@ -299,6 +299,89 @@ Content here`);
expect(result?.differences.categories?.fileValue).toEqual(['cat1']);
});
it('should detect language differences between DB and file', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
language: 'en',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: []
categories: []
language: fr
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-1');
expect(result).not.toBeNull();
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.language).toBeDefined();
expect(result?.differences.language?.dbValue).toBe('en');
expect(result?.differences.language?.fileValue).toBe('fr');
});
it('should detect missing language in file when DB has language', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
language: 'de',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
const result = await engine.comparePostMetadata('post-1');
expect(result).not.toBeNull();
expect(result?.hasDifferences).toBe(true);
expect(result?.differences.language).toBeDefined();
expect(result?.differences.language?.dbValue).toBe('de');
expect(result?.differences.language?.fileValue).toBe('');
});
it('should return hasDifferences=false when metadata matches', async () => {
const dbPost = {
id: 'post-1',
@@ -553,6 +636,47 @@ Content here`);
expect(mockLocalDb.update).toHaveBeenCalled();
});
it('should sync language field from file to database', async () => {
const dbPost = {
id: 'post-1',
projectId: 'test-project',
title: 'Published Post',
slug: 'published-post',
status: 'published',
filePath: '/mock/userData/posts/2024/01/published-post.md',
tags: '[]',
categories: '[]',
language: 'en',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15'),
publishedAt: new Date('2024-01-15'),
};
mockPosts.set('post-1', dbPost);
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
id: post-1
projectId: test-project
title: "Published Post"
slug: published-post
status: published
language: fr
tags: []
categories: []
createdAt: 2024-01-15T00:00:00.000Z
updatedAt: 2024-01-15T00:00:00.000Z
publishedAt: 2024-01-15T00:00:00.000Z
---
Content here`);
await engine.syncFileToDb(['post-1'], 'language');
expect(mockLocalDb.update).toHaveBeenCalled();
// Verify the set call includes language
const updateResult = mockLocalDb.update.mock.results[0].value;
const setCall = updateResult.set.mock.calls[0][0];
expect(setCall.language).toBe('fr');
});
it('should report progress on first and final items based on cadence', async () => {
const postIds = Array.from({ length: 11 }, (_, i) => `post-${i + 1}`);

View File

@@ -163,6 +163,22 @@ describe('PostEngine', () => {
// Reset the mock implementations
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
vi.mocked(mockLocalDb.insert).mockImplementation(() => ({
values: vi.fn((data: any) => {
if (data && data.id) {
mockPosts.set(data.id, data);
}
return Promise.resolve();
}),
}) as any);
vi.mocked(mockLocalDb.update).mockImplementation(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
}) as any);
vi.mocked(mockLocalDb.delete).mockImplementation(() => ({
where: vi.fn(() => Promise.resolve()),
}) as any);
// Reset fs implementations to use mockFiles map (fixes test leakage from other tests)
vi.mocked(fs.readFile).mockImplementation(createDefaultFsReadFile(mockFiles) as any);
@@ -783,6 +799,94 @@ Original content`);
expect(result?.content).toBe('New draft content');
});
it('should auto-transition published post to draft when language changes', async () => {
const created = await postEngine.createPost({ title: 'Language Draft Test' });
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: created.id,
projectId: created.projectId,
title: created.title,
slug: created.slug,
status: 'published',
content: null,
filePath: '/mock/published-lang.md',
tags: '[]',
categories: '[]',
createdAt: created.createdAt,
updatedAt: created.updatedAt,
}),
});
return chain;
});
mockFiles.set('/mock/published-lang.md', `---
id: ${created.id}
projectId: default
title: ${created.title}
slug: ${created.slug}
status: published
createdAt: ${created.createdAt.toISOString()}
updatedAt: ${created.updatedAt.toISOString()}
tags: []
categories: []
---
Original content`);
const result = await postEngine.updatePost(created.id, { language: 'fr' });
expect(result).not.toBeNull();
expect(result?.status).toBe('draft');
expect(result?.language).toBe('fr');
});
it('should auto-transition published post to draft when author changes', async () => {
const created = await postEngine.createPost({ title: 'Author Draft Test' });
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: created.id,
projectId: created.projectId,
title: created.title,
slug: created.slug,
status: 'published',
content: null,
filePath: '/mock/published-author.md',
tags: '[]',
categories: '[]',
createdAt: created.createdAt,
updatedAt: created.updatedAt,
}),
});
return chain;
});
mockFiles.set('/mock/published-author.md', `---
id: ${created.id}
projectId: default
title: ${created.title}
slug: ${created.slug}
status: published
createdAt: ${created.createdAt.toISOString()}
updatedAt: ${created.updatedAt.toISOString()}
tags: []
categories: []
---
Original content`);
const result = await postEngine.updatePost(created.id, { author: 'New Author' });
expect(result).not.toBeNull();
expect(result?.status).toBe('draft');
expect(result?.author).toBe('New Author');
});
it('should update tags and categories', async () => {
const created = await postEngine.createPost({
title: 'Tag Update Test',
@@ -3301,4 +3405,106 @@ Content with [link](/posts/other-post)`);
expect(result.processedFiles).toBe(0);
});
});
describe('Post Language', () => {
it('should create a post with no language by default', async () => {
const post = await postEngine.createPost({ title: 'No Language' });
expect(post.language).toBeUndefined();
});
it('should create a post with explicit language', async () => {
const post = await postEngine.createPost({ title: 'German Post', language: 'de' });
expect(post.language).toBe('de');
});
it('should update post language', async () => {
const post = await postEngine.createPost({ title: 'Lang Update' });
// Mock getPost to return the created post
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.get = vi.fn().mockResolvedValue({
...mockPosts.get(post.id),
tags: JSON.stringify([]),
categories: JSON.stringify([]),
});
return chain;
});
const updated = await postEngine.updatePost(post.id, { language: 'fr' });
expect(updated).not.toBeNull();
expect(updated!.language).toBe('fr');
});
it('should include language in frontmatter when publishing', async () => {
const post = await postEngine.createPost({ title: 'Publish Lang', language: 'es' });
const postId = post.id;
// Verify the post was stored in the mock DB
const stored = mockPosts.get(postId);
expect(stored).toBeDefined();
// The mock DB stores posts via insert; publishPost calls getPost internally,
// which needs DB select to return the post with content (draft).
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.get = vi.fn().mockImplementation(() => {
const s = mockPosts.get(postId);
if (!s) return Promise.resolve(undefined);
return Promise.resolve(s);
});
return chain;
});
const result = await postEngine.publishPost(postId);
expect(result).not.toBeNull();
// Check that the written file contains language in frontmatter
const writtenFiles = Array.from(mockFiles.entries());
const postFile = writtenFiles.find(([p]) => p.endsWith('.md'));
expect(postFile).toBeDefined();
expect(postFile![1]).toContain('language: es');
});
it('should read language from frontmatter in published posts', async () => {
const filePath = '/mock/data/posts/2025/01/lang-test.md';
mockFiles.set(filePath, [
'---',
'id: lang-test-post',
'title: Language Test',
'slug: lang-test',
'status: published',
'language: it',
'createdAt: 2025-01-15T10:00:00.000Z',
'updatedAt: 2025-01-15T10:00:00.000Z',
'tags: []',
'categories: []',
'---',
'Content here',
].join('\n'));
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.get = vi.fn().mockResolvedValue({
id: 'lang-test-post',
projectId: 'default',
title: 'Language Test',
slug: 'lang-test',
content: null,
status: 'published',
language: 'it',
createdAt: new Date('2025-01-15T10:00:00.000Z'),
updatedAt: new Date('2025-01-15T10:00:00.000Z'),
filePath,
tags: '[]',
categories: '[]',
});
return chain;
});
const post = await postEngine.getPost('lang-test-post');
expect(post).not.toBeNull();
expect(post!.language).toBe('it');
});
});
});

View File

@@ -340,6 +340,120 @@ describe('TaskManager', () => {
});
});
describe('TaskManager External Tasks', () => {
let taskManager: TaskManager;
beforeEach(() => {
taskManager = new TaskManager();
resetMockCounters();
});
it('should create an external task in running state', () => {
taskManager.startExternalTask('ext-1', 'Language detection');
const status = taskManager.getTaskStatus('ext-1');
expect(status).toBeDefined();
expect(status?.status).toBe('running');
expect(status?.name).toBe('Language detection');
expect(status?.progress).toBe(0);
});
it('should emit taskCreated and taskStarted for external tasks', () => {
const createdHandler = vi.fn();
const startedHandler = vi.fn();
taskManager.on('taskCreated', createdHandler);
taskManager.on('taskStarted', startedHandler);
taskManager.startExternalTask('ext-2', 'Script run');
expect(createdHandler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'ext-2', status: 'running' }));
expect(startedHandler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'ext-2', status: 'running' }));
});
it('should update progress on an external task', () => {
const progressHandler = vi.fn();
taskManager.on('taskProgress', progressHandler);
taskManager.startExternalTask('ext-3', 'Detect languages');
taskManager.updateExternalTaskProgress('ext-3', 50, 'Halfway done');
const status = taskManager.getTaskStatus('ext-3');
expect(status?.progress).toBe(50);
expect(status?.message).toBe('Halfway done');
expect(progressHandler).toHaveBeenCalledWith(expect.objectContaining({
taskId: 'ext-3',
progress: 50,
message: 'Halfway done',
}));
});
it('should complete an external task', () => {
const completedHandler = vi.fn();
taskManager.on('taskCompleted', completedHandler);
taskManager.startExternalTask('ext-4', 'Run utility');
taskManager.completeExternalTask('ext-4');
const status = taskManager.getTaskStatus('ext-4');
expect(status?.status).toBe('completed');
expect(status?.progress).toBe(100);
expect(status?.endTime).toBeInstanceOf(Date);
expect(completedHandler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'ext-4', status: 'completed' }));
});
it('should fail an external task', () => {
const failedHandler = vi.fn();
taskManager.on('taskFailed', failedHandler);
taskManager.startExternalTask('ext-5', 'Run utility');
taskManager.failExternalTask('ext-5', 'Script crashed');
const status = taskManager.getTaskStatus('ext-5');
expect(status?.status).toBe('failed');
expect(status?.error).toBe('Script crashed');
expect(status?.endTime).toBeInstanceOf(Date);
expect(failedHandler).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'ext-5', status: 'failed' }));
});
it('should ignore updates to non-existent external tasks', () => {
// These should not throw
taskManager.updateExternalTaskProgress('nope', 50, 'test');
taskManager.completeExternalTask('nope');
taskManager.failExternalTask('nope', 'error');
});
it('should include external tasks in getAllTasks and getRunningTasks', () => {
taskManager.startExternalTask('ext-6', 'Running script');
expect(taskManager.getAllTasks()).toHaveLength(1);
expect(taskManager.getRunningTasks()).toHaveLength(1);
taskManager.completeExternalTask('ext-6');
expect(taskManager.getAllTasks()).toHaveLength(1);
expect(taskManager.getRunningTasks()).toHaveLength(0);
});
it('should allow cancellation of external tasks', () => {
taskManager.startExternalTask('ext-7', 'Long script');
const cancelled = taskManager.cancelTask('ext-7');
expect(cancelled).toBe(true);
const status = taskManager.getTaskStatus('ext-7');
expect(status?.status).toBe('cancelled');
});
it('should be clearable like regular tasks', () => {
taskManager.startExternalTask('ext-8', 'Script');
taskManager.completeExternalTask('ext-8');
expect(taskManager.getAllTasks()).toHaveLength(1);
taskManager.clearCompletedTasks();
expect(taskManager.getAllTasks()).toHaveLength(0);
});
});
describe('TaskManager Concurrency', () => {
let taskManager: TaskManager;
const MAX_CONCURRENT = 3;