diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7649bd8..b16e5d3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,9 @@ "WebFetch(domain:www.copilotkit.ai)", "Bash(grep -l \"A2UIRenderer\\\\|useA2UISurface\\\\|a2ui-surface\" /Users/gb/Projects/bDS/src/renderer/components/**/*.tsx)", "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)" ] } } diff --git a/TODO.md b/TODO.md index 9abc474..9e6fa4c 100644 --- a/TODO.md +++ b/TODO.md @@ -6,7 +6,7 @@ independently. --- -## 1. Template Editor & Per-Entity Template Selection +## ~~1. Template Editor & Per-Entity Template Selection~~ ✅ Done ### Goal diff --git a/drizzle/0006_yummy_scorpion.sql b/drizzle/0006_yummy_scorpion.sql new file mode 100644 index 0000000..fcf1a28 --- /dev/null +++ b/drizzle/0006_yummy_scorpion.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..f97dad6 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1019 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "800cf66e-5f65-460f-98a8-b9451c078106", + "prevId": "b157a762-0743-4499-a635-16ac3fb5ee18", + "tables": { + "chat_conversations": { + "name": "chat_conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "copilot_session_id": { + "name": "copilot_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "generated_file_hashes": { + "name": "generated_file_hashes", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "relative_path": { + "name": "relative_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "generated_file_hashes_project_path_idx": { + "name": "generated_file_hashes_project_path_idx", + "columns": [ + "project_id", + "relative_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "import_definitions": { + "name": "import_definitions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wxr_file_path": { + "name": "wxr_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploads_folder_path": { + "name": "uploads_folder_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_analysis_result": { + "name": "last_analysis_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sidecar_path": { + "name": "sidecar_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "post_links": { + "name": "post_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_post_id": { + "name": "source_post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_post_id": { + "name": "target_post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "link_text": { + "name": "link_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "post_media": { + "name": "post_media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "post_media_post_media_idx": { + "name": "post_media_post_media_idx", + "columns": [ + "post_id", + "media_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "posts": { + "name": "posts", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "categories": { + "name": "categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_slug": { + "name": "template_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_title": { + "name": "published_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_content": { + "name": "published_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_tags": { + "name": "published_tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_categories": { + "name": "published_categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_excerpt": { + "name": "published_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "posts_project_slug_idx": { + "name": "posts_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data_path": { + "name": "data_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scripts": { + "name": "scripts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'utility'" + }, + "entrypoint": { + "name": "entrypoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'render'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "scripts_project_slug_idx": { + "name": "scripts_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "post_template_slug": { + "name": "post_template_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tags_project_name_idx": { + "name": "tags_project_name_idx", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "templates": { + "name": "templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'post'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "templates_project_slug_idx": { + "name": "templates_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5c1762b..d3b63be 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1771792324840, "tag": "0005_short_sally_floyd", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1772213213016, + "tag": "0006_yummy_scorpion", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index be695c1..b10fcb1 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -33,6 +33,7 @@ export const posts = sqliteTable('posts', { checksum: text('checksum'), tags: text('tags'), // 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) publishedTitle: text('published_title'), publishedContent: text('published_content'), @@ -111,6 +112,7 @@ export const tags = sqliteTable('tags', { projectId: text('project_id').notNull(), name: text('name').notNull(), 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(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (table) => ({ @@ -169,6 +171,23 @@ export const scripts = sqliteTable('scripts', { 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 export type Project = typeof projects.$inferSelect; export type NewProject = typeof projects.$inferInsert; @@ -194,3 +213,5 @@ export type ImportDefinition = typeof importDefinitions.$inferSelect; export type NewImportDefinition = typeof importDefinitions.$inferInsert; export type Script = typeof scripts.$inferSelect; export type NewScript = typeof scripts.$inferInsert; +export type Template = typeof templates.$inferSelect; +export type NewTemplate = typeof templates.$inferInsert; diff --git a/src/main/engine/GenerationRouteRendererFactory.ts b/src/main/engine/GenerationRouteRendererFactory.ts index 0a561ff..ea11edc 100644 --- a/src/main/engine/GenerationRouteRendererFactory.ts +++ b/src/main/engine/GenerationRouteRendererFactory.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import type { CategoryRenderSettings } from './PageRenderer'; import { buildCanonicalPostPath } from './PageRenderer'; import type { MenuDocument } from './MenuEngine'; @@ -210,6 +211,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: { getMenu: async () => menu, }, getActiveProjectContext: async () => projectContext, + userTemplatesDir: path.join(params.options.dataDir, 'templates'), }); const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map; canonicalMediaPathBySourcePath: Map }> = (async () => { diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index c6e22ba..c8a771c 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -3,6 +3,7 @@ import * as fsPromises from 'fs/promises'; import * as path from 'path'; import { execFile } from 'node:child_process'; import type { GitScriptFileChange, GitScriptFileChangeStatus } from './ScriptEngine'; +import type { GitTemplateFileChange, GitTemplateFileChangeStatus } from './TemplateEngine'; export interface GitAvailability { gitFound: boolean; @@ -142,6 +143,7 @@ export interface GitPostFileChange { } export type { GitScriptFileChange, GitScriptFileChangeStatus }; +export type { GitTemplateFileChange, GitTemplateFileChangeStatus }; type GitProvider = 'unknown' | 'github' | 'gitlab' | 'gitea-forgejo'; @@ -534,6 +536,11 @@ export class GitEngine { 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[] { const tokens = raw.split('\0').filter((token) => token.length > 0); const changes: GitPostFileChange[] = []; @@ -1388,6 +1395,33 @@ export class GitEngine { } } + async getChangedTemplateFilesBetween(projectPath: string, fromCommit: string, toCommit: string): Promise { + 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 { const git = this.createNonInteractiveGit(projectPath); try { diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index 25763e1..cf4b12a 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -33,6 +33,8 @@ export interface ProjectMetadata { export interface CategoryRenderSettings { renderInLists: boolean; showTitle: boolean; + postTemplateSlug?: string; + listTemplateSlug?: string; } /** @@ -167,6 +169,8 @@ function normalizeCategoryMetadata(value: unknown): Record [ category, - { renderInLists: data.renderInLists, showTitle: data.showTitle }, + { + renderInLists: data.renderInLists, + showTitle: data.showTitle, + postTemplateSlug: data.postTemplateSlug, + listTemplateSlug: data.listTemplateSlug, + }, ]), ); } diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index a825ca4..8dee2ed 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -47,6 +47,8 @@ export interface TemplatePostEntry { export interface CategoryRenderSettings { renderInLists: boolean; showTitle: boolean; + postTemplateSlug?: string; + listTemplateSlug?: string; } export interface DayBlockContext { @@ -1021,6 +1023,7 @@ export function resolvePageRendererTemplateRoots(options?: { moduleDir?: string; cwd?: string; resourcesPath?: string; + userTemplatesDir?: string; }): string[] { const moduleDir = options?.moduleDir ?? __dirname; const cwd = options?.cwd ?? process.cwd(); @@ -1036,9 +1039,67 @@ export function resolvePageRendererTemplateRoots(options?: { 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)); } +/** + * 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, + categorySettings?: Record, +): 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 { + if (routeCategory && categorySettings) { + const setting = categorySettings[routeCategory]; + if (setting?.listTemplateSlug) { + return setting.listTemplateSlug; + } + } + + return 'post-list'; +} + export class PageRenderer { private readonly mediaEngine: MediaEngineContract; private readonly postMediaEngine: PostMediaEngineContract; @@ -1051,13 +1112,14 @@ export class PageRenderer { postMediaEngine: PostMediaEngineContract, postEngineForMacros?: PostEngineContract, pythonMacroRenderer?: PythonMacroRendererContract, + userTemplatesDir?: string, ) { this.mediaEngine = mediaEngine; this.postMediaEngine = postMediaEngine; this.postEngineForMacros = postEngineForMacros; this.pythonMacroRenderer = pythonMacroRenderer; - const templateRoots = resolvePageRendererTemplateRoots(); + const templateRoots = resolvePageRendererTemplateRoots({ userTemplatesDir }); this.liquid = new Liquid({ root: templateRoots, @@ -1355,13 +1417,27 @@ export class PageRenderer { 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 | undefined, + ); + return this.liquid.renderFile(listTemplateName, templateContext); } async renderSinglePost( post: PostData, rewriteContext: HtmlRewriteContext, - pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string; html_theme_attribute?: string; tag_color_by_name?: Record }, + pageContext: { + page_title: string; + language: string; + menu_items?: TemplateMenuItem[]; + pico_stylesheet_href?: string; + html_theme_attribute?: string; + tag_color_by_name?: Record; + tagSettings?: Record; + categorySettings?: Record; + }, postEngine?: PostEngineContract, ): Promise { 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 { diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index 1d2e8ab..7471159 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -22,6 +22,7 @@ import { type PythonMacroRendererContract, } from './PageRenderer'; import { getScriptEngine } from './ScriptEngine'; +import { getTemplateEngine } from './TemplateEngine'; import { getPythonMacroWorkerRuntime } from './PythonMacroWorkerRuntime'; import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes'; import { renderRouteWithSharedContext } from './SharedRouteRenderer'; @@ -69,6 +70,7 @@ interface PreviewServerDependencies { settingsEngine: MetaEngineContract; menuEngine: MenuEngineContract; getActiveProjectContext: () => Promise; + userTemplatesDir?: string; } interface SerializedTag { @@ -106,7 +108,13 @@ export class PreviewServer { 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 { @@ -197,6 +205,7 @@ export class PreviewServer { resolveListExcludedCategories: (settings) => this.resolveListExcludedCategories(settings), buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(), resolveTagColorByName: (projectContext) => this.resolveTagColorByName(projectContext), + resolveTagTemplateSettings: (projectContext) => this.resolveTagTemplateSettings(projectContext), pageRenderer: this.pageRenderer, postEngineForMacros: this.postEngine, loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination), @@ -432,6 +441,39 @@ export class PreviewServer { } } + private async resolveTagTemplateSettings(projectContext: ActiveProjectContext): Promise> { + 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 = {}; + for (const rawEntry of parsed as SerializedTag[]) { + const name = typeof rawEntry?.name === 'string' ? rawEntry.name.trim() : ''; + const postTemplateSlug = typeof (rawEntry as Record)?.postTemplateSlug === 'string' + ? ((rawEntry as Record).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> { const match = pathname.match(/^\/assets\/([^/]+)$/); if (!match) return null; @@ -592,6 +634,8 @@ export class PreviewServer { mergedSettings[category] = { renderInLists: value.renderInLists, showTitle: value.showTitle, + postTemplateSlug: value.postTemplateSlug, + listTemplateSlug: value.listTemplateSlug, }; } return mergedSettings; diff --git a/src/main/engine/ProjectEngine.ts b/src/main/engine/ProjectEngine.ts index 77bcbe0..667280f 100644 --- a/src/main/engine/ProjectEngine.ts +++ b/src/main/engine/ProjectEngine.ts @@ -81,12 +81,73 @@ export class ProjectEngine extends EventEmitter { // - If custom dataPath is provided, all project data lives there (allows cloud storage backup) // - If no dataPath (default project), use internal userData storage const dataDir = this.getDataDir(projectId, dataPath); - + // Create all project directories in the data directory await fs.mkdir(path.join(dataDir, 'posts'), { 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, 'thumbnails'), { recursive: true }); + await fs.mkdir(path.join(dataDir, 'templates'), { recursive: true }); + } + + private async copyStarterTemplates(projectId: string, dataPath?: string | null): Promise { + 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 { + 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 { @@ -119,6 +180,9 @@ export class ProjectEngine extends EventEmitter { // Create directories using project ID (not slug) await this.ensureProjectDirectories(id, data.dataPath); + // Copy bundled templates as starter templates + await this.copyStarterTemplates(id, data.dataPath); + // Insert into database const dbProject: NewProject = { id: project.id, diff --git a/src/main/engine/SharedRouteRenderer.ts b/src/main/engine/SharedRouteRenderer.ts index 9bd6bd4..7f5d3d8 100644 --- a/src/main/engine/SharedRouteRenderer.ts +++ b/src/main/engine/SharedRouteRenderer.ts @@ -59,6 +59,7 @@ export interface SharedRouteRenderServices { resolveListExcludedCategories: (settings: Record) => string[]; buildHtmlRewriteContext: () => Promise; resolveTagColorByName: (projectContext: SharedActiveProjectContext) => Promise>; + resolveTagTemplateSettings?: (projectContext: SharedActiveProjectContext) => Promise>; pageRenderer: Pick; postEngineForMacros?: PostEngineContract; loadPublishedSnapshotsPage: ( @@ -96,6 +97,7 @@ async function resolveRouteWithSharedServices( categorySettings: Record, categoryMetadata: Record, tagColorByName: Record, + tagTemplateSettings: Record, listExcludedCategories: string[], services: SharedRouteRenderServices, allowEmptyArchiveRender: boolean, @@ -187,6 +189,8 @@ async function resolveRouteWithSharedServices( pico_stylesheet_href: pageContext.picoStylesheetHref, html_theme_attribute: pageContext.htmlThemeAttribute, tag_color_by_name: tagColorByName, + tagSettings: tagTemplateSettings, + categorySettings: categorySettings as Record, }, services.postEngineForMacros); } @@ -270,6 +274,8 @@ async function resolveRouteWithSharedServices( pico_stylesheet_href: pageContext.picoStylesheetHref, html_theme_attribute: pageContext.htmlThemeAttribute, tag_color_by_name: tagColorByName, + tagSettings: tagTemplateSettings, + categorySettings: categorySettings as Record, }, services.postEngineForMacros); } @@ -310,6 +316,7 @@ export async function renderRouteWithSharedContext( const picoStylesheetHref = getPicoStylesheetHref(appliedTheme); const htmlRewriteContext = options.htmlRewriteContext ?? await services.buildHtmlRewriteContext(); const tagColorByName = await services.resolveTagColorByName(options.projectContext); + const tagTemplateSettings = await services.resolveTagTemplateSettings?.(options.projectContext) ?? {}; const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/'); return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, { @@ -318,5 +325,5 @@ export async function renderRouteWithSharedContext( menuItems, picoStylesheetHref, htmlThemeAttribute: options.htmlThemeAttribute, - }, categorySettings, categoryMetadata as Record, tagColorByName, listExcludedCategories, services as SharedRouteRenderServices, options.allowEmptyArchiveRender === true, options.singlePostOptions); + }, categorySettings, categoryMetadata as Record, tagColorByName, tagTemplateSettings, listExcludedCategories, services as SharedRouteRenderServices, options.allowEmptyArchiveRender === true, options.singlePostOptions); } diff --git a/src/main/engine/TagEngine.ts b/src/main/engine/TagEngine.ts index bdbee41..8edfd15 100644 --- a/src/main/engine/TagEngine.ts +++ b/src/main/engine/TagEngine.ts @@ -18,6 +18,7 @@ export interface TagData { projectId: string; name: string; color?: string; + postTemplateSlug?: string; createdAt: Date; updatedAt: Date; } @@ -45,6 +46,7 @@ export interface CreateTagInput { export interface UpdateTagInput { name?: string; color?: string | null; + postTemplateSlug?: string | null; } /** @@ -110,6 +112,7 @@ function isValidHexColor(color: string): boolean { interface SerializedTag { name: 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'); } - if (input.color === undefined) { + const hasColorUpdate = input.color !== undefined; + const hasTemplateUpdate = input.postTemplateSlug !== undefined; + + if (!hasColorUpdate && !hasTemplateUpdate) { // No updates return this.rowToTagData(row); } const now = new Date(); + const setFields: Record = { updatedAt: now }; + if (hasColorUpdate) { + setFields.color = input.color; + } + if (hasTemplateUpdate) { + setFields.postTemplateSlug = input.postTemplateSlug; + } + await db .update(tags) - .set({ - color: input.color, - updatedAt: now, - }) + .set(setFields) .where(and( eq(tags.id, id), eq(tags.projectId, this.currentProjectId) @@ -422,7 +433,8 @@ export class TagEngine extends EventEmitter { id: row.id, projectId: row.projectId, 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, updatedAt: now, }; @@ -817,6 +829,7 @@ export class TagEngine extends EventEmitter { projectId: row.projectId, name: row.name, color: row.color || undefined, + postTemplateSlug: row.postTemplateSlug || undefined, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -838,6 +851,9 @@ export class TagEngine extends EventEmitter { if (tag.color) { entry.color = tag.color; } + if (tag.postTemplateSlug) { + entry.postTemplateSlug = tag.postTemplateSlug; + } return entry; }); @@ -867,6 +883,7 @@ export class TagEngine extends EventEmitter { if (!name) continue; const color = tag.color || null; + const postTemplateSlug = typeof tag.postTemplateSlug === 'string' ? tag.postTemplateSlug : null; // Check if tag with this name already exists const existing = await db @@ -884,17 +901,22 @@ export class TagEngine extends EventEmitter { projectId: this.currentProjectId, name, color, + postTemplateSlug, createdAt: now, updatedAt: now, }); - } else if (color) { - // Update color if provided and tag exists + } else if (color || postTemplateSlug) { + // Update color/postTemplateSlug if provided and tag exists + const setFields: Record = { updatedAt: now }; + if (color) { + setFields.color = color; + } + if (postTemplateSlug) { + setFields.postTemplateSlug = postTemplateSlug; + } await db .update(tags) - .set({ - color, - updatedAt: now, - }) + .set(setFields) .where(and( eq(tags.projectId, this.currentProjectId), sql`LOWER(${tags.name}) = LOWER(${name})` diff --git a/src/main/engine/TemplateEngine.ts b/src/main/engine/TemplateEngine.ts new file mode 100644 index 0000000..05348be --- /dev/null +++ b/src/main/engine/TemplateEngine.ts @@ -0,0 +1,762 @@ +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 { 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[]; +} + +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 { + 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 { + 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(); + + if (existing.filePath !== nextFilePath) { + await fs.mkdir(this.getTemplatesDir(), { recursive: true }); + await fs.rename(existing.filePath, nextFilePath); + } + + 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(nextFilePath); + + const nextRow = { + ...existing, + title: nextTitle, + slug: nextSlug, + kind: nextKind, + enabled: nextEnabled, + filePath: nextFilePath, + version: nextVersion, + updatedAt: now, + }; + + await fs.writeFile(nextFilePath, this.serializeTemplateFile(nextRow, nextContent), 'utf-8'); + + await getDatabase().getLocal() + .update(templates) + .set({ + title: nextTitle, + slug: nextSlug, + kind: nextKind, + enabled: nextEnabled, + filePath: nextFilePath, + version: nextVersion, + updatedAt: now, + }) + .where(and(eq(templates.id, existing.id), eq(templates.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 deleteTemplate(id: string): Promise { + const existing = await this.getTemplateRow(id); + if (!existing) { + return false; + } + + 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 true; + } + + async getTemplate(id: string): Promise { + const row = await this.getTemplateRow(id); + if (!row) { + return null; + } + return this.toTemplateData(row); + } + + async getAllTemplates(): Promise { + const rows = await this.getAllTemplateRows(); + return Promise.all(rows.map((item) => this.toTemplateData(item))); + } + + async getEnabledTemplatesByKind(kind: TemplateKind): Promise { + 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 { + 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 { + 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 { + 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(); + 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 { + 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(); + 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