fix: hooked up python API for templates

This commit is contained in:
2026-02-27 20:31:25 +01:00
parent f3364999ee
commit 6c2d2c48bf
7 changed files with 764 additions and 3 deletions

312
API.md
View File

@@ -1,6 +1,6 @@
# API Documentation
Contract version: 1.7.0
Contract version: 1.8.0
This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.
@@ -21,6 +21,7 @@ project = await bds.meta.get_project_metadata()
- [posts](#posts)
- [media](#media)
- [scripts](#scripts)
- [templates](#templates)
- [tasks](#tasks)
- [app](#app)
- [meta](#meta)
@@ -1949,6 +1950,295 @@ None
[↑ Back to Table of contents](#table-of-contents)
## templates
**Module APIs**
- [templates.create](#templatescreate)
- [templates.update](#templatesupdate)
- [templates.delete](#templatesdelete)
- [templates.get](#templatesget)
- [templates.getAll](#templatesgetall)
- [templates.getEnabledByKind](#templatesgetenabledbykind)
- [templates.validate](#templatesvalidate)
- [templates.rebuildFromFiles](#templatesrebuildfromfiles)
### templates.create
Create template. data must include: title (str), kind ("post"|"list"|"not-found"|"partial"), content (str). Optional: slug (str), enabled (bool).
**Parameters**
- data (dict, required)
**Response specification**
- Return type: `TemplateData`
- Data structures: `TemplateData`
**Example call**
```python
from bds_api import bds
result = await bds.templates.create(data={})
```
**Example response**
```python
{
'id': 'value',
'projectId': 'value',
'slug': 'value',
'title': 'value',
'kind': 'post',
'enabled': False,
'version': 0,
'filePath': 'value',
'content': 'value',
'createdAt': 'value',
'updatedAt': 'value'
}
```
### templates.update
Update template by id. data may include any of: title, kind, content, slug, enabled.
**Parameters**
- id (str, required)
- data (dict, required)
**Response specification**
- Return type: `TemplateData | null`
- Nullability: Returns `None` when no matching value exists.
- Data structures: `TemplateData`
**Example call**
```python
from bds_api import bds
result = await bds.templates.update(id='id-1', data={})
```
**Example response**
```python
None # or
{
'id': 'value',
'projectId': 'value',
'slug': 'value',
'title': 'value',
'kind': 'post',
'enabled': False,
'version': 0,
'filePath': 'value',
'content': 'value',
'createdAt': 'value',
'updatedAt': 'value'
}
```
### templates.delete
Delete template by id.
**Parameters**
- id (str, required)
**Response specification**
- Return type: `boolean`
**Example call**
```python
from bds_api import bds
result = await bds.templates.delete(id='id-1')
```
**Example response**
```python
True
```
### templates.get
Fetch template by id.
**Parameters**
- id (str, required)
**Response specification**
- Return type: `TemplateData | null`
- Nullability: Returns `None` when no matching value exists.
- Data structures: `TemplateData`
**Example call**
```python
from bds_api import bds
result = await bds.templates.get(id='id-1')
```
**Example response**
```python
None # or
{
'id': 'value',
'projectId': 'value',
'slug': 'value',
'title': 'value',
'kind': 'post',
'enabled': False,
'version': 0,
'filePath': 'value',
'content': 'value',
'createdAt': 'value',
'updatedAt': 'value'
}
```
### templates.getAll
Fetch all templates.
**Parameters**
- None
**Response specification**
- Return type: `TemplateData[]`
- Data structures: `TemplateData`
**Example call**
```python
from bds_api import bds
result = await bds.templates.get_all()
```
**Example response**
```python
[
{
'id': 'value',
'projectId': 'value',
'slug': 'value',
'title': 'value',
'kind': 'post',
'enabled': False,
'version': 0,
'filePath': 'value',
'content': 'value',
'createdAt': 'value',
'updatedAt': 'value'
}
]
```
### templates.getEnabledByKind
Fetch enabled templates filtered by kind.
**Parameters**
- kind (str, required)
**Response specification**
- Return type: `TemplateData[]`
- Data structures: `TemplateData`
**Example call**
```python
from bds_api import bds
result = await bds.templates.get_enabled_by_kind(kind='kind')
```
**Example response**
```python
[
{
'id': 'value',
'projectId': 'value',
'slug': 'value',
'title': 'value',
'kind': 'post',
'enabled': False,
'version': 0,
'filePath': 'value',
'content': 'value',
'createdAt': 'value',
'updatedAt': 'value'
}
]
```
### templates.validate
Validate Liquid template syntax.
**Parameters**
- content (str, required)
**Response specification**
- Return type: `{ valid: boolean; errors: string[] }`
**Example call**
```python
from bds_api import bds
result = await bds.templates.validate(content='content')
```
**Example response**
```python
{}
```
### templates.rebuildFromFiles
Rebuild templates from files.
**Parameters**
- None
**Response specification**
- Return type: `void`
**Example call**
```python
from bds_api import bds
result = await bds.templates.rebuild_from_files()
```
**Example response**
```python
None
```
[↑ Back to Table of contents](#table-of-contents)
## tasks
**Module APIs**
@@ -3579,6 +3869,26 @@ Script definition for Python macros, utilities, and transforms.
[↑ Back to Table of contents](#table-of-contents)
### TemplateData
Liquid template definition for posts, lists, not-found pages, and partials.
**Fields**
- id (`string`, required): Unique template identifier.
- projectId (`string`, required): Owning project id.
- slug (`string`, required): Stable template slug.
- title (`string`, required): Human-readable template title.
- kind (`'post' | 'list' | 'not-found' | 'partial'`, required): Template category.
- enabled (`boolean`, required): Whether template is enabled.
- version (`number`, required): Incrementing template version.
- filePath (`string`, required): Filesystem path to template file.
- content (`string`, required): Liquid template source code.
- createdAt (`string`, required): Creation timestamp (ISO string).
- updatedAt (`string`, required): Last update timestamp (ISO string).
[↑ Back to Table of contents](#table-of-contents)
### TaskProgress
Task queue status object for long-running operations.

View File

@@ -129,6 +129,15 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'),
method('scripts.rebuildFromFiles', 'Rebuild scripts from files.', [], 'void'),
method('templates.create', 'Create template. data must include: title (str), kind ("post"|"list"|"not-found"|"partial"), content (str). Optional: slug (str), enabled (bool).', [requiredObject('data')], 'TemplateData'),
method('templates.update', 'Update template by id. data may include any of: title, kind, content, slug, enabled.', [requiredString('id'), requiredObject('data')], 'TemplateData | null'),
method('templates.delete', 'Delete template by id.', [requiredString('id')], 'boolean'),
method('templates.get', 'Fetch template by id.', [requiredString('id')], 'TemplateData | null'),
method('templates.getAll', 'Fetch all templates.', [], 'TemplateData[]'),
method('templates.getEnabledByKind', 'Fetch enabled templates filtered by kind.', [requiredString('kind')], 'TemplateData[]'),
method('templates.validate', 'Validate Liquid template syntax.', [requiredString('content')], '{ valid: boolean; errors: string[] }'),
method('templates.rebuildFromFiles', 'Rebuild templates from files.', [], 'void'),
method('tasks.getAll', 'Fetch all tasks.', [], 'TaskProgress[]'),
method('tasks.getRunning', 'Fetch running tasks.', [], 'TaskProgress[]'),
method('tasks.cancel', 'Cancel task by id.', [requiredString('taskId')], 'boolean'),
@@ -276,6 +285,23 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'TemplateData',
description: 'Liquid template definition for posts, lists, not-found pages, and partials.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique template identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'slug', type: 'string', required: true, description: 'Stable template slug.' },
{ name: 'title', type: 'string', required: true, description: 'Human-readable template title.' },
{ name: 'kind', type: "'post' | 'list' | 'not-found' | 'partial'", required: true, description: 'Template category.' },
{ name: 'enabled', type: 'boolean', required: true, description: 'Whether template is enabled.' },
{ name: 'version', type: 'number', required: true, description: 'Incrementing template version.' },
{ name: 'filePath', type: 'string', required: true, description: 'Filesystem path to template file.' },
{ name: 'content', type: 'string', required: true, description: 'Liquid template source code.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'TaskProgress',
description: 'Task queue status object for long-running operations.',
@@ -370,7 +396,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.7.0',
version: '1.8.0',
generatedAt: '2026-02-27T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,

View File

@@ -343,4 +343,50 @@ describe('TemplateEngine', () => {
expect(created.slug).toBe('my_custom_slug');
});
});
describe('getTemplateBySlug', () => {
it('retrieves an enabled template by exact slug', async () => {
await templateEngine.createTemplate({
title: 'Custom Post',
kind: 'post',
content: '<main>Post</main>',
enabled: true,
});
const found = await templateEngine.getTemplateBySlug('custom_post');
expect(found).not.toBeNull();
expect(found?.slug).toBe('custom_post');
expect(found?.title).toBe('Custom Post');
});
it('matches slugs case-insensitively', async () => {
await templateEngine.createTemplate({
title: 'Custom Post',
kind: 'post',
content: '<main>Post</main>',
enabled: true,
});
const found = await templateEngine.getTemplateBySlug('CUSTOM_POST');
expect(found).not.toBeNull();
expect(found?.slug).toBe('custom_post');
});
it('returns null for non-existent slug', async () => {
const found = await templateEngine.getTemplateBySlug('does_not_exist');
expect(found).toBeNull();
});
it('does not return disabled templates', async () => {
await templateEngine.createTemplate({
title: 'Disabled Template',
kind: 'post',
content: '<main>Disabled</main>',
enabled: false,
});
const found = await templateEngine.getTemplateBySlug('disabled_template');
expect(found).toBeNull();
});
});
});

View File

@@ -2786,6 +2786,151 @@ describe('IPC Handlers', () => {
});
});
// ============ Template Handlers ============
describe('Template Handlers', () => {
describe('templates:create', () => {
it('should call TemplateEngine.createTemplate with payload', async () => {
const payload = {
title: 'Custom Post',
kind: 'post',
content: '<html>{{ post.title }}</html>',
};
const expected = {
id: 'template-1',
projectId: 'default',
...payload,
slug: 'custom_post',
enabled: true,
version: 1,
filePath: '/mock/userData/projects/default/templates/custom_post.liquid',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockTemplateEngine.createTemplate.mockResolvedValue(expected);
const result = await invokeHandler('templates:create', payload);
expect(mockTemplateEngine.createTemplate).toHaveBeenCalledWith(payload);
expect(result).toEqual(expected);
});
});
describe('templates:update', () => {
it('should call TemplateEngine.updateTemplate with id and updates', async () => {
const updates = { title: 'Updated Template', content: '<html>{{ post.content }}</html>' };
const expected = {
id: 'template-1',
projectId: 'default',
slug: 'updated_template',
title: 'Updated Template',
kind: 'post',
enabled: true,
version: 2,
filePath: '/mock/userData/projects/default/templates/updated_template.liquid',
content: '<html>{{ post.content }}</html>',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockTemplateEngine.updateTemplate.mockResolvedValue(expected);
const result = await invokeHandler('templates:update', 'template-1', updates);
expect(mockTemplateEngine.updateTemplate).toHaveBeenCalledWith('template-1', updates);
expect(result).toEqual(expected);
});
});
describe('templates:delete', () => {
it('should call TemplateEngine.deleteTemplate with id', async () => {
mockTemplateEngine.deleteTemplate.mockResolvedValue(true);
const result = await invokeHandler('templates:delete', 'template-1');
expect(mockTemplateEngine.deleteTemplate).toHaveBeenCalledWith('template-1');
expect(result).toBe(true);
});
});
describe('templates:get', () => {
it('should call TemplateEngine.getTemplate with id', async () => {
const expected = {
id: 'template-1',
projectId: 'default',
slug: 'custom_post',
title: 'Custom Post',
kind: 'post',
enabled: true,
version: 1,
filePath: '/mock/userData/projects/default/templates/custom_post.liquid',
content: '<html>{{ post.title }}</html>',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockTemplateEngine.getTemplate.mockResolvedValue(expected);
const result = await invokeHandler('templates:get', 'template-1');
expect(mockTemplateEngine.getTemplate).toHaveBeenCalledWith('template-1');
expect(result).toEqual(expected);
});
});
describe('templates:getAll', () => {
it('should call TemplateEngine.getAllTemplates', async () => {
const expected = [{ id: 'template-1' }, { id: 'template-2' }];
mockTemplateEngine.getAllTemplates.mockResolvedValue(expected);
const result = await invokeHandler('templates:getAll');
expect(mockTemplateEngine.getAllTemplates).toHaveBeenCalled();
expect(result).toEqual(expected);
});
});
describe('templates:getEnabledByKind', () => {
it('should call TemplateEngine.getEnabledTemplatesByKind with kind', async () => {
const expected = [{ id: 'template-1', kind: 'post' }];
mockTemplateEngine.getEnabledTemplatesByKind.mockResolvedValue(expected);
const result = await invokeHandler('templates:getEnabledByKind', 'post');
expect(mockTemplateEngine.getEnabledTemplatesByKind).toHaveBeenCalledWith('post');
expect(result).toEqual(expected);
});
});
describe('templates:validate', () => {
it('should call TemplateEngine.validateTemplate with content', async () => {
const expected = { valid: true, errors: [] };
mockTemplateEngine.validateTemplate.mockResolvedValue(expected);
const result = await invokeHandler('templates:validate', '<html>{{ post.title }}</html>');
expect(mockTemplateEngine.validateTemplate).toHaveBeenCalledWith('<html>{{ post.title }}</html>');
expect(result).toEqual(expected);
});
});
describe('templates:rebuildFromFiles', () => {
it('should set project context and trigger TemplateEngine rebuild', async () => {
mockProjectEngine.getActiveProject.mockResolvedValue({
id: 'project-1',
dataPath: '/external/data',
});
mockProjectEngine.getDataDir.mockReturnValue('/resolved/project-data');
mockTemplateEngine.rebuildDatabaseFromFiles.mockResolvedValue(undefined);
const result = await invokeHandler('templates:rebuildFromFiles');
expect(mockTemplateEngine.setProjectContext).toHaveBeenCalledWith('project-1', '/resolved/project-data');
expect(mockTemplateEngine.rebuildDatabaseFromFiles).toHaveBeenCalled();
expect(result).toBe(true);
});
});
});
// ============ Error Handling ============
describe('Error Handling', () => {
it('should silently handle "Database is closing" errors', async () => {

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('TemplatesView styles', () => {
const cssPath = path.resolve(
__dirname,
'../../../src/renderer/components/TemplatesView/TemplatesView.css'
);
it('uses full editor area layout for the templates container', () => {
const css = fs.readFileSync(cssPath, 'utf8');
expect(css).toMatch(/\.templates-view\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
});
it('keeps editor and monaco stretched to fill available space', () => {
const css = fs.readFileSync(cssPath, 'utf8');
expect(css).toMatch(/\.templates-editor\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
expect(css).toMatch(/\.templates-monaco\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
});
});

View File

@@ -0,0 +1,211 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { TemplatesView } from '../../../src/renderer/components/TemplatesView/TemplatesView';
import { useAppStore } from '../../../src/renderer/store';
const monacoPropsSpy = vi.fn();
vi.mock('@monaco-editor/react', () => ({
default: (props: {
value?: string;
defaultValue?: string;
onChange?: (value?: string) => void;
language?: string;
}) => {
monacoPropsSpy(props);
return (
<textarea
aria-label="Template Content"
defaultValue={props.defaultValue ?? props.value ?? ''}
onChange={(event) => props.onChange?.(event.target.value)}
/>
);
},
}));
const mockTemplate = {
id: 'template-1',
projectId: 'default',
slug: 'custom-post',
title: 'Custom Post',
kind: 'post' as const,
enabled: true,
version: 1,
filePath: '/tmp/custom-post.liquid',
content: '<main>{{ post.title }}</main>',
createdAt: '2026-02-22T00:00:00.000Z',
updatedAt: '2026-02-22T00:00:00.000Z',
};
describe('TemplatesView', () => {
beforeEach(() => {
vi.clearAllMocks();
(window as any).electronAPI = {
...(window as any).electronAPI,
templates: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn().mockResolvedValue({ ...mockTemplate }),
getAll: vi.fn().mockResolvedValue([]),
getEnabledByKind: vi.fn().mockResolvedValue([]),
validate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
rebuildFromFiles: vi.fn(),
},
};
});
it('loads template and displays metadata fields', async () => {
render(<TemplatesView templateId="template-1" />);
const titleInput = await screen.findByLabelText('Title') as HTMLInputElement;
const slugInput = screen.getByLabelText('Slug') as HTMLInputElement;
const kindSelect = screen.getByLabelText('Kind') as HTMLSelectElement;
const enabledInput = screen.getByLabelText('Enabled') as HTMLInputElement;
await vi.waitFor(() => {
expect(titleInput.value).toBe('Custom Post');
expect(slugInput.value).toBe('custom-post');
});
expect(kindSelect.value).toBe('post');
expect(enabledInput.checked).toBe(true);
expect(screen.getByText(/Created:/)).toBeInTheDocument();
expect(screen.getByText(/Updated:/)).toBeInTheDocument();
});
it('loads template content into Monaco editor with html language', async () => {
render(<TemplatesView templateId="template-1" />);
await vi.waitFor(() => {
const textarea = screen.getByLabelText('Template Content') as HTMLTextAreaElement;
expect(textarea.value).toContain('{{ post.title }}');
});
expect(monacoPropsSpy).toHaveBeenCalledWith(
expect.objectContaining({
language: 'html',
}),
);
});
it('saves template metadata via update IPC call', async () => {
const updateMock = vi.fn().mockResolvedValue({
...mockTemplate,
kind: 'list',
enabled: false,
version: 2,
updatedAt: '2026-02-22T00:01:00.000Z',
});
(window as any).electronAPI.templates.update = updateMock;
render(<TemplatesView templateId="template-1" />);
const kindSelect = screen.getByLabelText('Kind');
const enabledInput = screen.getByLabelText('Enabled');
await vi.waitFor(() => {
expect((screen.getByLabelText('Title') as HTMLInputElement).value).toBe('Custom Post');
});
fireEvent.change(kindSelect, { target: { value: 'list' } });
fireEvent.click(enabledInput);
await vi.waitFor(() => {
expect((kindSelect as HTMLSelectElement).value).toBe('list');
expect((enabledInput as HTMLInputElement).checked).toBe(false);
});
fireEvent.click(screen.getByRole('button', { name: 'Save Template' }));
await vi.waitFor(() => {
expect(updateMock).toHaveBeenCalledWith(
'template-1',
expect.objectContaining({
title: 'Custom Post',
slug: 'custom-post',
kind: 'list',
enabled: false,
content: '<main>{{ post.title }}</main>',
}),
);
});
});
it('validates before saving and blocks save on invalid syntax', async () => {
const validateMock = vi.fn().mockResolvedValue({
valid: false,
errors: ['unexpected end of tag'],
});
const updateMock = vi.fn();
(window as any).electronAPI.templates.validate = validateMock;
(window as any).electronAPI.templates.update = updateMock;
render(<TemplatesView templateId="template-1" />);
await vi.waitFor(() => {
expect((screen.getByLabelText('Title') as HTMLInputElement).value).toBe('Custom Post');
});
// Trigger hasChanges via kind change (select events work reliably)
fireEvent.change(screen.getByLabelText('Kind'), { target: { value: 'list' } });
await vi.waitFor(() => {
expect((screen.getByLabelText('Kind') as HTMLSelectElement).value).toBe('list');
});
fireEvent.click(screen.getByRole('button', { name: 'Save Template' }));
await vi.waitFor(() => {
expect(validateMock).toHaveBeenCalled();
});
expect(updateMock).not.toHaveBeenCalled();
});
it('validates template content via validate button', async () => {
const validateMock = vi.fn().mockResolvedValue({ valid: true, errors: [] });
(window as any).electronAPI.templates.validate = validateMock;
render(<TemplatesView templateId="template-1" />);
await screen.findByLabelText('Title');
fireEvent.click(screen.getByRole('button', { name: 'Validate' }));
await vi.waitFor(() => {
expect(validateMock).toHaveBeenCalledWith('<main>{{ post.title }}</main>');
});
});
it('deletes template and closes tab', async () => {
const deleteMock = vi.fn().mockResolvedValue(true);
(window as any).electronAPI.templates.delete = deleteMock;
useAppStore.setState({
tabs: [{ type: 'templates', id: 'template-1', isTransient: false }],
activeTabId: 'template-1',
});
render(<TemplatesView templateId="template-1" />);
fireEvent.click(await screen.findByRole('button', { name: 'Delete Template' }));
await vi.waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith('template-1');
expect(useAppStore.getState().tabs).toEqual([]);
});
});
it('disables save button when no changes exist', async () => {
render(<TemplatesView templateId="template-1" />);
await screen.findByLabelText('Title');
await vi.waitFor(() => {
const saveButton = screen.getByRole('button', { name: 'Save Template' });
expect(saveButton).toBeDisabled();
});
});
});

View File

@@ -67,7 +67,7 @@ describe('pythonApiContractV1', () => {
it('contains semantic version metadata for compatibility checks', () => {
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
version: '1.7.0',
version: '1.8.0',
generatedAt: expect.any(String),
});
});