Merge pull request #23 from rfc1437/feat/user-templates
Feat/user templates
This commit is contained in:
@@ -13,7 +13,9 @@
|
|||||||
"WebFetch(domain:www.copilotkit.ai)",
|
"WebFetch(domain:www.copilotkit.ai)",
|
||||||
"Bash(grep -l \"A2UIRenderer\\\\|useA2UISurface\\\\|a2ui-surface\" /Users/gb/Projects/bDS/src/renderer/components/**/*.tsx)",
|
"Bash(grep -l \"A2UIRenderer\\\\|useA2UISurface\\\\|a2ui-surface\" /Users/gb/Projects/bDS/src/renderer/components/**/*.tsx)",
|
||||||
"Bash(npm test)",
|
"Bash(npm test)",
|
||||||
"Bash(ls -la /Users/gb/Projects/bDS/*.md)"
|
"Bash(ls -la /Users/gb/Projects/bDS/*.md)",
|
||||||
|
"Bash(grep -n \"templateSlug\\\\|postTemplateSlug\" /Users/gb/Projects/bDS/src/main/engine/*.ts)",
|
||||||
|
"Bash(npm test -- tests/renderer/i18nLocaleCompleteness.test.ts)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
328
API.md
328
API.md
@@ -1,6 +1,6 @@
|
|||||||
# API Documentation
|
# API Documentation
|
||||||
|
|
||||||
Contract version: 1.7.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.
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ project = await bds.meta.get_project_metadata()
|
|||||||
- [posts](#posts)
|
- [posts](#posts)
|
||||||
- [media](#media)
|
- [media](#media)
|
||||||
- [scripts](#scripts)
|
- [scripts](#scripts)
|
||||||
|
- [templates](#templates)
|
||||||
- [tasks](#tasks)
|
- [tasks](#tasks)
|
||||||
- [app](#app)
|
- [app](#app)
|
||||||
- [meta](#meta)
|
- [meta](#meta)
|
||||||
@@ -1949,6 +1950,300 @@ None
|
|||||||
|
|
||||||
[↑ Back to Table of contents](#table-of-contents)
|
[↑ 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. Without options, returns references if the template is in use. Pass options={"force": True} to clear references and delete.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- id (str, required)
|
||||||
|
- options (dict, optional)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `TemplateDeleteResult`
|
||||||
|
- Data structures: `TemplateDeleteResult`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bds_api import bds
|
||||||
|
result = await bds.templates.delete(id='id-1')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
'deleted': False,
|
||||||
|
'references': 'value'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
## tasks
|
||||||
|
|
||||||
**Module APIs**
|
**Module APIs**
|
||||||
@@ -3579,6 +3874,37 @@ Script definition for Python macros, utilities, and transforms.
|
|||||||
|
|
||||||
[↑ Back to Table of contents](#table-of-contents)
|
[↑ 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)
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@@ -6,7 +6,7 @@ independently.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Template Editor & Per-Entity Template Selection
|
## ~~1. Template Editor & Per-Entity Template Selection~~ ✅ Done
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
|
|||||||
16
drizzle/0006_yummy_scorpion.sql
Normal file
16
drizzle/0006_yummy_scorpion.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE `templates` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`slug` text NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`kind` text DEFAULT 'post' NOT NULL,
|
||||||
|
`enabled` integer DEFAULT true NOT NULL,
|
||||||
|
`version` integer DEFAULT 1 NOT NULL,
|
||||||
|
`file_path` text NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `templates_project_slug_idx` ON `templates` (`project_id`,`slug`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `posts` ADD `template_slug` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `tags` ADD `post_template_slug` text;
|
||||||
1019
drizzle/meta/0006_snapshot.json
Normal file
1019
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
|||||||
"when": 1771792324840,
|
"when": 1771792324840,
|
||||||
"tag": "0005_short_sally_floyd",
|
"tag": "0005_short_sally_floyd",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772213213016,
|
||||||
|
"tag": "0006_yummy_scorpion",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,7 @@ export const posts = sqliteTable('posts', {
|
|||||||
checksum: text('checksum'),
|
checksum: text('checksum'),
|
||||||
tags: text('tags'), // JSON array stored as text
|
tags: text('tags'), // JSON array stored as text
|
||||||
categories: text('categories'), // JSON array stored as text
|
categories: text('categories'), // JSON array stored as text
|
||||||
|
templateSlug: text('template_slug'), // Optional user template override for this post
|
||||||
// Legacy columns (kept for migration compatibility, no longer written)
|
// Legacy columns (kept for migration compatibility, no longer written)
|
||||||
publishedTitle: text('published_title'),
|
publishedTitle: text('published_title'),
|
||||||
publishedContent: text('published_content'),
|
publishedContent: text('published_content'),
|
||||||
@@ -111,6 +112,7 @@ export const tags = sqliteTable('tags', {
|
|||||||
projectId: text('project_id').notNull(),
|
projectId: text('project_id').notNull(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
color: text('color'), // Optional hex color like #ff0000
|
color: text('color'), // Optional hex color like #ff0000
|
||||||
|
postTemplateSlug: text('post_template_slug'), // Optional user template override for posts with this tag
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
@@ -169,6 +171,23 @@ export const scripts = sqliteTable('scripts', {
|
|||||||
projectSlugIdx: uniqueIndex('scripts_project_slug_idx').on(table.projectId, table.slug),
|
projectSlugIdx: uniqueIndex('scripts_project_slug_idx').on(table.projectId, table.slug),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Templates table - stores metadata for Liquid templates persisted in templates/*.liquid
|
||||||
|
export const templates = sqliteTable('templates', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
projectId: text('project_id').notNull(),
|
||||||
|
slug: text('slug').notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
kind: text('kind', { enum: ['post', 'list', 'not-found', 'partial'] }).notNull().default('post'),
|
||||||
|
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
version: integer('version').notNull().default(1),
|
||||||
|
filePath: text('file_path').notNull(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
// Composite unique index: slug must be unique within each project
|
||||||
|
projectSlugIdx: uniqueIndex('templates_project_slug_idx').on(table.projectId, table.slug),
|
||||||
|
}));
|
||||||
|
|
||||||
// Types for TypeScript
|
// Types for TypeScript
|
||||||
export type Project = typeof projects.$inferSelect;
|
export type Project = typeof projects.$inferSelect;
|
||||||
export type NewProject = typeof projects.$inferInsert;
|
export type NewProject = typeof projects.$inferInsert;
|
||||||
@@ -194,3 +213,5 @@ export type ImportDefinition = typeof importDefinitions.$inferSelect;
|
|||||||
export type NewImportDefinition = typeof importDefinitions.$inferInsert;
|
export type NewImportDefinition = typeof importDefinitions.$inferInsert;
|
||||||
export type Script = typeof scripts.$inferSelect;
|
export type Script = typeof scripts.$inferSelect;
|
||||||
export type NewScript = typeof scripts.$inferInsert;
|
export type NewScript = typeof scripts.$inferInsert;
|
||||||
|
export type Template = typeof templates.$inferSelect;
|
||||||
|
export type NewTemplate = typeof templates.$inferInsert;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import path from 'node:path';
|
||||||
import type { CategoryRenderSettings } from './PageRenderer';
|
import type { CategoryRenderSettings } from './PageRenderer';
|
||||||
import { buildCanonicalPostPath } from './PageRenderer';
|
import { buildCanonicalPostPath } from './PageRenderer';
|
||||||
import type { MenuDocument } from './MenuEngine';
|
import type { MenuDocument } from './MenuEngine';
|
||||||
@@ -210,6 +211,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
|
|||||||
getMenu: async () => menu,
|
getMenu: async () => menu,
|
||||||
},
|
},
|
||||||
getActiveProjectContext: async () => projectContext,
|
getActiveProjectContext: async () => projectContext,
|
||||||
|
userTemplatesDir: path.join(params.options.dataDir, 'templates'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string> }> = (async () => {
|
const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string> }> = (async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as fsPromises from 'fs/promises';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import type { GitScriptFileChange, GitScriptFileChangeStatus } from './ScriptEngine';
|
import type { GitScriptFileChange, GitScriptFileChangeStatus } from './ScriptEngine';
|
||||||
|
import type { GitTemplateFileChange, GitTemplateFileChangeStatus } from './TemplateEngine';
|
||||||
|
|
||||||
export interface GitAvailability {
|
export interface GitAvailability {
|
||||||
gitFound: boolean;
|
gitFound: boolean;
|
||||||
@@ -142,6 +143,7 @@ export interface GitPostFileChange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type { GitScriptFileChange, GitScriptFileChangeStatus };
|
export type { GitScriptFileChange, GitScriptFileChangeStatus };
|
||||||
|
export type { GitTemplateFileChange, GitTemplateFileChangeStatus };
|
||||||
|
|
||||||
type GitProvider = 'unknown' | 'github' | 'gitlab' | 'gitea-forgejo';
|
type GitProvider = 'unknown' | 'github' | 'gitlab' | 'gitea-forgejo';
|
||||||
|
|
||||||
@@ -534,6 +536,11 @@ export class GitEngine {
|
|||||||
return normalized.startsWith('scripts/') && path.extname(normalized).toLowerCase() === '.py';
|
return normalized.startsWith('scripts/') && path.extname(normalized).toLowerCase() === '.py';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isTemplatesLiquidPath(value: string): boolean {
|
||||||
|
const normalized = this.normalizeRepoRelativePath(value);
|
||||||
|
return normalized.startsWith('templates/') && path.extname(normalized).toLowerCase() === '.liquid';
|
||||||
|
}
|
||||||
|
|
||||||
private parseNameStatusOutput(raw: string, pathMatcher: (value: string) => boolean): GitPostFileChange[] {
|
private parseNameStatusOutput(raw: string, pathMatcher: (value: string) => boolean): GitPostFileChange[] {
|
||||||
const tokens = raw.split('\0').filter((token) => token.length > 0);
|
const tokens = raw.split('\0').filter((token) => token.length > 0);
|
||||||
const changes: GitPostFileChange[] = [];
|
const changes: GitPostFileChange[] = [];
|
||||||
@@ -1388,6 +1395,33 @@ export class GitEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getChangedTemplateFilesBetween(projectPath: string, fromCommit: string, toCommit: string): Promise<GitTemplateFileChange[]> {
|
||||||
|
const fromRef = fromCommit.trim();
|
||||||
|
const toRef = toCommit.trim();
|
||||||
|
if (!fromRef || !toRef || fromRef === toRef) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
|
const args = ['diff', '--name-status', '--find-renames', '-z', `${fromRef}..${toRef}`, '--', 'templates'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await git.raw(args);
|
||||||
|
return this.parseNameStatusOutput(output, (value) => this.isTemplatesLiquidPath(value));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
try {
|
||||||
|
const output = await this.runGitCli(projectPath, args);
|
||||||
|
return this.parseNameStatusOutput(output, (value) => this.isTemplatesLiquidPath(value));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async pull(projectPath: string): Promise<GitActionResult> {
|
async pull(projectPath: string): Promise<GitActionResult> {
|
||||||
const git = this.createNonInteractiveGit(projectPath);
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface ProjectMetadata {
|
|||||||
export interface CategoryRenderSettings {
|
export interface CategoryRenderSettings {
|
||||||
renderInLists: boolean;
|
renderInLists: boolean;
|
||||||
showTitle: boolean;
|
showTitle: boolean;
|
||||||
|
postTemplateSlug?: string;
|
||||||
|
listTemplateSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,6 +169,8 @@ function normalizeCategoryMetadata(value: unknown): Record<string, CategoryMetad
|
|||||||
renderInLists: settings.renderInLists !== false,
|
renderInLists: settings.renderInLists !== false,
|
||||||
showTitle: settings.showTitle !== false,
|
showTitle: settings.showTitle !== false,
|
||||||
title: sanitizeCategoryTitle(settings.title, category),
|
title: sanitizeCategoryTitle(settings.title, category),
|
||||||
|
postTemplateSlug: typeof (settings as any).postTemplateSlug === 'string' ? (settings as any).postTemplateSlug : undefined,
|
||||||
|
listTemplateSlug: typeof (settings as any).listTemplateSlug === 'string' ? (settings as any).listTemplateSlug : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +182,12 @@ function normalizeCategorySettings(value: unknown): Record<string, CategoryRende
|
|||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(metadata).map(([category, data]) => [
|
Object.entries(metadata).map(([category, data]) => [
|
||||||
category,
|
category,
|
||||||
{ renderInLists: data.renderInLists, showTitle: data.showTitle },
|
{
|
||||||
|
renderInLists: data.renderInLists,
|
||||||
|
showTitle: data.showTitle,
|
||||||
|
postTemplateSlug: data.postTemplateSlug,
|
||||||
|
listTemplateSlug: data.listTemplateSlug,
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export interface TemplatePostEntry {
|
|||||||
export interface CategoryRenderSettings {
|
export interface CategoryRenderSettings {
|
||||||
renderInLists: boolean;
|
renderInLists: boolean;
|
||||||
showTitle: boolean;
|
showTitle: boolean;
|
||||||
|
postTemplateSlug?: string;
|
||||||
|
listTemplateSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DayBlockContext {
|
export interface DayBlockContext {
|
||||||
@@ -1021,6 +1023,7 @@ export function resolvePageRendererTemplateRoots(options?: {
|
|||||||
moduleDir?: string;
|
moduleDir?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
resourcesPath?: string;
|
resourcesPath?: string;
|
||||||
|
userTemplatesDir?: string;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const moduleDir = options?.moduleDir ?? __dirname;
|
const moduleDir = options?.moduleDir ?? __dirname;
|
||||||
const cwd = options?.cwd ?? process.cwd();
|
const cwd = options?.cwd ?? process.cwd();
|
||||||
@@ -1036,9 +1039,67 @@ export function resolvePageRendererTemplateRoots(options?: {
|
|||||||
roots.unshift(path.resolve(resourcesPath, 'templates'));
|
roots.unshift(path.resolve(resourcesPath, 'templates'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User templates directory takes highest priority so user templates override built-ins
|
||||||
|
if (options?.userTemplatesDir) {
|
||||||
|
roots.unshift(options.userTemplatesDir);
|
||||||
|
}
|
||||||
|
|
||||||
return Array.from(new Set(roots));
|
return Array.from(new Set(roots));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve which template to use for rendering a single post.
|
||||||
|
* Priority: post.templateSlug -> first matching tag.postTemplateSlug -> category.postTemplateSlug -> default.
|
||||||
|
*/
|
||||||
|
export function resolvePostTemplateName(
|
||||||
|
post: { templateSlug?: string | null; tags?: string[]; categories?: string[] },
|
||||||
|
tagSettings?: Record<string, { postTemplateSlug?: string | null }>,
|
||||||
|
categorySettings?: Record<string, { postTemplateSlug?: string | null }>,
|
||||||
|
): string {
|
||||||
|
if (post.templateSlug) {
|
||||||
|
return post.templateSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagSettings && post.tags) {
|
||||||
|
for (const tag of post.tags) {
|
||||||
|
const normalizedTag = tag.toLowerCase().trim();
|
||||||
|
const setting = tagSettings[normalizedTag] || tagSettings[tag];
|
||||||
|
if (setting?.postTemplateSlug) {
|
||||||
|
return setting.postTemplateSlug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categorySettings && post.categories) {
|
||||||
|
for (const category of post.categories) {
|
||||||
|
const setting = categorySettings[category];
|
||||||
|
if (setting?.postTemplateSlug) {
|
||||||
|
return setting.postTemplateSlug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'single-post';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve which template to use for rendering a post list.
|
||||||
|
* Priority: category.listTemplateSlug -> default.
|
||||||
|
*/
|
||||||
|
export function resolveListTemplateName(
|
||||||
|
routeCategory?: string,
|
||||||
|
categorySettings?: Record<string, { listTemplateSlug?: string | null }>,
|
||||||
|
): string {
|
||||||
|
if (routeCategory && categorySettings) {
|
||||||
|
const setting = categorySettings[routeCategory];
|
||||||
|
if (setting?.listTemplateSlug) {
|
||||||
|
return setting.listTemplateSlug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'post-list';
|
||||||
|
}
|
||||||
|
|
||||||
export class PageRenderer {
|
export class PageRenderer {
|
||||||
private readonly mediaEngine: MediaEngineContract;
|
private readonly mediaEngine: MediaEngineContract;
|
||||||
private readonly postMediaEngine: PostMediaEngineContract;
|
private readonly postMediaEngine: PostMediaEngineContract;
|
||||||
@@ -1051,13 +1112,14 @@ export class PageRenderer {
|
|||||||
postMediaEngine: PostMediaEngineContract,
|
postMediaEngine: PostMediaEngineContract,
|
||||||
postEngineForMacros?: PostEngineContract,
|
postEngineForMacros?: PostEngineContract,
|
||||||
pythonMacroRenderer?: PythonMacroRendererContract,
|
pythonMacroRenderer?: PythonMacroRendererContract,
|
||||||
|
userTemplatesDir?: string,
|
||||||
) {
|
) {
|
||||||
this.mediaEngine = mediaEngine;
|
this.mediaEngine = mediaEngine;
|
||||||
this.postMediaEngine = postMediaEngine;
|
this.postMediaEngine = postMediaEngine;
|
||||||
this.postEngineForMacros = postEngineForMacros;
|
this.postEngineForMacros = postEngineForMacros;
|
||||||
this.pythonMacroRenderer = pythonMacroRenderer;
|
this.pythonMacroRenderer = pythonMacroRenderer;
|
||||||
|
|
||||||
const templateRoots = resolvePageRendererTemplateRoots();
|
const templateRoots = resolvePageRendererTemplateRoots({ userTemplatesDir });
|
||||||
|
|
||||||
this.liquid = new Liquid({
|
this.liquid = new Liquid({
|
||||||
root: templateRoots,
|
root: templateRoots,
|
||||||
@@ -1355,13 +1417,27 @@ export class PageRenderer {
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.liquid.renderFile('post-list', templateContext);
|
const routeCategory = options.archiveContext?.kind === 'category' ? options.archiveContext.name : undefined;
|
||||||
|
const listTemplateName = resolveListTemplateName(
|
||||||
|
routeCategory ?? undefined,
|
||||||
|
options.categorySettings as Record<string, { listTemplateSlug?: string | null }> | undefined,
|
||||||
|
);
|
||||||
|
return this.liquid.renderFile(listTemplateName, templateContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderSinglePost(
|
async renderSinglePost(
|
||||||
post: PostData,
|
post: PostData,
|
||||||
rewriteContext: HtmlRewriteContext,
|
rewriteContext: HtmlRewriteContext,
|
||||||
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string; html_theme_attribute?: string; tag_color_by_name?: Record<string, string> },
|
pageContext: {
|
||||||
|
page_title: string;
|
||||||
|
language: string;
|
||||||
|
menu_items?: TemplateMenuItem[];
|
||||||
|
pico_stylesheet_href?: string;
|
||||||
|
html_theme_attribute?: string;
|
||||||
|
tag_color_by_name?: Record<string, string>;
|
||||||
|
tagSettings?: Record<string, { postTemplateSlug?: string | null }>;
|
||||||
|
categorySettings?: Record<string, { postTemplateSlug?: string | null }>;
|
||||||
|
},
|
||||||
postEngine?: PostEngineContract,
|
postEngine?: PostEngineContract,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const renderablePost = postEngine
|
const renderablePost = postEngine
|
||||||
@@ -1397,7 +1473,12 @@ export class PageRenderer {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.liquid.renderFile('single-post', context);
|
const postTemplateName = resolvePostTemplateName(
|
||||||
|
renderablePost as { templateSlug?: string | null; tags?: string[]; categories?: string[] },
|
||||||
|
pageContext.tagSettings,
|
||||||
|
pageContext.categorySettings,
|
||||||
|
);
|
||||||
|
return this.liquid.renderFile(postTemplateName, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderNotFound(context: NotFoundTemplateContext): Promise<string> {
|
async renderNotFound(context: NotFoundTemplateContext): Promise<string> {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
type PythonMacroRendererContract,
|
type PythonMacroRendererContract,
|
||||||
} from './PageRenderer';
|
} from './PageRenderer';
|
||||||
import { getScriptEngine } from './ScriptEngine';
|
import { getScriptEngine } from './ScriptEngine';
|
||||||
|
import { getTemplateEngine } from './TemplateEngine';
|
||||||
import { getPythonMacroWorkerRuntime } from './PythonMacroWorkerRuntime';
|
import { getPythonMacroWorkerRuntime } from './PythonMacroWorkerRuntime';
|
||||||
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
|
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
|
||||||
import { renderRouteWithSharedContext } from './SharedRouteRenderer';
|
import { renderRouteWithSharedContext } from './SharedRouteRenderer';
|
||||||
@@ -69,6 +70,7 @@ interface PreviewServerDependencies {
|
|||||||
settingsEngine: MetaEngineContract;
|
settingsEngine: MetaEngineContract;
|
||||||
menuEngine: MenuEngineContract;
|
menuEngine: MenuEngineContract;
|
||||||
getActiveProjectContext: () => Promise<ActiveProjectContext>;
|
getActiveProjectContext: () => Promise<ActiveProjectContext>;
|
||||||
|
userTemplatesDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SerializedTag {
|
interface SerializedTag {
|
||||||
@@ -106,7 +108,13 @@ export class PreviewServer {
|
|||||||
projectDescription: activeProject?.description ?? undefined,
|
projectDescription: activeProject?.description ?? undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
this.pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine, buildPythonMacroRenderer());
|
this.pageRenderer = new PageRenderer(
|
||||||
|
this.mediaEngine,
|
||||||
|
this.postMediaEngine,
|
||||||
|
this.postEngine,
|
||||||
|
buildPythonMacroRenderer(),
|
||||||
|
dependencies?.userTemplatesDir ?? getTemplateEngine().getTemplatesDirectory(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(preferredPort = 0): Promise<number> {
|
async start(preferredPort = 0): Promise<number> {
|
||||||
@@ -197,6 +205,7 @@ export class PreviewServer {
|
|||||||
resolveListExcludedCategories: (settings) => this.resolveListExcludedCategories(settings),
|
resolveListExcludedCategories: (settings) => this.resolveListExcludedCategories(settings),
|
||||||
buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(),
|
buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(),
|
||||||
resolveTagColorByName: (projectContext) => this.resolveTagColorByName(projectContext),
|
resolveTagColorByName: (projectContext) => this.resolveTagColorByName(projectContext),
|
||||||
|
resolveTagTemplateSettings: (projectContext) => this.resolveTagTemplateSettings(projectContext),
|
||||||
pageRenderer: this.pageRenderer,
|
pageRenderer: this.pageRenderer,
|
||||||
postEngineForMacros: this.postEngine,
|
postEngineForMacros: this.postEngine,
|
||||||
loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination),
|
loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination),
|
||||||
@@ -432,6 +441,39 @@ export class PreviewServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveTagTemplateSettings(projectContext: ActiveProjectContext): Promise<Record<string, { postTemplateSlug?: string | null }>> {
|
||||||
|
if (!projectContext.dataDir) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsPath = path.join(projectContext.dataDir, 'meta', 'tags.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const source = await readFile(tagsPath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(source);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings: Record<string, { postTemplateSlug?: string | null }> = {};
|
||||||
|
for (const rawEntry of parsed as SerializedTag[]) {
|
||||||
|
const name = typeof rawEntry?.name === 'string' ? rawEntry.name.trim() : '';
|
||||||
|
const postTemplateSlug = typeof (rawEntry as Record<string, unknown>)?.postTemplateSlug === 'string'
|
||||||
|
? ((rawEntry as Record<string, unknown>).postTemplateSlug as string).trim()
|
||||||
|
: undefined;
|
||||||
|
if (!name || !postTemplateSlug) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings[name] = { postTemplateSlug };
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> {
|
private async resolveAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> {
|
||||||
const match = pathname.match(/^\/assets\/([^/]+)$/);
|
const match = pathname.match(/^\/assets\/([^/]+)$/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
@@ -592,6 +634,8 @@ export class PreviewServer {
|
|||||||
mergedSettings[category] = {
|
mergedSettings[category] = {
|
||||||
renderInLists: value.renderInLists,
|
renderInLists: value.renderInLists,
|
||||||
showTitle: value.showTitle,
|
showTitle: value.showTitle,
|
||||||
|
postTemplateSlug: value.postTemplateSlug,
|
||||||
|
listTemplateSlug: value.listTemplateSlug,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return mergedSettings;
|
return mergedSettings;
|
||||||
|
|||||||
@@ -87,6 +87,67 @@ export class ProjectEngine extends EventEmitter {
|
|||||||
await fs.mkdir(path.join(dataDir, 'media'), { recursive: true });
|
await fs.mkdir(path.join(dataDir, 'media'), { recursive: true });
|
||||||
await fs.mkdir(path.join(dataDir, 'meta'), { recursive: true });
|
await fs.mkdir(path.join(dataDir, 'meta'), { recursive: true });
|
||||||
await fs.mkdir(path.join(dataDir, 'thumbnails'), { recursive: true });
|
await fs.mkdir(path.join(dataDir, 'thumbnails'), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(dataDir, 'templates'), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyStarterTemplates(projectId: string, dataPath?: string | null): Promise<void> {
|
||||||
|
const dataDir = this.getDataDir(projectId, dataPath);
|
||||||
|
const destDir = path.join(dataDir, 'templates');
|
||||||
|
|
||||||
|
// Resolve the bundled templates directory
|
||||||
|
const bundledRoots = [
|
||||||
|
path.resolve(__dirname, 'templates'),
|
||||||
|
path.resolve(process.cwd(), 'dist', 'main', 'engine', 'templates'),
|
||||||
|
path.resolve(process.cwd(), 'src', 'main', 'engine', 'templates'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (typeof process.resourcesPath === 'string' && process.resourcesPath.length > 0) {
|
||||||
|
bundledRoots.unshift(path.resolve(process.resourcesPath, 'templates'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceDir: string | null = null;
|
||||||
|
for (const root of bundledRoots) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(root);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
sourceDir = root;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist, try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.copyDirectoryRecursive(sourceDir, destDir);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ProjectEngine] Failed to copy starter templates:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyDirectoryRecursive(src: string, dest: string): Promise<void> {
|
||||||
|
await fs.mkdir(dest, { recursive: true });
|
||||||
|
const entries = await fs.readdir(src, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcPath = path.join(src, entry.name);
|
||||||
|
const destPath = path.join(dest, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await this.copyDirectoryRecursive(srcPath, destPath);
|
||||||
|
} else if (entry.name.endsWith('.liquid')) {
|
||||||
|
try {
|
||||||
|
await fs.access(destPath);
|
||||||
|
// File already exists, skip
|
||||||
|
} catch {
|
||||||
|
await fs.copyFile(srcPath, destPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createProject(data: { name: string; description?: string; slug?: string; dataPath?: string }): Promise<ProjectData> {
|
async createProject(data: { name: string; description?: string; slug?: string; dataPath?: string }): Promise<ProjectData> {
|
||||||
@@ -119,6 +180,9 @@ export class ProjectEngine extends EventEmitter {
|
|||||||
// Create directories using project ID (not slug)
|
// Create directories using project ID (not slug)
|
||||||
await this.ensureProjectDirectories(id, data.dataPath);
|
await this.ensureProjectDirectories(id, data.dataPath);
|
||||||
|
|
||||||
|
// Copy bundled templates as starter templates
|
||||||
|
await this.copyStarterTemplates(id, data.dataPath);
|
||||||
|
|
||||||
// Insert into database
|
// Insert into database
|
||||||
const dbProject: NewProject = {
|
const dbProject: NewProject = {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface SharedRouteRenderServices<TCategoryMetadata> {
|
|||||||
resolveListExcludedCategories: (settings: Record<string, CategoryRenderSettings>) => string[];
|
resolveListExcludedCategories: (settings: Record<string, CategoryRenderSettings>) => string[];
|
||||||
buildHtmlRewriteContext: () => Promise<HtmlRewriteContext>;
|
buildHtmlRewriteContext: () => Promise<HtmlRewriteContext>;
|
||||||
resolveTagColorByName: (projectContext: SharedActiveProjectContext) => Promise<Record<string, string>>;
|
resolveTagColorByName: (projectContext: SharedActiveProjectContext) => Promise<Record<string, string>>;
|
||||||
|
resolveTagTemplateSettings?: (projectContext: SharedActiveProjectContext) => Promise<Record<string, { postTemplateSlug?: string | null }>>;
|
||||||
pageRenderer: Pick<PageRenderer, 'renderPostList' | 'renderSinglePost'>;
|
pageRenderer: Pick<PageRenderer, 'renderPostList' | 'renderSinglePost'>;
|
||||||
postEngineForMacros?: PostEngineContract;
|
postEngineForMacros?: PostEngineContract;
|
||||||
loadPublishedSnapshotsPage: (
|
loadPublishedSnapshotsPage: (
|
||||||
@@ -96,6 +97,7 @@ async function resolveRouteWithSharedServices(
|
|||||||
categorySettings: Record<string, CategoryRenderSettings>,
|
categorySettings: Record<string, CategoryRenderSettings>,
|
||||||
categoryMetadata: Record<string, CategoryMetadata>,
|
categoryMetadata: Record<string, CategoryMetadata>,
|
||||||
tagColorByName: Record<string, string>,
|
tagColorByName: Record<string, string>,
|
||||||
|
tagTemplateSettings: Record<string, { postTemplateSlug?: string | null }>,
|
||||||
listExcludedCategories: string[],
|
listExcludedCategories: string[],
|
||||||
services: SharedRouteRenderServices<CategoryMetadata>,
|
services: SharedRouteRenderServices<CategoryMetadata>,
|
||||||
allowEmptyArchiveRender: boolean,
|
allowEmptyArchiveRender: boolean,
|
||||||
@@ -187,6 +189,8 @@ async function resolveRouteWithSharedServices(
|
|||||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
tag_color_by_name: tagColorByName,
|
tag_color_by_name: tagColorByName,
|
||||||
|
tagSettings: tagTemplateSettings,
|
||||||
|
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
|
||||||
}, services.postEngineForMacros);
|
}, services.postEngineForMacros);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +274,8 @@ async function resolveRouteWithSharedServices(
|
|||||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
tag_color_by_name: tagColorByName,
|
tag_color_by_name: tagColorByName,
|
||||||
|
tagSettings: tagTemplateSettings,
|
||||||
|
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
|
||||||
}, services.postEngineForMacros);
|
}, services.postEngineForMacros);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +316,7 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
|
|||||||
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
|
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
|
||||||
const htmlRewriteContext = options.htmlRewriteContext ?? await services.buildHtmlRewriteContext();
|
const htmlRewriteContext = options.htmlRewriteContext ?? await services.buildHtmlRewriteContext();
|
||||||
const tagColorByName = await services.resolveTagColorByName(options.projectContext);
|
const tagColorByName = await services.resolveTagColorByName(options.projectContext);
|
||||||
|
const tagTemplateSettings = await services.resolveTagTemplateSettings?.(options.projectContext) ?? {};
|
||||||
const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/');
|
const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/');
|
||||||
|
|
||||||
return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, {
|
return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, {
|
||||||
@@ -318,5 +325,5 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
|
|||||||
menuItems,
|
menuItems,
|
||||||
picoStylesheetHref,
|
picoStylesheetHref,
|
||||||
htmlThemeAttribute: options.htmlThemeAttribute,
|
htmlThemeAttribute: options.htmlThemeAttribute,
|
||||||
}, categorySettings, categoryMetadata as Record<string, CategoryMetadata>, tagColorByName, listExcludedCategories, services as SharedRouteRenderServices<CategoryMetadata>, options.allowEmptyArchiveRender === true, options.singlePostOptions);
|
}, categorySettings, categoryMetadata as Record<string, CategoryMetadata>, tagColorByName, tagTemplateSettings, listExcludedCategories, services as SharedRouteRenderServices<CategoryMetadata>, options.allowEmptyArchiveRender === true, options.singlePostOptions);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface TagData {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
postTemplateSlug?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -45,6 +46,7 @@ export interface CreateTagInput {
|
|||||||
export interface UpdateTagInput {
|
export interface UpdateTagInput {
|
||||||
name?: string;
|
name?: string;
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
|
postTemplateSlug?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,6 +112,7 @@ function isValidHexColor(color: string): boolean {
|
|||||||
interface SerializedTag {
|
interface SerializedTag {
|
||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
postTemplateSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -400,19 +403,27 @@ export class TagEngine extends EventEmitter {
|
|||||||
throw new Error('Invalid color format. Use hex format like #ff0000 or #f00');
|
throw new Error('Invalid color format. Use hex format like #ff0000 or #f00');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.color === undefined) {
|
const hasColorUpdate = input.color !== undefined;
|
||||||
|
const hasTemplateUpdate = input.postTemplateSlug !== undefined;
|
||||||
|
|
||||||
|
if (!hasColorUpdate && !hasTemplateUpdate) {
|
||||||
// No updates
|
// No updates
|
||||||
return this.rowToTagData(row);
|
return this.rowToTagData(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
const setFields: Record<string, unknown> = { updatedAt: now };
|
||||||
|
if (hasColorUpdate) {
|
||||||
|
setFields.color = input.color;
|
||||||
|
}
|
||||||
|
if (hasTemplateUpdate) {
|
||||||
|
setFields.postTemplateSlug = input.postTemplateSlug;
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(tags)
|
.update(tags)
|
||||||
.set({
|
.set(setFields)
|
||||||
color: input.color,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(tags.id, id),
|
eq(tags.id, id),
|
||||||
eq(tags.projectId, this.currentProjectId)
|
eq(tags.projectId, this.currentProjectId)
|
||||||
@@ -422,7 +433,8 @@ export class TagEngine extends EventEmitter {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
projectId: row.projectId,
|
projectId: row.projectId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
color: input.color !== undefined ? input.color || undefined : row.color || undefined,
|
color: hasColorUpdate ? input.color || undefined : row.color || undefined,
|
||||||
|
postTemplateSlug: hasTemplateUpdate ? input.postTemplateSlug || undefined : row.postTemplateSlug || undefined,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -817,6 +829,7 @@ export class TagEngine extends EventEmitter {
|
|||||||
projectId: row.projectId,
|
projectId: row.projectId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
color: row.color || undefined,
|
color: row.color || undefined,
|
||||||
|
postTemplateSlug: row.postTemplateSlug || undefined,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
@@ -838,6 +851,9 @@ export class TagEngine extends EventEmitter {
|
|||||||
if (tag.color) {
|
if (tag.color) {
|
||||||
entry.color = tag.color;
|
entry.color = tag.color;
|
||||||
}
|
}
|
||||||
|
if (tag.postTemplateSlug) {
|
||||||
|
entry.postTemplateSlug = tag.postTemplateSlug;
|
||||||
|
}
|
||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -867,6 +883,7 @@ export class TagEngine extends EventEmitter {
|
|||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
|
|
||||||
const color = tag.color || null;
|
const color = tag.color || null;
|
||||||
|
const postTemplateSlug = typeof tag.postTemplateSlug === 'string' ? tag.postTemplateSlug : null;
|
||||||
|
|
||||||
// Check if tag with this name already exists
|
// Check if tag with this name already exists
|
||||||
const existing = await db
|
const existing = await db
|
||||||
@@ -884,17 +901,22 @@ export class TagEngine extends EventEmitter {
|
|||||||
projectId: this.currentProjectId,
|
projectId: this.currentProjectId,
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
|
postTemplateSlug,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
} else if (color) {
|
} else if (color || postTemplateSlug) {
|
||||||
// Update color if provided and tag exists
|
// Update color/postTemplateSlug if provided and tag exists
|
||||||
|
const setFields: Record<string, unknown> = { updatedAt: now };
|
||||||
|
if (color) {
|
||||||
|
setFields.color = color;
|
||||||
|
}
|
||||||
|
if (postTemplateSlug) {
|
||||||
|
setFields.postTemplateSlug = postTemplateSlug;
|
||||||
|
}
|
||||||
await db
|
await db
|
||||||
.update(tags)
|
.update(tags)
|
||||||
.set({
|
.set(setFields)
|
||||||
color,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(tags.projectId, this.currentProjectId),
|
eq(tags.projectId, this.currentProjectId),
|
||||||
sql`LOWER(${tags.name}) = LOWER(${name})`
|
sql`LOWER(${tags.name}) = LOWER(${name})`
|
||||||
|
|||||||
836
src/main/engine/TemplateEngine.ts
Normal file
836
src/main/engine/TemplateEngine.ts
Normal file
@@ -0,0 +1,836 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { app } from 'electron';
|
||||||
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
|
import { Liquid } from 'liquidjs';
|
||||||
|
import { getDatabase } from '../database';
|
||||||
|
import { posts, tags, templates, type NewTemplate, type Template } from '../database/schema';
|
||||||
|
|
||||||
|
export type TemplateKind = 'post' | 'list' | 'not-found' | 'partial';
|
||||||
|
|
||||||
|
export interface TemplateData {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
kind: TemplateKind;
|
||||||
|
enabled: boolean;
|
||||||
|
version: number;
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTemplateInput {
|
||||||
|
title: string;
|
||||||
|
kind: TemplateKind;
|
||||||
|
content: string;
|
||||||
|
slug?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTemplateInput {
|
||||||
|
title?: string;
|
||||||
|
kind?: TemplateKind;
|
||||||
|
content?: string;
|
||||||
|
slug?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GitTemplateFileChangeStatus = 'added' | 'modified' | 'deleted' | 'renamed';
|
||||||
|
|
||||||
|
export interface GitTemplateFileChange {
|
||||||
|
status: GitTemplateFileChangeStatus;
|
||||||
|
path: string;
|
||||||
|
previousPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateReconcileResult {
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
deleted: number;
|
||||||
|
processedFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateDeleteResult {
|
||||||
|
deleted: boolean;
|
||||||
|
references?: { postIds: string[]; tagIds: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedTemplateFile {
|
||||||
|
metadata: {
|
||||||
|
id?: string;
|
||||||
|
projectId?: string;
|
||||||
|
slug?: string;
|
||||||
|
title?: string;
|
||||||
|
kind?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
version?: number;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TemplateEngine extends EventEmitter {
|
||||||
|
private currentProjectId = 'default';
|
||||||
|
private dataDir: string | null = null;
|
||||||
|
|
||||||
|
setProjectContext(projectId: string, dataDir?: string): void {
|
||||||
|
this.currentProjectId = projectId;
|
||||||
|
this.dataDir = dataDir || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProjectContext(): string {
|
||||||
|
return this.currentProjectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTemplatesDirectory(): string {
|
||||||
|
return this.getTemplatesDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTemplate(input: CreateTemplateInput): Promise<TemplateData> {
|
||||||
|
const now = new Date();
|
||||||
|
const allTemplates = await this.getAllTemplateRows();
|
||||||
|
const desiredSlug = this.normalizeSlug(input.slug || input.title || 'template');
|
||||||
|
const uniqueSlug = this.ensureUniqueSlug(desiredSlug, allTemplates);
|
||||||
|
const templateId = uuidv4();
|
||||||
|
const filePath = this.getTemplateFilePath(uniqueSlug);
|
||||||
|
|
||||||
|
const row: NewTemplate = {
|
||||||
|
id: templateId,
|
||||||
|
projectId: this.currentProjectId,
|
||||||
|
slug: uniqueSlug,
|
||||||
|
title: input.title,
|
||||||
|
kind: input.kind,
|
||||||
|
enabled: input.enabled ?? true,
|
||||||
|
version: 1,
|
||||||
|
filePath,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.mkdir(this.getTemplatesDir(), { recursive: true });
|
||||||
|
await fs.writeFile(filePath, this.serializeTemplateFile(row as Template, input.content), 'utf-8');
|
||||||
|
|
||||||
|
await getDatabase().getLocal().insert(templates).values(row);
|
||||||
|
|
||||||
|
const created = await this.toTemplateData(row as Template);
|
||||||
|
this.emit('templateCreated', created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTemplate(id: string, updates: UpdateTemplateInput): Promise<TemplateData | null> {
|
||||||
|
const existing = await this.getTemplateRow(id);
|
||||||
|
if (!existing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTemplates = await this.getAllTemplateRows();
|
||||||
|
const desiredSlug = typeof updates.slug === 'string'
|
||||||
|
? this.normalizeSlug(updates.slug)
|
||||||
|
: typeof updates.title === 'string'
|
||||||
|
? this.normalizeSlug(updates.title)
|
||||||
|
: existing.slug;
|
||||||
|
const nextSlug = this.ensureUniqueSlug(desiredSlug, allTemplates, existing.id);
|
||||||
|
const nextFilePath = this.getTemplateFilePath(nextSlug);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const nextTitle = updates.title ?? existing.title;
|
||||||
|
const nextKind = updates.kind ?? existing.kind;
|
||||||
|
const nextEnabled = updates.enabled ?? existing.enabled;
|
||||||
|
const nextVersion = existing.version + 1;
|
||||||
|
const nextContent = typeof updates.content === 'string'
|
||||||
|
? updates.content
|
||||||
|
: await this.readTemplateBody(existing.filePath);
|
||||||
|
|
||||||
|
const nextRow = {
|
||||||
|
...existing,
|
||||||
|
title: nextTitle,
|
||||||
|
slug: nextSlug,
|
||||||
|
kind: nextKind,
|
||||||
|
enabled: nextEnabled,
|
||||||
|
filePath: nextFilePath,
|
||||||
|
version: nextVersion,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbUpdates = {
|
||||||
|
title: nextTitle,
|
||||||
|
slug: nextSlug,
|
||||||
|
kind: nextKind,
|
||||||
|
enabled: nextEnabled,
|
||||||
|
filePath: nextFilePath,
|
||||||
|
version: nextVersion,
|
||||||
|
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)));
|
||||||
|
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);
|
||||||
|
if (!updatedRow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.toTemplateData(updatedRow);
|
||||||
|
this.emit('templateUpdated', updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (!existing) {
|
||||||
|
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()
|
||||||
|
.delete(templates)
|
||||||
|
.where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.unlink(existing.filePath);
|
||||||
|
} catch (error) {
|
||||||
|
const fsError = error as NodeJS.ErrnoException;
|
||||||
|
if (fsError.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('templateDeleted', id);
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTemplate(id: string): Promise<TemplateData | null> {
|
||||||
|
const row = await this.getTemplateRow(id);
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toTemplateData(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllTemplates(): Promise<TemplateData[]> {
|
||||||
|
const rows = await this.getAllTemplateRows();
|
||||||
|
return Promise.all(rows.map((item) => this.toTemplateData(item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEnabledTemplatesByKind(kind: TemplateKind): Promise<TemplateData[]> {
|
||||||
|
const rows = await this.getAllTemplateRows();
|
||||||
|
const kindRows = rows.filter((row) => row.kind === kind && row.enabled);
|
||||||
|
return Promise.all(kindRows.map((item) => this.toTemplateData(item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTemplateBySlug(slug: string): Promise<TemplateData | null> {
|
||||||
|
const normalizedSlug = slug.toLowerCase();
|
||||||
|
const rows = await this.getAllTemplateRows();
|
||||||
|
const match = rows.find(
|
||||||
|
(row) => row.enabled && row.slug.toLowerCase() === normalizedSlug,
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toTemplateData(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateTemplate(content: string): Promise<TemplateValidationResult> {
|
||||||
|
try {
|
||||||
|
const liquid = new Liquid({ strictVariables: false, strictFilters: false });
|
||||||
|
await liquid.parse(content);
|
||||||
|
return { valid: true, errors: [] };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { valid: false, errors: [message] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rebuildDatabaseFromFiles(): Promise<void> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const templatesDir = this.getTemplatesDir();
|
||||||
|
|
||||||
|
await db.delete(templates).where(eq(templates.projectId, this.currentProjectId));
|
||||||
|
|
||||||
|
const liquidFiles = await this.scanTemplateFiles(templatesDir);
|
||||||
|
if (liquidFiles.length === 0) {
|
||||||
|
this.emit('templatesRebuilt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedIds = new Set<string>();
|
||||||
|
const insertedRows: Template[] = [];
|
||||||
|
|
||||||
|
for (const filePath of liquidFiles) {
|
||||||
|
const parsed = await this.readTemplateFileWithMetadata(filePath);
|
||||||
|
if (!parsed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredSlug = this.normalizeSlug(parsed.metadata.slug || path.basename(filePath, '.liquid'));
|
||||||
|
const slug = this.ensureUniqueSlug(desiredSlug, insertedRows);
|
||||||
|
|
||||||
|
const desiredId = typeof parsed.metadata.id === 'string' && parsed.metadata.id.trim().length > 0
|
||||||
|
? parsed.metadata.id.trim()
|
||||||
|
: uuidv4();
|
||||||
|
const id = usedIds.has(desiredId) ? uuidv4() : desiredId;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const row: NewTemplate = {
|
||||||
|
id,
|
||||||
|
projectId: this.currentProjectId,
|
||||||
|
slug,
|
||||||
|
title: this.normalizeTitle(parsed.metadata.title, slug),
|
||||||
|
kind: this.normalizeKind(parsed.metadata.kind),
|
||||||
|
enabled: this.normalizeEnabled(parsed.metadata.enabled),
|
||||||
|
version: this.normalizeVersion(parsed.metadata.version),
|
||||||
|
filePath,
|
||||||
|
createdAt: this.normalizeDate(parsed.metadata.createdAt, now),
|
||||||
|
updatedAt: this.normalizeDate(parsed.metadata.updatedAt, now),
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.insert(templates).values(row);
|
||||||
|
insertedRows.push(row as Template);
|
||||||
|
usedIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('templatesRebuilt');
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconcileTemplatesFromGitChanges(projectPath: string, changes: GitTemplateFileChange[]): Promise<TemplateReconcileResult> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const normalizedProjectPath = path.resolve(projectPath);
|
||||||
|
|
||||||
|
const relevantChanges = changes.filter((change) => {
|
||||||
|
if (!this.isLiquidTemplatePath(change.path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (change.status === 'renamed' && change.previousPath && !this.isLiquidTemplatePath(change.previousPath) && !this.isLiquidTemplatePath(change.path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (relevantChanges.length === 0) {
|
||||||
|
return { created: 0, updated: 0, deleted: 0, processedFiles: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateRows = await this.getAllTemplateRows();
|
||||||
|
const templatesByPath = new Map<string, Template>();
|
||||||
|
for (const row of templateRows) {
|
||||||
|
templatesByPath.set(this.normalizePathForCompare(row.filePath), row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let deleted = 0;
|
||||||
|
let processedFiles = 0;
|
||||||
|
|
||||||
|
for (const change of relevantChanges) {
|
||||||
|
const absolutePath = this.normalizePathForCompare(path.resolve(normalizedProjectPath, change.path));
|
||||||
|
const previousAbsolutePath = change.previousPath
|
||||||
|
? this.normalizePathForCompare(path.resolve(normalizedProjectPath, change.previousPath))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (change.status === 'deleted') {
|
||||||
|
const existing = templatesByPath.get(absolutePath);
|
||||||
|
if (!existing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(templates).where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId)));
|
||||||
|
templatesByPath.delete(absolutePath);
|
||||||
|
this.emit('templateDeleted', existing.id);
|
||||||
|
deleted += 1;
|
||||||
|
processedFiles += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = previousAbsolutePath
|
||||||
|
? (templatesByPath.get(previousAbsolutePath) || templatesByPath.get(absolutePath))
|
||||||
|
: templatesByPath.get(absolutePath);
|
||||||
|
|
||||||
|
const parsed = await this.readTemplateFileWithMetadata(absolutePath);
|
||||||
|
if (!parsed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRows = await this.getAllTemplateRows();
|
||||||
|
const parsedId = typeof parsed.metadata.id === 'string' ? parsed.metadata.id.trim() : '';
|
||||||
|
if (!existing && parsedId.length > 0) {
|
||||||
|
const byId = allRows.find((row) => row.id === parsedId);
|
||||||
|
if (byId) {
|
||||||
|
existing = byId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const desiredSlug = this.normalizeSlug(parsed.metadata.slug || path.basename(absolutePath, '.liquid'));
|
||||||
|
const slug = this.ensureUniqueSlug(desiredSlug, allRows, existing?.id);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const updateNow = new Date();
|
||||||
|
const nextRow = {
|
||||||
|
title: this.normalizeTitle(parsed.metadata.title, slug, existing.title),
|
||||||
|
slug,
|
||||||
|
kind: this.normalizeKind(parsed.metadata.kind, existing.kind),
|
||||||
|
enabled: this.normalizeEnabled(parsed.metadata.enabled, existing.enabled),
|
||||||
|
version: this.normalizeVersion(parsed.metadata.version, existing.version),
|
||||||
|
filePath: absolutePath,
|
||||||
|
createdAt: this.normalizeDate(parsed.metadata.createdAt, existing.createdAt),
|
||||||
|
updatedAt: this.normalizeDate(parsed.metadata.updatedAt, updateNow),
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.update(templates)
|
||||||
|
.set(nextRow)
|
||||||
|
.where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId)));
|
||||||
|
|
||||||
|
const updatedRow = await this.getTemplateRow(existing.id);
|
||||||
|
if (updatedRow) {
|
||||||
|
const updatedTemplate = await this.toTemplateData(updatedRow);
|
||||||
|
this.emit('templateUpdated', updatedTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousAbsolutePath) {
|
||||||
|
templatesByPath.delete(previousAbsolutePath);
|
||||||
|
}
|
||||||
|
templatesByPath.set(absolutePath, {
|
||||||
|
...existing,
|
||||||
|
...nextRow,
|
||||||
|
});
|
||||||
|
updated += 1;
|
||||||
|
processedFiles += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredId = typeof parsed.metadata.id === 'string' && parsed.metadata.id.trim().length > 0
|
||||||
|
? parsed.metadata.id.trim()
|
||||||
|
: uuidv4();
|
||||||
|
const idExists = allRows.some((row) => row.id === desiredId);
|
||||||
|
const rowId = idExists ? uuidv4() : desiredId;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const newRow: NewTemplate = {
|
||||||
|
id: rowId,
|
||||||
|
projectId: this.currentProjectId,
|
||||||
|
slug,
|
||||||
|
title: this.normalizeTitle(parsed.metadata.title, slug),
|
||||||
|
kind: this.normalizeKind(parsed.metadata.kind),
|
||||||
|
enabled: this.normalizeEnabled(parsed.metadata.enabled),
|
||||||
|
version: this.normalizeVersion(parsed.metadata.version),
|
||||||
|
filePath: absolutePath,
|
||||||
|
createdAt: this.normalizeDate(parsed.metadata.createdAt, now),
|
||||||
|
updatedAt: this.normalizeDate(parsed.metadata.updatedAt, now),
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.insert(templates).values(newRow);
|
||||||
|
|
||||||
|
const createdRow = await this.getTemplateRow(newRow.id);
|
||||||
|
if (createdRow) {
|
||||||
|
const createdTemplate = await this.toTemplateData(createdRow);
|
||||||
|
this.emit('templateCreated', createdTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
templatesByPath.set(absolutePath, newRow as Template);
|
||||||
|
created += 1;
|
||||||
|
processedFiles += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
deleted,
|
||||||
|
processedFiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTemplateRow(id: string): Promise<Template | null> {
|
||||||
|
const rows = await this.getAllTemplateRows();
|
||||||
|
return rows.find((item) => item.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAllTemplateRows(): Promise<Template[]> {
|
||||||
|
return getDatabase().getLocal()
|
||||||
|
.select()
|
||||||
|
.from(templates)
|
||||||
|
.where(eq(templates.projectId, this.currentProjectId))
|
||||||
|
.orderBy(desc(templates.updatedAt))
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toTemplateData(row: Template): Promise<TemplateData> {
|
||||||
|
const content = await this.readTemplateBody(row.filePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
projectId: row.projectId,
|
||||||
|
slug: row.slug,
|
||||||
|
title: row.title,
|
||||||
|
kind: row.kind,
|
||||||
|
enabled: row.enabled,
|
||||||
|
version: row.version,
|
||||||
|
filePath: row.filePath,
|
||||||
|
content,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDataDir(): string {
|
||||||
|
if (this.dataDir) {
|
||||||
|
return this.dataDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(app.getPath('userData'), 'projects', this.currentProjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTemplatesDir(): string {
|
||||||
|
return path.join(this.getDataDir(), 'templates');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTemplateFilePath(slug: string): string {
|
||||||
|
return path.join(this.getTemplatesDir(), `${slug}.liquid`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePathForCompare(filePath: string): string {
|
||||||
|
return path.resolve(filePath).replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isLiquidTemplatePath(value: string): boolean {
|
||||||
|
const normalized = value.replace(/\\/g, '/').replace(/^\.\//, '');
|
||||||
|
return normalized.startsWith('templates/') && path.extname(normalized).toLowerCase() === '.liquid';
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSlug(value: string): string {
|
||||||
|
const normalized = value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
return normalized || 'template';
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureUniqueSlug(slug: string, rows: Template[], excludeId?: string): string {
|
||||||
|
const baseSlug = slug;
|
||||||
|
const taken = new Set(
|
||||||
|
rows
|
||||||
|
.filter((item) => item.id !== excludeId)
|
||||||
|
.map((item) => item.slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!taken.has(baseSlug)) {
|
||||||
|
return baseSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
let suffix = 2;
|
||||||
|
while (taken.has(`${baseSlug}_${suffix}`)) {
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseSlug}_${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeTemplateFile(row: Pick<Template, 'id' | 'projectId' | 'slug' | 'title' | 'kind' | 'enabled' | 'version' | 'createdAt' | 'updatedAt'>, content: string): string {
|
||||||
|
const lines = [
|
||||||
|
'---',
|
||||||
|
`id: ${this.toYamlString(row.id)}`,
|
||||||
|
`projectId: ${this.toYamlString(row.projectId)}`,
|
||||||
|
`slug: ${this.toYamlString(row.slug)}`,
|
||||||
|
`title: ${this.toYamlString(row.title)}`,
|
||||||
|
`kind: ${this.toYamlString(row.kind)}`,
|
||||||
|
`enabled: ${row.enabled ? 'true' : 'false'}`,
|
||||||
|
`version: ${row.version}`,
|
||||||
|
`createdAt: ${this.toYamlString(row.createdAt.toISOString())}`,
|
||||||
|
`updatedAt: ${this.toYamlString(row.updatedAt.toISOString())}`,
|
||||||
|
'---',
|
||||||
|
content,
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private toYamlString(value: string): string {
|
||||||
|
const escaped = value
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"');
|
||||||
|
return `"${escaped}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseTemplateBody(rawContent: string): string {
|
||||||
|
const frontmatterPattern = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/;
|
||||||
|
if (!frontmatterPattern.test(rawContent)) {
|
||||||
|
return rawContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawContent.replace(frontmatterPattern, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseTemplateFile(rawContent: string): ParsedTemplateFile {
|
||||||
|
const frontmatterPattern = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
||||||
|
const match = rawContent.match(frontmatterPattern);
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
metadata: {},
|
||||||
|
body: rawContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataLines = (match[1] || '').split(/\r?\n/);
|
||||||
|
const metadata: ParsedTemplateFile['metadata'] = {};
|
||||||
|
|
||||||
|
for (const rawLine of metadataLines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = line.indexOf(':');
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = line.slice(0, separatorIndex).trim();
|
||||||
|
const valueRaw = line.slice(separatorIndex + 1).trim();
|
||||||
|
const value = this.parseYamlScalar(valueRaw);
|
||||||
|
|
||||||
|
if (key === 'enabled') {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
metadata.enabled = value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'version') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
metadata.version = parsed;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
key === 'id' ||
|
||||||
|
key === 'projectId' ||
|
||||||
|
key === 'slug' ||
|
||||||
|
key === 'title' ||
|
||||||
|
key === 'kind' ||
|
||||||
|
key === 'createdAt' ||
|
||||||
|
key === 'updatedAt'
|
||||||
|
) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
metadata[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
body: rawContent.replace(frontmatterPattern, ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseYamlScalar(valueRaw: string): string | number | boolean {
|
||||||
|
if ((valueRaw.startsWith('"') && valueRaw.endsWith('"')) || (valueRaw.startsWith("'") && valueRaw.endsWith("'"))) {
|
||||||
|
return valueRaw.slice(1, -1)
|
||||||
|
.replace(/\\"/g, '"')
|
||||||
|
.replace(/\\\\/g, '\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueRaw === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueRaw === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(valueRaw);
|
||||||
|
if (!Number.isNaN(numeric)) {
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
|
||||||
|
return valueRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeKind(kind: string | undefined, fallback: TemplateKind = 'post'): TemplateKind {
|
||||||
|
if (kind === 'post' || kind === 'list' || kind === 'not-found' || kind === 'partial') {
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeEnabled(enabled: boolean | undefined, fallback = true): boolean {
|
||||||
|
if (typeof enabled === 'boolean') {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeVersion(version: number | undefined, fallback = 1): number {
|
||||||
|
if (typeof version === 'number' && Number.isFinite(version) && version > 0) {
|
||||||
|
return Math.floor(version);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeDate(value: string | undefined, fallback: Date): Date {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (!Number.isNaN(parsed.getTime())) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeTitle(title: string | undefined, slug: string, fallback?: string): string {
|
||||||
|
if (typeof title === 'string' && title.trim().length > 0) {
|
||||||
|
return title.trim();
|
||||||
|
}
|
||||||
|
if (typeof fallback === 'string' && fallback.trim().length > 0) {
|
||||||
|
return fallback.trim();
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanTemplateFiles(dir: string): Promise<string[]> {
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
|
const scan = async (currentDir: string): Promise<void> => {
|
||||||
|
let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }> = [];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(currentDir, { withFileTypes: true }) as Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(currentDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await scan(fullPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isFile() && path.extname(entry.name).toLowerCase() === '.liquid') {
|
||||||
|
results.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await scan(dir);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readTemplateFileWithMetadata(filePath: string): Promise<ParsedTemplateFile | null> {
|
||||||
|
try {
|
||||||
|
const rawContent = await fs.readFile(filePath, 'utf-8');
|
||||||
|
return this.parseTemplateFile(rawContent);
|
||||||
|
} catch (error) {
|
||||||
|
const fsError = error as NodeJS.ErrnoException;
|
||||||
|
if (fsError.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readTemplateBody(filePath: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const rawContent = await fs.readFile(filePath, 'utf-8');
|
||||||
|
return this.parseTemplateBody(rawContent);
|
||||||
|
} catch (error) {
|
||||||
|
const fsError = error as NodeJS.ErrnoException;
|
||||||
|
if (fsError.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let templateEngineInstance: TemplateEngine | null = null;
|
||||||
|
|
||||||
|
export function getTemplateEngine(): TemplateEngine {
|
||||||
|
if (!templateEngineInstance) {
|
||||||
|
templateEngineInstance = new TemplateEngine();
|
||||||
|
}
|
||||||
|
return templateEngineInstance;
|
||||||
|
}
|
||||||
@@ -90,6 +90,10 @@ export const ENGINE_MAP: Record<string, EngineGetter> = {
|
|||||||
const { getScriptEngine } = require('../engine/ScriptEngine');
|
const { getScriptEngine } = require('../engine/ScriptEngine');
|
||||||
return getScriptEngine();
|
return getScriptEngine();
|
||||||
},
|
},
|
||||||
|
templates: () => {
|
||||||
|
const { getTemplateEngine } = require('../engine/TemplateEngine');
|
||||||
|
return getTemplateEngine();
|
||||||
|
},
|
||||||
tasks: () => {
|
tasks: () => {
|
||||||
const { taskManager } = require('../engine/TaskManager');
|
const { taskManager } = require('../engine/TaskManager');
|
||||||
return taskManager;
|
return taskManager;
|
||||||
@@ -190,6 +194,14 @@ const METHOD_NAME_MAP: Record<string, string> = {
|
|||||||
'scripts.get': 'getScript',
|
'scripts.get': 'getScript',
|
||||||
'scripts.getAll': 'getAllScripts',
|
'scripts.getAll': 'getAllScripts',
|
||||||
'scripts.rebuildFromFiles': 'rebuildDatabaseFromFiles',
|
'scripts.rebuildFromFiles': 'rebuildDatabaseFromFiles',
|
||||||
|
'templates.create': 'createTemplate',
|
||||||
|
'templates.update': 'updateTemplate',
|
||||||
|
'templates.delete': 'deleteTemplate',
|
||||||
|
'templates.get': 'getTemplate',
|
||||||
|
'templates.getAll': 'getAllTemplates',
|
||||||
|
'templates.getEnabledByKind': 'getEnabledTemplatesByKind',
|
||||||
|
'templates.validate': 'validateTemplate',
|
||||||
|
'templates.rebuildFromFiles': 'rebuildDatabaseFromFiles',
|
||||||
'tasks.getAll': 'getAllTasks',
|
'tasks.getAll': 'getAllTasks',
|
||||||
'tasks.getRunning': 'getRunningTasks',
|
'tasks.getRunning': 'getRunningTasks',
|
||||||
'tasks.cancel': 'cancelTask',
|
'tasks.cancel': 'cancelTask',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getMenuEngine, type MenuDocument } from '../engine/MenuEngine';
|
|||||||
import { getTagEngine } from '../engine/TagEngine';
|
import { getTagEngine } from '../engine/TagEngine';
|
||||||
import { getPostMediaEngine } from '../engine/PostMediaEngine';
|
import { getPostMediaEngine } from '../engine/PostMediaEngine';
|
||||||
import { getScriptEngine, type CreateScriptInput, type UpdateScriptInput } from '../engine/ScriptEngine';
|
import { getScriptEngine, type CreateScriptInput, type UpdateScriptInput } from '../engine/ScriptEngine';
|
||||||
|
import { getTemplateEngine, type CreateTemplateInput, type UpdateTemplateInput } from '../engine/TemplateEngine';
|
||||||
import { getGitEngine } from '../engine/GitEngine';
|
import { getGitEngine } from '../engine/GitEngine';
|
||||||
import { getGitApiAdapter } from '../engine/GitApiAdapter';
|
import { getGitApiAdapter } from '../engine/GitApiAdapter';
|
||||||
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
||||||
@@ -191,11 +192,12 @@ export function registerIpcHandlers(): void {
|
|||||||
return pullResult;
|
return pullResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [changedPostFiles, changedScriptFiles] = await Promise.all([
|
const [changedPostFiles, changedScriptFiles, changedTemplateFiles] = await Promise.all([
|
||||||
engine.getChangedPostFilesBetween(projectPath, beforeHead, afterHead),
|
engine.getChangedPostFilesBetween(projectPath, beforeHead, afterHead),
|
||||||
engine.getChangedScriptFilesBetween(projectPath, beforeHead, afterHead),
|
engine.getChangedScriptFilesBetween(projectPath, beforeHead, afterHead),
|
||||||
|
engine.getChangedTemplateFilesBetween(projectPath, beforeHead, afterHead),
|
||||||
]);
|
]);
|
||||||
if (changedPostFiles.length === 0 && changedScriptFiles.length === 0) {
|
if (changedPostFiles.length === 0 && changedScriptFiles.length === 0 && changedTemplateFiles.length === 0) {
|
||||||
return pullResult;
|
return pullResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,11 +206,13 @@ export function registerIpcHandlers(): void {
|
|||||||
const project = await projectEngine.getActiveProject();
|
const project = await projectEngine.getActiveProject();
|
||||||
const postEngine = getPostEngine();
|
const postEngine = getPostEngine();
|
||||||
const scriptEngine = getScriptEngine();
|
const scriptEngine = getScriptEngine();
|
||||||
|
const templateEngine = getTemplateEngine();
|
||||||
|
|
||||||
if (project) {
|
if (project) {
|
||||||
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||||
postEngine.setProjectContext(project.id, dataDir);
|
postEngine.setProjectContext(project.id, dataDir);
|
||||||
scriptEngine.setProjectContext(project.id, dataDir);
|
scriptEngine.setProjectContext(project.id, dataDir);
|
||||||
|
templateEngine.setProjectContext(project.id, dataDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -218,9 +222,12 @@ export function registerIpcHandlers(): void {
|
|||||||
changedScriptFiles.length > 0
|
changedScriptFiles.length > 0
|
||||||
? scriptEngine.reconcileScriptsFromGitChanges(projectPath, changedScriptFiles)
|
? scriptEngine.reconcileScriptsFromGitChanges(projectPath, changedScriptFiles)
|
||||||
: Promise.resolve(),
|
: Promise.resolve(),
|
||||||
|
changedTemplateFiles.length > 0
|
||||||
|
? templateEngine.reconcileTemplatesFromGitChanges(projectPath, changedTemplateFiles)
|
||||||
|
: Promise.resolve(),
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to reconcile published posts/scripts after git pull:', error);
|
console.error('Failed to reconcile published posts/scripts/templates after git pull:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pullResult;
|
return pullResult;
|
||||||
@@ -304,12 +311,14 @@ export function registerIpcHandlers(): void {
|
|||||||
const menuEngine = getMenuEngine();
|
const menuEngine = getMenuEngine();
|
||||||
const tagEngine = getTagEngine();
|
const tagEngine = getTagEngine();
|
||||||
const scriptEngine = getScriptEngine();
|
const scriptEngine = getScriptEngine();
|
||||||
|
const templateEngine = getTemplateEngine();
|
||||||
postEngine.setProjectContext(project.id, dataDir);
|
postEngine.setProjectContext(project.id, dataDir);
|
||||||
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||||
metaEngine.setProjectContext(project.id, dataDir);
|
metaEngine.setProjectContext(project.id, dataDir);
|
||||||
menuEngine.setProjectContext(project.id, dataDir);
|
menuEngine.setProjectContext(project.id, dataDir);
|
||||||
tagEngine.setProjectContext(project.id, dataDir);
|
tagEngine.setProjectContext(project.id, dataDir);
|
||||||
scriptEngine.setProjectContext(project.id, dataDir);
|
scriptEngine.setProjectContext(project.id, dataDir);
|
||||||
|
templateEngine.setProjectContext(project.id, dataDir);
|
||||||
const postMediaEngine = getPostMediaEngine();
|
const postMediaEngine = getPostMediaEngine();
|
||||||
postMediaEngine.setProjectContext(project.id);
|
postMediaEngine.setProjectContext(project.id);
|
||||||
|
|
||||||
@@ -344,12 +353,14 @@ export function registerIpcHandlers(): void {
|
|||||||
const menuEngine = getMenuEngine();
|
const menuEngine = getMenuEngine();
|
||||||
const tagEngine = getTagEngine();
|
const tagEngine = getTagEngine();
|
||||||
const scriptEngine = getScriptEngine();
|
const scriptEngine = getScriptEngine();
|
||||||
|
const templateEngine = getTemplateEngine();
|
||||||
postEngine.setProjectContext(project.id, dataDir);
|
postEngine.setProjectContext(project.id, dataDir);
|
||||||
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||||
metaEngine.setProjectContext(project.id, dataDir);
|
metaEngine.setProjectContext(project.id, dataDir);
|
||||||
menuEngine.setProjectContext(project.id, dataDir);
|
menuEngine.setProjectContext(project.id, dataDir);
|
||||||
tagEngine.setProjectContext(project.id, dataDir);
|
tagEngine.setProjectContext(project.id, dataDir);
|
||||||
scriptEngine.setProjectContext(project.id, dataDir);
|
scriptEngine.setProjectContext(project.id, dataDir);
|
||||||
|
templateEngine.setProjectContext(project.id, dataDir);
|
||||||
const postMediaEngine = getPostMediaEngine();
|
const postMediaEngine = getPostMediaEngine();
|
||||||
postMediaEngine.setProjectContext(project.id);
|
postMediaEngine.setProjectContext(project.id);
|
||||||
|
|
||||||
@@ -794,6 +805,55 @@ export function registerIpcHandlers(): void {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ Template Handlers ============
|
||||||
|
|
||||||
|
safeHandle('templates:create', async (_, data: CreateTemplateInput) => {
|
||||||
|
const engine = getTemplateEngine();
|
||||||
|
return engine.createTemplate(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('templates:update', async (_, id: string, data: UpdateTemplateInput) => {
|
||||||
|
const engine = getTemplateEngine();
|
||||||
|
return engine.updateTemplate(id, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('templates:delete', async (_, id: string, options?: { force?: boolean }) => {
|
||||||
|
const engine = getTemplateEngine();
|
||||||
|
return engine.deleteTemplate(id, options);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('templates:get', async (_, id: string) => {
|
||||||
|
const engine = getTemplateEngine();
|
||||||
|
return engine.getTemplate(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('templates:getAll', async () => {
|
||||||
|
const engine = getTemplateEngine();
|
||||||
|
return engine.getAllTemplates();
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('templates:getEnabledByKind', async (_, kind: string) => {
|
||||||
|
const engine = getTemplateEngine();
|
||||||
|
return engine.getEnabledTemplatesByKind(kind as 'post' | 'list' | 'not-found' | 'partial');
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('templates:validate', async (_, content: string) => {
|
||||||
|
const engine = getTemplateEngine();
|
||||||
|
return engine.validateTemplate(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('templates:rebuildFromFiles', async () => {
|
||||||
|
const projectEngine = getProjectEngine();
|
||||||
|
const project = await projectEngine.getActiveProject();
|
||||||
|
const engine = getTemplateEngine();
|
||||||
|
if (project) {
|
||||||
|
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||||
|
engine.setProjectContext(project.id, dataDir);
|
||||||
|
}
|
||||||
|
await engine.rebuildDatabaseFromFiles();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Task Handlers ============
|
// ============ Task Handlers ============
|
||||||
|
|
||||||
safeHandle('tasks:getAll', async () => {
|
safeHandle('tasks:getAll', async () => {
|
||||||
@@ -1135,7 +1195,7 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.createTag(data);
|
return engine.createTag(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('tags:update', async (_, id: string, data: { name?: string; color?: string | null }) => {
|
safeHandle('tags:update', async (_, id: string, data: { name?: string; color?: string | null; postTemplateSlug?: string | null }) => {
|
||||||
const engine = getTagEngine();
|
const engine = getTagEngine();
|
||||||
return engine.updateTag(id, data);
|
return engine.updateTag(id, data);
|
||||||
});
|
});
|
||||||
@@ -1566,4 +1626,10 @@ export function registerIpcHandlers(): void {
|
|||||||
scriptEngine.on('scriptUpdated', forwardEvent('script:updated'));
|
scriptEngine.on('scriptUpdated', forwardEvent('script:updated'));
|
||||||
scriptEngine.on('scriptDeleted', forwardEvent('script:deleted'));
|
scriptEngine.on('scriptDeleted', forwardEvent('script:deleted'));
|
||||||
scriptEngine.on('scriptsRebuilt', forwardEvent('scripts:rebuilt'));
|
scriptEngine.on('scriptsRebuilt', forwardEvent('scripts:rebuilt'));
|
||||||
|
|
||||||
|
const templateEngine = getTemplateEngine();
|
||||||
|
templateEngine.on('templateCreated', forwardEvent('template:created'));
|
||||||
|
templateEngine.on('templateUpdated', forwardEvent('template:updated'));
|
||||||
|
templateEngine.on('templateDeleted', forwardEvent('template:deleted'));
|
||||||
|
templateEngine.on('templatesRebuilt', forwardEvent('templates:rebuilt'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { getMediaEngine } from './engine/MediaEngine';
|
import { getMediaEngine } from './engine/MediaEngine';
|
||||||
import { getPostEngine } from './engine/PostEngine';
|
import { getPostEngine } from './engine/PostEngine';
|
||||||
import { getMetaEngine } from './engine/MetaEngine';
|
import { getMetaEngine } from './engine/MetaEngine';
|
||||||
|
import { getTemplateEngine } from './engine/TemplateEngine';
|
||||||
import { getBlogmarkTransformService } from './engine/BlogmarkTransformService';
|
import { getBlogmarkTransformService } from './engine/BlogmarkTransformService';
|
||||||
import { PreviewServer } from './engine/PreviewServer';
|
import { PreviewServer } from './engine/PreviewServer';
|
||||||
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, APP_MENU_ITEM_IDS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands';
|
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, APP_MENU_ITEM_IDS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands';
|
||||||
@@ -496,6 +497,11 @@ async function initializeActiveProjectContext(): Promise<void> {
|
|||||||
mediaEngine.setProjectContext?.(project.id, dataDir, dataDir);
|
mediaEngine.setProjectContext?.(project.id, dataDir, dataDir);
|
||||||
metaEngine.setProjectContext?.(project.id, dataDir);
|
metaEngine.setProjectContext?.(project.id, dataDir);
|
||||||
|
|
||||||
|
const templateEngine = getTemplateEngine() as {
|
||||||
|
setProjectContext?: (projectId: string, dataDir?: string) => void;
|
||||||
|
};
|
||||||
|
templateEngine.setProjectContext?.(project.id, dataDir);
|
||||||
|
|
||||||
await metaEngine.syncOnStartup?.();
|
await metaEngine.syncOnStartup?.();
|
||||||
|
|
||||||
const metadata = await metaEngine.getProjectMetadata?.();
|
const metadata = await metaEngine.getProjectMetadata?.();
|
||||||
|
|||||||
@@ -112,6 +112,18 @@ export const electronAPI: ElectronAPI = {
|
|||||||
rebuildFromFiles: () => ipcRenderer.invoke('scripts:rebuildFromFiles'),
|
rebuildFromFiles: () => ipcRenderer.invoke('scripts:rebuildFromFiles'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
templates: {
|
||||||
|
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),
|
||||||
|
delete: (id: string, options?: { force?: boolean }) => ipcRenderer.invoke('templates:delete', id, options),
|
||||||
|
get: (id: string) => ipcRenderer.invoke('templates:get', id),
|
||||||
|
getAll: () => ipcRenderer.invoke('templates:getAll'),
|
||||||
|
getEnabledByKind: (kind: import('./shared/electronApi').TemplateKind) => ipcRenderer.invoke('templates:getEnabledByKind', kind),
|
||||||
|
validate: (content: string) => ipcRenderer.invoke('templates:validate', content),
|
||||||
|
rebuildFromFiles: () => ipcRenderer.invoke('templates:rebuildFromFiles'),
|
||||||
|
},
|
||||||
|
|
||||||
// Post-Media Links
|
// Post-Media Links
|
||||||
postMedia: {
|
postMedia: {
|
||||||
link: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:link', postId, mediaId),
|
link: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:link', postId, mediaId),
|
||||||
@@ -189,7 +201,7 @@ export const electronAPI: ElectronAPI = {
|
|||||||
get: (id: string) => ipcRenderer.invoke('tags:get', id),
|
get: (id: string) => ipcRenderer.invoke('tags:get', id),
|
||||||
getByName: (name: string) => ipcRenderer.invoke('tags:getByName', name),
|
getByName: (name: string) => ipcRenderer.invoke('tags:getByName', name),
|
||||||
create: (data: { name: string; color?: string }) => ipcRenderer.invoke('tags:create', data),
|
create: (data: { name: string; color?: string }) => ipcRenderer.invoke('tags:create', data),
|
||||||
update: (id: string, data: { name?: string; color?: string | null }) => ipcRenderer.invoke('tags:update', id, data),
|
update: (id: string, data: { name?: string; color?: string | null; postTemplateSlug?: string | null }) => ipcRenderer.invoke('tags:update', id, data),
|
||||||
delete: (id: string) => ipcRenderer.invoke('tags:delete', id),
|
delete: (id: string) => ipcRenderer.invoke('tags:delete', id),
|
||||||
merge: (sourceTagIds: string[], targetTagId: string) => ipcRenderer.invoke('tags:merge', sourceTagIds, targetTagId),
|
merge: (sourceTagIds: string[], targetTagId: string) => ipcRenderer.invoke('tags:merge', sourceTagIds, targetTagId),
|
||||||
rename: (id: string, newName: string) => ipcRenderer.invoke('tags:rename', id, newName),
|
rename: (id: string, newName: string) => ipcRenderer.invoke('tags:rename', id, newName),
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export interface ProjectMetadata {
|
|||||||
export interface CategoryRenderSettings {
|
export interface CategoryRenderSettings {
|
||||||
renderInLists: boolean;
|
renderInLists: boolean;
|
||||||
showTitle: boolean;
|
showTitle: boolean;
|
||||||
|
postTemplateSlug?: string;
|
||||||
|
listTemplateSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryMetadata extends CategoryRenderSettings {
|
export interface CategoryMetadata extends CategoryRenderSettings {
|
||||||
@@ -90,6 +92,7 @@ export interface PostData {
|
|||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
templateSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostFilter {
|
export interface PostFilter {
|
||||||
@@ -158,6 +161,27 @@ export interface ScriptData {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TemplateKind = 'post' | 'list' | 'not-found' | 'partial';
|
||||||
|
|
||||||
|
export interface TemplateData {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
kind: TemplateKind;
|
||||||
|
enabled: boolean;
|
||||||
|
version: number;
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: 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;
|
||||||
@@ -200,6 +224,7 @@ export interface TagData {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
postTemplateSlug?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -588,6 +613,28 @@ export interface ElectronAPI {
|
|||||||
getEnabledMacroSlugs: () => Promise<string[]>;
|
getEnabledMacroSlugs: () => Promise<string[]>;
|
||||||
rebuildFromFiles: () => Promise<void>;
|
rebuildFromFiles: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
templates: {
|
||||||
|
create: (data: {
|
||||||
|
title: string;
|
||||||
|
kind: TemplateKind;
|
||||||
|
content: string;
|
||||||
|
slug?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}) => Promise<TemplateData>;
|
||||||
|
update: (id: string, data: {
|
||||||
|
title?: string;
|
||||||
|
kind?: TemplateKind;
|
||||||
|
content?: string;
|
||||||
|
slug?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}) => Promise<TemplateData | null>;
|
||||||
|
delete: (id: string, options?: { force?: boolean }) => Promise<TemplateDeleteResult>;
|
||||||
|
get: (id: string) => Promise<TemplateData | null>;
|
||||||
|
getAll: () => Promise<TemplateData[]>;
|
||||||
|
getEnabledByKind: (kind: TemplateKind) => Promise<TemplateData[]>;
|
||||||
|
validate: (content: string) => Promise<{ valid: boolean; errors: string[] }>;
|
||||||
|
rebuildFromFiles: () => Promise<void>;
|
||||||
|
};
|
||||||
postMedia: {
|
postMedia: {
|
||||||
link: (postId: string, mediaId: string) => Promise<MediaLinkData>;
|
link: (postId: string, mediaId: string) => Promise<MediaLinkData>;
|
||||||
unlink: (postId: string, mediaId: string) => Promise<void>;
|
unlink: (postId: string, mediaId: string) => Promise<void>;
|
||||||
@@ -654,7 +701,7 @@ export interface ElectronAPI {
|
|||||||
get: (id: string) => Promise<TagData | null>;
|
get: (id: string) => Promise<TagData | null>;
|
||||||
getByName: (name: string) => Promise<TagData | null>;
|
getByName: (name: string) => Promise<TagData | null>;
|
||||||
create: (data: { name: string; color?: string }) => Promise<TagData>;
|
create: (data: { name: string; color?: string }) => Promise<TagData>;
|
||||||
update: (id: string, data: { name?: string; color?: string | null }) => Promise<TagData | null>;
|
update: (id: string, data: { name?: string; color?: string | null; postTemplateSlug?: string | null }) => Promise<TagData | null>;
|
||||||
delete: (id: string) => Promise<DeleteTagResult>;
|
delete: (id: string) => Promise<DeleteTagResult>;
|
||||||
merge: (sourceTagIds: string[], targetTagId: string) => Promise<MergeTagsResult>;
|
merge: (sourceTagIds: string[], targetTagId: string) => Promise<MergeTagsResult>;
|
||||||
rename: (id: string, newName: string) => Promise<RenameTagResult>;
|
rename: (id: string, newName: string) => Promise<RenameTagResult>;
|
||||||
|
|||||||
@@ -129,6 +129,15 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
|
|||||||
method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'),
|
method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'),
|
||||||
method('scripts.rebuildFromFiles', 'Rebuild scripts from files.', [], 'void'),
|
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. 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.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.getAll', 'Fetch all tasks.', [], 'TaskProgress[]'),
|
||||||
method('tasks.getRunning', 'Fetch running tasks.', [], 'TaskProgress[]'),
|
method('tasks.getRunning', 'Fetch running tasks.', [], 'TaskProgress[]'),
|
||||||
method('tasks.cancel', 'Cancel task by id.', [requiredString('taskId')], 'boolean'),
|
method('tasks.cancel', 'Cancel task by id.', [requiredString('taskId')], 'boolean'),
|
||||||
@@ -276,6 +285,31 @@ 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: '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: '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.',
|
||||||
@@ -370,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.7.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,
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ const ScriptsIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const TemplatesIcon = () => (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7zM5.5 5.5v4h4v-4h-4zm9 0v4h4v-4h-4zm-9 9v4h4v-4h-4zm9 0v4h4v-4h-4z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const SettingsIcon = () => (
|
const SettingsIcon = () => (
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
|
<path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
|
||||||
@@ -183,6 +189,13 @@ export const ActivityBar: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<ScriptsIcon />
|
<ScriptsIcon />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`activity-bar-item ${isActivityActive(snapshot, 'templates') ? 'active' : ''}`}
|
||||||
|
onClick={() => executeActivityClick('templates')}
|
||||||
|
title={getTitle('templates')}
|
||||||
|
>
|
||||||
|
<TemplatesIcon />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`activity-bar-item ${isActivityActive(snapshot, 'tags') ? 'active' : ''}`}
|
className={`activity-bar-item ${isActivityActive(snapshot, 'tags') ? 'active' : ''}`}
|
||||||
onClick={() => executeActivityClick('tags')}
|
onClick={() => executeActivityClick('tags')}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { GitDiffView } from '../GitDiffView/GitDiffView';
|
|||||||
import { DocumentationView } from '../DocumentationView/DocumentationView';
|
import { DocumentationView } from '../DocumentationView/DocumentationView';
|
||||||
import { SiteValidationView } from '../SiteValidationView';
|
import { SiteValidationView } from '../SiteValidationView';
|
||||||
import { ScriptsView } from '../ScriptsView/ScriptsView';
|
import { ScriptsView } from '../ScriptsView/ScriptsView';
|
||||||
|
import { TemplatesView } from '../TemplatesView/TemplatesView';
|
||||||
import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils';
|
import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils';
|
||||||
import { InsertModal } from '../InsertModal';
|
import { InsertModal } from '../InsertModal';
|
||||||
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
|
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
|
||||||
@@ -71,6 +72,9 @@ const autoSaveManager = new AutoSaveManager({
|
|||||||
if ('categories' in changes) {
|
if ('categories' in changes) {
|
||||||
update.categories = changes.categories as string[];
|
update.categories = changes.categories as string[];
|
||||||
}
|
}
|
||||||
|
if ('templateSlug' in changes) {
|
||||||
|
(update as Record<string, unknown>).templateSlug = changes.templateSlug as string || null;
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await window.electronAPI?.posts.update(id, update);
|
const updated = await window.electronAPI?.posts.update(id, update);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
@@ -191,6 +195,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
const [author, setAuthor] = useState('');
|
const [author, setAuthor] = useState('');
|
||||||
const [tags, setTags] = useState<string[]>([]);
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
|
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
|
||||||
|
const [templateSlug, setTemplateSlug] = useState('');
|
||||||
|
const [availablePostTemplates, setAvailablePostTemplates] = useState<Array<{ slug: string; title: string }>>([]);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
|
const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
|
||||||
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
|
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
|
||||||
@@ -319,10 +325,15 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
setAuthor(post.author || '');
|
setAuthor(post.author || '');
|
||||||
setTags(post.tags);
|
setTags(post.tags);
|
||||||
setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']);
|
setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']);
|
||||||
|
setTemplateSlug((post as PostData & { templateSlug?: string }).templateSlug || '');
|
||||||
setMetadataExpanded(post.title === '');
|
setMetadataExpanded(post.title === '');
|
||||||
markClean(postId);
|
markClean(postId);
|
||||||
// Mark as initialized AFTER setting local state
|
// Mark as initialized AFTER setting local state
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
|
// Load available post templates for the dropdown
|
||||||
|
window.electronAPI?.templates.getEnabledByKind('post').then((templates) => {
|
||||||
|
setAvailablePostTemplates((templates ?? []).map((tmpl) => ({ slug: tmpl.slug, title: tmpl.title })));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [post, postId, markClean, isInitialized]);
|
}, [post, postId, markClean, isInitialized]);
|
||||||
|
|
||||||
@@ -335,7 +346,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
const contentChanged = content !== post.content;
|
const contentChanged = content !== post.content;
|
||||||
const titleChanged = title !== post.title;
|
const titleChanged = title !== post.title;
|
||||||
const authorChanged = author !== (post.author || '');
|
const authorChanged = author !== (post.author || '');
|
||||||
const hasChanges = contentChanged || titleChanged || authorChanged ||
|
const templateSlugChanged = templateSlug !== ((post as PostData & { templateSlug?: string }).templateSlug || '');
|
||||||
|
const hasChanges = contentChanged || titleChanged || authorChanged || templateSlugChanged ||
|
||||||
JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()) ||
|
JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()) ||
|
||||||
JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort());
|
JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort());
|
||||||
|
|
||||||
@@ -349,11 +361,12 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
author,
|
author,
|
||||||
tags: tags.join(', '),
|
tags: tags.join(', '),
|
||||||
categories: selectedCategories,
|
categories: selectedCategories,
|
||||||
|
templateSlug: templateSlug || undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
markClean(postId);
|
markClean(postId);
|
||||||
}
|
}
|
||||||
}, [title, content, author, tags, selectedCategories, post, postId, isInitialized, isDirty, markDirty, markClean]);
|
}, [title, content, author, tags, selectedCategories, templateSlug, post, postId, isInitialized, isDirty, markDirty, markClean]);
|
||||||
|
|
||||||
// Handle editor mode change and persist preference
|
// Handle editor mode change and persist preference
|
||||||
const handleEditorModeChange = (mode: EditorMode) => {
|
const handleEditorModeChange = (mode: EditorMode) => {
|
||||||
@@ -375,7 +388,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
author: author || undefined,
|
author: author || undefined,
|
||||||
tags,
|
tags,
|
||||||
categories: selectedCategories.length > 0 ? selectedCategories : ['article'],
|
categories: selectedCategories.length > 0 ? selectedCategories : ['article'],
|
||||||
});
|
templateSlug: templateSlug || null,
|
||||||
|
} as Parameters<typeof window.electronAPI.posts.update>[1]);
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
updatePost(postId, updated as Partial<PostData>);
|
updatePost(postId, updated as Partial<PostData>);
|
||||||
@@ -799,6 +813,20 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{availablePostTemplates.length > 0 && (
|
||||||
|
<div className="editor-field">
|
||||||
|
<label>{tr('editor.field.template')}</label>
|
||||||
|
<select
|
||||||
|
value={templateSlug}
|
||||||
|
onChange={(e) => setTemplateSlug(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{tr('editor.field.templateDefault')}</option>
|
||||||
|
{availablePostTemplates.map((tmpl) => (
|
||||||
|
<option key={tmpl.slug} value={tmpl.slug}>{tmpl.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<PostLinks
|
<PostLinks
|
||||||
postId={postId}
|
postId={postId}
|
||||||
@@ -1836,6 +1864,7 @@ export const Editor: React.FC = () => {
|
|||||||
),
|
),
|
||||||
'site-validation': () => <SiteValidationView />,
|
'site-validation': () => <SiteValidationView />,
|
||||||
scripts: () => <ScriptsView scriptId={editorRoute.tabId} />,
|
scripts: () => <ScriptsView scriptId={editorRoute.tabId} />,
|
||||||
|
templates: () => <TemplatesView templateId={editorRoute.tabId} />,
|
||||||
post: () => (editorRoute.tabId ? <PostEditor key={editorRoute.tabId} postId={editorRoute.tabId} /> : <Dashboard />),
|
post: () => (editorRoute.tabId ? <PostEditor key={editorRoute.tabId} postId={editorRoute.tabId} /> : <Dashboard />),
|
||||||
media: () => (editorRoute.tabId ? <MediaEditor key={editorRoute.tabId} mediaId={editorRoute.tabId} /> : <Dashboard />),
|
media: () => (editorRoute.tabId ? <MediaEditor key={editorRoute.tabId} mediaId={editorRoute.tabId} /> : <Dashboard />),
|
||||||
dashboard: () => <Dashboard />,
|
dashboard: () => <Dashboard />,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ interface CategoryMetadata {
|
|||||||
renderInLists: boolean;
|
renderInLists: boolean;
|
||||||
showTitle: boolean;
|
showTitle: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
|
postTemplateSlug?: string;
|
||||||
|
listTemplateSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RENDER_LANGUAGE_LABEL_KEY: Record<SupportedLanguage, string> = {
|
const RENDER_LANGUAGE_LABEL_KEY: Record<SupportedLanguage, string> = {
|
||||||
@@ -151,6 +153,10 @@ export const SettingsView: React.FC = () => {
|
|||||||
const [categoryMetadata, setCategoryMetadata] = useState<Record<string, CategoryMetadata>>(DEFAULT_CATEGORY_METADATA);
|
const [categoryMetadata, setCategoryMetadata] = useState<Record<string, CategoryMetadata>>(DEFAULT_CATEGORY_METADATA);
|
||||||
const [newCategoryInput, setNewCategoryInput] = useState('');
|
const [newCategoryInput, setNewCategoryInput] = useState('');
|
||||||
|
|
||||||
|
// Available templates for category dropdowns
|
||||||
|
const [postTemplates, setPostTemplates] = useState<Array<{ slug: string; title: string }>>([]);
|
||||||
|
const [listTemplates, setListTemplates] = useState<Array<{ slug: string; title: string }>>([]);
|
||||||
|
|
||||||
// AI Assistant settings
|
// AI Assistant settings
|
||||||
const [aiSystemPrompt, setAiSystemPrompt] = useState('');
|
const [aiSystemPrompt, setAiSystemPrompt] = useState('');
|
||||||
const [aiSystemPromptModified, setAiSystemPromptModified] = useState(false);
|
const [aiSystemPromptModified, setAiSystemPromptModified] = useState(false);
|
||||||
@@ -221,6 +227,8 @@ export const SettingsView: React.FC = () => {
|
|||||||
title: typeof (settings as any)?.title === 'string' && (settings as any).title.trim().length > 0
|
title: typeof (settings as any)?.title === 'string' && (settings as any).title.trim().length > 0
|
||||||
? (settings as any).title.trim()
|
? (settings as any).title.trim()
|
||||||
: category,
|
: category,
|
||||||
|
postTemplateSlug: typeof (settings as any)?.postTemplateSlug === 'string' ? (settings as any).postTemplateSlug : undefined,
|
||||||
|
listTemplateSlug: typeof (settings as any)?.listTemplateSlug === 'string' ? (settings as any).listTemplateSlug : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,6 +238,18 @@ export const SettingsView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [activeProject]);
|
}, [activeProject]);
|
||||||
|
|
||||||
|
// Load available templates for category dropdowns
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeProject) {
|
||||||
|
window.electronAPI?.templates.getEnabledByKind('post').then((templates) => {
|
||||||
|
setPostTemplates(templates.map((t) => ({ slug: t.slug, title: t.title })));
|
||||||
|
});
|
||||||
|
window.electronAPI?.templates.getEnabledByKind('list').then((templates) => {
|
||||||
|
setListTemplates(templates.map((t) => ({ slug: t.slug, title: t.title })));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activeProject]);
|
||||||
|
|
||||||
// Load saved credentials and categories
|
// Load saved credentials and categories
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
@@ -771,6 +791,29 @@ export const SettingsView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCategoryTemplateChange = async (
|
||||||
|
category: string,
|
||||||
|
field: 'postTemplateSlug' | 'listTemplateSlug',
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const nextCategoryMetadata: Record<string, CategoryMetadata> = {
|
||||||
|
...categoryMetadata,
|
||||||
|
[category]: {
|
||||||
|
...(categoryMetadata[category] || { renderInLists: true, showTitle: true, title: category }),
|
||||||
|
[field]: value || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setCategoryMetadata(nextCategoryMetadata);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update category settings:', error);
|
||||||
|
showToast.error(t('settings.toast.categorySettingsUpdateFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderContentSettings = () => (
|
const renderContentSettings = () => (
|
||||||
<SettingSection
|
<SettingSection
|
||||||
id="settings-section-content"
|
id="settings-section-content"
|
||||||
@@ -786,6 +829,8 @@ export const SettingsView: React.FC = () => {
|
|||||||
<th>{t('settings.content.titleColumn')}</th>
|
<th>{t('settings.content.titleColumn')}</th>
|
||||||
<th>{t('settings.content.renderInLists')}</th>
|
<th>{t('settings.content.renderInLists')}</th>
|
||||||
<th>{t('settings.content.showTitles')}</th>
|
<th>{t('settings.content.showTitles')}</th>
|
||||||
|
<th>{t('settings.content.postTemplateColumn')}</th>
|
||||||
|
<th>{t('settings.content.listTemplateColumn')}</th>
|
||||||
<th>{t('settings.content.actionsColumn')}</th>
|
<th>{t('settings.content.actionsColumn')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -823,6 +868,30 @@ export const SettingsView: React.FC = () => {
|
|||||||
onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)}
|
onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={metadata.postTemplateSlug || ''}
|
||||||
|
onChange={(event) => handleCategoryTemplateChange(cat, 'postTemplateSlug', event.target.value)}
|
||||||
|
aria-label={t('settings.content.postTemplateAria', { category: cat })}
|
||||||
|
>
|
||||||
|
<option value="">{t('editor.field.templateDefault')}</option>
|
||||||
|
{postTemplates.map((tpl) => (
|
||||||
|
<option key={tpl.slug} value={tpl.slug}>{tpl.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={metadata.listTemplateSlug || ''}
|
||||||
|
onChange={(event) => handleCategoryTemplateChange(cat, 'listTemplateSlug', event.target.value)}
|
||||||
|
aria-label={t('settings.content.listTemplateAria', { category: cat })}
|
||||||
|
>
|
||||||
|
<option value="">{t('editor.field.templateDefault')}</option>
|
||||||
|
{listTemplates.map((tpl) => (
|
||||||
|
<option key={tpl.slug} value={tpl.slug}>{tpl.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
<td className="category-actions-cell">
|
<td className="category-actions-cell">
|
||||||
{!isProtected && (
|
{!isProtected && (
|
||||||
<button
|
<button
|
||||||
@@ -1213,6 +1282,29 @@ export const SettingsView: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
id="rebuild-templates"
|
||||||
|
label={t('settings.data.rebuildTemplatesLabel')}
|
||||||
|
description={t('settings.data.rebuildTemplatesDescription')}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
onClick={async () => {
|
||||||
|
showToast.loading(t('settings.toast.rebuildTemplatesLoading'));
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.templates.rebuildFromFiles();
|
||||||
|
showToast.dismiss();
|
||||||
|
showToast.success(t('settings.toast.rebuildTemplatesSuccess'));
|
||||||
|
} catch {
|
||||||
|
showToast.dismiss();
|
||||||
|
showToast.error(t('settings.toast.rebuildTemplatesFailed'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.data.rebuildTemplatesAction')}
|
||||||
|
</button>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
id="rebuild-links"
|
id="rebuild-links"
|
||||||
label={t('settings.data.rebuildLinksLabel')}
|
label={t('settings.data.rebuildLinksLabel')}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useAppStore, PostData, MediaData } from '../../store';
|
import { useAppStore, PostData, MediaData } from '../../store';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent, getContrastColor, groupPostsByStatus, loadTagColorMap } from '../../utils';
|
import { BDS_EVENT_SCRIPTS_CHANGED, BDS_EVENT_TEMPLATES_CHANGED, dispatchWindowEvent, getContrastColor, groupPostsByStatus, loadTagColorMap } from '../../utils';
|
||||||
import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
|
import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
|
||||||
import { GitSidebar } from '../GitSidebar/GitSidebar';
|
import { GitSidebar } from '../GitSidebar/GitSidebar';
|
||||||
import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView';
|
import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView';
|
||||||
import { scrollToTagsSection, TagsCategory } from '../TagsView';
|
import { scrollToTagsSection, TagsCategory } from '../TagsView';
|
||||||
import { activateSidebarSection } from '../../navigation/sectionActivation';
|
import { activateSidebarSection } from '../../navigation/sectionActivation';
|
||||||
import { getPersistedSidebarSection, setPersistedSidebarSection } from '../../navigation/sidebarUiPersistence';
|
import { getPersistedSidebarSection, setPersistedSidebarSection } from '../../navigation/sidebarUiPersistence';
|
||||||
import { openChatTab, openEntityTab, openImportTab, openScriptTab, openSingletonToolTab } from '../../navigation/tabPolicy';
|
import { openChatTab, openEntityTab, openImportTab, openScriptTab, openTemplateTab, openSingletonToolTab } from '../../navigation/tabPolicy';
|
||||||
import { createAndFocusPost } from '../../navigation/postCreation';
|
import { createAndFocusPost } from '../../navigation/postCreation';
|
||||||
import type { SidebarView } from '../../navigation/sidebarViewRegistry';
|
import type { SidebarView } from '../../navigation/sidebarViewRegistry';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
@@ -1702,6 +1702,139 @@ const ScriptsList: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TemplatesList: React.FC = () => {
|
||||||
|
const { t, language } = useI18n();
|
||||||
|
const { openTab, activeTabId, closeTab } = useAppStore();
|
||||||
|
const activeProjectId = useAppStore((state) => state.activeProject?.id);
|
||||||
|
|
||||||
|
const loadTemplates = useCallback(async (): Promise<Array<{ id: string; title: string; updatedAt: string }>> => {
|
||||||
|
const items = await window.electronAPI?.templates.getAll();
|
||||||
|
return (items ?? []).map((item) => ({ id: item.id, title: item.title, updatedAt: item.updatedAt }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: templates,
|
||||||
|
setItems: setTemplates,
|
||||||
|
isLoading,
|
||||||
|
reload: reloadTemplates,
|
||||||
|
} = useProjectScopedSidebarData<Array<{ id: string; title: string; updatedAt: string }>[number]>({
|
||||||
|
load: loadTemplates,
|
||||||
|
activeProjectId,
|
||||||
|
refreshEventName: BDS_EVENT_TEMPLATES_CHANGED,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const created = await window.electronAPI?.templates.create({
|
||||||
|
title: t('sidebar.templates.newTemplate'),
|
||||||
|
kind: 'post',
|
||||||
|
content: '',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTemplates((prev) => [
|
||||||
|
{ id: created.id, title: created.title, updatedAt: created.updatedAt },
|
||||||
|
...prev.filter((tmpl) => tmpl.id !== created.id),
|
||||||
|
]);
|
||||||
|
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
|
||||||
|
openTemplateTab(openTab, created.id, 'pin');
|
||||||
|
void reloadTemplates();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create template:', error);
|
||||||
|
showToast.error(t('sidebar.templates.createFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTemplate = async (event: React.MouseEvent, templateId: string) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI?.templates.delete(templateId);
|
||||||
|
if (!result) {
|
||||||
|
showToast.error(t('sidebar.templates.deleteFailed'));
|
||||||
|
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));
|
||||||
|
closeTab(templateId);
|
||||||
|
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete template:', error);
|
||||||
|
showToast.error(t('sidebar.templates.deleteFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarEntityList
|
||||||
|
header={t('sidebar.templates.header')}
|
||||||
|
createTitle={t('sidebar.templates.newTemplate')}
|
||||||
|
onCreate={handleCreateTemplate}
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingLabel={t('sidebar.loading')}
|
||||||
|
emptyMessage={t('sidebar.templates.none')}
|
||||||
|
emptyActionLabel={t('sidebar.templates.createTemplate')}
|
||||||
|
onEmptyAction={handleCreateTemplate}
|
||||||
|
items={templates}
|
||||||
|
getItemKey={(tmpl) => tmpl.id}
|
||||||
|
renderItem={(tmpl) => (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={tmpl.title}
|
||||||
|
className={`chat-list-item ${activeTabId === tmpl.id ? 'active' : ''}`}
|
||||||
|
onClick={() => openTemplateTab(openTab, tmpl.id, 'preview')}
|
||||||
|
onDoubleClick={() => openTemplateTab(openTab, tmpl.id, 'pin')}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
openTemplateTab(openTab, tmpl.id, 'pin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
openTemplateTab(openTab, tmpl.id, 'preview');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="chat-item-content">
|
||||||
|
<div className="chat-item-title">{tmpl.title}</div>
|
||||||
|
<div className="chat-item-date">
|
||||||
|
{formatSidebarRelativeDate({ dateString: tmpl.updatedAt, language, t })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="chat-item-delete"
|
||||||
|
onClick={(event) => handleDeleteTemplate(event, tmpl.id)}
|
||||||
|
title={t('sidebar.templates.deleteTemplate')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const Sidebar: React.FC = () => {
|
export const Sidebar: React.FC = () => {
|
||||||
const { activeView, sidebarVisible } = useAppStore();
|
const { activeView, sidebarVisible } = useAppStore();
|
||||||
|
|
||||||
@@ -1714,6 +1847,7 @@ export const Sidebar: React.FC = () => {
|
|||||||
pages: <PostsList mode="pages" isActive={true} />,
|
pages: <PostsList mode="pages" isActive={true} />,
|
||||||
media: <MediaList />,
|
media: <MediaList />,
|
||||||
scripts: <ScriptsList />,
|
scripts: <ScriptsList />,
|
||||||
|
templates: <TemplatesList />,
|
||||||
settings: <SettingsNav />,
|
settings: <SettingsNav />,
|
||||||
tags: <TagsNav />,
|
tags: <TagsNav />,
|
||||||
chat: <ChatList />,
|
chat: <ChatList />,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
import { useAppStore, Tab } from '../../store';
|
import { useAppStore, Tab } from '../../store';
|
||||||
import { parseGitDiffTabId } from '../../navigation/tabPolicy';
|
import { parseGitDiffTabId } from '../../navigation/tabPolicy';
|
||||||
|
import { BDS_EVENT_TEMPLATES_CHANGED, addWindowEventListener } from '../../utils';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import './TabBar.css';
|
import './TabBar.css';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ const getTabTitle = (
|
|||||||
chatTitles: Map<string, string>,
|
chatTitles: Map<string, string>,
|
||||||
importDefTitles: Map<string, string>,
|
importDefTitles: Map<string, string>,
|
||||||
commitTitles: Map<string, string>,
|
commitTitles: Map<string, string>,
|
||||||
|
templateTitles: Map<string, string>,
|
||||||
tr: (key: string, vars?: Record<string, string | number>) => string,
|
tr: (key: string, vars?: Record<string, string | number>) => string,
|
||||||
): string => {
|
): string => {
|
||||||
if (tab.type === 'git-diff') {
|
if (tab.type === 'git-diff') {
|
||||||
@@ -89,6 +91,10 @@ const getTabTitle = (
|
|||||||
return scriptTitles.get(tab.id) || tr('tabBar.scripts');
|
return scriptTitles.get(tab.id) || tr('tabBar.scripts');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tab.type === 'templates') {
|
||||||
|
return templateTitles.get(tab.id) || tr('editor.untitled');
|
||||||
|
}
|
||||||
|
|
||||||
return tr('tabBar.unknown');
|
return tr('tabBar.unknown');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,6 +186,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
|
|||||||
<path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"/>
|
<path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'templates':
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M14 2H2v4h12V2zM3 3h10v2H3V3zm-1 5h5v7H2V8zm1 1v5h3V9H3zm5-1h6v3H8V8zm1 1v1h4V9H9zm0 3h6v3H9v-3zm1 1v1h4v-1h-4z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
@@ -230,6 +242,7 @@ export const TabBar: React.FC = () => {
|
|||||||
const [chatTitles, setChatTitles] = useState<Map<string, string>>(new Map());
|
const [chatTitles, setChatTitles] = useState<Map<string, string>>(new Map());
|
||||||
const [importDefTitles, setImportDefTitles] = useState<Map<string, string>>(new Map());
|
const [importDefTitles, setImportDefTitles] = useState<Map<string, string>>(new Map());
|
||||||
const [commitTitles, setCommitTitles] = useState<Map<string, string>>(new Map());
|
const [commitTitles, setCommitTitles] = useState<Map<string, string>>(new Map());
|
||||||
|
const [templateTitles, setTemplateTitles] = useState<Map<string, string>>(new Map());
|
||||||
|
|
||||||
// Fetch post titles from database for post tabs
|
// Fetch post titles from database for post tabs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -552,6 +565,94 @@ export const TabBar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [tabs, activeProject, tr]);
|
}, [tabs, activeProject, tr]);
|
||||||
|
|
||||||
|
// Fetch template titles for template tabs
|
||||||
|
useEffect(() => {
|
||||||
|
const templateTabs = tabs.filter((t) => t.type === 'templates');
|
||||||
|
const templateTabIds = new Set(templateTabs.map((t) => t.id));
|
||||||
|
|
||||||
|
setTemplateTitles((previous) => {
|
||||||
|
const next = new Map(previous);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const id of Array.from(next.keys())) {
|
||||||
|
if (!templateTabIds.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? next : previous;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (templateTabs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTemplateTitles = async () => {
|
||||||
|
const newTitles = new Map(templateTitles);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const tab of templateTabs) {
|
||||||
|
if (templateTitles.has(tab.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tmpl = await window.electronAPI?.templates.get(tab.id);
|
||||||
|
if (tmpl) {
|
||||||
|
const title = tmpl.title || tr('editor.untitled');
|
||||||
|
if (newTitles.get(tab.id) !== title) {
|
||||||
|
newTitles.set(tab.id, title);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(tr('tabBar.error.fetchTemplateTitle'), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
setTemplateTitles(newTitles);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchTemplateTitles();
|
||||||
|
}, [tabs, tr]); // Note: intentionally not including templateTitles to avoid infinite loops
|
||||||
|
|
||||||
|
// Listen for template updates to refresh titles
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTemplatesChanged = async () => {
|
||||||
|
const templateTabs = tabs.filter((t) => t.type === 'templates');
|
||||||
|
if (templateTabs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = new Map(templateTitles);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const tab of templateTabs) {
|
||||||
|
try {
|
||||||
|
const tmpl = await window.electronAPI?.templates.get(tab.id);
|
||||||
|
if (tmpl) {
|
||||||
|
const title = tmpl.title || tr('editor.untitled');
|
||||||
|
if (updated.get(tab.id) !== title) {
|
||||||
|
updated.set(tab.id, title);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(tr('tabBar.error.fetchTemplateTitle'), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
setTemplateTitles(updated);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return addWindowEventListener(BDS_EVENT_TEMPLATES_CHANGED, handleTemplatesChanged);
|
||||||
|
}, [tabs, templateTitles, tr]);
|
||||||
|
|
||||||
// Check if arrows are needed based on scroll position
|
// Check if arrows are needed based on scroll position
|
||||||
const updateArrowVisibility = useCallback(() => {
|
const updateArrowVisibility = useCallback(() => {
|
||||||
const container = tabsContainerRef.current;
|
const container = tabsContainerRef.current;
|
||||||
@@ -674,7 +775,7 @@ export const TabBar: React.FC = () => {
|
|||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = tab.id === activeTabId;
|
const isActive = tab.id === activeTabId;
|
||||||
const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id);
|
const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id);
|
||||||
const title = getTabTitle(tab, postTitles, media, scriptTitles, chatTitles, importDefTitles, commitTitles, tr);
|
const title = getTabTitle(tab, postTitles, media, scriptTitles, chatTitles, importDefTitles, commitTitles, templateTitles, tr);
|
||||||
const icon = getTabIcon(tab);
|
const icon = getTabIcon(tab);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface TagData {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
postTemplateSlug?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -147,6 +148,8 @@ export const TagsView: React.FC = () => {
|
|||||||
const [editingTagId, setEditingTagId] = useState<string | null>(null);
|
const [editingTagId, setEditingTagId] = useState<string | null>(null);
|
||||||
const [editTagColor, setEditTagColor] = useState<string>('');
|
const [editTagColor, setEditTagColor] = useState<string>('');
|
||||||
const [editTagName, setEditTagName] = useState('');
|
const [editTagName, setEditTagName] = useState('');
|
||||||
|
const [editTagTemplate, setEditTagTemplate] = useState<string>('');
|
||||||
|
const [postTemplates, setPostTemplates] = useState<Array<{ slug: string; title: string }>>([]);
|
||||||
|
|
||||||
// Merge tags state
|
// Merge tags state
|
||||||
const [mergeTargetName, setMergeTargetName] = useState('');
|
const [mergeTargetName, setMergeTargetName] = useState('');
|
||||||
@@ -188,6 +191,13 @@ export const TagsView: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [loadTags]);
|
}, [loadTags]);
|
||||||
|
|
||||||
|
// Load post templates on mount
|
||||||
|
useEffect(() => {
|
||||||
|
window.electronAPI?.templates.getEnabledByKind('post').then((templates) => {
|
||||||
|
setPostTemplates(templates.map((t) => ({ slug: t.slug, title: t.title })));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Handle tag selection
|
// Handle tag selection
|
||||||
const handleTagSelect = (name: string) => {
|
const handleTagSelect = (name: string) => {
|
||||||
setSelectedTags(prev => {
|
setSelectedTags(prev => {
|
||||||
@@ -247,6 +257,7 @@ export const TagsView: React.FC = () => {
|
|||||||
setEditingTagId(tag.id);
|
setEditingTagId(tag.id);
|
||||||
setEditTagColor(tag.color || '');
|
setEditTagColor(tag.color || '');
|
||||||
setEditTagName(tag.name);
|
setEditTagName(tag.name);
|
||||||
|
setEditTagTemplate(tag.postTemplateSlug || '');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save tag edit
|
// Save tag edit
|
||||||
@@ -254,9 +265,10 @@ export const TagsView: React.FC = () => {
|
|||||||
if (!editingTagId) return;
|
if (!editingTagId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update color
|
// Update color and template
|
||||||
await window.electronAPI?.tags.update(editingTagId, {
|
await window.electronAPI?.tags.update(editingTagId, {
|
||||||
color: editTagColor || null,
|
color: editTagColor || null,
|
||||||
|
postTemplateSlug: editTagTemplate || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If name changed, rename the tag
|
// If name changed, rename the tag
|
||||||
@@ -455,6 +467,7 @@ export const TagsView: React.FC = () => {
|
|||||||
<div className="tag-edit-form">
|
<div className="tag-edit-form">
|
||||||
<h4>{t('tagsView.edit.title', { name: selectedTagObjects[0].name })}</h4>
|
<h4>{t('tagsView.edit.title', { name: selectedTagObjects[0].name })}</h4>
|
||||||
{editingTagId === selectedTagObjects[0].id ? (
|
{editingTagId === selectedTagObjects[0].id ? (
|
||||||
|
<>
|
||||||
<div className="tag-form-row">
|
<div className="tag-form-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -481,6 +494,14 @@ export const TagsView: React.FC = () => {
|
|||||||
<button onClick={handleSaveEdit} className="primary">{t('common.save')}</button>
|
<button onClick={handleSaveEdit} className="primary">{t('common.save')}</button>
|
||||||
<button onClick={() => setEditingTagId(null)}>{t('common.cancel')}</button>
|
<button onClick={() => setEditingTagId(null)}>{t('common.cancel')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="tagsview-field">
|
||||||
|
<label>{t('tagsView.edit.postTemplate')}</label>
|
||||||
|
<select value={editTagTemplate} onChange={(e) => setEditTagTemplate(e.target.value)}>
|
||||||
|
<option value="">{t('editor.field.templateDefault')}</option>
|
||||||
|
{postTemplates.map(tmpl => <option key={tmpl.slug} value={tmpl.slug}>{tmpl.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="tag-form-row">
|
<div className="tag-form-row">
|
||||||
<span className="tag-preview" style={
|
<span className="tag-preview" style={
|
||||||
|
|||||||
54
src/renderer/components/TemplatesView/TemplatesView.css
Normal file
54
src/renderer/components/TemplatesView/TemplatesView.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
.templates-view-shell {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-view {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-meta-row {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-enabled-field {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-enabled-field label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-editor {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-toolbar {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-monaco {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-save-button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.templates-validate-button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
379
src/renderer/components/TemplatesView/TemplatesView.tsx
Normal file
379
src/renderer/components/TemplatesView/TemplatesView.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import MonacoEditor from '@monaco-editor/react';
|
||||||
|
import type { TemplateData, TemplateKind } from '../../../main/shared/electronApi';
|
||||||
|
import { useAppStore } from '../../store';
|
||||||
|
import { BDS_EVENT_TEMPLATES_CHANGED, dispatchWindowEvent } from '../../utils';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
|
import { showToast } from '../Toast';
|
||||||
|
import './TemplatesView.css';
|
||||||
|
|
||||||
|
const UI_DATE_LOCALE: Record<string, string> = {
|
||||||
|
en: 'en-US',
|
||||||
|
de: 'de-DE',
|
||||||
|
fr: 'fr-FR',
|
||||||
|
it: 'it-IT',
|
||||||
|
es: 'es-ES',
|
||||||
|
};
|
||||||
|
|
||||||
|
const toTemplateSlug = (value: string) => {
|
||||||
|
const normalized = value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
return normalized || 'template';
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TemplatesViewProps {
|
||||||
|
templateId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplatesView: React.FC<TemplatesViewProps> = ({ templateId }) => {
|
||||||
|
const { t, language } = useI18n();
|
||||||
|
const closeTab = useAppStore((state) => state.closeTab);
|
||||||
|
const [template, setTemplate] = useState<TemplateData | null>(null);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [kind, setKind] = useState<TemplateKind>('post');
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [templateContent, setTemplateContent] = useState('');
|
||||||
|
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
const [monacoResetToken, setMonacoResetToken] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadTemplate = async () => {
|
||||||
|
if (!templateId) {
|
||||||
|
setTemplate(null);
|
||||||
|
setTitle('');
|
||||||
|
setSlug('');
|
||||||
|
setKind('post');
|
||||||
|
setEnabled(true);
|
||||||
|
setTemplateContent('');
|
||||||
|
setMonacoResetToken((prev) => prev + 1);
|
||||||
|
setIsSlugManuallyEdited(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await window.electronAPI?.templates.get(templateId);
|
||||||
|
if (cancelled || !item) {
|
||||||
|
setTemplate(null);
|
||||||
|
setTitle('');
|
||||||
|
setSlug('');
|
||||||
|
setKind('post');
|
||||||
|
setEnabled(true);
|
||||||
|
setTemplateContent('');
|
||||||
|
setMonacoResetToken((prev) => prev + 1);
|
||||||
|
setIsSlugManuallyEdited(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTemplate(item);
|
||||||
|
setTitle(item.title || '');
|
||||||
|
setSlug(item.slug || toTemplateSlug(item.title || ''));
|
||||||
|
setKind(item.kind || 'post');
|
||||||
|
setEnabled(item.enabled ?? true);
|
||||||
|
setTemplateContent(item.content || '');
|
||||||
|
setMonacoResetToken((prev) => prev + 1);
|
||||||
|
const normalizedExisting = toTemplateSlug(item.slug || item.title || '');
|
||||||
|
setIsSlugManuallyEdited(normalizedExisting !== toTemplateSlug(item.title || ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadTemplate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [templateId]);
|
||||||
|
|
||||||
|
const hasChanges =
|
||||||
|
!!template &&
|
||||||
|
(title !== template.title ||
|
||||||
|
slug !== template.slug ||
|
||||||
|
kind !== template.kind ||
|
||||||
|
enabled !== template.enabled ||
|
||||||
|
templateContent !== template.content);
|
||||||
|
|
||||||
|
const handleTitleChange = (nextTitle: string) => {
|
||||||
|
setTitle(nextTitle);
|
||||||
|
if (!isSlugManuallyEdited) {
|
||||||
|
setSlug(toTemplateSlug(nextTitle));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlugChange = (nextSlug: string) => {
|
||||||
|
setIsSlugManuallyEdited(true);
|
||||||
|
setSlug(toTemplateSlug(nextSlug));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValidate = async (options: { notify: boolean } = { notify: true }): Promise<boolean> => {
|
||||||
|
if (!template || isValidating) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsValidating(true);
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI?.templates.validate(templateContent);
|
||||||
|
if (!result) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
if (options.notify) {
|
||||||
|
showToast.error(t('templates.validate.invalid', { count: result.errors.length }));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.notify) {
|
||||||
|
showToast.success(t('templates.validate.valid'));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTemplate = async () => {
|
||||||
|
if (!template || isSaving || !hasChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const isValid = await handleValidate({ notify: true });
|
||||||
|
if (!isValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await window.electronAPI?.templates.update(template.id, {
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
kind,
|
||||||
|
enabled,
|
||||||
|
content: templateContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTemplate(updated);
|
||||||
|
setTitle(updated.title || '');
|
||||||
|
setSlug(updated.slug || toTemplateSlug(updated.title || ''));
|
||||||
|
setKind(updated.kind || 'post');
|
||||||
|
setEnabled(updated.enabled ?? true);
|
||||||
|
setTemplateContent(updated.content || '');
|
||||||
|
const normalizedExisting = toTemplateSlug(updated.slug || updated.title || '');
|
||||||
|
setIsSlugManuallyEdited(normalizedExisting !== toTemplateSlug(updated.title || ''));
|
||||||
|
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTemplate = async () => {
|
||||||
|
if (!template) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI?.templates.delete(template.id);
|
||||||
|
if (!result) {
|
||||||
|
showToast.error(t('sidebar.templates.deleteFailed'));
|
||||||
|
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);
|
||||||
|
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete template:', error);
|
||||||
|
showToast.error(t('sidebar.templates.deleteFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleSaveTemplate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window.addEventListener !== 'function' || typeof window.removeEventListener !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleSaveTemplate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="templates-view-shell">
|
||||||
|
<div className="editor-header templates-header">
|
||||||
|
<div className="editor-tabs">
|
||||||
|
<div className="editor-tab active">
|
||||||
|
<span className="editor-tab-title">{title || t('editor.untitled')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="editor-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="templates-save-button"
|
||||||
|
onClick={handleSaveTemplate}
|
||||||
|
disabled={!template || !hasChanges || isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? t('editor.saving') : t('templates.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="templates-validate-button"
|
||||||
|
onClick={() => {
|
||||||
|
void handleValidate({ notify: true });
|
||||||
|
}}
|
||||||
|
disabled={!template || isValidating || isSaving}
|
||||||
|
>
|
||||||
|
{isValidating ? t('templates.validate.checking') : t('templates.validate')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary danger"
|
||||||
|
onClick={handleDeleteTemplate}
|
||||||
|
disabled={!template}
|
||||||
|
title={t('templates.delete')}
|
||||||
|
>
|
||||||
|
{t('templates.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-content templates-view">
|
||||||
|
<div className="editor-header-row templates-meta-row">
|
||||||
|
<div className="editor-meta">
|
||||||
|
<div className="editor-field-row">
|
||||||
|
<div className="editor-field">
|
||||||
|
<label htmlFor="template-title">{t('editor.field.title')}</label>
|
||||||
|
<input
|
||||||
|
id="template-title"
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => handleTitleChange(event.target.value)}
|
||||||
|
disabled={!template}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="editor-field">
|
||||||
|
<label htmlFor="template-slug">{t('editor.field.slug')}</label>
|
||||||
|
<input
|
||||||
|
id="template-slug"
|
||||||
|
type="text"
|
||||||
|
value={slug}
|
||||||
|
onChange={(event) => handleSlugChange(event.target.value)}
|
||||||
|
disabled={!template}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="editor-field-row">
|
||||||
|
<div className="editor-field">
|
||||||
|
<label htmlFor="template-kind">{t('templates.field.kind')}</label>
|
||||||
|
<select
|
||||||
|
id="template-kind"
|
||||||
|
value={kind}
|
||||||
|
onChange={(event) => setKind(event.target.value as TemplateKind)}
|
||||||
|
disabled={!template}
|
||||||
|
>
|
||||||
|
<option value="post">{t('templates.kind.post')}</option>
|
||||||
|
<option value="list">{t('templates.kind.list')}</option>
|
||||||
|
<option value="not-found">{t('templates.kind.not_found')}</option>
|
||||||
|
<option value="partial">{t('templates.kind.partial')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="editor-field templates-enabled-field">
|
||||||
|
<label htmlFor="template-enabled">
|
||||||
|
<input
|
||||||
|
id="template-enabled"
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(event) => setEnabled(event.target.checked)}
|
||||||
|
disabled={!template}
|
||||||
|
/>
|
||||||
|
{t('templates.field.enabled')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-body templates-editor">
|
||||||
|
<div className="editor-toolbar templates-toolbar">
|
||||||
|
<div className="editor-toolbar-left">
|
||||||
|
<label>{t('templates.content')}</label>
|
||||||
|
</div>
|
||||||
|
<div className="editor-toolbar-center" />
|
||||||
|
<div className="editor-toolbar-right" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="templates-monaco">
|
||||||
|
<MonacoEditor
|
||||||
|
key={monacoResetToken}
|
||||||
|
height="100%"
|
||||||
|
language="html"
|
||||||
|
theme="vs-dark"
|
||||||
|
defaultValue={templateContent}
|
||||||
|
onChange={(value) => setTemplateContent(value || '')}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
wordWrap: 'on',
|
||||||
|
lineNumbers: 'on',
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
|
||||||
|
padding: { top: 12, bottom: 12 },
|
||||||
|
automaticLayout: true,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
renderLineHighlight: 'line',
|
||||||
|
formatOnPaste: true,
|
||||||
|
cursorStyle: 'line',
|
||||||
|
cursorBlinking: 'smooth',
|
||||||
|
readOnly: !template,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{template && (
|
||||||
|
<div className="editor-footer">
|
||||||
|
<span className="text-muted text-small">
|
||||||
|
{t('editor.footer.created')}:{' '}
|
||||||
|
{new Date(template.createdAt).toLocaleString(UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-small">
|
||||||
|
{t('editor.footer.updated')}:{' '}
|
||||||
|
{new Date(template.updatedAt).toLocaleString(UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"activity.media": "Medien",
|
"activity.media": "Medien",
|
||||||
"activity.scripts": "Skripte",
|
"activity.scripts": "Skripte",
|
||||||
"activity.tags": "Schlagwörter",
|
"activity.tags": "Schlagwörter",
|
||||||
|
"activity.templates": "Vorlagen",
|
||||||
"activity.aiAssistant": "KI-Assistent",
|
"activity.aiAssistant": "KI-Assistent",
|
||||||
"activity.import": "Importieren",
|
"activity.import": "Importieren",
|
||||||
"activity.sourceControl": "Versionskontrolle",
|
"activity.sourceControl": "Versionskontrolle",
|
||||||
@@ -179,6 +180,9 @@
|
|||||||
"settings.toast.rebuildScriptsLoading": "Skriptdatenbank wird neu aufgebaut...",
|
"settings.toast.rebuildScriptsLoading": "Skriptdatenbank wird neu aufgebaut...",
|
||||||
"settings.toast.rebuildScriptsSuccess": "Skriptdatenbank neu aufgebaut",
|
"settings.toast.rebuildScriptsSuccess": "Skriptdatenbank neu aufgebaut",
|
||||||
"settings.toast.rebuildScriptsFailed": "Skriptdatenbank konnte nicht neu aufgebaut werden",
|
"settings.toast.rebuildScriptsFailed": "Skriptdatenbank konnte nicht neu aufgebaut werden",
|
||||||
|
"settings.toast.rebuildTemplatesLoading": "Vorlagen-Datenbank wird neu aufgebaut...",
|
||||||
|
"settings.toast.rebuildTemplatesSuccess": "Vorlagen-Datenbank wurde neu aufgebaut",
|
||||||
|
"settings.toast.rebuildTemplatesFailed": "Fehler beim Neuaufbau der Vorlagen-Datenbank",
|
||||||
"settings.toast.rebuildLinksLoading": "Beitragslinks werden neu aufgebaut...",
|
"settings.toast.rebuildLinksLoading": "Beitragslinks werden neu aufgebaut...",
|
||||||
"settings.toast.rebuildLinksSuccess": "Beitragslinks neu aufgebaut",
|
"settings.toast.rebuildLinksSuccess": "Beitragslinks neu aufgebaut",
|
||||||
"settings.toast.rebuildLinksFailed": "Beitragslinks konnten nicht neu aufgebaut werden",
|
"settings.toast.rebuildLinksFailed": "Beitragslinks konnten nicht neu aufgebaut werden",
|
||||||
@@ -367,6 +371,7 @@
|
|||||||
"tabBar.error.fetchChatTitle": "Chat-Titel konnte nicht geladen werden:",
|
"tabBar.error.fetchChatTitle": "Chat-Titel konnte nicht geladen werden:",
|
||||||
"tabBar.error.fetchImportTitle": "Titel der Importdefinition konnte nicht geladen werden:",
|
"tabBar.error.fetchImportTitle": "Titel der Importdefinition konnte nicht geladen werden:",
|
||||||
"tabBar.error.fetchScriptTitle": "Skript-Titel konnte nicht geladen werden:",
|
"tabBar.error.fetchScriptTitle": "Skript-Titel konnte nicht geladen werden:",
|
||||||
|
"tabBar.error.fetchTemplateTitle": "Vorlagen-Titel konnte nicht geladen werden:",
|
||||||
"tabBar.error.fetchCommitTitle": "Commit-Titel konnten nicht geladen werden:",
|
"tabBar.error.fetchCommitTitle": "Commit-Titel konnten nicht geladen werden:",
|
||||||
"metadataDiff.title": "Metadaten-Diff-Werkzeug",
|
"metadataDiff.title": "Metadaten-Diff-Werkzeug",
|
||||||
"metadataDiff.description": "Vergleicht Beitragsmetadaten zwischen Datenbank und Markdown-Dateien. Behebt Abweichungen durch Bugs oder manuelle Änderungen.",
|
"metadataDiff.description": "Vergleicht Beitragsmetadaten zwischen Datenbank und Markdown-Dateien. Behebt Abweichungen durch Bugs oder manuelle Änderungen.",
|
||||||
@@ -451,6 +456,19 @@
|
|||||||
"scripts.kind.utility": "utility",
|
"scripts.kind.utility": "utility",
|
||||||
"scripts.kind.macro": "macro",
|
"scripts.kind.macro": "macro",
|
||||||
"scripts.kind.transform": "transform",
|
"scripts.kind.transform": "transform",
|
||||||
|
"templates.save": "Vorlage speichern",
|
||||||
|
"templates.delete": "Vorlage löschen",
|
||||||
|
"templates.content": "Vorlageninhalt",
|
||||||
|
"templates.field.kind": "Art",
|
||||||
|
"templates.field.enabled": "Aktiviert",
|
||||||
|
"templates.validate": "Validieren",
|
||||||
|
"templates.validate.valid": "Vorlagensyntax ist gültig",
|
||||||
|
"templates.validate.invalid": "Vorlagensyntaxfehler: {count}",
|
||||||
|
"templates.validate.checking": "Wird validiert...",
|
||||||
|
"templates.kind.post": "Beitrag",
|
||||||
|
"templates.kind.list": "Liste",
|
||||||
|
"templates.kind.not_found": "Nicht gefunden",
|
||||||
|
"templates.kind.partial": "Partial",
|
||||||
"sidebar.tagCloud": "Tag-Wolke",
|
"sidebar.tagCloud": "Tag-Wolke",
|
||||||
"sidebar.createEdit": "Erstellen & Bearbeiten",
|
"sidebar.createEdit": "Erstellen & Bearbeiten",
|
||||||
"sidebar.mergeTags": "Tags zusammenführen",
|
"sidebar.mergeTags": "Tags zusammenführen",
|
||||||
@@ -497,6 +515,8 @@
|
|||||||
"editor.field.slug": "Slug",
|
"editor.field.slug": "Slug",
|
||||||
"editor.field.categories": "Kategorien",
|
"editor.field.categories": "Kategorien",
|
||||||
"editor.field.content": "Inhalt",
|
"editor.field.content": "Inhalt",
|
||||||
|
"editor.field.template": "Vorlage",
|
||||||
|
"editor.field.templateDefault": "Standard",
|
||||||
"editor.placeholder.tags": "Tags hinzufügen...",
|
"editor.placeholder.tags": "Tags hinzufügen...",
|
||||||
"editor.placeholder.author": "Autorenname",
|
"editor.placeholder.author": "Autorenname",
|
||||||
"editor.placeholder.categories": "Kategorien hinzufügen...",
|
"editor.placeholder.categories": "Kategorien hinzufügen...",
|
||||||
@@ -587,6 +607,7 @@
|
|||||||
"tagsView.removeColor": "Farbe entfernen",
|
"tagsView.removeColor": "Farbe entfernen",
|
||||||
"tagsView.edit.title": "Tag bearbeiten: {name}",
|
"tagsView.edit.title": "Tag bearbeiten: {name}",
|
||||||
"tagsView.edit.action": "Bearbeiten",
|
"tagsView.edit.action": "Bearbeiten",
|
||||||
|
"tagsView.edit.postTemplate": "Beitragsvorlage",
|
||||||
"tagsView.deleteAction": "Löschen",
|
"tagsView.deleteAction": "Löschen",
|
||||||
"tagsView.merge.title": "Tags zusammenführen",
|
"tagsView.merge.title": "Tags zusammenführen",
|
||||||
"tagsView.merge.description": "Wähle oben mehrere Tags aus und führe sie zu einem einzigen zusammen. Alle Beiträge werden aktualisiert.",
|
"tagsView.merge.description": "Wähle oben mehrere Tags aus und führe sie zu einem einzigen zusammen. Alle Beiträge werden aktualisiert.",
|
||||||
@@ -683,6 +704,10 @@
|
|||||||
"settings.content.categoryColumn": "Kategorie",
|
"settings.content.categoryColumn": "Kategorie",
|
||||||
"settings.content.titleColumn": "Titel",
|
"settings.content.titleColumn": "Titel",
|
||||||
"settings.content.actionsColumn": "Aktionen",
|
"settings.content.actionsColumn": "Aktionen",
|
||||||
|
"settings.content.postTemplateColumn": "Beitragsvorlage",
|
||||||
|
"settings.content.listTemplateColumn": "Listenvorlage",
|
||||||
|
"settings.content.postTemplateAria": "{category} Beitragsvorlage",
|
||||||
|
"settings.content.listTemplateAria": "{category} Listenvorlage",
|
||||||
"settings.content.renderInListsAria": "{category} in Listen anzeigen",
|
"settings.content.renderInListsAria": "{category} in Listen anzeigen",
|
||||||
"settings.content.showTitlesAria": "{category} Titel anzeigen",
|
"settings.content.showTitlesAria": "{category} Titel anzeigen",
|
||||||
"settings.content.categoryTitleAria": "{category} Anzeigename",
|
"settings.content.categoryTitleAria": "{category} Anzeigename",
|
||||||
@@ -718,6 +743,9 @@
|
|||||||
"settings.data.rebuildScriptsLabel": "Skriptdatenbank neu aufbauen",
|
"settings.data.rebuildScriptsLabel": "Skriptdatenbank neu aufbauen",
|
||||||
"settings.data.rebuildScriptsDescription": "Alle Python-Skripte neu scannen und den Skript-Metadatenindex neu aufbauen.",
|
"settings.data.rebuildScriptsDescription": "Alle Python-Skripte neu scannen und den Skript-Metadatenindex neu aufbauen.",
|
||||||
"settings.data.rebuildScriptsAction": "Skripte neu aufbauen",
|
"settings.data.rebuildScriptsAction": "Skripte neu aufbauen",
|
||||||
|
"settings.data.rebuildTemplatesLabel": "Vorlagen-Datenbank neu aufbauen",
|
||||||
|
"settings.data.rebuildTemplatesDescription": "Alle Liquid-Vorlagen neu scannen und den Vorlagen-Metadaten-Index neu aufbauen.",
|
||||||
|
"settings.data.rebuildTemplatesAction": "Vorlagen neu aufbauen",
|
||||||
"settings.data.rebuildLinksLabel": "Beitragslinks neu aufbauen",
|
"settings.data.rebuildLinksLabel": "Beitragslinks neu aufbauen",
|
||||||
"settings.data.rebuildLinksDescription": "Alle Beiträge neu scannen und den internen Linkgraphen zwischen Beiträgen neu aufbauen.",
|
"settings.data.rebuildLinksDescription": "Alle Beiträge neu scannen und den internen Linkgraphen zwischen Beiträgen neu aufbauen.",
|
||||||
"settings.data.rebuildLinksAction": "Links neu aufbauen",
|
"settings.data.rebuildLinksAction": "Links neu aufbauen",
|
||||||
@@ -746,6 +774,14 @@
|
|||||||
"sidebar.scripts.createFailed": "Skript konnte nicht erstellt werden",
|
"sidebar.scripts.createFailed": "Skript konnte nicht erstellt werden",
|
||||||
"sidebar.scripts.deleteScript": "Skript löschen",
|
"sidebar.scripts.deleteScript": "Skript löschen",
|
||||||
"sidebar.scripts.deleteFailed": "Skript konnte nicht gelöscht werden",
|
"sidebar.scripts.deleteFailed": "Skript konnte nicht gelöscht werden",
|
||||||
|
"sidebar.templates.header": "VORLAGEN",
|
||||||
|
"sidebar.templates.newTemplate": "Neue Vorlage",
|
||||||
|
"sidebar.templates.none": "Noch keine Vorlagen",
|
||||||
|
"sidebar.templates.createTemplate": "Vorlage erstellen",
|
||||||
|
"sidebar.templates.createFailed": "Vorlage konnte nicht erstellt werden",
|
||||||
|
"sidebar.templates.deleteTemplate": "Vorlage löschen",
|
||||||
|
"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",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"activity.media": "Media",
|
"activity.media": "Media",
|
||||||
"activity.scripts": "Scripts",
|
"activity.scripts": "Scripts",
|
||||||
"activity.tags": "Tags",
|
"activity.tags": "Tags",
|
||||||
|
"activity.templates": "Templates",
|
||||||
"activity.aiAssistant": "AI Assistant",
|
"activity.aiAssistant": "AI Assistant",
|
||||||
"activity.import": "Import",
|
"activity.import": "Import",
|
||||||
"activity.sourceControl": "Source Control",
|
"activity.sourceControl": "Source Control",
|
||||||
@@ -179,6 +180,9 @@
|
|||||||
"settings.toast.rebuildScriptsLoading": "Rebuilding scripts database...",
|
"settings.toast.rebuildScriptsLoading": "Rebuilding scripts database...",
|
||||||
"settings.toast.rebuildScriptsSuccess": "Scripts database rebuilt",
|
"settings.toast.rebuildScriptsSuccess": "Scripts database rebuilt",
|
||||||
"settings.toast.rebuildScriptsFailed": "Failed to rebuild scripts database",
|
"settings.toast.rebuildScriptsFailed": "Failed to rebuild scripts database",
|
||||||
|
"settings.toast.rebuildTemplatesLoading": "Rebuilding templates database...",
|
||||||
|
"settings.toast.rebuildTemplatesSuccess": "Templates database rebuilt",
|
||||||
|
"settings.toast.rebuildTemplatesFailed": "Failed to rebuild templates database",
|
||||||
"settings.toast.rebuildLinksLoading": "Rebuilding post links...",
|
"settings.toast.rebuildLinksLoading": "Rebuilding post links...",
|
||||||
"settings.toast.rebuildLinksSuccess": "Post links rebuilt",
|
"settings.toast.rebuildLinksSuccess": "Post links rebuilt",
|
||||||
"settings.toast.rebuildLinksFailed": "Failed to rebuild post links",
|
"settings.toast.rebuildLinksFailed": "Failed to rebuild post links",
|
||||||
@@ -367,6 +371,7 @@
|
|||||||
"tabBar.error.fetchChatTitle": "Failed to fetch chat title:",
|
"tabBar.error.fetchChatTitle": "Failed to fetch chat title:",
|
||||||
"tabBar.error.fetchImportTitle": "Failed to fetch import definition title:",
|
"tabBar.error.fetchImportTitle": "Failed to fetch import definition title:",
|
||||||
"tabBar.error.fetchScriptTitle": "Failed to fetch script title:",
|
"tabBar.error.fetchScriptTitle": "Failed to fetch script title:",
|
||||||
|
"tabBar.error.fetchTemplateTitle": "Failed to fetch template title:",
|
||||||
"tabBar.error.fetchCommitTitle": "Failed to fetch commit titles:",
|
"tabBar.error.fetchCommitTitle": "Failed to fetch commit titles:",
|
||||||
"metadataDiff.title": "Metadata Diff Tool",
|
"metadataDiff.title": "Metadata Diff Tool",
|
||||||
"metadataDiff.description": "Compare post metadata between database and markdown files. Fix inconsistencies caused by bugs or manual edits.",
|
"metadataDiff.description": "Compare post metadata between database and markdown files. Fix inconsistencies caused by bugs or manual edits.",
|
||||||
@@ -451,6 +456,19 @@
|
|||||||
"scripts.kind.utility": "utility",
|
"scripts.kind.utility": "utility",
|
||||||
"scripts.kind.macro": "macro",
|
"scripts.kind.macro": "macro",
|
||||||
"scripts.kind.transform": "transform",
|
"scripts.kind.transform": "transform",
|
||||||
|
"templates.save": "Save Template",
|
||||||
|
"templates.delete": "Delete Template",
|
||||||
|
"templates.content": "Template Content",
|
||||||
|
"templates.field.kind": "Kind",
|
||||||
|
"templates.field.enabled": "Enabled",
|
||||||
|
"templates.validate": "Validate",
|
||||||
|
"templates.validate.valid": "Template syntax is valid",
|
||||||
|
"templates.validate.invalid": "Template syntax errors: {count}",
|
||||||
|
"templates.validate.checking": "Validating...",
|
||||||
|
"templates.kind.post": "post",
|
||||||
|
"templates.kind.list": "list",
|
||||||
|
"templates.kind.not_found": "not found",
|
||||||
|
"templates.kind.partial": "partial",
|
||||||
"sidebar.tagCloud": "Tag Cloud",
|
"sidebar.tagCloud": "Tag Cloud",
|
||||||
"sidebar.createEdit": "Create & Edit",
|
"sidebar.createEdit": "Create & Edit",
|
||||||
"sidebar.mergeTags": "Merge Tags",
|
"sidebar.mergeTags": "Merge Tags",
|
||||||
@@ -497,6 +515,8 @@
|
|||||||
"editor.field.slug": "Slug",
|
"editor.field.slug": "Slug",
|
||||||
"editor.field.categories": "Categories",
|
"editor.field.categories": "Categories",
|
||||||
"editor.field.content": "Content",
|
"editor.field.content": "Content",
|
||||||
|
"editor.field.template": "Template",
|
||||||
|
"editor.field.templateDefault": "Default",
|
||||||
"editor.placeholder.tags": "Add tags...",
|
"editor.placeholder.tags": "Add tags...",
|
||||||
"editor.placeholder.author": "Author name",
|
"editor.placeholder.author": "Author name",
|
||||||
"editor.placeholder.categories": "Add categories...",
|
"editor.placeholder.categories": "Add categories...",
|
||||||
@@ -587,6 +607,7 @@
|
|||||||
"tagsView.removeColor": "Remove color",
|
"tagsView.removeColor": "Remove color",
|
||||||
"tagsView.edit.title": "Edit Tag: {name}",
|
"tagsView.edit.title": "Edit Tag: {name}",
|
||||||
"tagsView.edit.action": "Edit",
|
"tagsView.edit.action": "Edit",
|
||||||
|
"tagsView.edit.postTemplate": "Post Template",
|
||||||
"tagsView.deleteAction": "Delete",
|
"tagsView.deleteAction": "Delete",
|
||||||
"tagsView.merge.title": "Merge Tags",
|
"tagsView.merge.title": "Merge Tags",
|
||||||
"tagsView.merge.description": "Select multiple tags above, then merge them into a single tag. All posts will be updated.",
|
"tagsView.merge.description": "Select multiple tags above, then merge them into a single tag. All posts will be updated.",
|
||||||
@@ -683,6 +704,10 @@
|
|||||||
"settings.content.categoryColumn": "Category",
|
"settings.content.categoryColumn": "Category",
|
||||||
"settings.content.titleColumn": "Title",
|
"settings.content.titleColumn": "Title",
|
||||||
"settings.content.actionsColumn": "Actions",
|
"settings.content.actionsColumn": "Actions",
|
||||||
|
"settings.content.postTemplateColumn": "Post Template",
|
||||||
|
"settings.content.listTemplateColumn": "List Template",
|
||||||
|
"settings.content.postTemplateAria": "{category} post template",
|
||||||
|
"settings.content.listTemplateAria": "{category} list template",
|
||||||
"settings.content.renderInListsAria": "{category} render in lists",
|
"settings.content.renderInListsAria": "{category} render in lists",
|
||||||
"settings.content.showTitlesAria": "{category} show titles",
|
"settings.content.showTitlesAria": "{category} show titles",
|
||||||
"settings.content.categoryTitleAria": "{category} display title",
|
"settings.content.categoryTitleAria": "{category} display title",
|
||||||
@@ -718,6 +743,9 @@
|
|||||||
"settings.data.rebuildScriptsLabel": "Rebuild Scripts Database",
|
"settings.data.rebuildScriptsLabel": "Rebuild Scripts Database",
|
||||||
"settings.data.rebuildScriptsDescription": "Re-scan all Python scripts and rebuild the scripts metadata index.",
|
"settings.data.rebuildScriptsDescription": "Re-scan all Python scripts and rebuild the scripts metadata index.",
|
||||||
"settings.data.rebuildScriptsAction": "Rebuild Scripts",
|
"settings.data.rebuildScriptsAction": "Rebuild Scripts",
|
||||||
|
"settings.data.rebuildTemplatesLabel": "Rebuild Templates Database",
|
||||||
|
"settings.data.rebuildTemplatesDescription": "Re-scan all Liquid templates and rebuild the templates metadata index.",
|
||||||
|
"settings.data.rebuildTemplatesAction": "Rebuild Templates",
|
||||||
"settings.data.rebuildLinksLabel": "Rebuild Post Links",
|
"settings.data.rebuildLinksLabel": "Rebuild Post Links",
|
||||||
"settings.data.rebuildLinksDescription": "Re-scan all posts and rebuild the internal link graph between posts.",
|
"settings.data.rebuildLinksDescription": "Re-scan all posts and rebuild the internal link graph between posts.",
|
||||||
"settings.data.rebuildLinksAction": "Rebuild Links",
|
"settings.data.rebuildLinksAction": "Rebuild Links",
|
||||||
@@ -746,6 +774,14 @@
|
|||||||
"sidebar.scripts.createFailed": "Failed to create script",
|
"sidebar.scripts.createFailed": "Failed to create script",
|
||||||
"sidebar.scripts.deleteScript": "Delete script",
|
"sidebar.scripts.deleteScript": "Delete script",
|
||||||
"sidebar.scripts.deleteFailed": "Failed to delete script",
|
"sidebar.scripts.deleteFailed": "Failed to delete script",
|
||||||
|
"sidebar.templates.header": "TEMPLATES",
|
||||||
|
"sidebar.templates.newTemplate": "New Template",
|
||||||
|
"sidebar.templates.none": "No templates yet",
|
||||||
|
"sidebar.templates.createTemplate": "Create a template",
|
||||||
|
"sidebar.templates.createFailed": "Failed to create template",
|
||||||
|
"sidebar.templates.deleteTemplate": "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",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"activity.media": "Medios",
|
"activity.media": "Medios",
|
||||||
"activity.scripts": "Scripts",
|
"activity.scripts": "Scripts",
|
||||||
"activity.tags": "Etiquetas",
|
"activity.tags": "Etiquetas",
|
||||||
|
"activity.templates": "Plantillas",
|
||||||
"activity.aiAssistant": "Asistente IA",
|
"activity.aiAssistant": "Asistente IA",
|
||||||
"activity.import": "Importar",
|
"activity.import": "Importar",
|
||||||
"activity.sourceControl": "Control de código fuente",
|
"activity.sourceControl": "Control de código fuente",
|
||||||
@@ -179,6 +180,9 @@
|
|||||||
"settings.toast.rebuildScriptsLoading": "Reconstruyendo base de datos de scripts...",
|
"settings.toast.rebuildScriptsLoading": "Reconstruyendo base de datos de scripts...",
|
||||||
"settings.toast.rebuildScriptsSuccess": "Base de datos de scripts reconstruida",
|
"settings.toast.rebuildScriptsSuccess": "Base de datos de scripts reconstruida",
|
||||||
"settings.toast.rebuildScriptsFailed": "No se pudo reconstruir la base de datos de scripts",
|
"settings.toast.rebuildScriptsFailed": "No se pudo reconstruir la base de datos de scripts",
|
||||||
|
"settings.toast.rebuildTemplatesLoading": "Reconstruyendo la base de datos de plantillas...",
|
||||||
|
"settings.toast.rebuildTemplatesSuccess": "Base de datos de plantillas reconstruida",
|
||||||
|
"settings.toast.rebuildTemplatesFailed": "Error al reconstruir la base de datos de plantillas",
|
||||||
"settings.toast.rebuildLinksLoading": "Reconstruyendo enlaces de entradas...",
|
"settings.toast.rebuildLinksLoading": "Reconstruyendo enlaces de entradas...",
|
||||||
"settings.toast.rebuildLinksSuccess": "Enlaces de publicaciones reconstruidos",
|
"settings.toast.rebuildLinksSuccess": "Enlaces de publicaciones reconstruidos",
|
||||||
"settings.toast.rebuildLinksFailed": "No se pudieron reconstruir los enlaces de entradas",
|
"settings.toast.rebuildLinksFailed": "No se pudieron reconstruir los enlaces de entradas",
|
||||||
@@ -367,6 +371,7 @@
|
|||||||
"tabBar.error.fetchChatTitle": "No se pudo cargar el título del chat:",
|
"tabBar.error.fetchChatTitle": "No se pudo cargar el título del chat:",
|
||||||
"tabBar.error.fetchImportTitle": "No se pudo cargar el título de la definición de importación:",
|
"tabBar.error.fetchImportTitle": "No se pudo cargar el título de la definición de importación:",
|
||||||
"tabBar.error.fetchScriptTitle": "No se pudo cargar el título del script:",
|
"tabBar.error.fetchScriptTitle": "No se pudo cargar el título del script:",
|
||||||
|
"tabBar.error.fetchTemplateTitle": "No se pudo cargar el título de la plantilla:",
|
||||||
"tabBar.error.fetchCommitTitle": "No se pudieron cargar los títulos de los commits:",
|
"tabBar.error.fetchCommitTitle": "No se pudieron cargar los títulos de los commits:",
|
||||||
"metadataDiff.title": "Herramienta diff de metadatos",
|
"metadataDiff.title": "Herramienta diff de metadatos",
|
||||||
"metadataDiff.description": "Compara los metadatos de las entradas entre la base de datos y los archivos Markdown. Corrige inconsistencias causadas por errores o ediciones manuales.",
|
"metadataDiff.description": "Compara los metadatos de las entradas entre la base de datos y los archivos Markdown. Corrige inconsistencias causadas por errores o ediciones manuales.",
|
||||||
@@ -451,6 +456,19 @@
|
|||||||
"scripts.kind.utility": "utility",
|
"scripts.kind.utility": "utility",
|
||||||
"scripts.kind.macro": "macro",
|
"scripts.kind.macro": "macro",
|
||||||
"scripts.kind.transform": "transform",
|
"scripts.kind.transform": "transform",
|
||||||
|
"templates.save": "Guardar plantilla",
|
||||||
|
"templates.delete": "Eliminar plantilla",
|
||||||
|
"templates.content": "Contenido de la plantilla",
|
||||||
|
"templates.field.kind": "Tipo",
|
||||||
|
"templates.field.enabled": "Habilitado",
|
||||||
|
"templates.validate": "Validar",
|
||||||
|
"templates.validate.valid": "La sintaxis de la plantilla es válida",
|
||||||
|
"templates.validate.invalid": "Errores de sintaxis de la plantilla: {count}",
|
||||||
|
"templates.validate.checking": "Validando...",
|
||||||
|
"templates.kind.post": "entrada",
|
||||||
|
"templates.kind.list": "lista",
|
||||||
|
"templates.kind.not_found": "no encontrado",
|
||||||
|
"templates.kind.partial": "parcial",
|
||||||
"sidebar.tagCloud": "Nube de etiquetas",
|
"sidebar.tagCloud": "Nube de etiquetas",
|
||||||
"sidebar.createEdit": "Crear y editar",
|
"sidebar.createEdit": "Crear y editar",
|
||||||
"sidebar.mergeTags": "Combinar etiquetas",
|
"sidebar.mergeTags": "Combinar etiquetas",
|
||||||
@@ -497,6 +515,8 @@
|
|||||||
"editor.field.slug": "Slug",
|
"editor.field.slug": "Slug",
|
||||||
"editor.field.categories": "Categorías",
|
"editor.field.categories": "Categorías",
|
||||||
"editor.field.content": "Contenido",
|
"editor.field.content": "Contenido",
|
||||||
|
"editor.field.template": "Plantilla",
|
||||||
|
"editor.field.templateDefault": "Predeterminada",
|
||||||
"editor.placeholder.tags": "Agregar etiquetas...",
|
"editor.placeholder.tags": "Agregar etiquetas...",
|
||||||
"editor.placeholder.author": "Nombre del autor",
|
"editor.placeholder.author": "Nombre del autor",
|
||||||
"editor.placeholder.categories": "Agregar categorías...",
|
"editor.placeholder.categories": "Agregar categorías...",
|
||||||
@@ -587,6 +607,7 @@
|
|||||||
"tagsView.removeColor": "Quitar color",
|
"tagsView.removeColor": "Quitar color",
|
||||||
"tagsView.edit.title": "Editar etiqueta: {name}",
|
"tagsView.edit.title": "Editar etiqueta: {name}",
|
||||||
"tagsView.edit.action": "Editar",
|
"tagsView.edit.action": "Editar",
|
||||||
|
"tagsView.edit.postTemplate": "Plantilla de entrada",
|
||||||
"tagsView.deleteAction": "Eliminar",
|
"tagsView.deleteAction": "Eliminar",
|
||||||
"tagsView.merge.title": "Combinar etiquetas",
|
"tagsView.merge.title": "Combinar etiquetas",
|
||||||
"tagsView.merge.description": "Selecciona varias etiquetas arriba y combínalas en una sola. Se actualizarán todas las entradas.",
|
"tagsView.merge.description": "Selecciona varias etiquetas arriba y combínalas en una sola. Se actualizarán todas las entradas.",
|
||||||
@@ -683,6 +704,10 @@
|
|||||||
"settings.content.categoryColumn": "Categoría",
|
"settings.content.categoryColumn": "Categoría",
|
||||||
"settings.content.titleColumn": "Título",
|
"settings.content.titleColumn": "Título",
|
||||||
"settings.content.actionsColumn": "Acciones",
|
"settings.content.actionsColumn": "Acciones",
|
||||||
|
"settings.content.postTemplateColumn": "Plantilla de entrada",
|
||||||
|
"settings.content.listTemplateColumn": "Plantilla de lista",
|
||||||
|
"settings.content.postTemplateAria": "{category} plantilla de entrada",
|
||||||
|
"settings.content.listTemplateAria": "{category} plantilla de lista",
|
||||||
"settings.content.renderInListsAria": "{category} mostrar en listas",
|
"settings.content.renderInListsAria": "{category} mostrar en listas",
|
||||||
"settings.content.showTitlesAria": "{category} mostrar títulos",
|
"settings.content.showTitlesAria": "{category} mostrar títulos",
|
||||||
"settings.content.categoryTitleAria": "Título visible para {category}",
|
"settings.content.categoryTitleAria": "Título visible para {category}",
|
||||||
@@ -718,6 +743,9 @@
|
|||||||
"settings.data.rebuildScriptsLabel": "Reconstruir base de datos de scripts",
|
"settings.data.rebuildScriptsLabel": "Reconstruir base de datos de scripts",
|
||||||
"settings.data.rebuildScriptsDescription": "Reescanea todos los scripts de Python y reconstruye el índice de metadatos de scripts.",
|
"settings.data.rebuildScriptsDescription": "Reescanea todos los scripts de Python y reconstruye el índice de metadatos de scripts.",
|
||||||
"settings.data.rebuildScriptsAction": "Reconstruir scripts",
|
"settings.data.rebuildScriptsAction": "Reconstruir scripts",
|
||||||
|
"settings.data.rebuildTemplatesLabel": "Reconstruir base de datos de plantillas",
|
||||||
|
"settings.data.rebuildTemplatesDescription": "Re-escanear todas las plantillas Liquid y reconstruir el índice de metadatos.",
|
||||||
|
"settings.data.rebuildTemplatesAction": "Reconstruir plantillas",
|
||||||
"settings.data.rebuildLinksLabel": "Reconstruir enlaces de publicaciones",
|
"settings.data.rebuildLinksLabel": "Reconstruir enlaces de publicaciones",
|
||||||
"settings.data.rebuildLinksDescription": "Reescanea todas las publicaciones y reconstruye el grafo interno de enlaces entre publicaciones.",
|
"settings.data.rebuildLinksDescription": "Reescanea todas las publicaciones y reconstruye el grafo interno de enlaces entre publicaciones.",
|
||||||
"settings.data.rebuildLinksAction": "Reconstruir enlaces",
|
"settings.data.rebuildLinksAction": "Reconstruir enlaces",
|
||||||
@@ -746,6 +774,14 @@
|
|||||||
"sidebar.scripts.createFailed": "No se pudo crear el script",
|
"sidebar.scripts.createFailed": "No se pudo crear el script",
|
||||||
"sidebar.scripts.deleteScript": "Eliminar script",
|
"sidebar.scripts.deleteScript": "Eliminar script",
|
||||||
"sidebar.scripts.deleteFailed": "No se pudo eliminar el script",
|
"sidebar.scripts.deleteFailed": "No se pudo eliminar el script",
|
||||||
|
"sidebar.templates.header": "PLANTILLAS",
|
||||||
|
"sidebar.templates.newTemplate": "Nueva plantilla",
|
||||||
|
"sidebar.templates.none": "Aún no hay plantillas",
|
||||||
|
"sidebar.templates.createTemplate": "Crear una plantilla",
|
||||||
|
"sidebar.templates.createFailed": "No se pudo crear la plantilla",
|
||||||
|
"sidebar.templates.deleteTemplate": "Eliminar 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",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"activity.media": "Médias",
|
"activity.media": "Médias",
|
||||||
"activity.scripts": "Scripts",
|
"activity.scripts": "Scripts",
|
||||||
"activity.tags": "Étiquettes",
|
"activity.tags": "Étiquettes",
|
||||||
|
"activity.templates": "Modèles",
|
||||||
"activity.aiAssistant": "Assistant IA",
|
"activity.aiAssistant": "Assistant IA",
|
||||||
"activity.import": "Importation",
|
"activity.import": "Importation",
|
||||||
"activity.sourceControl": "Contrôle de source",
|
"activity.sourceControl": "Contrôle de source",
|
||||||
@@ -177,6 +178,9 @@
|
|||||||
"settings.toast.rebuildScriptsLoading": "Reconstruction de la base des scripts...",
|
"settings.toast.rebuildScriptsLoading": "Reconstruction de la base des scripts...",
|
||||||
"settings.toast.rebuildScriptsSuccess": "Base des scripts reconstruite",
|
"settings.toast.rebuildScriptsSuccess": "Base des scripts reconstruite",
|
||||||
"settings.toast.rebuildScriptsFailed": "Impossible de reconstruire la base des scripts",
|
"settings.toast.rebuildScriptsFailed": "Impossible de reconstruire la base des scripts",
|
||||||
|
"settings.toast.rebuildTemplatesLoading": "Reconstruction de la base de données des modèles...",
|
||||||
|
"settings.toast.rebuildTemplatesSuccess": "Base de données des modèles reconstruite",
|
||||||
|
"settings.toast.rebuildTemplatesFailed": "Échec de la reconstruction de la base de données des modèles",
|
||||||
"settings.toast.rebuildLinksLoading": "Reconstruction des liens d’articles...",
|
"settings.toast.rebuildLinksLoading": "Reconstruction des liens d’articles...",
|
||||||
"settings.toast.rebuildLinksSuccess": "Liens d’articles reconstruits",
|
"settings.toast.rebuildLinksSuccess": "Liens d’articles reconstruits",
|
||||||
"settings.toast.rebuildLinksFailed": "Impossible de reconstruire les liens d’articles",
|
"settings.toast.rebuildLinksFailed": "Impossible de reconstruire les liens d’articles",
|
||||||
@@ -365,6 +369,7 @@
|
|||||||
"tabBar.error.fetchChatTitle": "Impossible de charger le titre du chat :",
|
"tabBar.error.fetchChatTitle": "Impossible de charger le titre du chat :",
|
||||||
"tabBar.error.fetchImportTitle": "Impossible de charger le titre de la définition d’import :",
|
"tabBar.error.fetchImportTitle": "Impossible de charger le titre de la définition d’import :",
|
||||||
"tabBar.error.fetchScriptTitle": "Impossible de charger le titre du script :",
|
"tabBar.error.fetchScriptTitle": "Impossible de charger le titre du script :",
|
||||||
|
"tabBar.error.fetchTemplateTitle": "Impossible de charger le titre du modèle :",
|
||||||
"tabBar.error.fetchCommitTitle": "Impossible de charger les titres des commits :",
|
"tabBar.error.fetchCommitTitle": "Impossible de charger les titres des commits :",
|
||||||
"metadataDiff.title": "Outil de diff des métadonnées",
|
"metadataDiff.title": "Outil de diff des métadonnées",
|
||||||
"metadataDiff.description": "Compare les métadonnées des articles entre la base de données et les fichiers Markdown. Corrige les incohérences causées par des bugs ou des modifications manuelles.",
|
"metadataDiff.description": "Compare les métadonnées des articles entre la base de données et les fichiers Markdown. Corrige les incohérences causées par des bugs ou des modifications manuelles.",
|
||||||
@@ -449,6 +454,19 @@
|
|||||||
"scripts.kind.utility": "utility",
|
"scripts.kind.utility": "utility",
|
||||||
"scripts.kind.macro": "macro",
|
"scripts.kind.macro": "macro",
|
||||||
"scripts.kind.transform": "transform",
|
"scripts.kind.transform": "transform",
|
||||||
|
"templates.save": "Enregistrer le modèle",
|
||||||
|
"templates.delete": "Supprimer le modèle",
|
||||||
|
"templates.content": "Contenu du modèle",
|
||||||
|
"templates.field.kind": "Type",
|
||||||
|
"templates.field.enabled": "Activé",
|
||||||
|
"templates.validate": "Valider",
|
||||||
|
"templates.validate.valid": "La syntaxe du modèle est valide",
|
||||||
|
"templates.validate.invalid": "Erreurs de syntaxe du modèle : {count}",
|
||||||
|
"templates.validate.checking": "Validation en cours...",
|
||||||
|
"templates.kind.post": "article",
|
||||||
|
"templates.kind.list": "liste",
|
||||||
|
"templates.kind.not_found": "non trouvé",
|
||||||
|
"templates.kind.partial": "partiel",
|
||||||
"sidebar.tagCloud": "Nuage d’étiquettes",
|
"sidebar.tagCloud": "Nuage d’étiquettes",
|
||||||
"sidebar.createEdit": "Créer & modifier",
|
"sidebar.createEdit": "Créer & modifier",
|
||||||
"sidebar.mergeTags": "Fusionner les étiquettes",
|
"sidebar.mergeTags": "Fusionner les étiquettes",
|
||||||
@@ -495,6 +513,8 @@
|
|||||||
"editor.field.slug": "Slug",
|
"editor.field.slug": "Slug",
|
||||||
"editor.field.categories": "Catégories",
|
"editor.field.categories": "Catégories",
|
||||||
"editor.field.content": "Contenu",
|
"editor.field.content": "Contenu",
|
||||||
|
"editor.field.template": "Modèle",
|
||||||
|
"editor.field.templateDefault": "Par défaut",
|
||||||
"editor.placeholder.tags": "Ajouter des étiquettes...",
|
"editor.placeholder.tags": "Ajouter des étiquettes...",
|
||||||
"editor.placeholder.author": "Nom de l’auteur",
|
"editor.placeholder.author": "Nom de l’auteur",
|
||||||
"editor.placeholder.categories": "Ajouter des catégories...",
|
"editor.placeholder.categories": "Ajouter des catégories...",
|
||||||
@@ -585,6 +605,7 @@
|
|||||||
"tagsView.removeColor": "Supprimer la couleur",
|
"tagsView.removeColor": "Supprimer la couleur",
|
||||||
"tagsView.edit.title": "Modifier le tag : {name}",
|
"tagsView.edit.title": "Modifier le tag : {name}",
|
||||||
"tagsView.edit.action": "Modifier",
|
"tagsView.edit.action": "Modifier",
|
||||||
|
"tagsView.edit.postTemplate": "Modèle d'article",
|
||||||
"tagsView.deleteAction": "Supprimer",
|
"tagsView.deleteAction": "Supprimer",
|
||||||
"tagsView.merge.title": "Fusionner des tags",
|
"tagsView.merge.title": "Fusionner des tags",
|
||||||
"tagsView.merge.description": "Sélectionnez plusieurs tags ci-dessus puis fusionnez-les en un seul. Tous les articles seront mis à jour.",
|
"tagsView.merge.description": "Sélectionnez plusieurs tags ci-dessus puis fusionnez-les en un seul. Tous les articles seront mis à jour.",
|
||||||
@@ -681,6 +702,10 @@
|
|||||||
"settings.content.categoryColumn": "Catégorie",
|
"settings.content.categoryColumn": "Catégorie",
|
||||||
"settings.content.titleColumn": "Titre",
|
"settings.content.titleColumn": "Titre",
|
||||||
"settings.content.actionsColumn": "Actions",
|
"settings.content.actionsColumn": "Actions",
|
||||||
|
"settings.content.postTemplateColumn": "Modèle d'article",
|
||||||
|
"settings.content.listTemplateColumn": "Modèle de liste",
|
||||||
|
"settings.content.postTemplateAria": "{category} modèle d'article",
|
||||||
|
"settings.content.listTemplateAria": "{category} modèle de liste",
|
||||||
"settings.content.renderInListsAria": "{category} afficher dans les listes",
|
"settings.content.renderInListsAria": "{category} afficher dans les listes",
|
||||||
"settings.content.showTitlesAria": "{category} afficher les titres",
|
"settings.content.showTitlesAria": "{category} afficher les titres",
|
||||||
"settings.content.categoryTitleAria": "Titre affiché pour {category}",
|
"settings.content.categoryTitleAria": "Titre affiché pour {category}",
|
||||||
@@ -716,6 +741,9 @@
|
|||||||
"settings.data.rebuildScriptsLabel": "Reconstruire la base des scripts",
|
"settings.data.rebuildScriptsLabel": "Reconstruire la base des scripts",
|
||||||
"settings.data.rebuildScriptsDescription": "Réanalyse tous les scripts Python et reconstruit l’index des métadonnées de scripts.",
|
"settings.data.rebuildScriptsDescription": "Réanalyse tous les scripts Python et reconstruit l’index des métadonnées de scripts.",
|
||||||
"settings.data.rebuildScriptsAction": "Reconstruire les scripts",
|
"settings.data.rebuildScriptsAction": "Reconstruire les scripts",
|
||||||
|
"settings.data.rebuildTemplatesLabel": "Reconstruire la base de données des modèles",
|
||||||
|
"settings.data.rebuildTemplatesDescription": "Re-scanner tous les modèles Liquid et reconstruire l'index des métadonnées.",
|
||||||
|
"settings.data.rebuildTemplatesAction": "Reconstruire les modèles",
|
||||||
"settings.data.rebuildLinksLabel": "Reconstruire les liens d’articles",
|
"settings.data.rebuildLinksLabel": "Reconstruire les liens d’articles",
|
||||||
"settings.data.rebuildLinksDescription": "Réanalyse tous les articles et reconstruit le graphe interne des liens entre articles.",
|
"settings.data.rebuildLinksDescription": "Réanalyse tous les articles et reconstruit le graphe interne des liens entre articles.",
|
||||||
"settings.data.rebuildLinksAction": "Reconstruire les liens",
|
"settings.data.rebuildLinksAction": "Reconstruire les liens",
|
||||||
@@ -744,6 +772,14 @@
|
|||||||
"sidebar.scripts.createFailed": "Impossible de créer le script",
|
"sidebar.scripts.createFailed": "Impossible de créer le script",
|
||||||
"sidebar.scripts.deleteScript": "Supprimer le script",
|
"sidebar.scripts.deleteScript": "Supprimer le script",
|
||||||
"sidebar.scripts.deleteFailed": "Impossible de supprimer le script",
|
"sidebar.scripts.deleteFailed": "Impossible de supprimer le script",
|
||||||
|
"sidebar.templates.header": "MODÈLES",
|
||||||
|
"sidebar.templates.newTemplate": "Nouveau modèle",
|
||||||
|
"sidebar.templates.none": "Aucun modèle",
|
||||||
|
"sidebar.templates.createTemplate": "Créer un modèle",
|
||||||
|
"sidebar.templates.createFailed": "Impossible de créer le modèle",
|
||||||
|
"sidebar.templates.deleteTemplate": "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",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"activity.media": "Contenuti media",
|
"activity.media": "Contenuti media",
|
||||||
"activity.scripts": "Script",
|
"activity.scripts": "Script",
|
||||||
"activity.tags": "Tag",
|
"activity.tags": "Tag",
|
||||||
|
"activity.templates": "Modelli",
|
||||||
"activity.aiAssistant": "Assistente IA",
|
"activity.aiAssistant": "Assistente IA",
|
||||||
"activity.import": "Importa",
|
"activity.import": "Importa",
|
||||||
"activity.sourceControl": "Controllo sorgente",
|
"activity.sourceControl": "Controllo sorgente",
|
||||||
@@ -177,6 +178,9 @@
|
|||||||
"settings.toast.rebuildScriptsLoading": "Ricostruzione database script...",
|
"settings.toast.rebuildScriptsLoading": "Ricostruzione database script...",
|
||||||
"settings.toast.rebuildScriptsSuccess": "Database script ricostruito",
|
"settings.toast.rebuildScriptsSuccess": "Database script ricostruito",
|
||||||
"settings.toast.rebuildScriptsFailed": "Impossibile ricostruire il database degli script",
|
"settings.toast.rebuildScriptsFailed": "Impossibile ricostruire il database degli script",
|
||||||
|
"settings.toast.rebuildTemplatesLoading": "Ricostruzione del database dei modelli...",
|
||||||
|
"settings.toast.rebuildTemplatesSuccess": "Database dei modelli ricostruito",
|
||||||
|
"settings.toast.rebuildTemplatesFailed": "Impossibile ricostruire il database dei modelli",
|
||||||
"settings.toast.rebuildLinksLoading": "Ricostruzione dei link dei post...",
|
"settings.toast.rebuildLinksLoading": "Ricostruzione dei link dei post...",
|
||||||
"settings.toast.rebuildLinksSuccess": "Link dei post ricostruiti",
|
"settings.toast.rebuildLinksSuccess": "Link dei post ricostruiti",
|
||||||
"settings.toast.rebuildLinksFailed": "Impossibile ricostruire i link dei post",
|
"settings.toast.rebuildLinksFailed": "Impossibile ricostruire i link dei post",
|
||||||
@@ -365,6 +369,7 @@
|
|||||||
"tabBar.error.fetchChatTitle": "Impossibile caricare il titolo della chat:",
|
"tabBar.error.fetchChatTitle": "Impossibile caricare il titolo della chat:",
|
||||||
"tabBar.error.fetchImportTitle": "Impossibile caricare il titolo della definizione di importazione:",
|
"tabBar.error.fetchImportTitle": "Impossibile caricare il titolo della definizione di importazione:",
|
||||||
"tabBar.error.fetchScriptTitle": "Impossibile caricare il titolo dello script:",
|
"tabBar.error.fetchScriptTitle": "Impossibile caricare il titolo dello script:",
|
||||||
|
"tabBar.error.fetchTemplateTitle": "Impossibile caricare il titolo del modello:",
|
||||||
"tabBar.error.fetchCommitTitle": "Impossibile caricare i titoli dei commit:",
|
"tabBar.error.fetchCommitTitle": "Impossibile caricare i titoli dei commit:",
|
||||||
"metadataDiff.title": "Strumento diff metadati",
|
"metadataDiff.title": "Strumento diff metadati",
|
||||||
"metadataDiff.description": "Confronta i metadati dei post tra database e file markdown. Correggi incongruenze causate da bug o modifiche manuali.",
|
"metadataDiff.description": "Confronta i metadati dei post tra database e file markdown. Correggi incongruenze causate da bug o modifiche manuali.",
|
||||||
@@ -449,6 +454,19 @@
|
|||||||
"scripts.kind.utility": "utility",
|
"scripts.kind.utility": "utility",
|
||||||
"scripts.kind.macro": "macro",
|
"scripts.kind.macro": "macro",
|
||||||
"scripts.kind.transform": "transform",
|
"scripts.kind.transform": "transform",
|
||||||
|
"templates.save": "Salva modello",
|
||||||
|
"templates.delete": "Elimina modello",
|
||||||
|
"templates.content": "Contenuto del modello",
|
||||||
|
"templates.field.kind": "Tipo",
|
||||||
|
"templates.field.enabled": "Abilitato",
|
||||||
|
"templates.validate": "Valida",
|
||||||
|
"templates.validate.valid": "La sintassi del modello è valida",
|
||||||
|
"templates.validate.invalid": "Errori di sintassi del modello: {count}",
|
||||||
|
"templates.validate.checking": "Validazione in corso...",
|
||||||
|
"templates.kind.post": "articolo",
|
||||||
|
"templates.kind.list": "elenco",
|
||||||
|
"templates.kind.not_found": "non trovato",
|
||||||
|
"templates.kind.partial": "parziale",
|
||||||
"sidebar.tagCloud": "Nuvola tag",
|
"sidebar.tagCloud": "Nuvola tag",
|
||||||
"sidebar.createEdit": "Crea e modifica",
|
"sidebar.createEdit": "Crea e modifica",
|
||||||
"sidebar.mergeTags": "Unisci tag",
|
"sidebar.mergeTags": "Unisci tag",
|
||||||
@@ -495,6 +513,8 @@
|
|||||||
"editor.field.slug": "Slug",
|
"editor.field.slug": "Slug",
|
||||||
"editor.field.categories": "Categorie",
|
"editor.field.categories": "Categorie",
|
||||||
"editor.field.content": "Contenuto",
|
"editor.field.content": "Contenuto",
|
||||||
|
"editor.field.template": "Modello",
|
||||||
|
"editor.field.templateDefault": "Predefinito",
|
||||||
"editor.placeholder.tags": "Aggiungi tag...",
|
"editor.placeholder.tags": "Aggiungi tag...",
|
||||||
"editor.placeholder.author": "Nome autore",
|
"editor.placeholder.author": "Nome autore",
|
||||||
"editor.placeholder.categories": "Aggiungi categorie...",
|
"editor.placeholder.categories": "Aggiungi categorie...",
|
||||||
@@ -585,6 +605,7 @@
|
|||||||
"tagsView.removeColor": "Rimuovi colore",
|
"tagsView.removeColor": "Rimuovi colore",
|
||||||
"tagsView.edit.title": "Modifica tag: {name}",
|
"tagsView.edit.title": "Modifica tag: {name}",
|
||||||
"tagsView.edit.action": "Modifica",
|
"tagsView.edit.action": "Modifica",
|
||||||
|
"tagsView.edit.postTemplate": "Modello articolo",
|
||||||
"tagsView.deleteAction": "Elimina",
|
"tagsView.deleteAction": "Elimina",
|
||||||
"tagsView.merge.title": "Unisci tag",
|
"tagsView.merge.title": "Unisci tag",
|
||||||
"tagsView.merge.description": "Seleziona più tag sopra, quindi uniscili in un unico tag. Tutti i post verranno aggiornati.",
|
"tagsView.merge.description": "Seleziona più tag sopra, quindi uniscili in un unico tag. Tutti i post verranno aggiornati.",
|
||||||
@@ -681,6 +702,10 @@
|
|||||||
"settings.content.categoryColumn": "Categoria",
|
"settings.content.categoryColumn": "Categoria",
|
||||||
"settings.content.titleColumn": "Titolo",
|
"settings.content.titleColumn": "Titolo",
|
||||||
"settings.content.actionsColumn": "Azioni",
|
"settings.content.actionsColumn": "Azioni",
|
||||||
|
"settings.content.postTemplateColumn": "Modello articolo",
|
||||||
|
"settings.content.listTemplateColumn": "Modello elenco",
|
||||||
|
"settings.content.postTemplateAria": "{category} modello articolo",
|
||||||
|
"settings.content.listTemplateAria": "{category} modello elenco",
|
||||||
"settings.content.renderInListsAria": "{category} mostra negli elenchi",
|
"settings.content.renderInListsAria": "{category} mostra negli elenchi",
|
||||||
"settings.content.showTitlesAria": "{category} mostra i titoli",
|
"settings.content.showTitlesAria": "{category} mostra i titoli",
|
||||||
"settings.content.categoryTitleAria": "Titolo visualizzato per {category}",
|
"settings.content.categoryTitleAria": "Titolo visualizzato per {category}",
|
||||||
@@ -716,6 +741,9 @@
|
|||||||
"settings.data.rebuildScriptsLabel": "Ricostruisci database script",
|
"settings.data.rebuildScriptsLabel": "Ricostruisci database script",
|
||||||
"settings.data.rebuildScriptsDescription": "Rianalizza tutti gli script Python e ricostruisce l’indice dei metadati degli script.",
|
"settings.data.rebuildScriptsDescription": "Rianalizza tutti gli script Python e ricostruisce l’indice dei metadati degli script.",
|
||||||
"settings.data.rebuildScriptsAction": "Ricostruisci script",
|
"settings.data.rebuildScriptsAction": "Ricostruisci script",
|
||||||
|
"settings.data.rebuildTemplatesLabel": "Ricostruisci database modelli",
|
||||||
|
"settings.data.rebuildTemplatesDescription": "Scansiona tutti i modelli Liquid e ricostruisci l'indice dei metadati.",
|
||||||
|
"settings.data.rebuildTemplatesAction": "Ricostruisci modelli",
|
||||||
"settings.data.rebuildLinksLabel": "Ricostruisci collegamenti post",
|
"settings.data.rebuildLinksLabel": "Ricostruisci collegamenti post",
|
||||||
"settings.data.rebuildLinksDescription": "Rianalizza tutti i post e ricostruisce il grafo interno dei collegamenti tra post.",
|
"settings.data.rebuildLinksDescription": "Rianalizza tutti i post e ricostruisce il grafo interno dei collegamenti tra post.",
|
||||||
"settings.data.rebuildLinksAction": "Ricostruisci collegamenti",
|
"settings.data.rebuildLinksAction": "Ricostruisci collegamenti",
|
||||||
@@ -744,6 +772,14 @@
|
|||||||
"sidebar.scripts.createFailed": "Impossibile creare lo script",
|
"sidebar.scripts.createFailed": "Impossibile creare lo script",
|
||||||
"sidebar.scripts.deleteScript": "Elimina script",
|
"sidebar.scripts.deleteScript": "Elimina script",
|
||||||
"sidebar.scripts.deleteFailed": "Impossibile eliminare lo script",
|
"sidebar.scripts.deleteFailed": "Impossibile eliminare lo script",
|
||||||
|
"sidebar.templates.header": "MODELLI",
|
||||||
|
"sidebar.templates.newTemplate": "Nuovo modello",
|
||||||
|
"sidebar.templates.none": "Nessun modello",
|
||||||
|
"sidebar.templates.createTemplate": "Crea un modello",
|
||||||
|
"sidebar.templates.createFailed": "Impossibile creare il modello",
|
||||||
|
"sidebar.templates.deleteTemplate": "Elimina 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,7 +1,7 @@
|
|||||||
import type { Tab } from '../store/appStore';
|
import type { Tab } from '../store/appStore';
|
||||||
import type { SidebarView } from './sidebarViewRegistry';
|
import type { SidebarView } from './sidebarViewRegistry';
|
||||||
|
|
||||||
export type ActivityId = 'posts' | 'pages' | 'media' | 'scripts' | 'tags' | 'chat' | 'import' | 'git' | 'settings';
|
export type ActivityId = 'posts' | 'pages' | 'media' | 'scripts' | 'templates' | 'tags' | 'chat' | 'import' | 'git' | 'settings';
|
||||||
|
|
||||||
export interface ActivitySnapshot {
|
export interface ActivitySnapshot {
|
||||||
activeView: SidebarView;
|
activeView: SidebarView;
|
||||||
@@ -50,6 +50,13 @@ const ACTIVITY_CONFIG: Record<ActivityId, ActivityConfig> = {
|
|||||||
activeStrategy: 'sidebar-owner',
|
activeStrategy: 'sidebar-owner',
|
||||||
clickStrategy: 'sidebar-toggle',
|
clickStrategy: 'sidebar-toggle',
|
||||||
},
|
},
|
||||||
|
templates: {
|
||||||
|
id: 'templates',
|
||||||
|
view: 'templates',
|
||||||
|
labelKey: 'activity.templates',
|
||||||
|
activeStrategy: 'sidebar-owner',
|
||||||
|
clickStrategy: 'sidebar-toggle',
|
||||||
|
},
|
||||||
tags: {
|
tags: {
|
||||||
id: 'tags',
|
id: 'tags',
|
||||||
view: 'tags',
|
view: 'tags',
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export type EditorRoute =
|
|||||||
| 'documentation'
|
| 'documentation'
|
||||||
| 'api-documentation'
|
| 'api-documentation'
|
||||||
| 'site-validation'
|
| 'site-validation'
|
||||||
| 'scripts';
|
| 'scripts'
|
||||||
|
| 'templates';
|
||||||
|
|
||||||
export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'dashboard'>> = {
|
export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'dashboard'>> = {
|
||||||
post: 'post',
|
post: 'post',
|
||||||
@@ -33,6 +34,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'da
|
|||||||
'api-documentation': 'api-documentation',
|
'api-documentation': 'api-documentation',
|
||||||
'site-validation': 'site-validation',
|
'site-validation': 'site-validation',
|
||||||
scripts: 'scripts',
|
scripts: 'scripts',
|
||||||
|
templates: 'templates',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface EditorRouteResolution {
|
export interface EditorRouteResolution {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const SIDEBAR_VIEW_REGISTRY = [
|
|||||||
'pages',
|
'pages',
|
||||||
'media',
|
'media',
|
||||||
'scripts',
|
'scripts',
|
||||||
|
'templates',
|
||||||
'settings',
|
'settings',
|
||||||
'tags',
|
'tags',
|
||||||
'chat',
|
'chat',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface CanonicalTabSpec {
|
|||||||
export type EntityTabType = 'post' | 'media';
|
export type EntityTabType = 'post' | 'media';
|
||||||
export type EntityTabOpenIntent = 'preview' | 'pin';
|
export type EntityTabOpenIntent = 'preview' | 'pin';
|
||||||
export type ScriptTabOpenIntent = 'preview' | 'pin';
|
export type ScriptTabOpenIntent = 'preview' | 'pin';
|
||||||
|
export type TemplateTabOpenIntent = 'preview' | 'pin';
|
||||||
export type GitDiffResourceOpenIntent = 'preview' | 'pin';
|
export type GitDiffResourceOpenIntent = 'preview' | 'pin';
|
||||||
|
|
||||||
const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec> = {
|
const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec> = {
|
||||||
@@ -112,6 +113,22 @@ export function openScriptTab(
|
|||||||
openTab(getScriptTabSpec(scriptId, intent));
|
openTab(getScriptTabSpec(scriptId, intent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTemplateTabSpec(templateId: string, intent: TemplateTabOpenIntent): CanonicalTabSpec {
|
||||||
|
return {
|
||||||
|
type: 'templates',
|
||||||
|
id: templateId,
|
||||||
|
isTransient: intent === 'preview',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openTemplateTab(
|
||||||
|
openTab: (tab: CanonicalTabSpec) => void,
|
||||||
|
templateId: string,
|
||||||
|
intent: TemplateTabOpenIntent,
|
||||||
|
): void {
|
||||||
|
openTab(getTemplateTabSpec(templateId, intent));
|
||||||
|
}
|
||||||
|
|
||||||
export function getGitDiffFileTabId(filePath: string): string {
|
export function getGitDiffFileTabId(filePath: string): string {
|
||||||
return `git-diff:${filePath}`;
|
return `git-diff:${filePath}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
const STORAGE_KEY = 'bds-app-state';
|
const STORAGE_KEY = 'bds-app-state';
|
||||||
|
|
||||||
// Tab types
|
// Tab types
|
||||||
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts';
|
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts' | 'templates';
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
type: TabType;
|
type: TabType;
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ export { unescapeMacroSyntax } from './markdownEscape';
|
|||||||
export { groupPostsByStatus, type GroupedPosts, type PostStatus } from './postGrouping';
|
export { groupPostsByStatus, type GroupedPosts, type PostStatus } from './postGrouping';
|
||||||
export { loadTabsForProject, saveTabsForProject } from './tabPersistence';
|
export { loadTabsForProject, saveTabsForProject } from './tabPersistence';
|
||||||
export { buildTagColorMap, loadTagColorMap } from './tagColors';
|
export { buildTagColorMap, loadTagColorMap } from './tagColors';
|
||||||
export { BDS_EVENT_SCRIPTS_CHANGED, addWindowEventListener, dispatchWindowEvent, type BdsWindowEventName } from './windowEvents';
|
export { BDS_EVENT_SCRIPTS_CHANGED, BDS_EVENT_TEMPLATES_CHANGED, addWindowEventListener, dispatchWindowEvent, type BdsWindowEventName } from './windowEvents';
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
export const BDS_EVENT_SCRIPTS_CHANGED = 'bds:scripts-changed' as const;
|
export const BDS_EVENT_SCRIPTS_CHANGED = 'bds:scripts-changed' as const;
|
||||||
|
export const BDS_EVENT_TEMPLATES_CHANGED = 'bds:templates-changed' as const;
|
||||||
|
|
||||||
export type BdsWindowEventName =
|
export type BdsWindowEventName =
|
||||||
| typeof BDS_EVENT_SCRIPTS_CHANGED
|
| typeof BDS_EVENT_SCRIPTS_CHANGED
|
||||||
|
| typeof BDS_EVENT_TEMPLATES_CHANGED
|
||||||
| 'bds:site-validation-updated';
|
| 'bds:site-validation-updated';
|
||||||
|
|
||||||
export function addWindowEventListener<TDetail = unknown>(
|
export function addWindowEventListener<TDetail = unknown>(
|
||||||
|
|||||||
134
tests/engine/PageRenderer.templateResolution.test.ts
Normal file
134
tests/engine/PageRenderer.templateResolution.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolvePostTemplateName, resolveListTemplateName, resolvePageRendererTemplateRoots } from '../../src/main/engine/PageRenderer';
|
||||||
|
|
||||||
|
describe('resolvePostTemplateName', () => {
|
||||||
|
it('returns default single-post when no overrides exist', () => {
|
||||||
|
const result = resolvePostTemplateName({});
|
||||||
|
expect(result).toBe('single-post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns post-level templateSlug when set', () => {
|
||||||
|
const result = resolvePostTemplateName({ templateSlug: 'photo-post' });
|
||||||
|
expect(result).toBe('photo-post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns tag-level override when post has no template and tag does', () => {
|
||||||
|
const result = resolvePostTemplateName(
|
||||||
|
{ tags: ['photography', 'travel'] },
|
||||||
|
{ photography: { postTemplateSlug: 'photo-layout' } },
|
||||||
|
);
|
||||||
|
expect(result).toBe('photo-layout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritizes post-level over tag-level', () => {
|
||||||
|
const result = resolvePostTemplateName(
|
||||||
|
{ templateSlug: 'custom-post', tags: ['photography'] },
|
||||||
|
{ photography: { postTemplateSlug: 'photo-layout' } },
|
||||||
|
);
|
||||||
|
expect(result).toBe('custom-post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns category-level override when no post or tag override', () => {
|
||||||
|
const result = resolvePostTemplateName(
|
||||||
|
{ categories: ['article'] },
|
||||||
|
undefined,
|
||||||
|
{ article: { postTemplateSlug: 'article-layout' } },
|
||||||
|
);
|
||||||
|
expect(result).toBe('article-layout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritizes tag-level over category-level', () => {
|
||||||
|
const result = resolvePostTemplateName(
|
||||||
|
{ tags: ['featured'], categories: ['article'] },
|
||||||
|
{ featured: { postTemplateSlug: 'featured-layout' } },
|
||||||
|
{ article: { postTemplateSlug: 'article-layout' } },
|
||||||
|
);
|
||||||
|
expect(result).toBe('featured-layout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips tags/categories with null postTemplateSlug', () => {
|
||||||
|
const result = resolvePostTemplateName(
|
||||||
|
{ tags: ['empty'], categories: ['article'] },
|
||||||
|
{ empty: { postTemplateSlug: null } },
|
||||||
|
{ article: { postTemplateSlug: 'article-layout' } },
|
||||||
|
);
|
||||||
|
expect(result).toBe('article-layout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default when all overrides are null/undefined', () => {
|
||||||
|
const result = resolvePostTemplateName(
|
||||||
|
{ templateSlug: null, tags: ['empty'], categories: ['plain'] },
|
||||||
|
{ empty: { postTemplateSlug: null } },
|
||||||
|
{ plain: { postTemplateSlug: undefined } },
|
||||||
|
);
|
||||||
|
expect(result).toBe('single-post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses first matching tag with a template slug', () => {
|
||||||
|
const result = resolvePostTemplateName(
|
||||||
|
{ tags: ['no-template', 'has-template', 'also-has'] },
|
||||||
|
{
|
||||||
|
'has-template': { postTemplateSlug: 'first-match' },
|
||||||
|
'also-has': { postTemplateSlug: 'second-match' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result).toBe('first-match');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveListTemplateName', () => {
|
||||||
|
it('returns default post-list when no overrides exist', () => {
|
||||||
|
const result = resolveListTemplateName();
|
||||||
|
expect(result).toBe('post-list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default when no route category', () => {
|
||||||
|
const result = resolveListTemplateName(undefined, {
|
||||||
|
article: { listTemplateSlug: 'article-list' },
|
||||||
|
});
|
||||||
|
expect(result).toBe('post-list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns category-level listTemplateSlug', () => {
|
||||||
|
const result = resolveListTemplateName('article', {
|
||||||
|
article: { listTemplateSlug: 'article-list' },
|
||||||
|
});
|
||||||
|
expect(result).toBe('article-list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default when category has no listTemplateSlug', () => {
|
||||||
|
const result = resolveListTemplateName('article', {
|
||||||
|
article: { listTemplateSlug: undefined },
|
||||||
|
});
|
||||||
|
expect(result).toBe('post-list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default when category not in settings', () => {
|
||||||
|
const result = resolveListTemplateName('unknown', {
|
||||||
|
article: { listTemplateSlug: 'article-list' },
|
||||||
|
});
|
||||||
|
expect(result).toBe('post-list');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolvePageRendererTemplateRoots with userTemplatesDir', () => {
|
||||||
|
it('adds user templates directory first when provided', () => {
|
||||||
|
const roots = resolvePageRendererTemplateRoots({
|
||||||
|
moduleDir: '/app/dist/main/engine',
|
||||||
|
cwd: '/app',
|
||||||
|
userTemplatesDir: '/data/project/templates',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(roots[0]).toBe('/data/project/templates');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add user templates when not provided', () => {
|
||||||
|
const roots = resolvePageRendererTemplateRoots({
|
||||||
|
moduleDir: '/app/dist/main/engine',
|
||||||
|
cwd: '/app',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(roots).not.toContain(undefined);
|
||||||
|
expect(roots.every(r => r.length > 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
520
tests/engine/TemplateEngine.test.ts
Normal file
520
tests/engine/TemplateEngine.test.ts
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { TemplateEngine } from '../../src/main/engine/TemplateEngine';
|
||||||
|
import { posts, tags, templates } from '../../src/main/database/schema';
|
||||||
|
|
||||||
|
const mockTemplates = new Map<string, any>();
|
||||||
|
const mockPosts = new Map<string, any>();
|
||||||
|
const mockTags = new Map<string, any>();
|
||||||
|
const mockFiles = new Map<string, string>();
|
||||||
|
|
||||||
|
function createSelectChain(dataSource: () => any[]) {
|
||||||
|
return {
|
||||||
|
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(),
|
||||||
|
orderBy: vi.fn().mockReturnThis(),
|
||||||
|
all: vi.fn().mockImplementation(() => Promise.resolve(dataSource())),
|
||||||
|
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDrizzleMock() {
|
||||||
|
return {
|
||||||
|
select: vi.fn(() => createSelectChain(() => Array.from(mockTemplates.values()))),
|
||||||
|
insert: vi.fn(() => ({
|
||||||
|
values: vi.fn((data: any) => {
|
||||||
|
mockTemplates.set(data.id, data);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
update: vi.fn((table?: any) => ({
|
||||||
|
set: vi.fn((updates: any) => ({
|
||||||
|
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()) {
|
||||||
|
mockTemplates.set(templateId, { ...existing, ...updates });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
delete: vi.fn(() => ({
|
||||||
|
where: vi.fn(async () => {
|
||||||
|
mockTemplates.clear();
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockLocalDb = createDrizzleMock();
|
||||||
|
|
||||||
|
vi.mock('../../src/main/database', () => ({
|
||||||
|
getDatabase: vi.fn(() => ({
|
||||||
|
getLocal: vi.fn(() => mockLocalDb),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('uuid', () => ({
|
||||||
|
v4: vi.fn(() => 'mock-template-id'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('fs/promises', () => ({
|
||||||
|
readdir: vi.fn(async (dirPath: string, options?: { withFileTypes?: boolean }) => {
|
||||||
|
if (options?.withFileTypes) {
|
||||||
|
const files = Array.from((globalThis as any).__mockTemplateFiles.keys()) as string[];
|
||||||
|
const names = files
|
||||||
|
.filter((filePath) => filePath.startsWith(`${dirPath}/`))
|
||||||
|
.map((filePath) => filePath.slice(dirPath.length + 1))
|
||||||
|
.filter((name) => !name.includes('/'));
|
||||||
|
|
||||||
|
return names.map((name) => ({
|
||||||
|
name,
|
||||||
|
isDirectory: () => false,
|
||||||
|
isFile: () => true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
readFile: vi.fn(async (filePath: string) => {
|
||||||
|
const value = (globalThis as any).__mockTemplateFiles.get(filePath);
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
const error = new Error('ENOENT');
|
||||||
|
(error as any).code = 'ENOENT';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}),
|
||||||
|
writeFile: vi.fn(async (filePath: string, content: string) => {
|
||||||
|
(globalThis as any).__mockTemplateFiles.set(filePath, content);
|
||||||
|
}),
|
||||||
|
unlink: vi.fn(async (filePath: string) => {
|
||||||
|
(globalThis as any).__mockTemplateFiles.delete(filePath);
|
||||||
|
}),
|
||||||
|
rename: vi.fn(async (fromPath: string, toPath: string) => {
|
||||||
|
const files = (globalThis as any).__mockTemplateFiles;
|
||||||
|
const content = files.get(fromPath);
|
||||||
|
files.delete(fromPath);
|
||||||
|
files.set(toPath, content);
|
||||||
|
}),
|
||||||
|
mkdir: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TemplateEngine', () => {
|
||||||
|
let templateEngine: TemplateEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockTemplates.clear();
|
||||||
|
mockPosts.clear();
|
||||||
|
mockTags.clear();
|
||||||
|
mockFiles.clear();
|
||||||
|
(globalThis as any).__mockTemplateFiles = mockFiles;
|
||||||
|
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain(() => Array.from(mockTemplates.values())));
|
||||||
|
|
||||||
|
templateEngine = new TemplateEngine();
|
||||||
|
templateEngine.setProjectContext('default', '/mock/userData/projects/default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates template metadata and liquid file', async () => {
|
||||||
|
const created = await templateEngine.createTemplate({
|
||||||
|
title: 'Custom Post Layout',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>\n <article>{{ post.content | markdown }}</article>\n</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.slug).toBe('custom_post_layout');
|
||||||
|
expect(mockTemplates.has(created.id)).toBe(true);
|
||||||
|
const persistedFile = mockFiles.get('/mock/userData/projects/default/templates/custom_post_layout.liquid') || '';
|
||||||
|
expect(persistedFile).toContain('---');
|
||||||
|
expect(persistedFile).toContain('title: "Custom Post Layout"');
|
||||||
|
expect(persistedFile).toContain('kind: "post"');
|
||||||
|
expect(persistedFile).toContain('<article>');
|
||||||
|
expect(created.content).toBe('<main>\n <article>{{ post.content | markdown }}</article>\n</main>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates template metadata and file content', async () => {
|
||||||
|
const created = await templateEngine.createTemplate({
|
||||||
|
title: 'Custom Post Layout',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main><article>Original</article></main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await templateEngine.updateTemplate(created.id, {
|
||||||
|
title: 'Updated Post Layout',
|
||||||
|
content: '<main><article>Updated</article></main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated?.slug).toBe('updated_post_layout');
|
||||||
|
expect(mockFiles.get('/mock/userData/projects/default/templates/updated_post_layout.liquid')).toContain('Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends underscore numeric suffix for duplicate slugs', async () => {
|
||||||
|
const first = await templateEngine.createTemplate({
|
||||||
|
title: 'Custom Post',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>First</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked((await import('uuid')).v4)
|
||||||
|
.mockReturnValueOnce('mock-template-id-2');
|
||||||
|
|
||||||
|
const second = await templateEngine.createTemplate({
|
||||||
|
title: 'Custom Post',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>Second</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.slug).toBe('custom_post');
|
||||||
|
expect(second.slug).toBe('custom_post_2');
|
||||||
|
expect(mockFiles.get('/mock/userData/projects/default/templates/custom_post_2.liquid')).toContain('Second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes template metadata and liquid file', async () => {
|
||||||
|
const created = await templateEngine.createTemplate({
|
||||||
|
title: 'Delete Me',
|
||||||
|
kind: 'partial',
|
||||||
|
content: '<footer>Footer</footer>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await templateEngine.deleteTemplate(created.id);
|
||||||
|
|
||||||
|
expect(result).toEqual({ deleted: true });
|
||||||
|
expect(mockTemplates.has(created.id)).toBe(false);
|
||||||
|
expect(mockFiles.has('/mock/userData/projects/default/templates/delete_me.liquid')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps template content clean when file contains YAML frontmatter', async () => {
|
||||||
|
const created = await templateEngine.createTemplate({
|
||||||
|
title: 'Metadata Test',
|
||||||
|
kind: 'list',
|
||||||
|
content: '<main>{{ day_blocks }}</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = await templateEngine.getTemplate(created.id);
|
||||||
|
|
||||||
|
expect(loaded?.content).toBe('<main>{{ day_blocks }}</main>');
|
||||||
|
expect(loaded?.title).toBe('Metadata Test');
|
||||||
|
expect(loaded?.kind).toBe('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds templates from filesystem and applies external file metadata', async () => {
|
||||||
|
const templatePath = '/mock/userData/projects/default/templates/external_post.liquid';
|
||||||
|
mockFiles.set(templatePath, [
|
||||||
|
'---',
|
||||||
|
'id: "external-template-id"',
|
||||||
|
'projectId: "default"',
|
||||||
|
'slug: "external_post"',
|
||||||
|
'title: "External Post Layout"',
|
||||||
|
'kind: "post"',
|
||||||
|
'enabled: false',
|
||||||
|
'version: 3',
|
||||||
|
'createdAt: "2026-02-20T10:00:00.000Z"',
|
||||||
|
'updatedAt: "2026-02-21T11:00:00.000Z"',
|
||||||
|
'---',
|
||||||
|
'<main><article>{{ post.content | markdown }}</article></main>',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
await templateEngine.rebuildDatabaseFromFiles();
|
||||||
|
|
||||||
|
const all = await templateEngine.getAllTemplates();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].id).toBe('external-template-id');
|
||||||
|
expect(all[0].slug).toBe('external_post');
|
||||||
|
expect(all[0].kind).toBe('post');
|
||||||
|
expect(all[0].enabled).toBe(false);
|
||||||
|
expect(all[0].version).toBe(3);
|
||||||
|
expect(all[0].title).toBe('External Post Layout');
|
||||||
|
expect(all[0].content).toContain('<article>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconciles git changes for templates (modify/add/delete)', async () => {
|
||||||
|
const created = await templateEngine.createTemplate({
|
||||||
|
title: 'Custom Post',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>Original</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingPath = '/repo/templates/custom_post.liquid';
|
||||||
|
mockFiles.set(existingPath, [
|
||||||
|
'---',
|
||||||
|
`id: "${created.id}"`,
|
||||||
|
'projectId: "default"',
|
||||||
|
'slug: "custom_post"',
|
||||||
|
'title: "Custom Post Updated Outside"',
|
||||||
|
'kind: "post"',
|
||||||
|
'enabled: true',
|
||||||
|
'version: 8',
|
||||||
|
'createdAt: "2026-02-20T10:00:00.000Z"',
|
||||||
|
'updatedAt: "2026-02-21T11:00:00.000Z"',
|
||||||
|
'---',
|
||||||
|
'<main>Updated Outside</main>',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const addedPath = '/repo/templates/new_list.liquid';
|
||||||
|
mockFiles.set(addedPath, [
|
||||||
|
'---',
|
||||||
|
'id: "added-template-id"',
|
||||||
|
'projectId: "default"',
|
||||||
|
'slug: "new_list"',
|
||||||
|
'title: "New List Layout"',
|
||||||
|
'kind: "list"',
|
||||||
|
'enabled: true',
|
||||||
|
'version: 1',
|
||||||
|
'createdAt: "2026-02-22T10:00:00.000Z"',
|
||||||
|
'updatedAt: "2026-02-22T11:00:00.000Z"',
|
||||||
|
'---',
|
||||||
|
'<main>{{ day_blocks }}</main>',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const result = await templateEngine.reconcileTemplatesFromGitChanges('/repo', [
|
||||||
|
{ status: 'modified', path: 'templates/custom_post.liquid' },
|
||||||
|
{ status: 'added', path: 'templates/new_list.liquid' },
|
||||||
|
{ status: 'deleted', path: 'templates/custom_post.liquid' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.updated).toBe(1);
|
||||||
|
expect(result.created).toBe(1);
|
||||||
|
expect(result.deleted).toBe(1);
|
||||||
|
expect(result.processedFiles).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('template kind queries', () => {
|
||||||
|
it('getEnabledTemplatesByKind returns only enabled templates of specified kind', async () => {
|
||||||
|
await templateEngine.createTemplate({
|
||||||
|
title: 'Post Template',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>Post</main>',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked((await import('uuid')).v4).mockReturnValueOnce('mock-template-id-2');
|
||||||
|
|
||||||
|
await templateEngine.createTemplate({
|
||||||
|
title: 'List Template',
|
||||||
|
kind: 'list',
|
||||||
|
content: '<main>List</main>',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked((await import('uuid')).v4).mockReturnValueOnce('mock-template-id-3');
|
||||||
|
|
||||||
|
await templateEngine.createTemplate({
|
||||||
|
title: 'Disabled Post',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>Disabled</main>',
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const postTemplates = await templateEngine.getEnabledTemplatesByKind('post');
|
||||||
|
expect(postTemplates).toHaveLength(1);
|
||||||
|
expect(postTemplates[0].kind).toBe('post');
|
||||||
|
expect(postTemplates[0].enabled).toBe(true);
|
||||||
|
expect(postTemplates[0].title).toBe('Post Template');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('template validation', () => {
|
||||||
|
it('validates correct liquid syntax', async () => {
|
||||||
|
const result = await templateEngine.validateTemplate('<main>{{ post.title }}</main>');
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports invalid liquid syntax', async () => {
|
||||||
|
const result = await templateEngine.validateTemplate('<main>{% if unclosed %}</main>');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('template slug normalization', () => {
|
||||||
|
it('normalizes slugs to lowercase with underscores', async () => {
|
||||||
|
const created = await templateEngine.createTemplate({
|
||||||
|
title: 'My Custom Layout!',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>test</main>',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.slug).toBe('my_custom_layout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses slug from input when provided', async () => {
|
||||||
|
const created = await templateEngine.createTemplate({
|
||||||
|
title: 'Custom Post Layout',
|
||||||
|
kind: 'post',
|
||||||
|
content: '<main>test</main>',
|
||||||
|
slug: 'my-custom-slug',
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,17 @@ const mockScriptEngine: Record<string, ReturnType<typeof vi.fn>> = {
|
|||||||
rebuildDatabaseFromFiles: vi.fn().mockResolvedValue(undefined),
|
rebuildDatabaseFromFiles: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockTemplateEngine: Record<string, ReturnType<typeof vi.fn>> = {
|
||||||
|
createTemplate: vi.fn().mockResolvedValue({ id: 't1' }),
|
||||||
|
updateTemplate: vi.fn().mockResolvedValue(null),
|
||||||
|
deleteTemplate: vi.fn().mockResolvedValue({ deleted: true }),
|
||||||
|
getTemplate: vi.fn().mockResolvedValue(null),
|
||||||
|
getAllTemplates: vi.fn().mockResolvedValue([]),
|
||||||
|
getEnabledTemplatesByKind: vi.fn().mockResolvedValue([]),
|
||||||
|
validateTemplate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||||
|
rebuildDatabaseFromFiles: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
const mockTagEngine: Record<string, ReturnType<typeof vi.fn>> = {
|
const mockTagEngine: Record<string, ReturnType<typeof vi.fn>> = {
|
||||||
getAllTags: vi.fn().mockResolvedValue([]),
|
getAllTags: vi.fn().mockResolvedValue([]),
|
||||||
getTagsWithCounts: vi.fn().mockResolvedValue([]),
|
getTagsWithCounts: vi.fn().mockResolvedValue([]),
|
||||||
@@ -145,6 +156,7 @@ describe('invokeMainProcessPythonApi', () => {
|
|||||||
ENGINE_MAP.meta = () => mockMetaEngine as Record<string, (...args: unknown[]) => unknown>;
|
ENGINE_MAP.meta = () => mockMetaEngine as Record<string, (...args: unknown[]) => unknown>;
|
||||||
ENGINE_MAP.tags = () => mockTagEngine as Record<string, (...args: unknown[]) => unknown>;
|
ENGINE_MAP.tags = () => mockTagEngine as Record<string, (...args: unknown[]) => unknown>;
|
||||||
ENGINE_MAP.scripts = () => mockScriptEngine as Record<string, (...args: unknown[]) => unknown>;
|
ENGINE_MAP.scripts = () => mockScriptEngine as Record<string, (...args: unknown[]) => unknown>;
|
||||||
|
ENGINE_MAP.templates = () => mockTemplateEngine as Record<string, (...args: unknown[]) => unknown>;
|
||||||
ENGINE_MAP.tasks = () => mockTaskManager as Record<string, (...args: unknown[]) => unknown>;
|
ENGINE_MAP.tasks = () => mockTaskManager as Record<string, (...args: unknown[]) => unknown>;
|
||||||
ENGINE_MAP.sync = () => mockGitApiAdapter as Record<string, (...args: unknown[]) => unknown>;
|
ENGINE_MAP.sync = () => mockGitApiAdapter as Record<string, (...args: unknown[]) => unknown>;
|
||||||
ENGINE_MAP.publish = () => mockPublishApiAdapter as Record<string, (...args: unknown[]) => unknown>;
|
ENGINE_MAP.publish = () => mockPublishApiAdapter as Record<string, (...args: unknown[]) => unknown>;
|
||||||
@@ -188,6 +200,42 @@ describe('invokeMainProcessPythonApi', () => {
|
|||||||
expect(mockScriptEngine.deleteScript).toHaveBeenCalledWith('s1');
|
expect(mockScriptEngine.deleteScript).toHaveBeenCalledWith('s1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('routes templates.create to TemplateEngine.createTemplate', async () => {
|
||||||
|
const data = { title: 'My Template', kind: 'post', content: '<p>hello</p>' };
|
||||||
|
await invokeMainProcessPythonApi('templates.create', { data });
|
||||||
|
expect(mockTemplateEngine.createTemplate).toHaveBeenCalledWith(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes templates.get to TemplateEngine.getTemplate', async () => {
|
||||||
|
await invokeMainProcessPythonApi('templates.get', { id: 't1' });
|
||||||
|
expect(mockTemplateEngine.getTemplate).toHaveBeenCalledWith('t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes templates.delete to TemplateEngine.deleteTemplate', async () => {
|
||||||
|
await invokeMainProcessPythonApi('templates.delete', { id: 't1' });
|
||||||
|
expect(mockTemplateEngine.deleteTemplate).toHaveBeenCalledWith('t1', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes templates.getAll to TemplateEngine.getAllTemplates', async () => {
|
||||||
|
await invokeMainProcessPythonApi('templates.getAll', {});
|
||||||
|
expect(mockTemplateEngine.getAllTemplates).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes templates.getEnabledByKind to TemplateEngine.getEnabledTemplatesByKind', async () => {
|
||||||
|
await invokeMainProcessPythonApi('templates.getEnabledByKind', { kind: 'post' });
|
||||||
|
expect(mockTemplateEngine.getEnabledTemplatesByKind).toHaveBeenCalledWith('post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes templates.validate to TemplateEngine.validateTemplate', async () => {
|
||||||
|
await invokeMainProcessPythonApi('templates.validate', { content: '<p>{{ title }}</p>' });
|
||||||
|
expect(mockTemplateEngine.validateTemplate).toHaveBeenCalledWith('<p>{{ title }}</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes templates.rebuildFromFiles to TemplateEngine.rebuildDatabaseFromFiles', async () => {
|
||||||
|
await invokeMainProcessPythonApi('templates.rebuildFromFiles', {});
|
||||||
|
expect(mockTemplateEngine.rebuildDatabaseFromFiles).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
it('routes tags.getAll to TagEngine.getAllTags', async () => {
|
it('routes tags.getAll to TagEngine.getAllTags', async () => {
|
||||||
await invokeMainProcessPythonApi('tags.getAll', {});
|
await invokeMainProcessPythonApi('tags.getAll', {});
|
||||||
expect(mockTagEngine.getAllTags).toHaveBeenCalledWith();
|
expect(mockTagEngine.getAllTags).toHaveBeenCalledWith();
|
||||||
|
|||||||
@@ -170,11 +170,28 @@ const mockScriptEngine = {
|
|||||||
reconcileScriptsFromGitChanges: vi.fn(),
|
reconcileScriptsFromGitChanges: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockTemplateEngine = {
|
||||||
|
on: vi.fn(),
|
||||||
|
createTemplate: vi.fn(),
|
||||||
|
updateTemplate: vi.fn(),
|
||||||
|
deleteTemplate: vi.fn(),
|
||||||
|
getTemplate: vi.fn(),
|
||||||
|
getAllTemplates: vi.fn(),
|
||||||
|
getEnabledTemplatesByKind: vi.fn(),
|
||||||
|
getTemplateBySlug: vi.fn(),
|
||||||
|
validateTemplate: vi.fn(),
|
||||||
|
rebuildDatabaseFromFiles: vi.fn(),
|
||||||
|
reconcileTemplatesFromGitChanges: vi.fn(),
|
||||||
|
setProjectContext: vi.fn(),
|
||||||
|
getTemplatesDirectory: vi.fn().mockReturnValue('/tmp/templates'),
|
||||||
|
};
|
||||||
|
|
||||||
const mockGitEngine = {
|
const mockGitEngine = {
|
||||||
checkAvailability: vi.fn(),
|
checkAvailability: vi.fn(),
|
||||||
getHeadCommit: vi.fn(),
|
getHeadCommit: vi.fn(),
|
||||||
getChangedPostFilesBetween: vi.fn(),
|
getChangedPostFilesBetween: vi.fn(),
|
||||||
getChangedScriptFilesBetween: vi.fn(),
|
getChangedScriptFilesBetween: vi.fn(),
|
||||||
|
getChangedTemplateFilesBetween: vi.fn(),
|
||||||
getRepoState: vi.fn(),
|
getRepoState: vi.fn(),
|
||||||
getStatus: vi.fn(),
|
getStatus: vi.fn(),
|
||||||
getDiff: vi.fn(),
|
getDiff: vi.fn(),
|
||||||
@@ -280,6 +297,10 @@ vi.mock('../../src/main/engine/ScriptEngine', () => ({
|
|||||||
getScriptEngine: vi.fn(() => mockScriptEngine),
|
getScriptEngine: vi.fn(() => mockScriptEngine),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/TemplateEngine', () => ({
|
||||||
|
getTemplateEngine: vi.fn(() => mockTemplateEngine),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../src/main/engine/GitEngine', () => ({
|
vi.mock('../../src/main/engine/GitEngine', () => ({
|
||||||
getGitEngine: vi.fn(() => mockGitEngine),
|
getGitEngine: vi.fn(() => mockGitEngine),
|
||||||
}));
|
}));
|
||||||
@@ -581,6 +602,9 @@ describe('IPC Handlers', () => {
|
|||||||
mockGitEngine.getChangedScriptFilesBetween.mockResolvedValue([
|
mockGitEngine.getChangedScriptFilesBetween.mockResolvedValue([
|
||||||
{ status: 'modified', path: 'scripts/transform.py' },
|
{ status: 'modified', path: 'scripts/transform.py' },
|
||||||
]);
|
]);
|
||||||
|
mockGitEngine.getChangedTemplateFilesBetween.mockResolvedValue([
|
||||||
|
{ status: 'added', path: 'templates/custom_post.liquid' },
|
||||||
|
]);
|
||||||
mockPostEngine.reconcilePublishedPostsFromGitChanges.mockResolvedValue({
|
mockPostEngine.reconcilePublishedPostsFromGitChanges.mockResolvedValue({
|
||||||
created: 1,
|
created: 1,
|
||||||
updated: 1,
|
updated: 1,
|
||||||
@@ -593,6 +617,12 @@ describe('IPC Handlers', () => {
|
|||||||
deleted: 0,
|
deleted: 0,
|
||||||
processedFiles: 1,
|
processedFiles: 1,
|
||||||
});
|
});
|
||||||
|
mockTemplateEngine.reconcileTemplatesFromGitChanges.mockResolvedValue({
|
||||||
|
created: 1,
|
||||||
|
updated: 0,
|
||||||
|
deleted: 0,
|
||||||
|
processedFiles: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await invokeHandler('git:pull', '/repo');
|
const result = await invokeHandler('git:pull', '/repo');
|
||||||
|
|
||||||
@@ -601,6 +631,7 @@ describe('IPC Handlers', () => {
|
|||||||
expect(mockGitEngine.getHeadCommit).toHaveBeenNthCalledWith(2, '/repo');
|
expect(mockGitEngine.getHeadCommit).toHaveBeenNthCalledWith(2, '/repo');
|
||||||
expect(mockGitEngine.getChangedPostFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head');
|
expect(mockGitEngine.getChangedPostFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head');
|
||||||
expect(mockGitEngine.getChangedScriptFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head');
|
expect(mockGitEngine.getChangedScriptFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head');
|
||||||
|
expect(mockGitEngine.getChangedTemplateFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head');
|
||||||
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).toHaveBeenCalledWith('/repo', [
|
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).toHaveBeenCalledWith('/repo', [
|
||||||
{ status: 'modified', path: 'posts/2026/02/existing.md' },
|
{ status: 'modified', path: 'posts/2026/02/existing.md' },
|
||||||
{ status: 'added', path: 'posts/2026/02/new-post.md' },
|
{ status: 'added', path: 'posts/2026/02/new-post.md' },
|
||||||
@@ -608,6 +639,9 @@ describe('IPC Handlers', () => {
|
|||||||
expect(mockScriptEngine.reconcileScriptsFromGitChanges).toHaveBeenCalledWith('/repo', [
|
expect(mockScriptEngine.reconcileScriptsFromGitChanges).toHaveBeenCalledWith('/repo', [
|
||||||
{ status: 'modified', path: 'scripts/transform.py' },
|
{ status: 'modified', path: 'scripts/transform.py' },
|
||||||
]);
|
]);
|
||||||
|
expect(mockTemplateEngine.reconcileTemplatesFromGitChanges).toHaveBeenCalledWith('/repo', [
|
||||||
|
{ status: 'added', path: 'templates/custom_post.liquid' },
|
||||||
|
]);
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -620,8 +654,10 @@ describe('IPC Handlers', () => {
|
|||||||
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
||||||
expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled();
|
expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled();
|
||||||
expect(mockGitEngine.getChangedScriptFilesBetween).not.toHaveBeenCalled();
|
expect(mockGitEngine.getChangedScriptFilesBetween).not.toHaveBeenCalled();
|
||||||
|
expect(mockGitEngine.getChangedTemplateFilesBetween).not.toHaveBeenCalled();
|
||||||
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled();
|
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled();
|
||||||
expect(mockScriptEngine.reconcileScriptsFromGitChanges).not.toHaveBeenCalled();
|
expect(mockScriptEngine.reconcileScriptsFromGitChanges).not.toHaveBeenCalled();
|
||||||
|
expect(mockTemplateEngine.reconcileTemplatesFromGitChanges).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual({ success: false, code: 'conflict' });
|
expect(result).toEqual({ success: false, code: 'conflict' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -636,8 +672,10 @@ describe('IPC Handlers', () => {
|
|||||||
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
||||||
expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled();
|
expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled();
|
||||||
expect(mockGitEngine.getChangedScriptFilesBetween).not.toHaveBeenCalled();
|
expect(mockGitEngine.getChangedScriptFilesBetween).not.toHaveBeenCalled();
|
||||||
|
expect(mockGitEngine.getChangedTemplateFilesBetween).not.toHaveBeenCalled();
|
||||||
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled();
|
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled();
|
||||||
expect(mockScriptEngine.reconcileScriptsFromGitChanges).not.toHaveBeenCalled();
|
expect(mockScriptEngine.reconcileScriptsFromGitChanges).not.toHaveBeenCalled();
|
||||||
|
expect(mockTemplateEngine.reconcileTemplatesFromGitChanges).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2764,6 +2802,160 @@ 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({ deleted: true });
|
||||||
|
|
||||||
|
const result = await invokeHandler('templates:delete', 'template-1');
|
||||||
|
|
||||||
|
expect(mockTemplateEngine.deleteTemplate).toHaveBeenCalledWith('template-1', undefined);
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 ============
|
// ============ Error Handling ============
|
||||||
describe('Error Handling', () => {
|
describe('Error Handling', () => {
|
||||||
it('should silently handle "Database is closing" errors', async () => {
|
it('should silently handle "Database is closing" errors', async () => {
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ describe('SettingsView i18n', () => {
|
|||||||
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||||
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
||||||
},
|
},
|
||||||
|
templates: {
|
||||||
|
...(window as Window & { electronAPI: any }).electronAPI?.templates,
|
||||||
|
getEnabledByKind: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ describe('SettingsView Diff Preferences', () => {
|
|||||||
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||||
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
||||||
},
|
},
|
||||||
|
templates: {
|
||||||
|
...(window as any).electronAPI?.templates,
|
||||||
|
getEnabledByKind: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
projects: {
|
projects: {
|
||||||
...(window as any).electronAPI?.projects,
|
...(window as any).electronAPI?.projects,
|
||||||
update: updateProjectMock,
|
update: updateProjectMock,
|
||||||
@@ -178,6 +182,107 @@ describe('SettingsView Diff Preferences', () => {
|
|||||||
expect(rebuildScriptsMock).toHaveBeenCalledTimes(1);
|
expect(rebuildScriptsMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('triggers templates rebuild from data maintenance section', async () => {
|
||||||
|
const rebuildTemplatesMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
(window as any).electronAPI = {
|
||||||
|
...(window as any).electronAPI,
|
||||||
|
templates: {
|
||||||
|
...(window as any).electronAPI?.templates,
|
||||||
|
getEnabledByKind: vi.fn().mockResolvedValue([]),
|
||||||
|
rebuildFromFiles: rebuildTemplatesMock,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<SettingsView />);
|
||||||
|
|
||||||
|
const rebuildTemplatesButton = await screen.findByRole('button', { name: /rebuild templates/i });
|
||||||
|
fireEvent.click(rebuildTemplatesButton);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(rebuildTemplatesMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders category template dropdowns populated with enabled templates', async () => {
|
||||||
|
(window as any).electronAPI = {
|
||||||
|
...(window as any).electronAPI,
|
||||||
|
templates: {
|
||||||
|
...(window as any).electronAPI?.templates,
|
||||||
|
getEnabledByKind: vi.fn().mockImplementation((kind: string) => {
|
||||||
|
if (kind === 'post') {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ slug: 'custom_post', title: 'Custom Post' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (kind === 'list') {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ slug: 'custom_list', title: 'Custom List' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<SettingsView />);
|
||||||
|
|
||||||
|
const postTemplateSelect = await screen.findByLabelText(/article post template/i);
|
||||||
|
expect(postTemplateSelect).toBeInTheDocument();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const options = postTemplateSelect.querySelectorAll('option');
|
||||||
|
const optionTexts = Array.from(options).map((o) => o.textContent);
|
||||||
|
expect(optionTexts).toContain('Custom Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
const listTemplateSelect = screen.getByLabelText(/article list template/i);
|
||||||
|
expect(listTemplateSelect).toBeInTheDocument();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const options = listTemplateSelect.querySelectorAll('option');
|
||||||
|
const optionTexts = Array.from(options).map((o) => o.textContent);
|
||||||
|
expect(optionTexts).toContain('Custom List');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists category template selection via project metadata update', async () => {
|
||||||
|
(window as any).electronAPI = {
|
||||||
|
...(window as any).electronAPI,
|
||||||
|
templates: {
|
||||||
|
...(window as any).electronAPI?.templates,
|
||||||
|
getEnabledByKind: vi.fn().mockImplementation((kind: string) => {
|
||||||
|
if (kind === 'post') {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ slug: 'custom_post', title: 'Custom Post' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<SettingsView />);
|
||||||
|
|
||||||
|
const postTemplateSelect = await screen.findByLabelText(/article post template/i);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const options = postTemplateSelect.querySelectorAll('option');
|
||||||
|
expect(Array.from(options).map((o) => o.textContent)).toContain('Custom Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(postTemplateSelect, { target: { value: 'custom_post' } });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
categoryMetadata: expect.objectContaining({
|
||||||
|
article: expect.objectContaining({ postTemplateSlug: 'custom_post' }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('persists category settings changes via project metadata update', async () => {
|
it('persists category settings changes via project metadata update', async () => {
|
||||||
render(<SettingsView />);
|
render(<SettingsView />);
|
||||||
|
|
||||||
|
|||||||
281
tests/renderer/components/SidebarTemplates.test.tsx
Normal file
281
tests/renderer/components/SidebarTemplates.test.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { act, render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { Sidebar } from '../../../src/renderer/components/Sidebar/Sidebar';
|
||||||
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
|
describe('Sidebar templates list behavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const listeners = new Map<string, Set<(event: Event) => void>>();
|
||||||
|
(window as any).addEventListener = vi.fn((type: string, listener: (event: Event) => void) => {
|
||||||
|
if (!listeners.has(type)) {
|
||||||
|
listeners.set(type, new Set());
|
||||||
|
}
|
||||||
|
listeners.get(type)?.add(listener);
|
||||||
|
});
|
||||||
|
(window as any).removeEventListener = vi.fn((type: string, listener: (event: Event) => void) => {
|
||||||
|
listeners.get(type)?.delete(listener);
|
||||||
|
});
|
||||||
|
(window as any).dispatchEvent = vi.fn((event: Event) => {
|
||||||
|
listeners.get(event.type)?.forEach((listener) => listener(event));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).electronAPI = {
|
||||||
|
...(window as any).electronAPI,
|
||||||
|
templates: {
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn().mockResolvedValue({ deleted: true }),
|
||||||
|
get: vi.fn(),
|
||||||
|
getAll: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'template-1',
|
||||||
|
projectId: 'default',
|
||||||
|
slug: 'custom_post',
|
||||||
|
title: 'Custom Post',
|
||||||
|
kind: 'post',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
getEnabledByKind: vi.fn().mockResolvedValue([]),
|
||||||
|
validate: vi.fn(),
|
||||||
|
rebuildFromFiles: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useAppStore.setState({
|
||||||
|
activeView: 'templates',
|
||||||
|
sidebarVisible: true,
|
||||||
|
tabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a transient template tab on single click', async () => {
|
||||||
|
const { container } = render(<Sidebar />);
|
||||||
|
|
||||||
|
const templateRow = await screen.findByRole('button', { name: 'Custom Post' });
|
||||||
|
expect(templateRow).toHaveClass('chat-list-item');
|
||||||
|
expect(container.querySelector('.chat-item-date')).not.toBeNull();
|
||||||
|
fireEvent.click(templateRow);
|
||||||
|
|
||||||
|
expect(useAppStore.getState().tabs).toEqual([
|
||||||
|
{
|
||||||
|
type: 'templates',
|
||||||
|
id: 'template-1',
|
||||||
|
isTransient: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(useAppStore.getState().activeTabId).toBe('template-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders templates section title and create button', async () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
expect(screen.getByText('TEMPLATES')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('button', { name: 'New Template' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state while templates are being fetched', () => {
|
||||||
|
(window as any).electronAPI.templates.getAll = vi.fn().mockImplementation(
|
||||||
|
() => new Promise(() => {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state with create action when no templates exist', async () => {
|
||||||
|
(window as any).electronAPI.templates.getAll = vi.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('No templates yet')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Create a template' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new template from the create button and opens it pinned', async () => {
|
||||||
|
const createMock = vi.fn().mockResolvedValue({
|
||||||
|
id: 'template-new',
|
||||||
|
projectId: 'default',
|
||||||
|
slug: 'new_template',
|
||||||
|
title: 'New Template',
|
||||||
|
kind: 'post',
|
||||||
|
enabled: true,
|
||||||
|
version: 1,
|
||||||
|
filePath: '/tmp/new_template.liquid',
|
||||||
|
content: '',
|
||||||
|
createdAt: '2026-02-22T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).electronAPI.templates.create = createMock;
|
||||||
|
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'New Template' }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'New Template',
|
||||||
|
kind: 'post',
|
||||||
|
content: '',
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(useAppStore.getState().tabs).toEqual([
|
||||||
|
{
|
||||||
|
type: 'templates',
|
||||||
|
id: 'template-new',
|
||||||
|
isTransient: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(useAppStore.getState().activeTabId).toBe('template-new');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a pinned template tab on double click', async () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
const templateRow = await screen.findByRole('button', { name: 'Custom Post' });
|
||||||
|
fireEvent.doubleClick(templateRow);
|
||||||
|
|
||||||
|
expect(useAppStore.getState().tabs).toEqual([
|
||||||
|
{
|
||||||
|
type: 'templates',
|
||||||
|
id: 'template-1',
|
||||||
|
isTransient: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(useAppStore.getState().activeTabId).toBe('template-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a template from sidebar action', async () => {
|
||||||
|
const deleteMock = vi.fn().mockResolvedValue({ deleted: true });
|
||||||
|
(window as any).electronAPI.templates.delete = deleteMock;
|
||||||
|
|
||||||
|
useAppStore.setState({
|
||||||
|
tabs: [{ type: 'templates', id: 'template-1', isTransient: false }],
|
||||||
|
activeTabId: 'template-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
const deleteButton = await screen.findByTitle('Delete template');
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(deleteMock).toHaveBeenCalledWith('template-1');
|
||||||
|
expect(useAppStore.getState().tabs).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes templates list when templates-changed event is emitted', async () => {
|
||||||
|
const getAllMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 'template-1',
|
||||||
|
projectId: 'default',
|
||||||
|
slug: 'custom_post',
|
||||||
|
title: 'Custom Post',
|
||||||
|
kind: 'post',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 'template-1',
|
||||||
|
projectId: 'default',
|
||||||
|
slug: 'renamed_template',
|
||||||
|
title: 'Renamed Template',
|
||||||
|
kind: 'post',
|
||||||
|
enabled: true,
|
||||||
|
version: 2,
|
||||||
|
filePath: '/tmp/custom_post.liquid',
|
||||||
|
content: '<main>{{ post.title }}</main>',
|
||||||
|
createdAt: '2026-02-22T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-02-22T00:01:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
(window as any).electronAPI.templates.getAll = getAllMock;
|
||||||
|
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
await screen.findByRole('button', { name: 'Custom Post' });
|
||||||
|
window.dispatchEvent(new CustomEvent('bds:templates-changed'));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: 'Renamed Template' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads templates when active project context becomes available after mount', async () => {
|
||||||
|
const getAllMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 'template-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
slug: 'custom_post',
|
||||||
|
title: 'Custom Post',
|
||||||
|
kind: 'post',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
(window as any).electronAPI.templates.getAll = getAllMock;
|
||||||
|
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: null,
|
||||||
|
activeView: 'templates',
|
||||||
|
sidebarVisible: true,
|
||||||
|
tabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('No templates yet')).toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: {
|
||||||
|
id: 'project-1',
|
||||||
|
name: 'Project 1',
|
||||||
|
slug: 'project-1',
|
||||||
|
dataPath: '/tmp/project-1',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2026-02-22T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: 'Custom Post' })).toBeInTheDocument();
|
||||||
|
expect(getAllMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -73,6 +73,10 @@ describe('TabBar', () => {
|
|||||||
...(window as any).electronAPI?.scripts,
|
...(window as any).electronAPI?.scripts,
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
},
|
},
|
||||||
|
templates: {
|
||||||
|
...(window as any).electronAPI?.templates,
|
||||||
|
get: vi.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,4 +164,65 @@ describe('TabBar', () => {
|
|||||||
expect(await screen.findByText('Publish Macro')).toBeInTheDocument();
|
expect(await screen.findByText('Publish Macro')).toBeInTheDocument();
|
||||||
expect((window as any).electronAPI.scripts.get).toHaveBeenCalledWith('script-1');
|
expect((window as any).electronAPI.scripts.get).toHaveBeenCalledWith('script-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders template title for template tab', async () => {
|
||||||
|
useAppStore.setState({
|
||||||
|
tabs: [{ type: 'templates', id: 'template-1', isTransient: false }],
|
||||||
|
activeTabId: 'template-1',
|
||||||
|
posts: [],
|
||||||
|
media: [],
|
||||||
|
dirtyPosts: new Set<string>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).electronAPI.templates.get = vi.fn().mockResolvedValue({
|
||||||
|
id: 'template-1',
|
||||||
|
title: 'Blog Post Layout',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<TabBar />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Blog Post Layout')).toBeInTheDocument();
|
||||||
|
expect((window as any).electronAPI.templates.get).toHaveBeenCalledWith('template-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates template tab title when template changes', async () => {
|
||||||
|
useAppStore.setState({
|
||||||
|
tabs: [{ type: 'templates', id: 'template-1', isTransient: false }],
|
||||||
|
activeTabId: 'template-1',
|
||||||
|
posts: [],
|
||||||
|
media: [],
|
||||||
|
dirtyPosts: new Set<string>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).electronAPI.templates.get = vi.fn().mockResolvedValue({
|
||||||
|
id: 'template-1',
|
||||||
|
title: 'Old Title',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capture the bds:templates-changed listener
|
||||||
|
let templatesChangedHandler: (() => void) | null = null;
|
||||||
|
(window as any).addEventListener = vi.fn((event: string, handler: () => void) => {
|
||||||
|
if (event === 'bds:templates-changed') {
|
||||||
|
templatesChangedHandler = handler;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(window as any).removeEventListener = vi.fn();
|
||||||
|
|
||||||
|
render(<TabBar />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Old Title')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Now simulate the template being updated
|
||||||
|
(window as any).electronAPI.templates.get = vi.fn().mockResolvedValue({
|
||||||
|
id: 'template-1',
|
||||||
|
title: 'New Title',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger the templates-changed event
|
||||||
|
await act(async () => {
|
||||||
|
templatesChangedHandler?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText('New Title')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { render, act } from '@testing-library/react';
|
import { render, act, screen, fireEvent } from '@testing-library/react';
|
||||||
import { TagsView } from '../../../src/renderer/components/TagsView/TagsView';
|
import { TagsView } from '../../../src/renderer/components/TagsView/TagsView';
|
||||||
|
|
||||||
describe('TagsView subscriptions', () => {
|
describe('TagsView subscriptions', () => {
|
||||||
@@ -19,6 +19,9 @@ describe('TagsView subscriptions', () => {
|
|||||||
merge: vi.fn(),
|
merge: vi.fn(),
|
||||||
syncFromPosts: vi.fn(),
|
syncFromPosts: vi.fn(),
|
||||||
},
|
},
|
||||||
|
templates: {
|
||||||
|
getEnabledByKind: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
on: onMock,
|
on: onMock,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -58,3 +61,98 @@ describe('TagsView subscriptions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TagsView template dropdown', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const onMock = vi.fn((_channel: string, _callback: (...args: unknown[]) => void) => vi.fn());
|
||||||
|
|
||||||
|
(window as any).electronAPI = {
|
||||||
|
...(window as any).electronAPI,
|
||||||
|
tags: {
|
||||||
|
getWithCounts: vi.fn().mockResolvedValue([
|
||||||
|
{ name: 'javascript', count: 5 },
|
||||||
|
]),
|
||||||
|
getAll: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'tag-1', name: 'javascript', color: null, postTemplateSlug: null },
|
||||||
|
]),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
delete: vi.fn(),
|
||||||
|
rename: vi.fn(),
|
||||||
|
merge: vi.fn(),
|
||||||
|
syncFromPosts: vi.fn(),
|
||||||
|
},
|
||||||
|
templates: {
|
||||||
|
getEnabledByKind: vi.fn().mockResolvedValue([
|
||||||
|
{ slug: 'custom_post', title: 'Custom Post' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
on: onMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads post templates and shows them in tag edit dropdown', async () => {
|
||||||
|
render(<TagsView />);
|
||||||
|
|
||||||
|
// Wait for tags to load and click the tag to select it
|
||||||
|
const tagButton = await screen.findByText('javascript');
|
||||||
|
fireEvent.click(tagButton);
|
||||||
|
|
||||||
|
// Click the edit button to enter edit mode
|
||||||
|
const editButton = await screen.findByRole('button', { name: /edit/i });
|
||||||
|
fireEvent.click(editButton);
|
||||||
|
|
||||||
|
// Verify template dropdown appears with the loaded template
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const label = screen.getByText('Post Template');
|
||||||
|
const fieldDiv = label.closest('.tagsview-field')!;
|
||||||
|
const select = fieldDiv.querySelector('select')!;
|
||||||
|
const options = Array.from(select.querySelectorAll('option'));
|
||||||
|
const optionTexts = options.map((o) => o.textContent);
|
||||||
|
expect(optionTexts).toContain('Custom Post');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves tag template selection via update IPC call', async () => {
|
||||||
|
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
(window as any).electronAPI.tags.update = updateMock;
|
||||||
|
|
||||||
|
render(<TagsView />);
|
||||||
|
|
||||||
|
// Select the tag
|
||||||
|
const tagButton = await screen.findByText('javascript');
|
||||||
|
fireEvent.click(tagButton);
|
||||||
|
|
||||||
|
// Enter edit mode
|
||||||
|
const editButton = await screen.findByRole('button', { name: /edit/i });
|
||||||
|
fireEvent.click(editButton);
|
||||||
|
|
||||||
|
// Wait for template dropdown to populate
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const label = screen.getByText('Post Template');
|
||||||
|
const fieldDiv = label.closest('.tagsview-field')!;
|
||||||
|
const select = fieldDiv.querySelector('select')!;
|
||||||
|
const options = Array.from(select.querySelectorAll('option'));
|
||||||
|
expect(options.map((o) => o.textContent)).toContain('Custom Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select a template
|
||||||
|
const label = screen.getByText('Post Template');
|
||||||
|
const fieldDiv = label.closest('.tagsview-field')!;
|
||||||
|
const templateSelect = fieldDiv.querySelector('select')!;
|
||||||
|
fireEvent.change(templateSelect, { target: { value: 'custom_post' } });
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(updateMock).toHaveBeenCalledWith(
|
||||||
|
'tag-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
postTemplateSlug: 'custom_post',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
23
tests/renderer/components/TemplatesView.styles.test.ts
Normal file
23
tests/renderer/components/TemplatesView.styles.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
211
tests/renderer/components/TemplatesView.test.tsx
Normal file
211
tests/renderer/components/TemplatesView.test.tsx
Normal 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().mockResolvedValue({ deleted: true }),
|
||||||
|
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({ deleted: 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ describe('editorRouting', () => {
|
|||||||
'api-documentation': 'api-documentation',
|
'api-documentation': 'api-documentation',
|
||||||
'site-validation': 'site-validation',
|
'site-validation': 'site-validation',
|
||||||
scripts: 'scripts',
|
scripts: 'scripts',
|
||||||
|
templates: 'templates',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ describe('sidebarViewRegistry', () => {
|
|||||||
'pages',
|
'pages',
|
||||||
'media',
|
'media',
|
||||||
'scripts',
|
'scripts',
|
||||||
|
'templates',
|
||||||
'settings',
|
'settings',
|
||||||
'tags',
|
'tags',
|
||||||
'chat',
|
'chat',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getGitDiffFileTabSpec,
|
getGitDiffFileTabSpec,
|
||||||
getImportTabSpec,
|
getImportTabSpec,
|
||||||
getScriptTabSpec,
|
getScriptTabSpec,
|
||||||
|
getTemplateTabSpec,
|
||||||
parseGitDiffTabId,
|
parseGitDiffTabId,
|
||||||
openChatTab,
|
openChatTab,
|
||||||
getSingletonToolTabSpec,
|
getSingletonToolTabSpec,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
openGitDiffFileTab,
|
openGitDiffFileTab,
|
||||||
openImportTab,
|
openImportTab,
|
||||||
openScriptTab,
|
openScriptTab,
|
||||||
|
openTemplateTab,
|
||||||
openSingletonToolTab,
|
openSingletonToolTab,
|
||||||
} from '../../../src/renderer/navigation/tabPolicy';
|
} from '../../../src/renderer/navigation/tabPolicy';
|
||||||
|
|
||||||
@@ -163,4 +165,33 @@ describe('tabPolicy', () => {
|
|||||||
{ type: 'git-diff', id: 'git-diff:commit:def456', isTransient: false },
|
{ type: 'git-diff', id: 'git-diff:commit:def456', isTransient: false },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('provides canonical template tab spec for preview and pin intents', () => {
|
||||||
|
expect(getTemplateTabSpec('template-1', 'preview')).toEqual({
|
||||||
|
type: 'templates',
|
||||||
|
id: 'template-1',
|
||||||
|
isTransient: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getTemplateTabSpec('template-1', 'pin')).toEqual({
|
||||||
|
type: 'templates',
|
||||||
|
id: 'template-1',
|
||||||
|
isTransient: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens template tabs from shared policy', () => {
|
||||||
|
const opened: Array<{ type: string; id: string; isTransient: boolean }> = [];
|
||||||
|
const openTab = (tab: { type: string; id: string; isTransient: boolean }) => {
|
||||||
|
opened.push(tab);
|
||||||
|
};
|
||||||
|
|
||||||
|
openTemplateTab(openTab, 'template-preview', 'preview');
|
||||||
|
openTemplateTab(openTab, 'template-pin', 'pin');
|
||||||
|
|
||||||
|
expect(opened).toEqual([
|
||||||
|
{ type: 'templates', id: 'template-preview', isTransient: true },
|
||||||
|
{ type: 'templates', id: 'template-pin', isTransient: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.7.0',
|
version: '1.9.0',
|
||||||
generatedAt: expect.any(String),
|
generatedAt: expect.any(String),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -146,6 +146,16 @@ Object.defineProperty(globalThis, 'window', {
|
|||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
},
|
},
|
||||||
|
templates: {
|
||||||
|
getEnabledByKind: vi.fn().mockResolvedValue([]),
|
||||||
|
getAll: vi.fn().mockResolvedValue([]),
|
||||||
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
create: vi.fn().mockResolvedValue(null),
|
||||||
|
update: vi.fn().mockResolvedValue(null),
|
||||||
|
delete: vi.fn().mockResolvedValue({ deleted: true }),
|
||||||
|
validate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||||
|
rebuildFromFiles: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
on: vi.fn(() => () => {}),
|
on: vi.fn(() => () => {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user