fix: race condition and delete checking for templates
This commit is contained in:
24
API.md
24
API.md
@@ -1,6 +1,6 @@
|
|||||||
# API Documentation
|
# API Documentation
|
||||||
|
|
||||||
Contract version: 1.8.0
|
Contract version: 1.9.0
|
||||||
|
|
||||||
This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.
|
This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.
|
||||||
|
|
||||||
@@ -2044,15 +2044,17 @@ None # or
|
|||||||
|
|
||||||
### templates.delete
|
### templates.delete
|
||||||
|
|
||||||
Delete template by id.
|
Delete template by id. Without options, returns references if the template is in use. Pass options={"force": True} to clear references and delete.
|
||||||
|
|
||||||
**Parameters**
|
**Parameters**
|
||||||
|
|
||||||
- id (str, required)
|
- id (str, required)
|
||||||
|
- options (dict, optional)
|
||||||
|
|
||||||
**Response specification**
|
**Response specification**
|
||||||
|
|
||||||
- Return type: `boolean`
|
- Return type: `TemplateDeleteResult`
|
||||||
|
- Data structures: `TemplateDeleteResult`
|
||||||
|
|
||||||
**Example call**
|
**Example call**
|
||||||
|
|
||||||
@@ -2064,7 +2066,10 @@ result = await bds.templates.delete(id='id-1')
|
|||||||
**Example response**
|
**Example response**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
True
|
{
|
||||||
|
'deleted': False,
|
||||||
|
'references': 'value'
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### templates.get
|
### templates.get
|
||||||
@@ -3889,6 +3894,17 @@ Liquid template definition for posts, lists, not-found pages, and partials.
|
|||||||
|
|
||||||
[↑ Back to Table of contents](#table-of-contents)
|
[↑ Back to Table of contents](#table-of-contents)
|
||||||
|
|
||||||
|
### TemplateDeleteResult
|
||||||
|
|
||||||
|
Result of a template delete operation. If the template is referenced by posts or tags, deleted is false and references lists the referencing IDs.
|
||||||
|
|
||||||
|
**Fields**
|
||||||
|
|
||||||
|
- deleted (`boolean`, required): Whether the template was deleted.
|
||||||
|
- references (`{ postIds: string[]; tagIds: string[] }`, optional): Post and tag IDs referencing this template (present when deleted is false and references exist).
|
||||||
|
|
||||||
|
[↑ Back to Table of contents](#table-of-contents)
|
||||||
|
|
||||||
### TaskProgress
|
### TaskProgress
|
||||||
|
|
||||||
Task queue status object for long-running operations.
|
Task queue status object for long-running operations.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { app } from 'electron';
|
|||||||
import { and, desc, eq } from 'drizzle-orm';
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
import { Liquid } from 'liquidjs';
|
import { Liquid } from 'liquidjs';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { templates, type NewTemplate, type Template } from '../database/schema';
|
import { posts, tags, templates, type NewTemplate, type Template } from '../database/schema';
|
||||||
|
|
||||||
export type TemplateKind = 'post' | 'list' | 'not-found' | 'partial';
|
export type TemplateKind = 'post' | 'list' | 'not-found' | 'partial';
|
||||||
|
|
||||||
@@ -60,6 +60,11 @@ export interface TemplateValidationResult {
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TemplateDeleteResult {
|
||||||
|
deleted: boolean;
|
||||||
|
references?: { postIds: string[]; tagIds: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
interface ParsedTemplateFile {
|
interface ParsedTemplateFile {
|
||||||
metadata: {
|
metadata: {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -139,18 +144,13 @@ export class TemplateEngine extends EventEmitter {
|
|||||||
const nextFilePath = this.getTemplateFilePath(nextSlug);
|
const nextFilePath = this.getTemplateFilePath(nextSlug);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
if (existing.filePath !== nextFilePath) {
|
|
||||||
await fs.mkdir(this.getTemplatesDir(), { recursive: true });
|
|
||||||
await fs.rename(existing.filePath, nextFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextTitle = updates.title ?? existing.title;
|
const nextTitle = updates.title ?? existing.title;
|
||||||
const nextKind = updates.kind ?? existing.kind;
|
const nextKind = updates.kind ?? existing.kind;
|
||||||
const nextEnabled = updates.enabled ?? existing.enabled;
|
const nextEnabled = updates.enabled ?? existing.enabled;
|
||||||
const nextVersion = existing.version + 1;
|
const nextVersion = existing.version + 1;
|
||||||
const nextContent = typeof updates.content === 'string'
|
const nextContent = typeof updates.content === 'string'
|
||||||
? updates.content
|
? updates.content
|
||||||
: await this.readTemplateBody(nextFilePath);
|
: await this.readTemplateBody(existing.filePath);
|
||||||
|
|
||||||
const nextRow = {
|
const nextRow = {
|
||||||
...existing,
|
...existing,
|
||||||
@@ -163,11 +163,7 @@ export class TemplateEngine extends EventEmitter {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await fs.writeFile(nextFilePath, this.serializeTemplateFile(nextRow, nextContent), 'utf-8');
|
const dbUpdates = {
|
||||||
|
|
||||||
await getDatabase().getLocal()
|
|
||||||
.update(templates)
|
|
||||||
.set({
|
|
||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
slug: nextSlug,
|
slug: nextSlug,
|
||||||
kind: nextKind,
|
kind: nextKind,
|
||||||
@@ -175,8 +171,49 @@ export class TemplateEngine extends EventEmitter {
|
|||||||
filePath: nextFilePath,
|
filePath: nextFilePath,
|
||||||
version: nextVersion,
|
version: nextVersion,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// DB-first: update the database row before touching the filesystem
|
||||||
|
await getDatabase().getLocal()
|
||||||
|
.update(templates)
|
||||||
|
.set(dbUpdates)
|
||||||
|
.where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existing.filePath !== nextFilePath) {
|
||||||
|
await fs.mkdir(this.getTemplatesDir(), { recursive: true });
|
||||||
|
await fs.rename(existing.filePath, nextFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(nextFilePath, this.serializeTemplateFile(nextRow, nextContent), 'utf-8');
|
||||||
|
} catch (fileError) {
|
||||||
|
// Roll back the DB row to previous values on file operation failure
|
||||||
|
await getDatabase().getLocal()
|
||||||
|
.update(templates)
|
||||||
|
.set({
|
||||||
|
title: existing.title,
|
||||||
|
slug: existing.slug,
|
||||||
|
kind: existing.kind,
|
||||||
|
enabled: existing.enabled,
|
||||||
|
filePath: existing.filePath,
|
||||||
|
version: existing.version,
|
||||||
|
updatedAt: existing.updatedAt,
|
||||||
})
|
})
|
||||||
.where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId)));
|
.where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId)));
|
||||||
|
throw fileError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade slug updates to referencing posts and tags
|
||||||
|
if (existing.slug !== nextSlug) {
|
||||||
|
await getDatabase().getLocal()
|
||||||
|
.update(posts)
|
||||||
|
.set({ templateSlug: nextSlug })
|
||||||
|
.where(and(eq(posts.templateSlug, existing.slug), eq(posts.projectId, this.currentProjectId)));
|
||||||
|
await getDatabase().getLocal()
|
||||||
|
.update(tags)
|
||||||
|
.set({ postTemplateSlug: nextSlug })
|
||||||
|
.where(and(eq(tags.postTemplateSlug, existing.slug), eq(tags.projectId, this.currentProjectId)));
|
||||||
|
}
|
||||||
|
|
||||||
const updatedRow = await this.getTemplateRow(existing.id);
|
const updatedRow = await this.getTemplateRow(existing.id);
|
||||||
if (!updatedRow) {
|
if (!updatedRow) {
|
||||||
@@ -188,10 +225,47 @@ export class TemplateEngine extends EventEmitter {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTemplate(id: string): Promise<boolean> {
|
async getTemplateReferences(slug: string): Promise<{ postIds: string[]; tagIds: string[] }> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const referencingPosts = await db
|
||||||
|
.select({ id: posts.id })
|
||||||
|
.from(posts)
|
||||||
|
.where(and(eq(posts.templateSlug, slug), eq(posts.projectId, this.currentProjectId)))
|
||||||
|
.all();
|
||||||
|
const referencingTags = await db
|
||||||
|
.select({ id: tags.id })
|
||||||
|
.from(tags)
|
||||||
|
.where(and(eq(tags.postTemplateSlug, slug), eq(tags.projectId, this.currentProjectId)))
|
||||||
|
.all();
|
||||||
|
return {
|
||||||
|
postIds: referencingPosts.map((row) => row.id),
|
||||||
|
tagIds: referencingTags.map((row) => row.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTemplate(id: string, options?: { force?: boolean }): Promise<TemplateDeleteResult> {
|
||||||
const existing = await this.getTemplateRow(id);
|
const existing = await this.getTemplateRow(id);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return false;
|
return { deleted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const refs = await this.getTemplateReferences(existing.slug);
|
||||||
|
const hasReferences = refs.postIds.length > 0 || refs.tagIds.length > 0;
|
||||||
|
|
||||||
|
if (hasReferences && !options?.force) {
|
||||||
|
return { deleted: false, references: refs };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasReferences && options?.force) {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
await db
|
||||||
|
.update(posts)
|
||||||
|
.set({ templateSlug: null })
|
||||||
|
.where(and(eq(posts.templateSlug, existing.slug), eq(posts.projectId, this.currentProjectId)));
|
||||||
|
await db
|
||||||
|
.update(tags)
|
||||||
|
.set({ postTemplateSlug: null })
|
||||||
|
.where(and(eq(tags.postTemplateSlug, existing.slug), eq(tags.projectId, this.currentProjectId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
await getDatabase().getLocal()
|
await getDatabase().getLocal()
|
||||||
@@ -208,7 +282,7 @@ export class TemplateEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.emit('templateDeleted', id);
|
this.emit('templateDeleted', id);
|
||||||
return true;
|
return { deleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTemplate(id: string): Promise<TemplateData | null> {
|
async getTemplate(id: string): Promise<TemplateData | null> {
|
||||||
|
|||||||
@@ -817,9 +817,9 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.updateTemplate(id, data);
|
return engine.updateTemplate(id, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('templates:delete', async (_, id: string) => {
|
safeHandle('templates:delete', async (_, id: string, options?: { force?: boolean }) => {
|
||||||
const engine = getTemplateEngine();
|
const engine = getTemplateEngine();
|
||||||
return engine.deleteTemplate(id);
|
return engine.deleteTemplate(id, options);
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('templates:get', async (_, id: string) => {
|
safeHandle('templates:get', async (_, id: string) => {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export const electronAPI: ElectronAPI = {
|
|||||||
templates: {
|
templates: {
|
||||||
create: (data: { title: string; kind: import('./shared/electronApi').TemplateKind; content: string; slug?: string; enabled?: boolean }) => ipcRenderer.invoke('templates:create', data),
|
create: (data: { title: string; kind: import('./shared/electronApi').TemplateKind; content: string; slug?: string; enabled?: boolean }) => ipcRenderer.invoke('templates:create', data),
|
||||||
update: (id: string, data: { title?: string; kind?: import('./shared/electronApi').TemplateKind; content?: string; slug?: string; enabled?: boolean }) => ipcRenderer.invoke('templates:update', id, data),
|
update: (id: string, data: { title?: string; kind?: import('./shared/electronApi').TemplateKind; content?: string; slug?: string; enabled?: boolean }) => ipcRenderer.invoke('templates:update', id, data),
|
||||||
delete: (id: string) => ipcRenderer.invoke('templates:delete', id),
|
delete: (id: string, options?: { force?: boolean }) => ipcRenderer.invoke('templates:delete', id, options),
|
||||||
get: (id: string) => ipcRenderer.invoke('templates:get', id),
|
get: (id: string) => ipcRenderer.invoke('templates:get', id),
|
||||||
getAll: () => ipcRenderer.invoke('templates:getAll'),
|
getAll: () => ipcRenderer.invoke('templates:getAll'),
|
||||||
getEnabledByKind: (kind: import('./shared/electronApi').TemplateKind) => ipcRenderer.invoke('templates:getEnabledByKind', kind),
|
getEnabledByKind: (kind: import('./shared/electronApi').TemplateKind) => ipcRenderer.invoke('templates:getEnabledByKind', kind),
|
||||||
|
|||||||
@@ -177,6 +177,11 @@ export interface TemplateData {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TemplateDeleteResult {
|
||||||
|
deleted: boolean;
|
||||||
|
references?: { postIds: string[]; tagIds: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskProgress {
|
export interface TaskProgress {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -623,7 +628,7 @@ export interface ElectronAPI {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}) => Promise<TemplateData | null>;
|
}) => Promise<TemplateData | null>;
|
||||||
delete: (id: string) => Promise<boolean>;
|
delete: (id: string, options?: { force?: boolean }) => Promise<TemplateDeleteResult>;
|
||||||
get: (id: string) => Promise<TemplateData | null>;
|
get: (id: string) => Promise<TemplateData | null>;
|
||||||
getAll: () => Promise<TemplateData[]>;
|
getAll: () => Promise<TemplateData[]>;
|
||||||
getEnabledByKind: (kind: TemplateKind) => Promise<TemplateData[]>;
|
getEnabledByKind: (kind: TemplateKind) => Promise<TemplateData[]>;
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
|
|||||||
|
|
||||||
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.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.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.delete', 'Delete template by id. Without options, returns references if the template is in use. Pass options={"force": True} to clear references and delete.', [requiredString('id'), optionalObject('options')], 'TemplateDeleteResult'),
|
||||||
method('templates.get', 'Fetch template by id.', [requiredString('id')], 'TemplateData | null'),
|
method('templates.get', 'Fetch template by id.', [requiredString('id')], 'TemplateData | null'),
|
||||||
method('templates.getAll', 'Fetch all templates.', [], 'TemplateData[]'),
|
method('templates.getAll', 'Fetch all templates.', [], 'TemplateData[]'),
|
||||||
method('templates.getEnabledByKind', 'Fetch enabled templates filtered by kind.', [requiredString('kind')], 'TemplateData[]'),
|
method('templates.getEnabledByKind', 'Fetch enabled templates filtered by kind.', [requiredString('kind')], 'TemplateData[]'),
|
||||||
@@ -302,6 +302,14 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
|||||||
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
|
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'TemplateDeleteResult',
|
||||||
|
description: 'Result of a template delete operation. If the template is referenced by posts or tags, deleted is false and references lists the referencing IDs.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'deleted', type: 'boolean', required: true, description: 'Whether the template was deleted.' },
|
||||||
|
{ name: 'references', type: '{ postIds: string[]; tagIds: string[] }', required: false, description: 'Post and tag IDs referencing this template (present when deleted is false and references exist).' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'TaskProgress',
|
name: 'TaskProgress',
|
||||||
description: 'Task queue status object for long-running operations.',
|
description: 'Task queue status object for long-running operations.',
|
||||||
@@ -396,7 +404,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
|
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
|
||||||
version: '1.8.0',
|
version: '1.9.0',
|
||||||
generatedAt: '2026-02-27T00:00:00.000Z',
|
generatedAt: '2026-02-27T00:00:00.000Z',
|
||||||
methods: METHODS_V1,
|
methods: METHODS_V1,
|
||||||
dataStructures: DATA_STRUCTURES_V1,
|
dataStructures: DATA_STRUCTURES_V1,
|
||||||
|
|||||||
@@ -1752,11 +1752,30 @@ const TemplatesList: React.FC = () => {
|
|||||||
const handleDeleteTemplate = async (event: React.MouseEvent, templateId: string) => {
|
const handleDeleteTemplate = async (event: React.MouseEvent, templateId: string) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
try {
|
try {
|
||||||
const deleted = await window.electronAPI?.templates.delete(templateId);
|
const result = await window.electronAPI?.templates.delete(templateId);
|
||||||
if (!deleted) {
|
if (!result) {
|
||||||
showToast.error(t('sidebar.templates.deleteFailed'));
|
showToast.error(t('sidebar.templates.deleteFailed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!result.deleted && result.references) {
|
||||||
|
const { postIds, tagIds } = result.references;
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
t('sidebar.templates.deleteConfirmWithRefs', {
|
||||||
|
postCount: String(postIds.length),
|
||||||
|
tagCount: String(tagIds.length),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const forceResult = await window.electronAPI?.templates.delete(templateId, { force: true });
|
||||||
|
if (!forceResult?.deleted) {
|
||||||
|
showToast.error(t('sidebar.templates.deleteFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setTemplates((prev) => prev.filter((tmpl) => tmpl.id !== templateId));
|
setTemplates((prev) => prev.filter((tmpl) => tmpl.id !== templateId));
|
||||||
closeTab(templateId);
|
closeTab(templateId);
|
||||||
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
|
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
|
||||||
|
|||||||
@@ -182,11 +182,30 @@ export const TemplatesView: React.FC<TemplatesViewProps> = ({ templateId }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deleted = await window.electronAPI?.templates.delete(template.id);
|
const result = await window.electronAPI?.templates.delete(template.id);
|
||||||
if (!deleted) {
|
if (!result) {
|
||||||
showToast.error(t('sidebar.templates.deleteFailed'));
|
showToast.error(t('sidebar.templates.deleteFailed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!result.deleted && result.references) {
|
||||||
|
const { postIds, tagIds } = result.references;
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
t('sidebar.templates.deleteConfirmWithRefs', {
|
||||||
|
postCount: String(postIds.length),
|
||||||
|
tagCount: String(tagIds.length),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const forceResult = await window.electronAPI?.templates.delete(template.id, { force: true });
|
||||||
|
if (!forceResult?.deleted) {
|
||||||
|
showToast.error(t('sidebar.templates.deleteFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
closeTab(template.id);
|
closeTab(template.id);
|
||||||
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
|
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -780,6 +780,7 @@
|
|||||||
"sidebar.templates.createFailed": "Vorlage konnte nicht erstellt werden",
|
"sidebar.templates.createFailed": "Vorlage konnte nicht erstellt werden",
|
||||||
"sidebar.templates.deleteTemplate": "Vorlage löschen",
|
"sidebar.templates.deleteTemplate": "Vorlage löschen",
|
||||||
"sidebar.templates.deleteFailed": "Vorlage konnte nicht gelöscht werden",
|
"sidebar.templates.deleteFailed": "Vorlage konnte nicht gelöscht werden",
|
||||||
|
"sidebar.templates.deleteConfirmWithRefs": "Diese Vorlage wird von {postCount} Beitrag/Beiträgen und {tagCount} Tag(s) referenziert. Trotzdem löschen? Die Referenzen werden entfernt.",
|
||||||
"sidebar.import.none": "Noch keine Importdefinitionen",
|
"sidebar.import.none": "Noch keine Importdefinitionen",
|
||||||
"sidebar.import.createDefinition": "Eine Importdefinition erstellen",
|
"sidebar.import.createDefinition": "Eine Importdefinition erstellen",
|
||||||
"sidebar.import.deleteDefinition": "Importdefinition löschen",
|
"sidebar.import.deleteDefinition": "Importdefinition löschen",
|
||||||
|
|||||||
@@ -780,6 +780,7 @@
|
|||||||
"sidebar.templates.createFailed": "Failed to create template",
|
"sidebar.templates.createFailed": "Failed to create template",
|
||||||
"sidebar.templates.deleteTemplate": "Delete template",
|
"sidebar.templates.deleteTemplate": "Delete template",
|
||||||
"sidebar.templates.deleteFailed": "Failed to delete template",
|
"sidebar.templates.deleteFailed": "Failed to delete template",
|
||||||
|
"sidebar.templates.deleteConfirmWithRefs": "This template is referenced by {postCount} post(s) and {tagCount} tag(s). Delete anyway? References will be cleared.",
|
||||||
"sidebar.import.none": "No import definitions yet",
|
"sidebar.import.none": "No import definitions yet",
|
||||||
"sidebar.import.createDefinition": "Create an import definition",
|
"sidebar.import.createDefinition": "Create an import definition",
|
||||||
"sidebar.import.deleteDefinition": "Delete import definition",
|
"sidebar.import.deleteDefinition": "Delete import definition",
|
||||||
|
|||||||
@@ -780,6 +780,7 @@
|
|||||||
"sidebar.templates.createFailed": "No se pudo crear la plantilla",
|
"sidebar.templates.createFailed": "No se pudo crear la plantilla",
|
||||||
"sidebar.templates.deleteTemplate": "Eliminar plantilla",
|
"sidebar.templates.deleteTemplate": "Eliminar plantilla",
|
||||||
"sidebar.templates.deleteFailed": "No se pudo eliminar la plantilla",
|
"sidebar.templates.deleteFailed": "No se pudo eliminar la plantilla",
|
||||||
|
"sidebar.templates.deleteConfirmWithRefs": "Esta plantilla está referenciada por {postCount} entrada(s) y {tagCount} etiqueta(s). ¿Eliminar de todos modos? Las referencias serán eliminadas.",
|
||||||
"sidebar.import.none": "Sin definiciones de importación",
|
"sidebar.import.none": "Sin definiciones de importación",
|
||||||
"sidebar.import.createDefinition": "Crear definición",
|
"sidebar.import.createDefinition": "Crear definición",
|
||||||
"sidebar.import.deleteDefinition": "Eliminar definición",
|
"sidebar.import.deleteDefinition": "Eliminar definición",
|
||||||
|
|||||||
@@ -778,6 +778,7 @@
|
|||||||
"sidebar.templates.createFailed": "Impossible de créer le modèle",
|
"sidebar.templates.createFailed": "Impossible de créer le modèle",
|
||||||
"sidebar.templates.deleteTemplate": "Supprimer le modèle",
|
"sidebar.templates.deleteTemplate": "Supprimer le modèle",
|
||||||
"sidebar.templates.deleteFailed": "Impossible de supprimer le modèle",
|
"sidebar.templates.deleteFailed": "Impossible de supprimer le modèle",
|
||||||
|
"sidebar.templates.deleteConfirmWithRefs": "Ce modèle est référencé par {postCount} article(s) et {tagCount} tag(s). Supprimer quand même ? Les références seront supprimées.",
|
||||||
"sidebar.import.none": "Aucune définition d’import",
|
"sidebar.import.none": "Aucune définition d’import",
|
||||||
"sidebar.import.createDefinition": "Créer une définition",
|
"sidebar.import.createDefinition": "Créer une définition",
|
||||||
"sidebar.import.deleteDefinition": "Supprimer la définition",
|
"sidebar.import.deleteDefinition": "Supprimer la définition",
|
||||||
|
|||||||
@@ -778,6 +778,7 @@
|
|||||||
"sidebar.templates.createFailed": "Impossibile creare il modello",
|
"sidebar.templates.createFailed": "Impossibile creare il modello",
|
||||||
"sidebar.templates.deleteTemplate": "Elimina modello",
|
"sidebar.templates.deleteTemplate": "Elimina modello",
|
||||||
"sidebar.templates.deleteFailed": "Impossibile eliminare il modello",
|
"sidebar.templates.deleteFailed": "Impossibile eliminare il modello",
|
||||||
|
"sidebar.templates.deleteConfirmWithRefs": "Questo modello è referenziato da {postCount} articolo/i e {tagCount} tag. Eliminare comunque? I riferimenti verranno rimossi.",
|
||||||
"sidebar.import.none": "Nessuna definizione di importazione",
|
"sidebar.import.none": "Nessuna definizione di importazione",
|
||||||
"sidebar.import.createDefinition": "Crea definizione",
|
"sidebar.import.createDefinition": "Crea definizione",
|
||||||
"sidebar.import.deleteDefinition": "Elimina definizione",
|
"sidebar.import.deleteDefinition": "Elimina definizione",
|
||||||
|
|||||||
@@ -1,34 +1,54 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { TemplateEngine } from '../../src/main/engine/TemplateEngine';
|
import { TemplateEngine } from '../../src/main/engine/TemplateEngine';
|
||||||
|
import { posts, tags, templates } from '../../src/main/database/schema';
|
||||||
|
|
||||||
const mockTemplates = new Map<string, any>();
|
const mockTemplates = new Map<string, any>();
|
||||||
|
const mockPosts = new Map<string, any>();
|
||||||
|
const mockTags = new Map<string, any>();
|
||||||
const mockFiles = new Map<string, string>();
|
const mockFiles = new Map<string, string>();
|
||||||
|
|
||||||
function createSelectChain() {
|
function createSelectChain(dataSource: () => any[]) {
|
||||||
return {
|
return {
|
||||||
from: vi.fn().mockReturnThis(),
|
from: vi.fn(function (this: any, table: any) {
|
||||||
|
if (table === posts) {
|
||||||
|
this.all = vi.fn(() => Promise.resolve(Array.from(mockPosts.values())));
|
||||||
|
} else if (table === tags) {
|
||||||
|
this.all = vi.fn(() => Promise.resolve(Array.from(mockTags.values())));
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}),
|
||||||
where: vi.fn().mockReturnThis(),
|
where: vi.fn().mockReturnThis(),
|
||||||
orderBy: vi.fn().mockReturnThis(),
|
orderBy: vi.fn().mockReturnThis(),
|
||||||
all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockTemplates.values()))),
|
all: vi.fn().mockImplementation(() => Promise.resolve(dataSource())),
|
||||||
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
|
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDrizzleMock() {
|
function createDrizzleMock() {
|
||||||
return {
|
return {
|
||||||
select: vi.fn(() => createSelectChain()),
|
select: vi.fn(() => createSelectChain(() => Array.from(mockTemplates.values()))),
|
||||||
insert: vi.fn(() => ({
|
insert: vi.fn(() => ({
|
||||||
values: vi.fn((data: any) => {
|
values: vi.fn((data: any) => {
|
||||||
mockTemplates.set(data.id, data);
|
mockTemplates.set(data.id, data);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
update: vi.fn(() => ({
|
update: vi.fn((table?: any) => ({
|
||||||
set: vi.fn((updates: any) => ({
|
set: vi.fn((updates: any) => ({
|
||||||
where: vi.fn(async () => {
|
where: vi.fn(async () => {
|
||||||
|
if (table === posts) {
|
||||||
|
for (const [postId, existing] of mockPosts.entries()) {
|
||||||
|
mockPosts.set(postId, { ...existing, ...updates });
|
||||||
|
}
|
||||||
|
} else if (table === tags) {
|
||||||
|
for (const [tagId, existing] of mockTags.entries()) {
|
||||||
|
mockTags.set(tagId, { ...existing, ...updates });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
for (const [templateId, existing] of mockTemplates.entries()) {
|
for (const [templateId, existing] of mockTemplates.entries()) {
|
||||||
mockTemplates.set(templateId, { ...existing, ...updates });
|
mockTemplates.set(templateId, { ...existing, ...updates });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
@@ -101,9 +121,11 @@ describe('TemplateEngine', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockTemplates.clear();
|
mockTemplates.clear();
|
||||||
|
mockPosts.clear();
|
||||||
|
mockTags.clear();
|
||||||
mockFiles.clear();
|
mockFiles.clear();
|
||||||
(globalThis as any).__mockTemplateFiles = mockFiles;
|
(globalThis as any).__mockTemplateFiles = mockFiles;
|
||||||
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
|
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain(() => Array.from(mockTemplates.values())));
|
||||||
|
|
||||||
templateEngine = new TemplateEngine();
|
templateEngine = new TemplateEngine();
|
||||||
templateEngine.setProjectContext('default', '/mock/userData/projects/default');
|
templateEngine.setProjectContext('default', '/mock/userData/projects/default');
|
||||||
@@ -170,9 +192,9 @@ describe('TemplateEngine', () => {
|
|||||||
content: '<footer>Footer</footer>',
|
content: '<footer>Footer</footer>',
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleted = await templateEngine.deleteTemplate(created.id);
|
const result = await templateEngine.deleteTemplate(created.id);
|
||||||
|
|
||||||
expect(deleted).toBe(true);
|
expect(result).toEqual({ deleted: true });
|
||||||
expect(mockTemplates.has(created.id)).toBe(false);
|
expect(mockTemplates.has(created.id)).toBe(false);
|
||||||
expect(mockFiles.has('/mock/userData/projects/default/templates/delete_me.liquid')).toBe(false);
|
expect(mockFiles.has('/mock/userData/projects/default/templates/delete_me.liquid')).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -389,4 +411,110 @@ describe('TemplateEngine', () => {
|
|||||||
expect(found).toBeNull();
|
expect(found).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('referential integrity', () => {
|
||||||
|
it('getTemplateReferences returns empty arrays when no references exist', async () => {
|
||||||
|
const refs = await templateEngine.getTemplateReferences('custom_post');
|
||||||
|
expect(refs).toEqual({ postIds: [], tagIds: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getTemplateReferences returns referencing post and tag IDs', async () => {
|
||||||
|
mockPosts.set('post-1', { id: 'post-1', projectId: 'default', templateSlug: 'custom_post' });
|
||||||
|
mockTags.set('tag-1', { id: 'tag-1', projectId: 'default', postTemplateSlug: 'custom_post' });
|
||||||
|
|
||||||
|
const refs = await templateEngine.getTemplateReferences('custom_post');
|
||||||
|
expect(refs.postIds).toContain('post-1');
|
||||||
|
expect(refs.tagIds).toContain('tag-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteTemplate blocks deletion when references exist and force is not set', async () => {
|
||||||
|
const created = await templateEngine.createTemplate({
|
||||||
|
title: 'Referenced Template',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>Referenced</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPosts.set('post-1', { id: 'post-1', projectId: 'default', templateSlug: created.slug });
|
||||||
|
|
||||||
|
const result = await templateEngine.deleteTemplate(created.id);
|
||||||
|
|
||||||
|
expect(result.deleted).toBe(false);
|
||||||
|
expect(result.references?.postIds).toContain('post-1');
|
||||||
|
expect(mockTemplates.has(created.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteTemplate with force clears references and deletes', async () => {
|
||||||
|
const created = await templateEngine.createTemplate({
|
||||||
|
title: 'Force Delete',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>Force</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPosts.set('post-1', { id: 'post-1', projectId: 'default', templateSlug: created.slug });
|
||||||
|
mockTags.set('tag-1', { id: 'tag-1', projectId: 'default', postTemplateSlug: created.slug });
|
||||||
|
|
||||||
|
const result = await templateEngine.deleteTemplate(created.id, { force: true });
|
||||||
|
|
||||||
|
expect(result.deleted).toBe(true);
|
||||||
|
expect(mockTemplates.has(created.id)).toBe(false);
|
||||||
|
expect(mockPosts.get('post-1').templateSlug).toBeNull();
|
||||||
|
expect(mockTags.get('tag-1').postTemplateSlug).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('slug cascade on rename', () => {
|
||||||
|
it('cascades slug changes to posts.templateSlug on template rename', async () => {
|
||||||
|
const created = await templateEngine.createTemplate({
|
||||||
|
title: 'Original Name',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>Content</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPosts.set('post-1', { id: 'post-1', projectId: 'default', templateSlug: created.slug });
|
||||||
|
|
||||||
|
await templateEngine.updateTemplate(created.id, { title: 'Renamed Template' });
|
||||||
|
|
||||||
|
expect(mockPosts.get('post-1').templateSlug).toBe('renamed_template');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cascades slug changes to tags.postTemplateSlug on template rename', async () => {
|
||||||
|
const created = await templateEngine.createTemplate({
|
||||||
|
title: 'Original Name',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>Content</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockTags.set('tag-1', { id: 'tag-1', projectId: 'default', postTemplateSlug: created.slug });
|
||||||
|
|
||||||
|
await templateEngine.updateTemplate(created.id, { title: 'Renamed Template' });
|
||||||
|
|
||||||
|
expect(mockTags.get('tag-1').postTemplateSlug).toBe('renamed_template');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('race condition protection', () => {
|
||||||
|
it('rolls back DB on file operation failure during update', async () => {
|
||||||
|
const created = await templateEngine.createTemplate({
|
||||||
|
title: 'Rollback Test',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>Original</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalSlug = created.slug;
|
||||||
|
const originalTitle = created.title;
|
||||||
|
|
||||||
|
// Make fs.rename throw to simulate file operation failure
|
||||||
|
const fsModule = await import('fs/promises');
|
||||||
|
vi.mocked(fsModule.rename).mockRejectedValueOnce(new Error('EPERM'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
templateEngine.updateTemplate(created.id, { title: 'Should Fail' }),
|
||||||
|
).rejects.toThrow('EPERM');
|
||||||
|
|
||||||
|
// DB should have been rolled back to original values
|
||||||
|
const row = mockTemplates.get(created.id);
|
||||||
|
expect(row.slug).toBe(originalSlug);
|
||||||
|
expect(row.title).toBe(originalTitle);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2844,12 +2844,21 @@ describe('IPC Handlers', () => {
|
|||||||
|
|
||||||
describe('templates:delete', () => {
|
describe('templates:delete', () => {
|
||||||
it('should call TemplateEngine.deleteTemplate with id', async () => {
|
it('should call TemplateEngine.deleteTemplate with id', async () => {
|
||||||
mockTemplateEngine.deleteTemplate.mockResolvedValue(true);
|
mockTemplateEngine.deleteTemplate.mockResolvedValue({ deleted: true });
|
||||||
|
|
||||||
const result = await invokeHandler('templates:delete', 'template-1');
|
const result = await invokeHandler('templates:delete', 'template-1');
|
||||||
|
|
||||||
expect(mockTemplateEngine.deleteTemplate).toHaveBeenCalledWith('template-1');
|
expect(mockTemplateEngine.deleteTemplate).toHaveBeenCalledWith('template-1', undefined);
|
||||||
expect(result).toBe(true);
|
expect(result).toEqual({ deleted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should forward force option to TemplateEngine.deleteTemplate', async () => {
|
||||||
|
mockTemplateEngine.deleteTemplate.mockResolvedValue({ deleted: true });
|
||||||
|
|
||||||
|
const result = await invokeHandler('templates:delete', 'template-1', { force: true });
|
||||||
|
|
||||||
|
expect(mockTemplateEngine.deleteTemplate).toHaveBeenCalledWith('template-1', { force: true });
|
||||||
|
expect(result).toEqual({ deleted: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ describe('TemplatesView', () => {
|
|||||||
templates: {
|
templates: {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn().mockResolvedValue({ deleted: true }),
|
||||||
get: vi.fn().mockResolvedValue({ ...mockTemplate }),
|
get: vi.fn().mockResolvedValue({ ...mockTemplate }),
|
||||||
getAll: vi.fn().mockResolvedValue([]),
|
getAll: vi.fn().mockResolvedValue([]),
|
||||||
getEnabledByKind: vi.fn().mockResolvedValue([]),
|
getEnabledByKind: vi.fn().mockResolvedValue([]),
|
||||||
@@ -180,7 +180,7 @@ describe('TemplatesView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('deletes template and closes tab', async () => {
|
it('deletes template and closes tab', async () => {
|
||||||
const deleteMock = vi.fn().mockResolvedValue(true);
|
const deleteMock = vi.fn().mockResolvedValue({ deleted: true });
|
||||||
(window as any).electronAPI.templates.delete = deleteMock;
|
(window as any).electronAPI.templates.delete = deleteMock;
|
||||||
|
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ describe('pythonApiContractV1', () => {
|
|||||||
|
|
||||||
it('contains semantic version metadata for compatibility checks', () => {
|
it('contains semantic version metadata for compatibility checks', () => {
|
||||||
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
|
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
|
||||||
version: '1.8.0',
|
version: '1.9.0',
|
||||||
generatedAt: expect.any(String),
|
generatedAt: expect.any(String),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ Object.defineProperty(globalThis, 'window', {
|
|||||||
get: vi.fn().mockResolvedValue(null),
|
get: vi.fn().mockResolvedValue(null),
|
||||||
create: vi.fn().mockResolvedValue(null),
|
create: vi.fn().mockResolvedValue(null),
|
||||||
update: vi.fn().mockResolvedValue(null),
|
update: vi.fn().mockResolvedValue(null),
|
||||||
delete: vi.fn().mockResolvedValue(false),
|
delete: vi.fn().mockResolvedValue({ deleted: true }),
|
||||||
validate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
validate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||||
rebuildFromFiles: vi.fn().mockResolvedValue(undefined),
|
rebuildFromFiles: vi.fn().mockResolvedValue(undefined),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user