From c7a939736c730d74ee1e331c8b5734b9d92d1cd7 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 5 Apr 2026 12:47:58 +0200 Subject: [PATCH] fix: database migration --- .gitignore | 3 + AGENTS.md | 1 + .../V2__fix_defaults_and_collation.sql | 58 ++++++ .../meta/categories.json | 6 - .../meta/category-meta.json | 1 - .../meta/project.json | 6 - .../meta/publishing.json | 1 - .../meta/tags.json | 1 - .../meta/categories.json | 6 - .../meta/category-meta.json | 1 - .../meta/project.json | 6 - .../meta/publishing.json | 1 - .../meta/tags.json | 1 - .../meta/categories.json | 6 - .../meta/category-meta.json | 1 - .../meta/project.json | 6 - .../meta/publishing.json | 1 - .../meta/tags.json | 1 - .../meta/categories.json | 6 - .../meta/category-meta.json | 1 - .../meta/project.json | 6 - .../meta/publishing.json | 1 - .../meta/tags.json | 1 - .../meta/categories.json | 6 - .../meta/category-meta.json | 1 - .../meta/project.json | 6 - .../meta/publishing.json | 1 - .../meta/tags.json | 1 - .../meta/categories.json | 6 - .../meta/category-meta.json | 1 - .../meta/project.json | 6 - .../meta/publishing.json | 1 - .../meta/tags.json | 1 - .../meta/categories.json | 6 - .../meta/category-meta.json | 1 - .../meta/project.json | 6 - .../meta/publishing.json | 1 - .../meta/tags.json | 1 - crates/bds-core/src/db/fts.rs | 120 +++++++++-- crates/bds-core/src/db/migrations.rs | 4 +- crates/bds-core/src/engine/media.rs | 19 +- crates/bds-core/src/engine/post.rs | 59 ++++-- crates/bds-core/src/engine/project.rs | 28 ++- crates/bds-core/src/engine/search.rs | 34 ++-- crates/bds-core/src/engine/task.rs | 53 +++-- crates/bds-core/src/model/generation.rs | 5 + crates/bds-core/tests/spec_claims.rs | 6 +- crates/bds-ui/src/app.rs | 64 ++++-- crates/bds-ui/src/platform/menu.rs | 40 ++-- crates/bds-ui/src/state/tabs.rs | 18 ++ crates/bds-ui/src/views/panel.rs | 3 + crates/bds-ui/src/views/sidebar.rs | 188 ++++++++++++++---- crates/bds-ui/src/views/tab_bar.rs | 150 ++++++++++++-- crates/bds-ui/src/views/workspace.rs | 39 ++-- locales/ui/de.json | 15 +- locales/ui/en.json | 15 +- locales/ui/es.json | 15 +- locales/ui/fr.json | 15 +- locales/ui/it.json | 15 +- 59 files changed, 766 insertions(+), 306 deletions(-) create mode 100644 crates/bds-core/migrations/V2__fix_defaults_and_collation.sql delete mode 100644 crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/categories.json delete mode 100644 crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/category-meta.json delete mode 100644 crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/project.json delete mode 100644 crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/publishing.json delete mode 100644 crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/tags.json delete mode 100644 crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/categories.json delete mode 100644 crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/category-meta.json delete mode 100644 crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/project.json delete mode 100644 crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/publishing.json delete mode 100644 crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/tags.json delete mode 100644 crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/categories.json delete mode 100644 crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/category-meta.json delete mode 100644 crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/project.json delete mode 100644 crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/publishing.json delete mode 100644 crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/tags.json delete mode 100644 crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/categories.json delete mode 100644 crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/category-meta.json delete mode 100644 crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/project.json delete mode 100644 crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/publishing.json delete mode 100644 crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/tags.json delete mode 100644 crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/categories.json delete mode 100644 crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/category-meta.json delete mode 100644 crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/project.json delete mode 100644 crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/publishing.json delete mode 100644 crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/tags.json delete mode 100644 crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/categories.json delete mode 100644 crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/category-meta.json delete mode 100644 crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/project.json delete mode 100644 crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/publishing.json delete mode 100644 crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/tags.json delete mode 100644 crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/categories.json delete mode 100644 crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/category-meta.json delete mode 100644 crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/project.json delete mode 100644 crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/publishing.json delete mode 100644 crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/tags.json diff --git a/.gitignore b/.gitignore index 000ab0a..758ccd9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ *~ .DS_Store +# Test artifacts +crates/bds-core/projects/ + # Environment .env .env.* diff --git a/AGENTS.md b/AGENTS.md index 5ac2cb7..67a9113 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,4 +44,5 @@ Invariants and behaviours in the allium spec should be covered by unit tests of - always make sure you follow proper i18n best practices. no untranslated string constants. - when creating rust source code, always follow what the allium spec is saying for that part - when tending the allium spec, make sure you validate the spec with the installed command line utility +- don't be lazy. don't defer or skip implementations just because you have to write code for that, that is ridiculous. if the spec says something has to be there, it has to be there. diff --git a/crates/bds-core/migrations/V2__fix_defaults_and_collation.sql b/crates/bds-core/migrations/V2__fix_defaults_and_collation.sql new file mode 100644 index 0000000..5d23b2e --- /dev/null +++ b/crates/bds-core/migrations/V2__fix_defaults_and_collation.sql @@ -0,0 +1,58 @@ +-- Fix script and template default status from 'published' to 'draft' +-- and make tag name uniqueness case-insensitive. +-- +-- SQLite cannot ALTER COLUMN defaults, so we recreate the affected tables. + +-- ── Scripts: default status 'published' → 'draft' ── + +CREATE TABLE scripts_new ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + slug TEXT NOT NULL, + title TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'utility', + entrypoint TEXT NOT NULL DEFAULT 'render', + enabled INTEGER NOT NULL DEFAULT 1, + version INTEGER NOT NULL DEFAULT 1, + file_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + content TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +INSERT INTO scripts_new SELECT * FROM scripts; +DROP TABLE scripts; +ALTER TABLE scripts_new RENAME TO scripts; + +CREATE UNIQUE INDEX IF NOT EXISTS scripts_project_slug_idx + ON scripts(project_id, slug); + +-- ── Templates: default status 'published' → 'draft' ── + +CREATE TABLE templates_new ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + slug TEXT NOT NULL, + title TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'post', + enabled INTEGER NOT NULL DEFAULT 1, + version INTEGER NOT NULL DEFAULT 1, + file_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + content TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +INSERT INTO templates_new SELECT * FROM templates; +DROP TABLE templates; +ALTER TABLE templates_new RENAME TO templates; + +CREATE UNIQUE INDEX IF NOT EXISTS templates_project_slug_idx + ON templates(project_id, slug); + +-- ── Tags: case-insensitive unique index ── + +DROP INDEX IF EXISTS tags_project_name_idx; +CREATE UNIQUE INDEX tags_project_name_idx ON tags(project_id, name COLLATE NOCASE); diff --git a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/categories.json b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/categories.json deleted file mode 100644 index 942a464..0000000 --- a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/categories.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - "article", - "aside", - "page", - "picture" -] \ No newline at end of file diff --git a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/category-meta.json b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/category-meta.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/category-meta.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/project.json b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/project.json deleted file mode 100644 index f156406..0000000 --- a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "P", - "maxPostsPerPage": 50, - "semanticSimilarityEnabled": false, - "blogLanguages": [] -} \ No newline at end of file diff --git a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/publishing.json b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/publishing.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/publishing.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/tags.json b/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/tags.json deleted file mode 100644 index 0637a08..0000000 --- a/crates/bds-core/projects/12a1bccb-6774-475c-b595-adad4d6c3e18/meta/tags.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/categories.json b/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/categories.json deleted file mode 100644 index 942a464..0000000 --- a/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/categories.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - "article", - "aside", - "page", - "picture" -] \ No newline at end of file diff --git a/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/category-meta.json b/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/category-meta.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/category-meta.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/project.json b/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/project.json deleted file mode 100644 index f156406..0000000 --- a/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "P", - "maxPostsPerPage": 50, - "semanticSimilarityEnabled": false, - "blogLanguages": [] -} \ No newline at end of file diff --git a/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/publishing.json b/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/publishing.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/publishing.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/tags.json b/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/tags.json deleted file mode 100644 index 0637a08..0000000 --- a/crates/bds-core/projects/131fbd64-cd05-4018-9aff-e9678ce4429d/meta/tags.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/categories.json b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/categories.json deleted file mode 100644 index 942a464..0000000 --- a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/categories.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - "article", - "aside", - "page", - "picture" -] \ No newline at end of file diff --git a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/category-meta.json b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/category-meta.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/category-meta.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/project.json b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/project.json deleted file mode 100644 index f156406..0000000 --- a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "P", - "maxPostsPerPage": 50, - "semanticSimilarityEnabled": false, - "blogLanguages": [] -} \ No newline at end of file diff --git a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/publishing.json b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/publishing.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/publishing.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/tags.json b/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/tags.json deleted file mode 100644 index 0637a08..0000000 --- a/crates/bds-core/projects/19521f8e-36f1-41e5-9786-145615bcda61/meta/tags.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/categories.json b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/categories.json deleted file mode 100644 index 942a464..0000000 --- a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/categories.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - "article", - "aside", - "page", - "picture" -] \ No newline at end of file diff --git a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/category-meta.json b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/category-meta.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/category-meta.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/project.json b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/project.json deleted file mode 100644 index f156406..0000000 --- a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "P", - "maxPostsPerPage": 50, - "semanticSimilarityEnabled": false, - "blogLanguages": [] -} \ No newline at end of file diff --git a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/publishing.json b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/publishing.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/publishing.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/tags.json b/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/tags.json deleted file mode 100644 index 0637a08..0000000 --- a/crates/bds-core/projects/2c1652b0-6e4f-4f35-95d5-16039eb8815b/meta/tags.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/categories.json b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/categories.json deleted file mode 100644 index 942a464..0000000 --- a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/categories.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - "article", - "aside", - "page", - "picture" -] \ No newline at end of file diff --git a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/category-meta.json b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/category-meta.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/category-meta.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/project.json b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/project.json deleted file mode 100644 index f156406..0000000 --- a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "P", - "maxPostsPerPage": 50, - "semanticSimilarityEnabled": false, - "blogLanguages": [] -} \ No newline at end of file diff --git a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/publishing.json b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/publishing.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/publishing.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/tags.json b/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/tags.json deleted file mode 100644 index 0637a08..0000000 --- a/crates/bds-core/projects/393be80c-1610-4c32-8242-934f4b069d92/meta/tags.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/categories.json b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/categories.json deleted file mode 100644 index 942a464..0000000 --- a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/categories.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - "article", - "aside", - "page", - "picture" -] \ No newline at end of file diff --git a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/category-meta.json b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/category-meta.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/category-meta.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/project.json b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/project.json deleted file mode 100644 index f156406..0000000 --- a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "P", - "maxPostsPerPage": 50, - "semanticSimilarityEnabled": false, - "blogLanguages": [] -} \ No newline at end of file diff --git a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/publishing.json b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/publishing.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/publishing.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/tags.json b/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/tags.json deleted file mode 100644 index 0637a08..0000000 --- a/crates/bds-core/projects/757a4e73-53ea-422b-9197-5ba43ac88205/meta/tags.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/categories.json b/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/categories.json deleted file mode 100644 index 942a464..0000000 --- a/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/categories.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - "article", - "aside", - "page", - "picture" -] \ No newline at end of file diff --git a/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/category-meta.json b/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/category-meta.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/category-meta.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/project.json b/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/project.json deleted file mode 100644 index f156406..0000000 --- a/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/project.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "P", - "maxPostsPerPage": 50, - "semanticSimilarityEnabled": false, - "blogLanguages": [] -} \ No newline at end of file diff --git a/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/publishing.json b/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/publishing.json deleted file mode 100644 index 9e26dfe..0000000 --- a/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/publishing.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/tags.json b/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/tags.json deleted file mode 100644 index 0637a08..0000000 --- a/crates/bds-core/projects/b2e4edde-1794-4f96-b491-ed1c2fb76788/meta/tags.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/crates/bds-core/src/db/fts.rs b/crates/bds-core/src/db/fts.rs index 77d7395..df9bbc4 100644 --- a/crates/bds-core/src/db/fts.rs +++ b/crates/bds-core/src/db/fts.rs @@ -63,9 +63,25 @@ pub fn stem_text(text: &str, language: &str) -> String { .join(" ") } +/// Structured translation data for FTS indexing of posts. +pub struct PostTranslationFts { + pub title: String, + pub excerpt: Option, + pub content: Option, + pub language: String, +} + +/// Structured translation data for FTS indexing of media. +pub struct MediaTranslationFts { + pub title: Option, + pub alt: Option, + pub caption: Option, + pub language: String, +} + /// Index a post in the FTS table with separate columns per spec. /// -/// Concatenates translation text into the content column (stemmed per-language). +/// Translation titles go to the title column, excerpts to excerpt, content to content. pub fn index_post( conn: &Connection, post_id: &str, @@ -74,22 +90,37 @@ pub fn index_post( content: Option<&str>, tags: &[String], categories: &[String], - translations: &[(String, String)], // (text, language) pairs + translations: &[PostTranslationFts], language: &str, ) -> rusqlite::Result<()> { // Remove existing entry remove_post_from_index(conn, post_id)?; - let stemmed_title = stem_text(title, language); - let stemmed_excerpt = stem_text(excerpt.unwrap_or(""), language); + // Title column: post title + all translation titles + let mut title_parts = vec![stem_text(title, language)]; + for t in translations { + title_parts.push(stem_text(&t.title, &t.language)); + } + let stemmed_title = title_parts.join(" "); - // Content column: post content + all translation texts + // Excerpt column: post excerpt + all translation excerpts + let mut excerpt_parts = vec![stem_text(excerpt.unwrap_or(""), language)]; + for t in translations { + if let Some(ref exc) = t.excerpt { + excerpt_parts.push(stem_text(exc, &t.language)); + } + } + let stemmed_excerpt = excerpt_parts.join(" "); + + // Content column: post content + all translation content let mut content_parts = Vec::new(); if let Some(cnt) = content { content_parts.push(stem_text(cnt, language)); } - for (text, trans_lang) in translations { - content_parts.push(stem_text(text, trans_lang)); + for t in translations { + if let Some(ref cnt) = t.content { + content_parts.push(stem_text(cnt, &t.language)); + } } let stemmed_content = content_parts.join(" "); @@ -104,6 +135,8 @@ pub fn index_post( } /// Index a media item in the FTS table with separate columns per spec. +/// +/// Translation titles go to the title column, alts to alt, captions to caption. pub fn index_media( conn: &Connection, media_id: &str, @@ -112,21 +145,38 @@ pub fn index_media( caption: Option<&str>, original_name: &str, tags: &[String], - translations: &[(String, String)], // (text, language) pairs + translations: &[MediaTranslationFts], language: &str, ) -> rusqlite::Result<()> { remove_media_from_index(conn, media_id)?; - let stemmed_title = stem_text(title.unwrap_or(""), language); - let stemmed_alt = stem_text(alt.unwrap_or(""), language); + // Title column: media title + all translation titles + let mut title_parts = vec![stem_text(title.unwrap_or(""), language)]; + for t in translations { + if let Some(ref ttl) = t.title { + title_parts.push(stem_text(ttl, &t.language)); + } + } + let stemmed_title = title_parts.join(" "); - // Caption column: media caption + all translation texts + // Alt column: media alt + all translation alts + let mut alt_parts = vec![stem_text(alt.unwrap_or(""), language)]; + for t in translations { + if let Some(ref a) = t.alt { + alt_parts.push(stem_text(a, &t.language)); + } + } + let stemmed_alt = alt_parts.join(" "); + + // Caption column: media caption + all translation captions let mut caption_parts = Vec::new(); if let Some(c) = caption { caption_parts.push(stem_text(c, language)); } - for (text, trans_lang) in translations { - caption_parts.push(stem_text(text, trans_lang)); + for t in translations { + if let Some(ref cap) = t.caption { + caption_parts.push(stem_text(cap, &t.language)); + } } let stemmed_caption = caption_parts.join(" "); @@ -176,8 +226,12 @@ pub struct PostSearchFilters<'a> { pub status: Option<&'a str>, pub tags: Option<&'a [String]>, pub categories: Option<&'a [String]>, + pub language: Option<&'a str>, + pub missing_translation_language: Option<&'a str>, pub year: Option, pub month: Option, + pub from: Option, + pub to: Option, pub limit: Option, pub offset: Option, } @@ -263,6 +317,32 @@ pub fn search_posts_filtered( } } + if let Some(lang) = filters.language { + sql.push_str(&format!("AND (language = ?{param_idx} OR language IS NULL) ")); + params.push(Box::new(lang.to_string())); + param_idx += 1; + } + + if let Some(missing_lang) = filters.missing_translation_language { + sql.push_str(&format!( + "AND NOT EXISTS (SELECT 1 FROM post_translations WHERE post_translations.translation_for = posts.id AND post_translations.language = ?{param_idx}) " + )); + params.push(Box::new(missing_lang.to_string())); + param_idx += 1; + } + + if let Some(from_ts) = filters.from { + sql.push_str(&format!("AND created_at >= ?{param_idx} ")); + params.push(Box::new(from_ts)); + param_idx += 1; + } + + if let Some(to_ts) = filters.to { + sql.push_str(&format!("AND created_at <= ?{param_idx} ")); + params.push(Box::new(to_ts)); + param_idx += 1; + } + let _ = param_idx; // suppress unused warning // First get total count (without LIMIT/OFFSET) @@ -439,7 +519,12 @@ mod tests { Some("Beautiful spider web"), &["fotografie".into(), "natur".into()], &["picture".into()], - &[("Esmeralda English".into(), "en".into())], + &[PostTranslationFts { + title: "Esmeralda English".into(), + excerpt: None, + content: None, + language: "en".into(), + }], "en", ).unwrap(); @@ -503,7 +588,12 @@ mod tests { index_post( db.conn(), "p1", "Programmierung", None, Some("Deutsche Entwicklung"), &[], &[], - &[("English development programming".into(), "en".into())], + &[PostTranslationFts { + title: "English development programming".into(), + excerpt: None, + content: Some("English development programming".into()), + language: "en".into(), + }], "de", ).unwrap(); diff --git a/crates/bds-core/src/db/migrations.rs b/crates/bds-core/src/db/migrations.rs index 66195ae..7762d25 100644 --- a/crates/bds-core/src/db/migrations.rs +++ b/crates/bds-core/src/db/migrations.rs @@ -745,7 +745,7 @@ mod tests { assert_eq!(kind, "post", "default kind must be 'post'"); assert_eq!(enabled, 1, "default enabled must be 1"); assert_eq!(version, 1, "default version must be 1"); - assert_eq!(status, "published", "default status must be 'published'"); + assert_eq!(status, "draft", "default status must be 'draft'"); } #[test] @@ -766,6 +766,6 @@ mod tests { assert_eq!(ep, "render", "default entrypoint must be 'render'"); assert_eq!(enabled, 1, "default enabled must be 1"); assert_eq!(version, 1, "default version must be 1"); - assert_eq!(status, "published", "default status must be 'published'"); + assert_eq!(status, "draft", "default status must be 'draft'"); } } diff --git a/crates/bds-core/src/engine/media.rs b/crates/bds-core/src/engine/media.rs index 79873b8..d40fa64 100644 --- a/crates/bds-core/src/engine/media.rs +++ b/crates/bds-core/src/engine/media.rs @@ -444,20 +444,13 @@ pub fn rebuild_media_from_filesystem_with_progress( /// Index a media item in FTS, gathering translation texts. fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> { let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default(); - let translation_data: Vec<(String, String)> = translations + let translation_data: Vec = translations .iter() - .map(|t| { - let mut parts = Vec::new(); - if let Some(ref title) = t.title { - parts.push(title.clone()); - } - if let Some(ref alt) = t.alt { - parts.push(alt.clone()); - } - if let Some(ref caption) = t.caption { - parts.push(caption.clone()); - } - (parts.join(" "), t.language.clone()) + .map(|t| fts::MediaTranslationFts { + title: t.title.clone(), + alt: t.alt.clone(), + caption: t.caption.clone(), + language: t.language.clone(), }) .collect(); diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index 5479d94..d4a2eaf 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -156,8 +156,19 @@ pub fn update_post( post.do_not_translate = dnt; } - // Auto-transition published post back to draft on content/metadata change - if post.status == PostStatus::Published { + // Auto-transition published or archived post back to draft on content/metadata change + if post.status == PostStatus::Published || post.status == PostStatus::Archived { + // Reload content from filesystem if content field is NULL (published state) + if post.content.is_none() && !post.file_path.is_empty() { + let abs_path = _data_dir.join(&post.file_path); + if abs_path.exists() { + if let Ok(file_content) = fs::read_to_string(&abs_path) { + if let Ok((_fm, body)) = read_post_file(&file_content) { + post.content = Some(body); + } + } + } + } post.status = PostStatus::Draft; } @@ -294,10 +305,24 @@ pub fn publish_post( } /// Archive a post. -pub fn archive_post(conn: &Connection, post_id: &str) -> EngineResult<()> { - let post = qp::get_post_by_id(conn, post_id)?; +pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<()> { + let mut post = qp::get_post_by_id(conn, post_id)?; if post.status == PostStatus::Archived { - return Ok(()); + return Err(EngineError::Conflict( + "post is already archived".to_string(), + )); + } + // Reload content from filesystem if transitioning from published (content is NULL) + if post.status == PostStatus::Published && post.content.is_none() && !post.file_path.is_empty() { + let abs_path = data_dir.join(&post.file_path); + if abs_path.exists() { + if let Ok(file_content) = fs::read_to_string(&abs_path) { + if let Ok((_fm, body)) = read_post_file(&file_content) { + post.content = Some(body); + qp::update_post(conn, &post)?; + } + } + } } let now = now_unix_ms(); qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?; @@ -646,17 +671,13 @@ fn publish_translation( /// Index a post in FTS, gathering translation texts. fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> { let translations = qt::list_post_translations_by_post(conn, &post.id).unwrap_or_default(); - let translation_data: Vec<(String, String)> = translations + let translation_data: Vec = translations .iter() - .map(|t| { - let mut parts = vec![t.title.clone()]; - if let Some(ref exc) = t.excerpt { - parts.push(exc.clone()); - } - if let Some(ref cnt) = t.content { - parts.push(cnt.clone()); - } - (parts.join(" "), t.language.clone()) + .map(|t| fts::PostTranslationFts { + title: t.title.clone(), + excerpt: t.excerpt.clone(), + content: t.content.clone(), + language: t.language.clone(), }) .collect(); @@ -1031,7 +1052,7 @@ mod tests { publish_post(db.conn(), dir.path(), &post.id).unwrap(); // Archive so we can try updating - archive_post(db.conn(), &post.id).unwrap(); + archive_post(db.conn(), dir.path(), &post.id).unwrap(); // Try to change slug - should fail let result = update_post( @@ -1124,7 +1145,7 @@ mod tests { let original_published_at = published.published_at.unwrap(); // Archive then re-publish - archive_post(db.conn(), &post.id).unwrap(); + archive_post(db.conn(), dir.path(), &post.id).unwrap(); // Brief delay to ensure now() is different let republished = publish_post(db.conn(), dir.path(), &post.id).unwrap(); @@ -1406,7 +1427,7 @@ mod tests { publish_post(db.conn(), dir.path(), &post.id).unwrap(); // Archive then update to test auto-draft - archive_post(db.conn(), &post.id).unwrap(); + archive_post(db.conn(), dir.path(), &post.id).unwrap(); // Re-publish let _ = publish_post(db.conn(), dir.path(), &post.id).unwrap(); @@ -1475,7 +1496,7 @@ mod tests { vec![], vec![], None, None, None, ).unwrap(); publish_post(db.conn(), dir.path(), &post.id).unwrap(); - archive_post(db.conn(), &post.id).unwrap(); + archive_post(db.conn(), dir.path(), &post.id).unwrap(); let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap(); assert_eq!(from_db.status, PostStatus::Archived); diff --git a/crates/bds-core/src/engine/project.rs b/crates/bds-core/src/engine/project.rs index eb030aa..5f60168 100644 --- a/crates/bds-core/src/engine/project.rs +++ b/crates/bds-core/src/engine/project.rs @@ -298,16 +298,24 @@ mod tests { #[test] fn delete_project_removes_row() { let (db, dir) = setup(); - // Project with no custom data_path → uses internal directory - let p = create_project( - db.conn(), - "P", - None, - ) - .unwrap(); - let internal_dir = dir.path().join("projects").join(&p.id); - let _ = std::fs::create_dir_all(&internal_dir); - delete_project(db.conn(), &p.id, Some(&internal_dir)).unwrap(); + // Simulate an internal project (no custom data_path) by inserting directly + let now = crate::util::now_unix_ms(); + let project = Project { + id: uuid::Uuid::new_v4().to_string(), + name: "P".to_string(), + slug: "p".to_string(), + description: None, + data_path: None, + is_active: false, + created_at: now, + updated_at: now, + }; + crate::db::queries::project::insert_project(db.conn(), &project).unwrap(); + // Create the internal directory structure under the temp dir + let internal_dir = dir.path().join("projects").join(&project.id); + create_directory_structure(&internal_dir).unwrap(); + assert!(internal_dir.join("posts").is_dir()); + delete_project(db.conn(), &project.id, Some(&internal_dir)).unwrap(); assert!(list_projects(db.conn()).unwrap().is_empty()); assert!(!internal_dir.exists(), "internal directory should be cleaned up"); } diff --git a/crates/bds-core/src/engine/search.rs b/crates/bds-core/src/engine/search.rs index 307d96f..799423b 100644 --- a/crates/bds-core/src/engine/search.rs +++ b/crates/bds-core/src/engine/search.rs @@ -50,16 +50,13 @@ pub fn reindex_all_with_progress( } let translations = post_translation::list_post_translations_by_post(conn, &post.id)?; - let trans_pairs: Vec<(String, String)> = translations + let trans_data: Vec = translations .iter() - .map(|t| { - let text = [ - t.title.as_str(), - t.excerpt.as_deref().unwrap_or(""), - t.content.as_deref().unwrap_or(""), - ] - .join(" "); - (text, t.language.clone()) + .map(|t| fts::PostTranslationFts { + title: t.title.clone(), + excerpt: t.excerpt.clone(), + content: t.content.clone(), + language: t.language.clone(), }) .collect(); @@ -72,7 +69,7 @@ pub fn reindex_all_with_progress( post.content.as_deref(), &post.tags, &post.categories, - &trans_pairs, + &trans_data, language, )?; @@ -87,16 +84,13 @@ pub fn reindex_all_with_progress( } let translations = media_translation::list_media_translations_by_media(conn, &m.id)?; - let trans_pairs: Vec<(String, String)> = translations + let trans_data: Vec = translations .iter() - .map(|t| { - let text = [ - t.title.as_deref().unwrap_or(""), - t.alt.as_deref().unwrap_or(""), - t.caption.as_deref().unwrap_or(""), - ] - .join(" "); - (text, t.language.clone()) + .map(|t| fts::MediaTranslationFts { + title: t.title.clone(), + alt: t.alt.clone(), + caption: t.caption.clone(), + language: t.language.clone(), }) .collect(); @@ -109,7 +103,7 @@ pub fn reindex_all_with_progress( m.caption.as_deref(), &m.original_name, &m.tags, - &trans_pairs, + &trans_data, language, )?; diff --git a/crates/bds-core/src/engine/task.rs b/crates/bds-core/src/engine/task.rs index f31a765..bbed545 100644 --- a/crates/bds-core/src/engine/task.rs +++ b/crates/bds-core/src/engine/task.rs @@ -8,7 +8,7 @@ pub type TaskId = u64; /// Task status. #[derive(Debug, Clone, PartialEq, Eq)] pub enum TaskStatus { - Queued, + Pending, Running, Completed, Failed(String), @@ -28,11 +28,14 @@ pub struct TaskProgress { struct TaskEntry { id: TaskId, label: String, + group_id: Option, + group_name: Option, status: TaskStatus, cancel_flag: Arc, progress: Option, message: Option, created_at: Instant, + last_progress_report: Option, } /// Manages concurrent tasks with a max concurrency limit and FIFO queue. @@ -61,11 +64,14 @@ impl TaskManager { let entry = TaskEntry { id, label: label.to_owned(), - status: TaskStatus::Queued, + group_id: None, + group_name: None, + status: TaskStatus::Pending, cancel_flag: Arc::new(AtomicBool::new(false)), progress: None, message: None, created_at: Instant::now(), + last_progress_report: None, }; let mut tasks = self.tasks.lock().unwrap(); @@ -73,13 +79,24 @@ impl TaskManager { // Auto-start if under capacity let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count(); if running < self.max_concurrent { - if let Some(t) = tasks.iter_mut().find(|t| t.id == id && t.status == TaskStatus::Queued) { + if let Some(t) = tasks.iter_mut().find(|t| t.id == id && t.status == TaskStatus::Pending) { t.status = TaskStatus::Running; } } id } + /// Submit a new task within a group. Returns its unique identifier. + pub fn submit_grouped(&self, label: &str, group_id: &str, group_name: &str) -> TaskId { + let id = self.submit(label); + let mut tasks = self.tasks.lock().unwrap(); + if let Some(entry) = tasks.iter_mut().find(|t| t.id == id) { + entry.group_id = Some(group_id.to_owned()); + entry.group_name = Some(group_name.to_owned()); + } + id + } + /// Try to start a queued task. Returns true if the task was moved to /// Running, false if concurrency is at capacity or the task is not Queued. pub fn try_start(&self, task_id: TaskId) -> bool { @@ -89,7 +106,7 @@ impl TaskManager { return false; } if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { - if entry.status == TaskStatus::Queued { + if entry.status == TaskStatus::Pending { entry.status = TaskStatus::Running; return true; } @@ -125,7 +142,7 @@ impl TaskManager { pub fn cancel(&self, task_id: TaskId) { let mut tasks = self.tasks.lock().unwrap(); if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { - if matches!(entry.status, TaskStatus::Running | TaskStatus::Queued) { + if matches!(entry.status, TaskStatus::Running | TaskStatus::Pending) { entry.cancel_flag.store(true, Ordering::Release); entry.status = TaskStatus::Cancelled; } @@ -152,7 +169,7 @@ impl TaskManager { /// Count tasks that are still queued. pub fn pending_count(&self) -> usize { let tasks = self.tasks.lock().unwrap(); - tasks.iter().filter(|t| t.status == TaskStatus::Queued).count() + tasks.iter().filter(|t| t.status == TaskStatus::Pending).count() } /// Count tasks that are currently running. @@ -164,7 +181,7 @@ impl TaskManager { /// Remove all completed, failed, and cancelled tasks. pub fn drain_completed(&self) { let mut tasks = self.tasks.lock().unwrap(); - tasks.retain(|t| matches!(t.status, TaskStatus::Queued | TaskStatus::Running)); + tasks.retain(|t| matches!(t.status, TaskStatus::Pending | TaskStatus::Running)); } /// Return the label of a task. @@ -176,16 +193,24 @@ impl TaskManager { /// Return the id of the first queued task (FIFO order). pub fn next_queued(&self) -> Option { let tasks = self.tasks.lock().unwrap(); - tasks.iter().find(|t| t.status == TaskStatus::Queued).map(|t| t.id) + tasks.iter().find(|t| t.status == TaskStatus::Pending).map(|t| t.id) } - /// Update progress for a running task. + /// Update progress for a running task. Throttled to at most once per 250ms. pub fn report_progress(&self, task_id: TaskId, progress: Option, message: Option) { let mut tasks = self.tasks.lock().unwrap(); if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { if entry.status == TaskStatus::Running { - entry.progress = progress; - entry.message = message; + let now = Instant::now(); + let should_report = match entry.last_progress_report { + Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128, + None => true, + }; + if should_report { + entry.progress = progress; + entry.message = message; + entry.last_progress_report = Some(now); + } } } } @@ -215,7 +240,7 @@ impl TaskManager { fn promote_next(tasks: &mut Vec, max_concurrent: usize) { let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count(); if running < max_concurrent { - if let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Queued) { + if let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Pending) { t.status = TaskStatus::Running; } } @@ -279,7 +304,7 @@ mod tests { // First 3 auto-started, 4th stays queued assert_eq!(mgr.running_count(), 3); - assert_eq!(mgr.status(ids[3]), Some(TaskStatus::Queued)); + assert_eq!(mgr.status(ids[3]), Some(TaskStatus::Pending)); } #[test] @@ -354,7 +379,7 @@ mod tests { let b = mgr.submit("second"); // queued assert_eq!(mgr.status(a), Some(TaskStatus::Running)); - assert_eq!(mgr.status(b), Some(TaskStatus::Queued)); + assert_eq!(mgr.status(b), Some(TaskStatus::Pending)); mgr.complete(a); assert_eq!(mgr.status(b), Some(TaskStatus::Running)); diff --git a/crates/bds-core/src/model/generation.rs b/crates/bds-core/src/model/generation.rs index bd402b0..333b269 100644 --- a/crates/bds-core/src/model/generation.rs +++ b/crates/bds-core/src/model/generation.rs @@ -58,5 +58,10 @@ pub struct PublishingPreferences { pub ssh_user: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ssh_remote_path: Option, + #[serde(default = "default_ssh_mode")] pub ssh_mode: SshMode, } + +fn default_ssh_mode() -> SshMode { + SshMode::Scp +} diff --git a/crates/bds-core/tests/spec_claims.rs b/crates/bds-core/tests/spec_claims.rs index c9d38b4..2f56e59 100644 --- a/crates/bds-core/tests/spec_claims.rs +++ b/crates/bds-core/tests/spec_claims.rs @@ -309,11 +309,11 @@ fn script_defaults_match_spec() { assert_eq!(entrypoint, "render", "spec: default entrypoint is 'render'"); assert!(enabled, "spec: default enabled is true"); assert_eq!(version, 1, "spec: default version is 1"); - assert_eq!(status, "published", "spec: default status is published"); + assert_eq!(status, "draft", "spec: default status is draft"); } // ── spec: schema.allium — Template defaults ── -// kind default 'post', enabled default true, version default 1, status default 'published' +// kind default 'post', enabled default true, version default 1, status default 'draft' #[test] fn template_defaults_match_spec() { @@ -339,7 +339,7 @@ fn template_defaults_match_spec() { assert_eq!(kind, "post", "spec: default kind is post"); assert!(enabled, "spec: default enabled is true"); assert_eq!(version, 1, "spec: default version is 1"); - assert_eq!(status, "published", "spec: default status is published"); + assert_eq!(status, "draft", "spec: default status is draft"); } // ── spec: tag.allium — UniqueTagNamePerProject (case-insensitive) ── diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 4010a46..fd09e2f 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -32,11 +32,17 @@ pub enum Message { ToggleSidebar, TogglePanel, + // Sidebar resize + SidebarResizeStart, + SidebarResizeMove(f32), + SidebarResizeEnd, + // Tabs OpenTab(Tab), CloseTab(String), SelectTab(String), PinTab(String), + ClearTabs, // Project ProjectsLoaded(Vec), @@ -111,6 +117,8 @@ pub struct BdsApp { // Navigation sidebar_view: SidebarView, sidebar_visible: bool, + sidebar_width: f32, + sidebar_dragging: bool, // Tabs tabs: Vec, @@ -230,6 +238,8 @@ impl BdsApp { sidebar_media: Vec::new(), sidebar_view: SidebarView::Posts, sidebar_visible: true, + sidebar_width: 280.0, + sidebar_dragging: false, tabs: Vec::new(), active_tab: None, panel_visible: false, @@ -276,6 +286,22 @@ impl BdsApp { self.panel_visible = !self.panel_visible; Task::none() } + Message::SidebarResizeStart => { + self.sidebar_dragging = true; + Task::none() + } + Message::SidebarResizeMove(x) => { + if self.sidebar_dragging { + // x is global cursor position; subtract activity bar width (~48px) + let effective = x - 48.0; + self.sidebar_width = effective.clamp(200.0, 500.0); + } + Task::none() + } + Message::SidebarResizeEnd => { + self.sidebar_dragging = false; + Task::none() + } // ── Tabs ── Message::OpenTab(tab) => { @@ -308,6 +334,11 @@ impl BdsApp { tabs::pin_tab(&mut self.tabs, &id); Task::none() } + Message::ClearTabs => { + self.tabs.clear(); + self.active_tab = None; + Task::none() + } // ── Project management ── Message::ProjectsLoaded(projects) => { @@ -463,6 +494,12 @@ impl BdsApp { self.ui_locale = locale; self.locale_dropdown_open = false; menu::update_menu_labels(&self.menu_registry, locale); + // Re-translate singleton tab titles per tabs.allium + for tab in &mut self.tabs { + if let Some(key) = tab.tab_type.i18n_key() { + tab.title = t(locale, key); + } + } Task::none() } Message::ToggleLocaleDropdown => { @@ -533,7 +570,7 @@ impl BdsApp { ) } Message::ValidateTranslations => { - self.open_singleton_tab(TabType::TranslationValidation, "Translation Validation"); + self.open_singleton_tab(TabType::TranslationValidation, "tabBar.translationValidation"); self.spawn_engine_task( "engine.validateTranslationsStarted", |db_path, project_id, data_dir, tm, tid| { @@ -569,7 +606,7 @@ impl BdsApp { ) } Message::RunMetadataDiff => { - self.open_singleton_tab(TabType::MetadataDiff, "Metadata Diff"); + self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff"); Task::none() } Message::EngineTaskDone { task_id, label, result } => { @@ -617,6 +654,7 @@ impl BdsApp { workspace::view( self.sidebar_view, self.sidebar_visible, + self.sidebar_width, &self.tabs, self.active_tab.as_deref(), self.panel_visible, @@ -664,12 +702,12 @@ impl BdsApp { if let (Some(db), Some(project), Some(data_dir)) = (&self.db, &self.active_project, &self.data_dir) { - let title = t(self.ui_locale, "post.untitled"); + let display_title = t(self.ui_locale, "post.untitled"); match engine::post::create_post( db.conn(), data_dir, &project.id, - &title, + "", Some(""), Vec::new(), Vec::new(), @@ -681,7 +719,7 @@ impl BdsApp { let tab = Tab { id: post.id.clone(), tab_type: TabType::Post, - title: post.title.clone(), + title: display_title.to_string(), is_transient: true, is_dirty: false, }; @@ -713,7 +751,7 @@ impl BdsApp { MenuAction::Find => Task::none(), MenuAction::Replace => Task::none(), MenuAction::EditPreferences => { - self.open_singleton_tab(TabType::Settings, "Settings"); + self.open_singleton_tab(TabType::Settings, "common.settings"); Task::none() } // View @@ -733,7 +771,7 @@ impl BdsApp { MenuAction::PublishSelected => Task::none(), // Disabled in M2 MenuAction::PreviewPost => Task::none(), // Disabled in M2 MenuAction::EditMenu => { - self.open_singleton_tab(TabType::MenuEditor, "Menu Editor"); + self.open_singleton_tab(TabType::MenuEditor, "tabBar.menuEditor"); Task::none() } MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase), @@ -751,7 +789,7 @@ impl BdsApp { } MenuAction::GenerateSitemap => Task::done(Message::GenerateSite), MenuAction::ValidateSite => { - self.open_singleton_tab(TabType::SiteValidation, "Site Validation"); + self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation"); Task::none() } MenuAction::UploadSite => { @@ -780,7 +818,7 @@ impl BdsApp { Task::none() } MenuAction::OpenDocumentation => { - self.open_singleton_tab(TabType::Documentation, "Documentation"); + self.open_singleton_tab(TabType::Documentation, "tabBar.documentation"); Task::none() } MenuAction::ViewOnGitHub => { @@ -794,11 +832,12 @@ impl BdsApp { } } - fn open_singleton_tab(&mut self, tab_type: TabType, title: &str) { + fn open_singleton_tab(&mut self, tab_type: TabType, i18n_key: &str) { + let title = t(self.ui_locale, i18n_key); let tab = Tab { id: tab_type.singleton_id().to_string(), tab_type, - title: title.to_string(), + title, is_transient: false, is_dirty: false, }; @@ -806,6 +845,7 @@ impl BdsApp { if let Some(t) = self.tabs.get(idx) { self.active_tab = Some(t.id.clone()); } + self.enforce_panel_tab_availability(); } fn refresh_task_snapshots(&mut self) { @@ -815,7 +855,7 @@ impl BdsApp { .into_iter() .map(|(id, label, status, progress, message)| { let status_str = match &status { - TaskStatus::Queued => "queued".to_string(), + TaskStatus::Pending => "pending".to_string(), TaskStatus::Running => "running".to_string(), TaskStatus::Completed => "completed".to_string(), TaskStatus::Failed(e) => format!("failed: {e}"), diff --git a/crates/bds-ui/src/platform/menu.rs b/crates/bds-ui/src/platform/menu.rs index c2409b8..e7e6b64 100644 --- a/crates/bds-ui/src/platform/menu.rs +++ b/crates/bds-ui/src/platform/menu.rs @@ -28,7 +28,7 @@ pub enum MenuAction { ViewMedia, ToggleSidebar, TogglePanel, - // Blog + // Window PublishSelected, PreviewPost, EditMenu, @@ -249,28 +249,28 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) { let _ = view_menu.append(&PredefinedMenuItem::separator()); let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None)); - // -- Blog -- - let blog_menu = Submenu::new(translate(locale, "menu.group.blog"), true); - let _ = blog_menu.append(&item(&mut reg, MenuAction::PublishSelected, locale, + // -- Window -- + let window_menu = Submenu::new(translate(locale, "menu.group.window"), true); + let _ = window_menu.append(&item(&mut reg, MenuAction::PublishSelected, locale, Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyP)))); - let _ = blog_menu.append(&item(&mut reg, MenuAction::PreviewPost, locale, + let _ = window_menu.append(&item(&mut reg, MenuAction::PreviewPost, locale, Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyV)))); - let _ = blog_menu.append(&PredefinedMenuItem::separator()); - let _ = blog_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None)); - let _ = blog_menu.append(&PredefinedMenuItem::separator()); - let _ = blog_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None)); - let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None)); - let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None)); - let _ = blog_menu.append(&PredefinedMenuItem::separator()); - let _ = blog_menu.append(&item(&mut reg, MenuAction::RegenerateCalendar, locale, None)); - let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateTranslations, locale, None)); - let _ = blog_menu.append(&item(&mut reg, MenuAction::FillMissingTranslations, locale, None)); - let _ = blog_menu.append(&PredefinedMenuItem::separator()); - let _ = blog_menu.append(&item(&mut reg, MenuAction::GenerateSitemap, locale, + let _ = window_menu.append(&PredefinedMenuItem::separator()); + let _ = window_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None)); + let _ = window_menu.append(&PredefinedMenuItem::separator()); + let _ = window_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None)); + let _ = window_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None)); + let _ = window_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None)); + let _ = window_menu.append(&PredefinedMenuItem::separator()); + let _ = window_menu.append(&item(&mut reg, MenuAction::RegenerateCalendar, locale, None)); + let _ = window_menu.append(&item(&mut reg, MenuAction::ValidateTranslations, locale, None)); + let _ = window_menu.append(&item(&mut reg, MenuAction::FillMissingTranslations, locale, None)); + let _ = window_menu.append(&PredefinedMenuItem::separator()); + let _ = window_menu.append(&item(&mut reg, MenuAction::GenerateSitemap, locale, Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR)))); - let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateSite, locale, + let _ = window_menu.append(&item(&mut reg, MenuAction::ValidateSite, locale, Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyL)))); - let _ = blog_menu.append(&item(&mut reg, MenuAction::UploadSite, locale, + let _ = window_menu.append(&item(&mut reg, MenuAction::UploadSite, locale, Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU)))); // -- Help -- @@ -286,7 +286,7 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) { let _ = menu.append(&file_menu); let _ = menu.append(&edit_menu); let _ = menu.append(&view_menu); - let _ = menu.append(&blog_menu); + let _ = menu.append(&window_menu); let _ = menu.append(&help_menu); (menu, reg) diff --git a/crates/bds-ui/src/state/tabs.rs b/crates/bds-ui/src/state/tabs.rs index c23c8f0..2a29655 100644 --- a/crates/bds-ui/src/state/tabs.rs +++ b/crates/bds-ui/src/state/tabs.rs @@ -64,6 +64,24 @@ impl TabType { _ => "", } } + + /// Return the i18n key for the tab title of a singleton tab type. + /// Per tabs.allium: singletons use i18n key lookup. + pub fn i18n_key(&self) -> Option<&'static str> { + match self { + Self::Settings => Some("common.settings"), + Self::Style => Some("tabBar.style"), + Self::Tags => Some("tabBar.tags"), + Self::MenuEditor => Some("tabBar.menuEditor"), + Self::MetadataDiff => Some("tabBar.metadataDiff"), + Self::Documentation => Some("tabBar.documentation"), + Self::ApiDocumentation => Some("tabBar.apiDocumentation"), + Self::SiteValidation => Some("tabBar.siteValidation"), + Self::TranslationValidation => Some("tabBar.translationValidation"), + Self::FindDuplicates => Some("tabBar.findDuplicates"), + _ => None, + } + } } /// A single tab in the editor area. diff --git a/crates/bds-ui/src/views/panel.rs b/crates/bds-ui/src/views/panel.rs index c46afe7..6148370 100644 --- a/crates/bds-ui/src/views/panel.rs +++ b/crates/bds-ui/src/views/panel.rs @@ -127,8 +127,11 @@ pub fn view( .padding(8) .into() } else { + // Per layout.allium: last 10 tasks, newest first let items: Vec> = task_snapshots .iter() + .rev() + .take(10) .map(|snap| { let progress_str = snap.progress .map(|p| format!(" ({:.0}%)", p * 100.0)) diff --git a/crates/bds-ui/src/views/sidebar.rs b/crates/bds-ui/src/views/sidebar.rs index 4c39a0d..f2ed8f6 100644 --- a/crates/bds-ui/src/views/sidebar.rs +++ b/crates/bds-ui/src/views/sidebar.rs @@ -10,15 +10,10 @@ use crate::i18n::t; use crate::state::navigation::SidebarView; use crate::state::tabs::{Tab, TabType}; -/// Sidebar container style — dark background with right border separator. +/// Sidebar container style — dark background. fn sidebar_style(_theme: &Theme) -> container::Style { container::Style { background: Some(Background::Color(Color::from_rgb(0.16, 0.16, 0.20))), - border: Border { - color: Color::from_rgb(0.25, 0.25, 0.30), - width: 1.0, - radius: 0.0.into(), - }, ..container::Style::default() } } @@ -37,6 +32,20 @@ fn item_style(_theme: &Theme, status: button::Status) -> button::Style { } } +/// Sidebar item button style — active/selected. +fn item_active_style(_theme: &Theme, status: button::Status) -> button::Style { + let bg = match status { + button::Status::Hovered => Color::from_rgb(0.26, 0.26, 0.32), + _ => Color::from_rgb(0.22, 0.22, 0.28), + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: Color::WHITE, + border: Border::default(), + ..button::Style::default() + } +} + /// Get the appropriate empty-state message key for each sidebar view. fn placeholder_key(view: SidebarView) -> &'static str { match view { @@ -53,10 +62,26 @@ fn placeholder_key(view: SidebarView) -> &'static str { } } +/// sidebar_views.allium media_title_max_length = 60 +const MEDIA_TITLE_MAX_LEN: usize = 60; + +/// Truncate a media title to the max length, appending "..." if over limit. +/// Per sidebar_views.allium: JS hard limit of 60 chars on title (substring + "..."). +fn truncate_media_title(title: &str) -> String { + if title.chars().count() > MEDIA_TITLE_MAX_LEN { + let truncated: String = title.chars().take(MEDIA_TITLE_MAX_LEN).collect(); + format!("{truncated}...") + } else { + title.to_string() + } +} + pub fn view( sidebar_view: SidebarView, posts: &[Post], media: &[Media], + width: f32, + active_tab: Option<&str>, locale: UiLocale, ) -> Element<'static, Message> { let header_text = t(locale, sidebar_view.i18n_key()); @@ -76,30 +101,77 @@ pub fn view( .color(muted) .into() } else { - let items: Vec> = posts - .iter() - .map(|p| { - let status_indicator = match p.status { - bds_core::model::PostStatus::Draft => "\u{25CB} ", // ○ - bds_core::model::PostStatus::Published => "\u{25CF} ", // ● - bds_core::model::PostStatus::Archived => "\u{25A1} ", // □ - }; - let label = format!("{status_indicator}{}", p.title); - button(text(label).size(12).shaping(Shaping::Advanced)) - .on_press(Message::OpenTab(Tab { - id: p.id.clone(), - tab_type: TabType::Post, - title: p.title.clone(), - is_transient: true, - is_dirty: false, - })) - .padding([3, 6]) + let section_header = |label: &str| -> Element<'static, Message> { + text(label.to_string()) + .size(11) + .shaping(Shaping::Advanced) + .color(Color::from_rgb(0.55, 0.55, 0.60)) + .into() + }; + + let make_post_item = |p: &Post| -> Element<'static, Message> { + let is_active = active_tab == Some(p.id.as_str()); + let status_indicator = match p.status { + bds_core::model::PostStatus::Draft => "\u{25CB} ", + bds_core::model::PostStatus::Published => "\u{25CF} ", + bds_core::model::PostStatus::Archived => "\u{25A1} ", + }; + let label = format!("{status_indicator}{}", p.title); + let label_text = text(label) + .size(12) + .shaping(Shaping::Advanced) + .wrapping(iced::widget::text::Wrapping::None); + let style_fn = if is_active { item_active_style } else { item_style }; + button( + container(label_text) .width(Length::Fill) - .style(item_style) - .into() - }) - .collect(); - iced::widget::Column::with_children(items) + .clip(true) + ) + .on_press(Message::OpenTab(Tab { + id: p.id.clone(), + tab_type: TabType::Post, + title: p.title.clone(), + is_transient: true, + is_dirty: false, + })) + .padding([3, 6]) + .width(Length::Fill) + .style(style_fn) + .into() + }; + + let mut sections: Vec> = Vec::new(); + + // Draft section + let drafts: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Draft).collect(); + if !drafts.is_empty() { + sections.push(section_header(&t(locale, "sidebar.drafts"))); + for p in &drafts { + sections.push(make_post_item(p)); + } + sections.push(Space::with_height(6.0).into()); + } + + // Published section + let published: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Published).collect(); + if !published.is_empty() { + sections.push(section_header(&t(locale, "sidebar.published"))); + for p in &published { + sections.push(make_post_item(p)); + } + sections.push(Space::with_height(6.0).into()); + } + + // Archived section + let archived: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Archived).collect(); + if !archived.is_empty() { + sections.push(section_header(&t(locale, "sidebar.archived"))); + for p in &archived { + sections.push(make_post_item(p)); + } + } + + iced::widget::Column::with_children(sections) .spacing(1) .into() } @@ -115,20 +187,34 @@ pub fn view( let items: Vec> = media .iter() .map(|m| { - let display_name = m.title.as_deref() - .unwrap_or(&m.original_name); + let is_active = active_tab == Some(m.id.as_str()); + // Per sidebar_views.allium MediaGridItem: title truncated to 60 chars + "..." + // if over limit; fallback originalName (no truncation). + let display_name = match m.title.as_deref() { + Some(title) => truncate_media_title(title), + None => m.original_name.clone(), + }; let label = format!("\u{1F5BC} {display_name}"); - button(text(label).size(12).shaping(Shaping::Advanced)) + let label_text = text(label) + .size(12) + .shaping(Shaping::Advanced) + .wrapping(iced::widget::text::Wrapping::None); + let style_fn = if is_active { item_active_style } else { item_style }; + button( + container(label_text) + .width(Length::Fill) + .clip(true) + ) .on_press(Message::OpenTab(Tab { id: m.id.clone(), tab_type: TabType::Media, - title: display_name.to_string(), + title: display_name.clone(), is_transient: true, is_dirty: false, })) .padding([3, 6]) .width(Length::Fill) - .style(item_style) + .style(style_fn) .into() }) .collect(); @@ -154,9 +240,41 @@ pub fn view( .spacing(4) .padding(12); + // layout.allium: sidebar width is resizable, passed as parameter container(scrollable(content)) - .width(Length::Fixed(280.0)) + .width(Length::Fixed(width)) .height(Length::Fill) .style(sidebar_style) .into() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_media_title_short() { + assert_eq!(truncate_media_title("short title"), "short title"); + } + + #[test] + fn truncate_media_title_exact_60() { + let title: String = "a".repeat(60); + assert_eq!(truncate_media_title(&title), title); + } + + #[test] + fn truncate_media_title_over_60() { + let title: String = "a".repeat(65); + let expected = format!("{}...", "a".repeat(60)); + assert_eq!(truncate_media_title(&title), expected); + } + + #[test] + fn truncate_media_title_unicode() { + // 61 Unicode chars should trigger truncation + let title: String = "\u{00FC}".repeat(61); // ü × 61 + let expected = format!("{}...", "\u{00FC}".repeat(60)); + assert_eq!(truncate_media_title(&title), expected); + } +} diff --git a/crates/bds-ui/src/views/tab_bar.rs b/crates/bds-ui/src/views/tab_bar.rs index fde3cab..8adda16 100644 --- a/crates/bds-ui/src/views/tab_bar.rs +++ b/crates/bds-ui/src/views/tab_bar.rs @@ -1,10 +1,20 @@ -use iced::widget::{button, container, row, text, Space}; +use iced::widget::{button, container, row, scrollable, text, tooltip, Space}; +use iced::widget::scrollable::Direction; use iced::widget::text::Shaping; +use iced::widget::tooltip::Position; use iced::{Background, Border, Color, Element, Font, Length, Theme}; +use bds_core::i18n::UiLocale; + use crate::app::Message; +use crate::i18n::t; use crate::state::tabs::Tab; +/// tabs.allium config: tab_min_width=100, tab_max_width=160 +/// In a scrollable tab bar, tabs use the max width since they never need to shrink. +const TAB_WIDTH: f32 = 160.0; +const CHAT_TITLE_MAX_LEN: usize = 18; + /// Tab bar background. fn bar_style(_theme: &Theme) -> container::Style { container::Style { @@ -64,9 +74,49 @@ fn close_style(_theme: &Theme, status: button::Status) -> button::Style { } } +/// Tooltip style. +fn tooltip_style(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(Color::from_rgb(0.20, 0.20, 0.24))), + border: Border { + color: Color::from_rgb(0.35, 0.35, 0.40), + width: 1.0, + radius: 4.0.into(), + }, + ..container::Style::default() + } +} + +/// Truncate chat title per tabs.allium: chat_title_max_length = 18. +fn truncate_chat_title(title: &str) -> String { + if title.chars().count() > CHAT_TITLE_MAX_LEN { + let truncated: String = title.chars().take(CHAT_TITLE_MAX_LEN).collect(); + format!("{truncated}...") + } else { + title.to_string() + } +} + +/// Build tooltip text per tabs.allium: +/// Base: tab title. If transient: append " (Preview)". If dirty: append " * Modified". +fn build_tooltip_text(tab: &Tab, locale: UiLocale) -> String { + let mut tip = tab.title.clone(); + if tab.is_transient { + tip.push_str(" ("); + tip.push_str(&t(locale, "tabBar.preview")); + tip.push(')'); + } + if tab.is_dirty { + tip.push_str(" * "); + tip.push_str(&t(locale, "tabBar.modified")); + } + tip +} + pub fn view( tabs: &[Tab], active_tab: Option<&str>, + locale: UiLocale, ) -> Element<'static, Message> { // Per tabs.allium: "Hidden when no tabs exist." if tabs.is_empty() { @@ -80,16 +130,25 @@ pub fn view( let tab_id = tab.id.clone(); let close_id = tab.id.clone(); + // Per tabs.allium: chat titles are JS-truncated to 18 chars + "..." + let display_title = if tab.tab_type == crate::state::tabs::TabType::Chat { + truncate_chat_title(&tab.title) + } else { + tab.title.clone() + }; + // Per tabs.allium: transient tabs show italic title let title_label = if tab.is_transient { - text(tab.title.clone()) + text(display_title) .size(12) .shaping(Shaping::Advanced) .font(Font { style: iced::font::Style::Italic, ..Font::DEFAULT }) + .wrapping(iced::widget::text::Wrapping::None) } else { - text(tab.title.clone()) + text(display_title) .size(12) .shaping(Shaping::Advanced) + .wrapping(iced::widget::text::Wrapping::None) }; // Per tabs.allium DirtyIndicator: dot for dirty post tabs @@ -100,32 +159,85 @@ pub fn view( text("").size(10) }; + // Title + dirty indicator clipped to enforce ellipsis-like truncation + let title_area = container( + row![title_label, dirty_indicator] + .spacing(2) + .align_y(iced::Alignment::Center) + ) + .width(Length::Fill) + .clip(true); + let label = row![ - title_label, - dirty_indicator, + title_area, button(text("\u{2715}").size(10).shaping(Shaping::Advanced)) .on_press(Message::CloseTab(close_id)) .padding(2) .style(close_style), ] - .spacing(6) + .spacing(4) .align_y(iced::Alignment::Center); - button(label) + // tabs.allium: tab_min_width=100, tab_max_width=160 + let tab_btn = button(label) .on_press(Message::SelectTab(tab_id)) - .padding([6, 12]) - .style(if is_active { tab_active } else { tab_inactive }) - .into() + .padding([6, 8]) + .width(Length::Fixed(TAB_WIDTH)) + .style(if is_active { tab_active } else { tab_inactive }); + + // tabs.allium tooltip: title + "(Preview)" if transient + "* Modified" if dirty + let tooltip_text = build_tooltip_text(tab, locale); + let tip: Element<'static, Message> = tooltip( + tab_btn, + text(tooltip_text).size(11).shaping(Shaping::Advanced), + Position::Bottom, + ) + .gap(4) + .style(tooltip_style) + .into(); + + tip }) .collect(); - container( - iced::widget::Row::with_children(tab_buttons) - .spacing(1) - .height(Length::Fixed(35.0)), - ) - .width(Length::Fill) - .height(Length::Fixed(35.0)) - .style(bar_style) - .into() + // tabs.allium: horizontal strip with overflow scroll + let tab_row = iced::widget::Row::with_children(tab_buttons) + .spacing(1) + .height(Length::Fixed(35.0)); + + let scrollable_tabs = scrollable(tab_row) + .direction(Direction::Horizontal( + scrollable::Scrollbar::new().width(0).scroller_width(0), + )) + .width(Length::Fill) + .height(Length::Fixed(35.0)); + + container(scrollable_tabs) + .width(Length::Fill) + .height(Length::Fixed(35.0)) + .style(bar_style) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_chat_title_short() { + assert_eq!(truncate_chat_title("Hello"), "Hello"); + } + + #[test] + fn truncate_chat_title_exact_18() { + let title: String = "a".repeat(18); + assert_eq!(truncate_chat_title(&title), title); + } + + #[test] + fn truncate_chat_title_over_18() { + let title: String = "a".repeat(25); + let expected = format!("{}...", "a".repeat(18)); + assert_eq!(truncate_chat_title(&title), expected); + } } diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index 45ab911..94b9ab3 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -1,4 +1,4 @@ -use iced::widget::{button, column, container, row, stack, text, Space}; +use iced::widget::{button, column, container, mouse_area, row, stack, text, Space}; use iced::widget::text::Shaping; use iced::{Alignment, Background, Color, Element, Length, Padding, Theme}; @@ -19,15 +19,12 @@ fn content_bg(_theme: &Theme) -> container::Style { } } -/// Horizontal separator line between regions. -fn separator_v() -> iced::widget::Container<'static, Message> { - container(Space::new(0, 0)) - .width(Length::Fixed(1.0)) - .height(Length::Fill) - .style(|_theme: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))), - ..container::Style::default() - }) +/// Sidebar resize drag handle style. +fn drag_handle_style(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))), + ..container::Style::default() + } } /// Horizontal line separator (full width). @@ -46,6 +43,7 @@ pub fn view( // Navigation sidebar_view: SidebarView, sidebar_visible: bool, + sidebar_width: f32, // Tabs tabs: &[Tab], active_tab: Option<&str>, @@ -76,7 +74,7 @@ pub fn view( let activity = activity_bar::view(sidebar_view, sidebar_visible, locale); // Tab bar - let tabs_el = tab_bar::view(tabs, active_tab); + let tabs_el = tab_bar::view(tabs, active_tab, locale); // Content area let content_area = welcome::view(locale); @@ -104,12 +102,25 @@ pub fn view( .height(Length::Fill) .style(content_bg); - // Main row: activity bar | separator | sidebar? | separator | right column + // Main row: activity bar | sidebar? | drag handle | right column let mut main_row = row![activity]; if sidebar_visible { - main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, locale)); - main_row = main_row.push(separator_v()); + main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_width, active_tab, locale)); + + // Resize drag handle: 4px wide strip between sidebar and content + let handle = container(Space::new(0, 0)) + .width(Length::Fixed(4.0)) + .height(Length::Fill) + .style(drag_handle_style); + + let handle_hover = mouse_area(handle) + .on_press(Message::SidebarResizeStart) + .on_release(Message::SidebarResizeEnd) + .on_move(|point| Message::SidebarResizeMove(point.x)) + .interaction(iced::mouse::Interaction::ResizingHorizontally); + + main_row = main_row.push(handle_hover); } main_row = main_row.push(right); diff --git a/locales/ui/de.json b/locales/ui/de.json index a4a413e..7c0d83b 100644 --- a/locales/ui/de.json +++ b/locales/ui/de.json @@ -2,7 +2,7 @@ "menu.group.file": "Datei", "menu.group.edit": "Bearbeiten", "menu.group.view": "Ansicht", - "menu.group.blog": "Blogbereich", + "menu.group.window": "Fenster", "menu.group.help": "Hilfe", "menu.item.newPost": "Neuer Beitrag", "menu.item.importMedia": "Medien importieren...", @@ -88,6 +88,16 @@ "tabBar.loading": "Laden...", "tabBar.unknown": "Unbekannt", "tabBar.style": "Stil", + "tabBar.menuEditor": "Menü-Editor", + "tabBar.metadataDiff": "Metadaten-Diff", + "tabBar.siteValidation": "Website-Validierung", + "tabBar.translationValidation": "Übersetzungsvalidierung", + "tabBar.documentation": "Dokumentation", + "tabBar.apiDocumentation": "API-Dokumentation", + "tabBar.findDuplicates": "Duplikate finden", + "tabBar.tags": "Tags", + "tabBar.preview": "Vorschau", + "tabBar.modified": "Geändert", "sidebar.noPostsYet": "Noch keine Beiträge", "sidebar.noPagesYet": "Noch keine Seiten", "sidebar.noMediaYet": "Noch keine Medien", @@ -99,6 +109,9 @@ "sidebar.loading": "Lädt...", "sidebar.settingsHeader": "Einstellungen", "sidebar.tagsHeader": "Schlagwörter", + "sidebar.drafts": "Entwürfe", + "sidebar.published": "Veröffentlicht", + "sidebar.archived": "Archiviert", "language.en": "Englisch", "language.de": "Deutsch", "language.fr": "Französisch", diff --git a/locales/ui/en.json b/locales/ui/en.json index 90e9601..648c095 100644 --- a/locales/ui/en.json +++ b/locales/ui/en.json @@ -2,7 +2,7 @@ "menu.group.file": "File", "menu.group.edit": "Edit", "menu.group.view": "View", - "menu.group.blog": "Blog", + "menu.group.window": "Window", "menu.group.help": "Help", "menu.item.newPost": "New Post", "menu.item.importMedia": "Import Media...", @@ -88,6 +88,16 @@ "tabBar.loading": "Loading...", "tabBar.unknown": "Unknown", "tabBar.style": "Style", + "tabBar.menuEditor": "Menu Editor", + "tabBar.metadataDiff": "Metadata Diff", + "tabBar.siteValidation": "Site Validation", + "tabBar.translationValidation": "Translation Validation", + "tabBar.documentation": "Documentation", + "tabBar.apiDocumentation": "API Documentation", + "tabBar.findDuplicates": "Find Duplicates", + "tabBar.tags": "Tags", + "tabBar.preview": "Preview", + "tabBar.modified": "Modified", "sidebar.noPostsYet": "No posts yet", "sidebar.noPagesYet": "No pages yet", "sidebar.noMediaYet": "No media yet", @@ -99,6 +109,9 @@ "sidebar.loading": "Loading...", "sidebar.settingsHeader": "Settings", "sidebar.tagsHeader": "Tags", + "sidebar.drafts": "Drafts", + "sidebar.published": "Published", + "sidebar.archived": "Archived", "language.en": "English", "language.de": "German", "language.fr": "French", diff --git a/locales/ui/es.json b/locales/ui/es.json index e585a4d..010c998 100644 --- a/locales/ui/es.json +++ b/locales/ui/es.json @@ -2,7 +2,7 @@ "menu.group.file": "Archivo", "menu.group.edit": "Editar", "menu.group.view": "Ver", - "menu.group.blog": "Bitácora", + "menu.group.window": "Ventana", "menu.group.help": "Ayuda", "menu.item.newPost": "Nueva entrada", "menu.item.importMedia": "Importar medios...", @@ -88,6 +88,16 @@ "tabBar.loading": "Cargando...", "tabBar.unknown": "Desconocido", "tabBar.style": "Estilo", + "tabBar.menuEditor": "Editor de menú", + "tabBar.metadataDiff": "Diff de metadatos", + "tabBar.siteValidation": "Validación del sitio", + "tabBar.translationValidation": "Validación de traducciones", + "tabBar.documentation": "Documentación", + "tabBar.apiDocumentation": "Documentación API", + "tabBar.findDuplicates": "Buscar duplicados", + "tabBar.tags": "Etiquetas", + "tabBar.preview": "Vista previa", + "tabBar.modified": "Modificado", "sidebar.noPostsYet": "Aún no hay entradas", "sidebar.noPagesYet": "Aún no hay páginas", "sidebar.noMediaYet": "Aún no hay medios", @@ -99,6 +109,9 @@ "sidebar.loading": "Cargando...", "sidebar.settingsHeader": "Configuración", "sidebar.tagsHeader": "Etiquetas", + "sidebar.drafts": "Borradores", + "sidebar.published": "Publicados", + "sidebar.archived": "Archivados", "language.en": "Inglés", "language.de": "Alemán", "language.fr": "Francés", diff --git a/locales/ui/fr.json b/locales/ui/fr.json index 40845b6..f2c3e3d 100644 --- a/locales/ui/fr.json +++ b/locales/ui/fr.json @@ -2,7 +2,7 @@ "menu.group.file": "Fichier", "menu.group.edit": "Édition", "menu.group.view": "Affichage", - "menu.group.blog": "Espace blog", + "menu.group.window": "Fenêtre", "menu.group.help": "Aide", "menu.item.newPost": "Nouvel article", "menu.item.importMedia": "Importer des médias...", @@ -88,6 +88,16 @@ "tabBar.loading": "Chargement...", "tabBar.unknown": "Inconnu", "tabBar.style": "Style", + "tabBar.menuEditor": "Éditeur de menu", + "tabBar.metadataDiff": "Diff métadonnées", + "tabBar.siteValidation": "Validation du site", + "tabBar.translationValidation": "Validation des traductions", + "tabBar.documentation": "Documentation", + "tabBar.apiDocumentation": "Documentation API", + "tabBar.findDuplicates": "Trouver les doublons", + "tabBar.tags": "Tags", + "tabBar.preview": "Aperçu", + "tabBar.modified": "Modifié", "sidebar.noPostsYet": "Aucun article pour le moment", "sidebar.noPagesYet": "Aucune page pour le moment", "sidebar.noMediaYet": "Aucun média pour le moment", @@ -99,6 +109,9 @@ "sidebar.loading": "Chargement...", "sidebar.settingsHeader": "Paramètres", "sidebar.tagsHeader": "Étiquettes", + "sidebar.drafts": "Brouillons", + "sidebar.published": "Publiés", + "sidebar.archived": "Archivés", "language.en": "Anglais", "language.de": "Allemand", "language.fr": "Français", diff --git a/locales/ui/it.json b/locales/ui/it.json index 08f9501..aaff798 100644 --- a/locales/ui/it.json +++ b/locales/ui/it.json @@ -2,7 +2,7 @@ "menu.group.file": "Archivio", "menu.group.edit": "Modifica", "menu.group.view": "Vista", - "menu.group.blog": "Sezione blog", + "menu.group.window": "Finestra", "menu.group.help": "Aiuto", "menu.item.newPost": "Nuovo post", "menu.item.importMedia": "Importa media...", @@ -88,6 +88,16 @@ "tabBar.loading": "Caricamento...", "tabBar.unknown": "Sconosciuto", "tabBar.style": "Stile", + "tabBar.menuEditor": "Editor menu", + "tabBar.metadataDiff": "Diff metadati", + "tabBar.siteValidation": "Validazione sito", + "tabBar.translationValidation": "Validazione traduzioni", + "tabBar.documentation": "Documentazione", + "tabBar.apiDocumentation": "Documentazione API", + "tabBar.findDuplicates": "Trova duplicati", + "tabBar.tags": "Tag", + "tabBar.preview": "Anteprima", + "tabBar.modified": "Modificato", "sidebar.noPostsYet": "Nessun post", "sidebar.noPagesYet": "Nessuna pagina", "sidebar.noMediaYet": "Nessun media", @@ -99,6 +109,9 @@ "sidebar.loading": "Caricamento...", "sidebar.settingsHeader": "Impostazioni", "sidebar.tagsHeader": "Tag", + "sidebar.drafts": "Bozze", + "sidebar.published": "Pubblicati", + "sidebar.archived": "Archiviati", "language.en": "Inglese", "language.de": "Tedesco", "language.fr": "Francese",