From cea493a58055781dd03e35cbc22757861b3c76bb Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Fri, 23 Oct 2026 01:09:11 +0300
Subject: [PATCH 01/22] Require laravel/mcp and test the AI assistant MCP
server
laravel/mcp moves from a dev-only dependency to require, so production
installs can serve the AI assistant plugin's MCP endpoint. Adds tests
for the MCP endpoint auth (site JWT in X-Authorization), the
list-sections tool, SiteContentReader and the chat prompt's sections
text.
Co-Authored-By: Claude Opus 5.5
---
_api_app/composer.json | 1 +
_api_app/composer.lock | 151 +++++++++---------
_api_app/tests/Feature/AiAssistantMcpTest.php | 62 +++++++
.../tests/Feature/AiChatControllerTest.php | 35 ++++
.../tests/Feature/SiteContentReaderTest.php | 47 ++++++
5 files changed, 221 insertions(+), 75 deletions(-)
create mode 100644 _api_app/tests/Feature/AiAssistantMcpTest.php
create mode 100644 _api_app/tests/Feature/SiteContentReaderTest.php
diff --git a/_api_app/composer.json b/_api_app/composer.json
index 5f5168a5e..bd62949d3 100644
--- a/_api_app/composer.json
+++ b/_api_app/composer.json
@@ -23,6 +23,7 @@
"intervention/image": "^3.11",
"laravel/ai": "^1.0",
"laravel/framework": "^13.30",
+ "laravel/mcp": "^1.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0",
"mobiledetect/mobiledetectlib": "^4.8",
diff --git a/_api_app/composer.lock b/_api_app/composer.lock
index c4b495e5b..13b3e855d 100644
--- a/_api_app/composer.lock
+++ b/_api_app/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "27814c6f2a0f2dd4cfce7cd20e95ec08",
+ "content-hash": "863e8d78c2d8d8dd0e23b9fe373d1dd3",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
@@ -1880,6 +1880,81 @@
},
"time": "2026-09-29T12:46:21+00:00"
},
+ {
+ "name": "laravel/mcp",
+ "version": "v1.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/mcp.git",
+ "reference": "e77af70cd009aaa31455626bd463b0e7e39cfcf9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/mcp/zipball/e77af70cd009aaa31455626bd463b0e7e39cfcf9",
+ "reference": "e77af70cd009aaa31455626bd463b0e7e39cfcf9",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "illuminate/console": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/container": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/http": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/json-schema": "^12.41.1|^13.0",
+ "illuminate/routing": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/support": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/validation": "^11.45.3|^12.41.1|^13.0",
+ "php": "^8.2",
+ "symfony/process": "^7.4.5|^8.0.5"
+ },
+ "require-dev": {
+ "jasonmccreary/double": "^1.0",
+ "laravel/pint": "^1.20",
+ "orchestra/testbench": "^9.15|^10.8|^11.0",
+ "pestphp/pest": "^3.8.5|^4.3.2",
+ "phpstan/phpstan": "^2.1.27",
+ "rector/rector": "^2.2.4"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Mcp": "Laravel\\Mcp\\Facades\\Mcp"
+ },
+ "providers": [
+ "Laravel\\Mcp\\Server\\McpServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Mcp\\": "src/",
+ "Laravel\\Mcp\\Server\\": "src/Server/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Rapidly build MCP servers for your Laravel applications.",
+ "homepage": "https://github.com/laravel/mcp",
+ "keywords": [
+ "laravel",
+ "mcp"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/mcp/issues",
+ "source": "https://github.com/laravel/mcp"
+ },
+ "time": "2026-10-21T11:50:42+00:00"
+ },
{
"name": "laravel/prompts",
"version": "v0.3.24",
@@ -8161,80 +8236,6 @@
},
"time": "2026-09-23T09:33:31+00:00"
},
- {
- "name": "laravel/mcp",
- "version": "v1.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/laravel/mcp.git",
- "reference": "92987a9d03847801299ff4abe909ba7686147dde"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/laravel/mcp/zipball/92987a9d03847801299ff4abe909ba7686147dde",
- "reference": "92987a9d03847801299ff4abe909ba7686147dde",
- "shasum": ""
- },
- "require": {
- "ext-json": "*",
- "ext-mbstring": "*",
- "illuminate/console": "^11.45.3|^12.41.1|^13.0",
- "illuminate/container": "^11.45.3|^12.41.1|^13.0",
- "illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
- "illuminate/http": "^11.45.3|^12.41.1|^13.0",
- "illuminate/json-schema": "^12.41.1|^13.0",
- "illuminate/routing": "^11.45.3|^12.41.1|^13.0",
- "illuminate/support": "^11.45.3|^12.41.1|^13.0",
- "illuminate/validation": "^11.45.3|^12.41.1|^13.0",
- "php": "^8.2",
- "symfony/process": "^7.4.5|^8.0.5"
- },
- "require-dev": {
- "laravel/pint": "^1.20",
- "orchestra/testbench": "^9.15|^10.8|^11.0",
- "pestphp/pest": "^3.8.5|^4.3.2",
- "phpstan/phpstan": "^2.1.27",
- "rector/rector": "^2.2.4"
- },
- "type": "library",
- "extra": {
- "laravel": {
- "aliases": {
- "Mcp": "Laravel\\Mcp\\Facades\\Mcp"
- },
- "providers": [
- "Laravel\\Mcp\\Server\\McpServiceProvider"
- ]
- }
- },
- "autoload": {
- "psr-4": {
- "Laravel\\Mcp\\": "src/",
- "Laravel\\Mcp\\Server\\": "src/Server/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Taylor Otwell",
- "email": "taylor@laravel.com"
- }
- ],
- "description": "Rapidly build MCP servers for your Laravel applications.",
- "homepage": "https://github.com/laravel/mcp",
- "keywords": [
- "laravel",
- "mcp"
- ],
- "support": {
- "issues": "https://github.com/laravel/mcp/issues",
- "source": "https://github.com/laravel/mcp"
- },
- "time": "2026-09-24T10:45:09+00:00"
- },
{
"name": "laravel/pint",
"version": "v1.32.1",
diff --git a/_api_app/tests/Feature/AiAssistantMcpTest.php b/_api_app/tests/Feature/AiAssistantMcpTest.php
new file mode 100644
index 000000000..492ca11c3
--- /dev/null
+++ b/_api_app/tests/Feature/AiAssistantMcpTest.php
@@ -0,0 +1,62 @@
+ '2.0', 'id' => 1, 'method' => 'ping'])
+ ->assertStatus(401);
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+it('accepts mcp requests with a site jwt in the X-Authorization header', function () {
+ $token = JWT::encode(['iat' => time(), 'exp' => time() + 300, 'uid' => 1], config('app.key'), 'HS256');
+
+ postJson('/v1/plugin/ai/mcp', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping'], ['X-Authorization' => "Bearer {$token}"])
+ ->assertOk()
+ ->assertJsonPath('id', 1);
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+describe('list-sections tool', function () {
+ beforeEach(function () {
+ $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+
+ // SetupMiddleware reconciles the real .env file on every request.
+ withoutMiddleware(SetupMiddleware::class);
+ });
+
+ afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+ });
+
+ // Normalization cases are covered in SiteContentReaderTest.
+ it('returns the main site sections as structured content', function () {
+ file_put_contents(
+ $this->bertaRoot . '/storage/sections.xml',
+ ''
+ . ''
+ . ' ',
+ );
+
+ BertaSiteServer::actingAs(new User)
+ ->tool(ListSectionsTool::class)
+ ->assertOk()
+ // The hosting gateway calls the tool by this name.
+ ->assertName('list-sections')
+ ->assertStructuredContent(fn (AssertableJson $json) => $json->where('sections', [[
+ 'name' => 'shop', 'title' => 'Shop', 'type' => 'shop', 'published' => true, 'entry_count' => 2, 'order' => 0,
+ 'seo' => ['title' => '', 'keywords' => '', 'description' => ''],
+ ]]));
+ });
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/AiChatControllerTest.php b/_api_app/tests/Feature/AiChatControllerTest.php
index 28c6ca78a..68847bb01 100644
--- a/_api_app/tests/Feature/AiChatControllerTest.php
+++ b/_api_app/tests/Feature/AiChatControllerTest.php
@@ -3,6 +3,8 @@
use App\Plugins\AiAssistant\AiChatController;
use App\Plugins\AiAssistant\AiChatRequest;
use App\Plugins\AiAssistant\AssistantAgent;
+use App\Plugins\AiAssistant\SiteContentReader;
+use Illuminate\Support\Facades\File;
use function Pest\Laravel\post;
@@ -335,3 +337,36 @@
->toContain('file_index')
->toContain('manual');
})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+it('describes current sections in the system prompt', function () {
+ $bertaRoot = sys_get_temp_dir() . '/berta_ai_sections_' . uniqid();
+ File::ensureDirectoryExists($bertaRoot . '/storage');
+ config(['app.old_berta_root' => $bertaRoot]);
+ file_put_contents(
+ $bertaRoot . '/storage/sections.xml',
+ ''
+ . ''
+ . ''
+ . ''
+ . ' ',
+ );
+
+ try {
+ $controller = new AiChatController;
+ $method = new ReflectionMethod($controller, 'buildSectionsSection');
+
+ $result = $method->invoke($controller, (new SiteContentReader)->sections());
+ } finally {
+ File::deleteDirectory($bertaRoot);
+ }
+
+ expect($result)->toContain(
+ "### Current Sections\n"
+ . " order 0: \"Works\" (name: works, type: default, published: 1)\n"
+ . " order 1: \"Shop\" (name: shop, type: shop, published: 0)\n"
+ . " SEO — title: \"Buy prints\", keywords: \"prints, art\", description: \"Prints shop\"\n"
+ . " order 2: \"About\" (name: about, type: default, published: 1)\n"
+ . "\n### Operations",
+ );
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php
new file mode 100644
index 000000000..e51381b27
--- /dev/null
+++ b/_api_app/tests/Feature/SiteContentReaderTest.php
@@ -0,0 +1,47 @@
+bertaRoot = sys_get_temp_dir() . '/berta_reader_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+it('normalizes sections from storage', function () {
+ file_put_contents(
+ $this->bertaRoot . '/storage/sections.xml',
+ ''
+ . ''
+ . ''
+ . ''
+ . ' ',
+ );
+
+ expect((new SiteContentReader)->sections())->toBe([
+ [
+ 'name' => 'works', 'title' => 'Works', 'type' => 'default', 'published' => true, 'entry_count' => 3, 'order' => 0,
+ 'seo' => ['title' => '', 'keywords' => '', 'description' => ''],
+ ],
+ [
+ 'name' => 'shop', 'title' => '', 'type' => 'shop', 'published' => false, 'entry_count' => 2, 'order' => 1,
+ 'seo' => ['title' => 'Buy prints', 'keywords' => '', 'description' => ''],
+ ],
+ [
+ 'name' => 'about', 'title' => 'About', 'type' => 'default', 'published' => true, 'entry_count' => 0, 'order' => 2,
+ 'seo' => ['title' => '', 'keywords' => '', 'description' => ''],
+ ],
+ ]);
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+it('returns no sections when the site has no sections file', function () {
+ expect((new SiteContentReader)->sections())->toBe([]);
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
From 8208d503da80154525e672fbba832ca124a64ac4 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Fri, 23 Oct 2026 12:22:59 +0300
Subject: [PATCH 02/22] Share the entry title rule and test the list-entries
MCP tool
SectionEntryRenderService::showsEntryTitles() holds the rule "entry
titles are shown on the default template and in portfolio sections",
used by the renderer and the AI assistant plugin.
Tests cover the chat's entries tool output, SiteContentReader entries
(title rule, unknown and ../ section names) and the list-entries tool.
New linkBertaEngine() and writeTemplate() test helpers; the shop test
root now uses linkBertaEngine() too.
Co-Authored-By: Claude Opus 5.5
---
.../Entries/SectionEntryRenderService.php | 10 ++-
_api_app/tests/Feature/AiAssistantMcpTest.php | 57 +++++++++++++
.../Feature/ListSectionEntriesToolTest.php | 55 +++++++++++++
.../tests/Feature/SiteContentReaderTest.php | 79 +++++++++++++++++++
_api_app/tests/Pest.php | 33 ++++++--
5 files changed, 227 insertions(+), 7 deletions(-)
create mode 100644 _api_app/tests/Feature/ListSectionEntriesToolTest.php
diff --git a/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php b/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php
index d1464c5bb..1a2c4c67f 100644
--- a/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php
+++ b/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php
@@ -35,6 +35,14 @@ public function __construct()
$this->galleryGridRenderService = new GalleryGridRenderService;
}
+ /**
+ * Whether entry titles are shown: on the "default" template and in portfolio sections.
+ */
+ public static function showsEntryTitles(string $templateName, ?string $sectionType): bool
+ {
+ return $sectionType == 'portfolio' || $templateName == 'default';
+ }
+
/**
* Prepare data for view
*/
@@ -57,7 +65,7 @@ public function getViewData(
$isResponsiveTemplate = isset($siteTemplateSettings['pageLayout']['responsive']) && $siteTemplateSettings['pageLayout']['responsive'] == 'yes';
$isResponsive = $sectionType == 'portfolio' || $isResponsiveTemplate;
- if (($sectionType == 'portfolio' || $templateName == 'default') && ($isEditMode || (isset($entry['content']['title']) && ! empty($entry['content']['title'])))) {
+ if (self::showsEntryTitles($templateName, $sectionType) && ($isEditMode || (isset($entry['content']['title']) && ! empty($entry['content']['title'])))) {
$entryTitle = view('Sites/Sections/Entries/_entryTitle', array_merge($entry, [
'attributes' => [
'title' => Helpers::arrayToHtmlAttributes([
diff --git a/_api_app/tests/Feature/AiAssistantMcpTest.php b/_api_app/tests/Feature/AiAssistantMcpTest.php
index 492ca11c3..f66f956c0 100644
--- a/_api_app/tests/Feature/AiAssistantMcpTest.php
+++ b/_api_app/tests/Feature/AiAssistantMcpTest.php
@@ -3,6 +3,7 @@
use App\Http\Middleware\SetupMiddleware;
use App\Models\User;
use App\Plugins\AiAssistant\Mcp\BertaSiteServer;
+use App\Plugins\AiAssistant\Mcp\Tools\ListEntriesTool;
use App\Plugins\AiAssistant\Mcp\Tools\ListSectionsTool;
use Firebase\JWT\JWT;
use Illuminate\Support\Facades\File;
@@ -60,3 +61,59 @@
]]));
});
})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+describe('list-entries tool', function () {
+ beforeEach(function () {
+ $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ // The default template shows entry titles; the title rule is covered in SiteContentReaderTest.
+ linkBertaEngine($this->bertaRoot);
+ writeTemplate($this->bertaRoot, 'default');
+ file_put_contents(
+ $this->bertaRoot . '/storage/sections.xml',
+ ' ',
+ );
+
+ // SetupMiddleware reconciles the real .env file on every request.
+ withoutMiddleware(SetupMiddleware::class);
+ });
+
+ afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+ });
+
+ // Normalization cases are covered in SiteContentReaderTest.
+ it('returns the entries of a section as structured content', function () {
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ ' '
+ . 'Hello
]]>',
+ );
+
+ BertaSiteServer::actingAs(new User)
+ ->tool(ListEntriesTool::class, ['section' => 'works'])
+ ->assertOk()
+ // The hosting gateway calls the tool by this name.
+ ->assertName('list-entries')
+ ->assertStructuredContent(fn (AssertableJson $json) => $json
+ ->where('section', 'works')
+ ->where('entry_titles', true)
+ ->where('entries', [[
+ 'id' => '1', 'title' => 'First', 'description' => 'Hello
', 'order' => 0,
+ 'gallery' => ['type' => null, 'file_count' => 0, 'image_count' => 0, 'video_count' => 0],
+ ]]));
+ });
+
+ it('rejects sections that do not exist', function () {
+ BertaSiteServer::actingAs(new User)
+ ->tool(ListEntriesTool::class, ['section' => '../sections'])
+ ->assertHasErrors(['Section "../sections" not found. Call list-sections to see the sections.']);
+ });
+
+ it('requires the section argument', function () {
+ BertaSiteServer::actingAs(new User)
+ ->tool(ListEntriesTool::class, [])
+ ->assertHasErrors(['Provide the section name. Call list-sections to see the sections.']);
+ });
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/ListSectionEntriesToolTest.php b/_api_app/tests/Feature/ListSectionEntriesToolTest.php
new file mode 100644
index 000000000..dbe9409a7
--- /dev/null
+++ b/_api_app/tests/Feature/ListSectionEntriesToolTest.php
@@ -0,0 +1,55 @@
+bertaRoot = sys_get_temp_dir() . '/berta_chat_entries_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ // The default template shows entry titles.
+ linkBertaEngine($this->bertaRoot);
+ writeTemplate($this->bertaRoot, 'default');
+ file_put_contents(
+ $this->bertaRoot . '/storage/sections.xml',
+ ' ',
+ );
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+it('lists section entries for the ai chat', function () {
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ ''
+ . ' '
+ . ' '
+ . 'Hello]]> '
+ . ' '
+ . ' ',
+ );
+
+ $result = (new ListSectionEntriesTool(''))->handle(new Request(['section_name' => 'works']));
+
+ expect(json_decode($result, true))->toBe([
+ [
+ 'id' => '1', 'title' => 'First', 'description' => 'Hello
', 'order' => 0,
+ 'gallery' => ['type' => 'slideshow', 'file_count' => 3, 'image_count' => 2, 'video_count' => 1],
+ ],
+ [
+ 'id' => '2', 'title' => '', 'description' => '', 'order' => 1,
+ 'gallery' => ['type' => null, 'file_count' => 0, 'image_count' => 0, 'video_count' => 0],
+ ],
+ ]);
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+it('tells the ai chat when a section has no entries', function () {
+ $result = (new ListSectionEntriesTool(''))->handle(new Request(['section_name' => 'works']));
+
+ expect($result)->toBe('No entries found in this section.');
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php
index e51381b27..7c6e952a1 100644
--- a/_api_app/tests/Feature/SiteContentReaderTest.php
+++ b/_api_app/tests/Feature/SiteContentReaderTest.php
@@ -45,3 +45,82 @@
it('returns no sections when the site has no sections file', function () {
expect((new SiteContentReader)->sections())->toBe([]);
})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+describe('entries', function () {
+ beforeEach(function () {
+ // Site settings (for the template) read the engine's setting definitions.
+ linkBertaEngine($this->bertaRoot);
+ writeTemplate($this->bertaRoot, 'default');
+ file_put_contents(
+ $this->bertaRoot . '/storage/sections.xml',
+ ' ',
+ );
+ });
+
+ it('normalizes entries of a section', function () {
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ ''
+ . ' '
+ . ' '
+ . 'Text]]> '
+ . ' '
+ . ' ',
+ );
+
+ expect((new SiteContentReader)->entries('works'))->toBe([
+ [
+ 'id' => '7', 'title' => 'Series', 'description' => 'Text
', 'order' => 0,
+ 'gallery' => ['type' => 'row', 'file_count' => 2, 'image_count' => 1, 'video_count' => 1],
+ ],
+ [
+ 'id' => '8', 'title' => '', 'description' => '', 'order' => 1,
+ 'gallery' => ['type' => 'slideshow', 'file_count' => 0, 'image_count' => 0, 'video_count' => 0],
+ ],
+ ]);
+ });
+
+ it('leaves out entry titles where the template does not show them', function () {
+ writeTemplate($this->bertaRoot, 'messy-0.4.2');
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ ' ',
+ );
+
+ $reader = new SiteContentReader;
+
+ expect($reader->showsEntryTitles('works'))->toBeFalse()
+ ->and($reader->entries('works')[0])->not->toHaveKey('title');
+ });
+
+ it('keeps entry titles in portfolio sections on any template', function () {
+ writeTemplate($this->bertaRoot, 'messy-0.4.2');
+ file_put_contents(
+ $this->bertaRoot . '/storage/sections.xml',
+ ' ',
+ );
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ ' ',
+ );
+
+ $reader = new SiteContentReader;
+
+ expect($reader->showsEntryTitles('works'))->toBeTrue()
+ ->and($reader->entries('works')[0]['title'])->toBe('Project');
+ });
+
+ it('knows which sections exist', function () {
+ $reader = new SiteContentReader;
+
+ expect($reader->hasSection('works'))->toBeTrue()
+ ->and($reader->hasSection('missing'))->toBeFalse()
+ ->and($reader->showsEntryTitles('missing'))->toBeFalse();
+ });
+
+ it('reads no entries for sections that do not exist', function (string $section) {
+ file_put_contents($this->bertaRoot . '/storage/blog.missing.xml', '1 ');
+
+ expect((new SiteContentReader)->entries($section))->toBe([]);
+ })->with(['missing', '../storage/blog.missing', '']);
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Pest.php b/_api_app/tests/Pest.php
index 3d5aed648..70059498c 100644
--- a/_api_app/tests/Pest.php
+++ b/_api_app/tests/Pest.php
@@ -71,6 +71,32 @@ function skipWithoutShopPlugin(): void
}
}
+/**
+ * Links the legacy engine into a temporary Berta root. Site settings read the engine's
+ * definitions, and an anonymous request's auth check logs out through the legacy engine.
+ */
+function linkBertaEngine(string $bertaRoot): void
+{
+ // The real Berta root, as in config/app.php; tests may already point app.old_berta_root elsewhere
+ $realRoot = dirname(base_path());
+ File::ensureDirectoryExists($bertaRoot . '/engine');
+
+ foreach (['engine/_classes', 'engine/lang', 'engine/inc.setting-definition.php', 'engine/inc.font-options.php', 'engine/inc.version.php', '_templates'] as $path) {
+ symlink("{$realRoot}/{$path}", "{$bertaRoot}/{$path}");
+ }
+}
+
+/**
+ * Sets the main site's template in a temporary Berta root. Needs linkBertaEngine() to be read.
+ */
+function writeTemplate(string $bertaRoot, string $template): void
+{
+ file_put_contents(
+ $bertaRoot . '/storage/settings.xml',
+ " ",
+ );
+}
+
/**
* A temporary Berta root for the shop plugin's tests, with its shop database and file cache
*
@@ -81,13 +107,8 @@ function setUpShopTestRoot(): string
$realRoot = config('app.old_berta_root');
$bertaRoot = sys_get_temp_dir() . '/berta_shop_' . uniqid();
File::ensureDirectoryExists($bertaRoot . '/storage');
- File::ensureDirectoryExists($bertaRoot . '/engine');
File::ensureDirectoryExists($bertaRoot . '/_plugin_shop');
- // Site settings read the engine's definitions, and an anonymous request's auth check logs
- // out through the legacy engine
- foreach (['engine/_classes', 'engine/lang', 'engine/inc.setting-definition.php', 'engine/inc.font-options.php', 'engine/inc.version.php', '_templates'] as $path) {
- symlink("{$realRoot}/{$path}", "{$bertaRoot}/{$path}");
- }
+ linkBertaEngine($bertaRoot);
File::copy($realRoot . '/_plugin_shop/inc.setting-definition.php', $bertaRoot . '/_plugin_shop/inc.setting-definition.php');
File::put($bertaRoot . '/engine/hosting', json_encode(['emailFromAddress' => 'shop@berta.test']));
From 249b1bd78977d0ab3c969b6471fe3a9f01eb62c0 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Fri, 23 Oct 2026 15:38:58 +0300
Subject: [PATCH 03/22] Test the get-entry-gallery MCP tool and shared gallery
reading
Pins the chat's GetEntryGalleryTool output, covers
SiteContentReader::entryGallery() (untyped files, entries without a
gallery, unknown or ../ sections and entries) and the
get-entry-gallery tool.
Co-Authored-By: Claude Opus 5.5
---
_api_app/tests/Feature/AiAssistantMcpTest.php | 61 +++++++++++++++++++
.../tests/Feature/GetEntryGalleryToolTest.php | 50 +++++++++++++++
.../tests/Feature/SiteContentReaderTest.php | 38 ++++++++++++
3 files changed, 149 insertions(+)
create mode 100644 _api_app/tests/Feature/GetEntryGalleryToolTest.php
diff --git a/_api_app/tests/Feature/AiAssistantMcpTest.php b/_api_app/tests/Feature/AiAssistantMcpTest.php
index f66f956c0..4e3565997 100644
--- a/_api_app/tests/Feature/AiAssistantMcpTest.php
+++ b/_api_app/tests/Feature/AiAssistantMcpTest.php
@@ -3,6 +3,7 @@
use App\Http\Middleware\SetupMiddleware;
use App\Models\User;
use App\Plugins\AiAssistant\Mcp\BertaSiteServer;
+use App\Plugins\AiAssistant\Mcp\Tools\GetEntryGalleryTool;
use App\Plugins\AiAssistant\Mcp\Tools\ListEntriesTool;
use App\Plugins\AiAssistant\Mcp\Tools\ListSectionsTool;
use Firebase\JWT\JWT;
@@ -117,3 +118,63 @@
->assertHasErrors(['Provide the section name. Call list-sections to see the sections.']);
});
})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+describe('get-entry-gallery tool', function () {
+ beforeEach(function () {
+ $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ file_put_contents(
+ $this->bertaRoot . '/storage/sections.xml',
+ ' ',
+ );
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ ' '
+ . ' ',
+ );
+
+ // SetupMiddleware reconciles the real .env file on every request.
+ withoutMiddleware(SetupMiddleware::class);
+ });
+
+ afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+ });
+
+ // Gallery normalization is covered in SiteContentReaderTest and GetEntryGalleryToolTest.
+ it('returns the gallery of an entry as structured content', function () {
+ BertaSiteServer::actingAs(new User)
+ ->tool(GetEntryGalleryTool::class, ['section' => 'works', 'entry_id' => '5'])
+ ->assertOk()
+ // The hosting gateway offers the tool under this name.
+ ->assertName('get-entry-gallery')
+ ->assertStructuredContent([
+ 'section' => 'works',
+ 'entry_id' => '5',
+ 'settings' => ['type' => 'row'],
+ 'file_count' => 1,
+ 'image_count' => 1,
+ 'video_count' => 0,
+ 'files' => [['type' => 'image', 'extension' => 'jpg', 'src' => 'a.jpg', 'caption' => 'Caption']],
+ ]);
+ });
+
+ it('rejects unknown sections and entries', function (array $arguments, string $error) {
+ BertaSiteServer::actingAs(new User)
+ ->tool(GetEntryGalleryTool::class, $arguments)
+ ->assertHasErrors([$error]);
+ })->with([
+ 'unknown section' => [['section' => '../sections', 'entry_id' => '5'], 'Section "../sections" not found. Call list-sections to see the sections.'],
+ 'unknown entry' => [['section' => 'works', 'entry_id' => '99'], 'Entry "99" not found in section "works". Call list-entries to see the entries.'],
+ ]);
+
+ it('requires the section and entry', function () {
+ BertaSiteServer::actingAs(new User)
+ ->tool(GetEntryGalleryTool::class, [])
+ ->assertHasErrors([
+ 'Provide the section name. Call list-sections to see the sections.',
+ 'Provide the entry ID. Call list-entries to see the entries of the section.',
+ ]);
+ });
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/GetEntryGalleryToolTest.php b/_api_app/tests/Feature/GetEntryGalleryToolTest.php
new file mode 100644
index 000000000..5b4bc0518
--- /dev/null
+++ b/_api_app/tests/Feature/GetEntryGalleryToolTest.php
@@ -0,0 +1,50 @@
+bertaRoot = sys_get_temp_dir() . '/berta_chat_gallery_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ file_put_contents(
+ $this->bertaRoot . '/storage/sections.xml',
+ ' ',
+ );
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ ' '
+ . ''
+ . 'Sunset]]> '
+ . ' '
+ . ' ',
+ );
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+it('describes the gallery of an entry for the ai chat', function () {
+ $result = (new GetEntryGalleryTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '5']));
+
+ expect(json_decode($result, true))->toBe([
+ 'settings' => ['type' => 'slideshow', 'autoplay' => '3'],
+ 'file_count' => 2,
+ 'image_count' => 1,
+ 'video_count' => 1,
+ 'files' => [
+ ['type' => 'image', 'extension' => 'jpg', 'src' => 'Photo.JPG', 'caption' => 'Sunset
'],
+ ['type' => 'video', 'extension' => 'mp4', 'src' => 'clip.mp4', 'caption' => ''],
+ ],
+ ]);
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+it('tells the ai chat when the entry does not exist', function () {
+ $result = (new GetEntryGalleryTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '99']));
+
+ expect($result)->toBe('Entry not found.');
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php
index 7c6e952a1..17fbb7efc 100644
--- a/_api_app/tests/Feature/SiteContentReaderTest.php
+++ b/_api_app/tests/Feature/SiteContentReaderTest.php
@@ -124,3 +124,41 @@
expect((new SiteContentReader)->entries($section))->toBe([]);
})->with(['missing', '../storage/blog.missing', '']);
})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+// File type, extension and caption normalization is pinned in GetEntryGalleryToolTest.
+describe('entry gallery', function () {
+ beforeEach(function () {
+ file_put_contents(
+ $this->bertaRoot . '/storage/sections.xml',
+ ' ',
+ );
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ ''
+ . ' '
+ . ' '
+ . ' ',
+ );
+ });
+
+ it('treats files without a type as images', function () {
+ $gallery = (new SiteContentReader)->entryGallery('works', '1');
+
+ expect($gallery['files'])->toBe([['type' => 'image', 'extension' => 'png', 'src' => 'untyped.png', 'caption' => '']])
+ ->and($gallery['image_count'])->toBe(1);
+ });
+
+ it('describes an entry without gallery as empty', function () {
+ expect((new SiteContentReader)->entryGallery('works', '2'))->toBe([
+ 'settings' => [], 'file_count' => 0, 'image_count' => 0, 'video_count' => 0, 'files' => [],
+ ]);
+ });
+
+ it('finds no gallery for unknown sections or entries', function (string $section, string $entryId) {
+ expect((new SiteContentReader)->entryGallery($section, $entryId))->toBeNull();
+ })->with([
+ 'unknown entry' => ['works', '99'],
+ 'unknown section' => ['missing', '1'],
+ 'path outside storage' => ['../storage/blog.works', '1'],
+ ]);
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
From 86621e66ed3dc01fb0aeb5a39faf0b7d26015d20 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Fri, 23 Oct 2026 17:11:01 +0300
Subject: [PATCH 04/22] Test the chat's entry content tool and
SiteContentReader::entryContent()
Pins the GetEntryContentTool output on the default template, checks
that titles are left out on templates without entry titles, and that
entryContent() matches the entry list and rejects unknown or ../
sections and entries.
Co-Authored-By: Claude Opus 5.5
---
.../tests/Feature/GetEntryContentToolTest.php | 53 +++++++++++++++++++
.../tests/Feature/SiteContentReaderTest.php | 13 +++++
2 files changed, 66 insertions(+)
create mode 100644 _api_app/tests/Feature/GetEntryContentToolTest.php
diff --git a/_api_app/tests/Feature/GetEntryContentToolTest.php b/_api_app/tests/Feature/GetEntryContentToolTest.php
new file mode 100644
index 000000000..18a58f9ea
--- /dev/null
+++ b/_api_app/tests/Feature/GetEntryContentToolTest.php
@@ -0,0 +1,53 @@
+bertaRoot = sys_get_temp_dir() . '/berta_chat_content_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ // The default template shows entry titles.
+ linkBertaEngine($this->bertaRoot);
+ writeTemplate($this->bertaRoot, 'default');
+ file_put_contents(
+ $this->bertaRoot . '/storage/sections.xml',
+ ' ',
+ );
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ ''
+ . ' '
+ . 'Hello]]> '
+ . ' ',
+ );
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+it('returns the content of an entry for the ai chat', function () {
+ $result = (new GetEntryContentTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '2']));
+
+ expect(json_decode($result, true))->toBe([
+ 'id' => '2', 'title' => 'Second', 'description' => 'Hello
', 'order' => 1,
+ ]);
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+it('tells the ai chat when the entry does not exist', function () {
+ $result = (new GetEntryContentTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '99']));
+
+ expect($result)->toBe('Entry not found.');
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+it('leaves out the title where the template has no entry titles', function () {
+ writeTemplate($this->bertaRoot, 'messy-0.4.2');
+
+ $result = (new GetEntryContentTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '2']));
+
+ expect(json_decode($result, true))->toBe(['id' => '2', 'description' => 'Hello
', 'order' => 1]);
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php
index 17fbb7efc..6af3eb21b 100644
--- a/_api_app/tests/Feature/SiteContentReaderTest.php
+++ b/_api_app/tests/Feature/SiteContentReaderTest.php
@@ -118,6 +118,19 @@
->and($reader->showsEntryTitles('missing'))->toBeFalse();
});
+ it('reads one entry like the entry list', function () {
+ file_put_contents(
+ $this->bertaRoot . '/storage/blog.works.xml',
+ ' ',
+ );
+ $reader = new SiteContentReader;
+
+ expect($reader->entryContent('works', '1'))->toBe($reader->entries('works')[0])
+ ->and($reader->entryContent('works', '99'))->toBeNull()
+ ->and($reader->entryContent('missing', '1'))->toBeNull()
+ ->and($reader->entryContent('../storage/blog.works', '1'))->toBeNull();
+ });
+
it('reads no entries for sections that do not exist', function (string $section) {
file_put_contents($this->bertaRoot . '/storage/blog.missing.xml', '1 ');
From 71c85b45c38006fca3c81fa7e9219d0f895982f4 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Fri, 23 Oct 2026 18:47:02 +0300
Subject: [PATCH 05/22] Test the get-site-settings MCP tool and snapshot the
chat's settings text
Covers SiteContentReader::siteSettings() (titles, unknown keys, list
items, internal group left out, siteTexts kept) and the
get-site-settings tool. A snapshot pins the AI chat's current site
settings prompt text ahead of moving it onto the shared reader.
Co-Authored-By: Claude Opus 5.5
---
...nt_site_settings_in_the_system_prompt.snap | 74 +++++++++++++++++++
_api_app/tests/Feature/AiAssistantMcpTest.php | 35 +++++++++
.../tests/Feature/AiChatControllerTest.php | 32 ++++++++
.../tests/Feature/SiteContentReaderTest.php | 38 ++++++++++
4 files changed, 179 insertions(+)
create mode 100644 _api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap
diff --git a/_api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap b/_api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap
new file mode 100644
index 000000000..8e800383a
--- /dev/null
+++ b/_api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap
@@ -0,0 +1,74 @@
+berta:
+ installed: 1
+siteTexts (Texts in the website):
+ siteHeading (Site heading — Name of your site. Visible in all sections.):
+template (Template):
+ template (Template — Templates are like skins or themes for your site. You can choose one template from the ones installed in your templates folder. To add a new template to this list, upload it to the templates folder via FTP.): messy-0.4.2
+theme (Theme):
+ theme (Choose theme — Themes are ready made sites with predefined design settings.): /themes
+texts (Texts):
+ ownerName (Your name — Your name will be put in a meta-tag in the code of your site. You can choose any name ;)):
+ pageTitle (Page title (title bar) — Text that appears in the bowser title bar): My site
+ metaDescription ( description — Short site description. It should not be longer than one or two sentences.): Personal portfolio built with Berta
+ unknownKey: kept
+pageLayout (Page layout):
+ favicon (Favicon — Small picture to display in the address bar of the browser. The file must be in PNG format and 180x180 pixels big.):
+ gridStep (Grid step — Distance in pixels for snap-to-grid dragging.): 10
+ showGrid (Show gridlines — Choose "yes" to show gridlines in background. Available only in Messy template.): no
+ gridColor (Gridlines color): black
+navigation (Navigation):
+ landingSectionVisible (Is first section visible in menu? — Choose "no" to hide the first section in the main menu. Link from the page title (or header image) will lead to it. NOTE: This setting has no effect, if the section has a submenu; then it is visible at all times.): yes
+ landingSectionPageHeadingVisible (Show page heading in first section? — Choose "no" to hide page heading in first section.): yes
+ landingSectionMenuVisible (Show menu in first section? — Choose "no" to hide the menu in first section.): yes
+ alwaysSelectTag (Always auto-select a submenu item? — Choose "yes" to automatically select the first submenu item when clicking on a menu item. This works only when there is a submenu.): yes
+ backToTopEnabled (Enable navigation to page top — Shows navigation button at the bottom when page is scrolled.): no
+entryLayout (Entry layout):
+ galleryFullScreenSettings (Gallery fullscreen settings:):
+ galleryFullScreenDefault (Is enabled by default — Enables gallery fullscreen mode for new entries.): yes
+ galleryFullScreenBackground (Background color — Color of the fullscreen gallery background.): black
+ galleryFullScreenImageNumbers (Image numbers — Enables fullscreen gallery slide numbers.): yes
+ galleryFullScreenCaptionAlign (Caption alignment — Positioning of the image caption text.): left
+ group_gallery (Image gallery appearance:):
+ gallerySlideshowAutoRewind (Slideshow loop mode): no
+ gallerySlideNumberVisibilityDefault (Show slideshow image numbers — Set the default state of image number visibility in slideshow galleries.): yes
+media (Media):
+ imagesSmallWidth (Small image width — Maximum size of a small image (visible if 'Small images' are switched on in the gallery editor). These settings don't affect original image.): 200
+ imagesSmallHeight (Small image height): 200
+ imagesMediumWidth (Medium image width — Maximum size of a medium image (visible if 'Medium images' are switched on in the gallery editor). These settings don't affect original image.): 400
+ imagesMediumHeight (Medium image height): 400
+ imagesLargeWidth (Large image width — Maximum size of a large image (visible if 'Large images' are switched on in the gallery editor). These settings don't affect original image.): 600
+ imagesLargeHeight (Large image height): 600
+socialMediaLinks (Social media buttons):
+ location (Location — Location of social media buttons. Content of additional text or additional footer will be replaced.): footer
+socialMediaButtons (Custom social media buttons):
+ socialMediaHTML (HTML code — Paste or write your HTML code here.):
+ socialMediaJS (Javascript code — Paste or write your Javascript code here.):
+ socialMediaLocation (Location — Location of social media buttons. Content of additional text or additional footer will be replaced.): footer
+banners (Banners):
+ banner1_image (Banner image (1) — Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays. ):
+ banner1_link (Banner link (1) — Banner link 'https://' should be in front of the address):
+ banner2_image (Banner image (2)):
+ banner2_link (Banner link (2)):
+ banner3_image (Banner image (3)):
+ banner3_link (Banner link (3)):
+ banner4_image (Banner image (4)):
+ banner4_link (Banner link (4)):
+ banner5_image (Banner image (5)):
+ banner5_link (Banner link (5)):
+ banner6_image (Banner image (6)):
+ banner6_link (Banner link (6)):
+ banner7_image (Banner image (7)):
+ banner7_link (Banner link (7)):
+ banner8_image (Banner image (8)):
+ banner8_link (Banner link (8)):
+ banner9_image (Banner image (9)):
+ banner9_link (Banner link (9)):
+ banner10_image (Banner image (10)):
+ banner10_link (Banner link (10)):
+language (Language):
+ language (Interface language — Choose language of interface.): en
+settings (Other settings):
+ googleAnalyticsId (Google Analytics measurement ID — How to get the measurement ID . Leave blank if Google Tag Manager container ID is filled.):
+ googleTagManagerContainerId (Google Tag Manager container ID — How to get the container ID . Leave blank if Google Analytics measurement ID is filled.):
+ googleSiteVerification (Google site verification tag — Google ownership verification tag. More info .):
+ jsInclude (Javascript include — Javascript code included right before closing </body> element. Learn more how to include JavaScript code .):
\ No newline at end of file
diff --git a/_api_app/tests/Feature/AiAssistantMcpTest.php b/_api_app/tests/Feature/AiAssistantMcpTest.php
index 4e3565997..2ef843ba7 100644
--- a/_api_app/tests/Feature/AiAssistantMcpTest.php
+++ b/_api_app/tests/Feature/AiAssistantMcpTest.php
@@ -4,6 +4,7 @@
use App\Models\User;
use App\Plugins\AiAssistant\Mcp\BertaSiteServer;
use App\Plugins\AiAssistant\Mcp\Tools\GetEntryGalleryTool;
+use App\Plugins\AiAssistant\Mcp\Tools\GetSiteSettingsTool;
use App\Plugins\AiAssistant\Mcp\Tools\ListEntriesTool;
use App\Plugins\AiAssistant\Mcp\Tools\ListSectionsTool;
use Firebase\JWT\JWT;
@@ -178,3 +179,37 @@
]);
});
})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+describe('get-site-settings tool', function () {
+ beforeEach(function () {
+ $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ linkBertaEngine($this->bertaRoot);
+ file_put_contents(
+ $this->bertaRoot . '/storage/settings.xml',
+ ' ',
+ );
+
+ // SetupMiddleware reconciles the real .env file on every request.
+ withoutMiddleware(SetupMiddleware::class);
+ });
+
+ afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+ });
+
+ // Settings normalization is covered in SiteContentReaderTest.
+ it('returns the site settings as structured content', function () {
+ BertaSiteServer::actingAs(new User)
+ ->tool(GetSiteSettingsTool::class)
+ ->assertOk()
+ // The hosting gateway offers the tool under this name.
+ ->assertName('get-site-settings')
+ ->assertStructuredContent(fn (AssertableJson $json) => $json->where(
+ 'groups',
+ fn ($groups) => $groups->pluck('name')->doesntContain('berta')
+ && collect($groups->firstWhere('name', 'texts')['settings'])->firstWhere('key', 'pageTitle')['value'] === 'My site',
+ ));
+ });
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/AiChatControllerTest.php b/_api_app/tests/Feature/AiChatControllerTest.php
index 68847bb01..330de5d7d 100644
--- a/_api_app/tests/Feature/AiChatControllerTest.php
+++ b/_api_app/tests/Feature/AiChatControllerTest.php
@@ -1,9 +1,11 @@
skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+it('describes current site settings in the system prompt', function () {
+ $bertaRoot = sys_get_temp_dir() . '/berta_ai_settings_' . uniqid();
+ File::ensureDirectoryExists($bertaRoot . '/storage');
+ linkBertaEngine($bertaRoot);
+ config(['app.old_berta_root' => $bertaRoot]);
+ file_put_contents(
+ $bertaRoot . '/storage/settings.xml',
+ ''
+ . ' '
+ . ' '
+ . ' '
+ . ' ',
+ );
+
+ try {
+ $controller = new AiChatController;
+ $method = new ReflectionMethod($controller, 'formatCurrentSettings');
+
+ $result = $method->invoke($controller, (new SiteSettingsDataService(''))->getState(), (new SiteSettingsConfigService)->get());
+ } finally {
+ File::deleteDirectory($bertaRoot);
+ }
+
+ expect($result)->toContain('texts (')
+ ->and($result)->toContain(' pageTitle (')
+ ->and($result)->toContain(' unknownKey: kept')
+ ->and($result)->not->toContain('https://x.com')
+ ->and($result)->toMatchSnapshot();
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php
index 6af3eb21b..58865461f 100644
--- a/_api_app/tests/Feature/SiteContentReaderTest.php
+++ b/_api_app/tests/Feature/SiteContentReaderTest.php
@@ -175,3 +175,41 @@
'path outside storage' => ['../storage/blog.works', '1'],
]);
})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+describe('site settings', function () {
+ beforeEach(function () {
+ // Site settings read the engine's setting definitions.
+ linkBertaEngine($this->bertaRoot);
+ file_put_contents(
+ $this->bertaRoot . '/storage/settings.xml',
+ ''
+ . ' '
+ . ' '
+ . ' '
+ . ' '
+ . ' ',
+ );
+ });
+
+ it('describes settings with their titles and leaves out the internal group', function () {
+ $groups = collect((new SiteContentReader)->siteSettings())->keyBy('name');
+ $texts = collect($groups['texts']['settings'])->keyBy('key');
+
+ expect($groups->keys())->not->toContain('berta')
+ // Not on the settings page, but real site content: the site heading and footer texts.
+ ->and(collect($groups['siteTexts']['settings'])->keyBy('key')['siteHeading']['value'])->toBe('Acme')
+ ->and($groups['texts']['title'])->toBeString()
+ ->and($texts['pageTitle']['value'])->toBe('My site')
+ ->and($texts['pageTitle']['title'])->toBeString()
+ ->and($texts['pageTitle']['items'])->toBeNull()
+ ->and($texts['unknownKey'])->toBe(['key' => 'unknownKey', 'title' => null, 'description' => null, 'value' => 'kept', 'items' => null]);
+ });
+
+ it('lists the entries of list settings as items', function () {
+ $groups = collect((new SiteContentReader)->siteSettings())->keyBy('name');
+ $links = collect($groups['socialMediaLinks']['settings'])->keyBy('key')['links'];
+
+ expect($links['value'])->toBe('')
+ ->and($links['items'])->toBe([['icon' => 'x', 'url' => 'https://x.com']]);
+ });
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
From 2480a2dfcb4fc7cdeda7730a43fa904931204990 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Fri, 23 Oct 2026 19:43:16 +0300
Subject: [PATCH 06/22] Test the get-design-settings MCP tool and design
settings reading
Co-Authored-By: Claude Opus 5.5
---
_api_app/tests/Feature/AiAssistantMcpTest.php | 37 ++++++++++++++++++
.../tests/Feature/SiteContentReaderTest.php | 38 +++++++++++++++++++
2 files changed, 75 insertions(+)
diff --git a/_api_app/tests/Feature/AiAssistantMcpTest.php b/_api_app/tests/Feature/AiAssistantMcpTest.php
index 2ef843ba7..a387d85fd 100644
--- a/_api_app/tests/Feature/AiAssistantMcpTest.php
+++ b/_api_app/tests/Feature/AiAssistantMcpTest.php
@@ -3,6 +3,7 @@
use App\Http\Middleware\SetupMiddleware;
use App\Models\User;
use App\Plugins\AiAssistant\Mcp\BertaSiteServer;
+use App\Plugins\AiAssistant\Mcp\Tools\GetDesignSettingsTool;
use App\Plugins\AiAssistant\Mcp\Tools\GetEntryGalleryTool;
use App\Plugins\AiAssistant\Mcp\Tools\GetSiteSettingsTool;
use App\Plugins\AiAssistant\Mcp\Tools\ListEntriesTool;
@@ -213,3 +214,39 @@
));
});
})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+describe('get-design-settings tool', function () {
+ beforeEach(function () {
+ $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ linkBertaEngine($this->bertaRoot);
+ writeTemplate($this->bertaRoot, 'messy-0.4.2');
+ file_put_contents(
+ $this->bertaRoot . '/storage/settings.messy.xml',
+ ' ',
+ );
+
+ // SetupMiddleware reconciles the real .env file on every request.
+ withoutMiddleware(SetupMiddleware::class);
+ });
+
+ afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+ });
+
+ // Settings normalization is covered in SiteContentReaderTest.
+ it('returns the design settings of the active template as structured content', function () {
+ BertaSiteServer::actingAs(new User)
+ ->tool(GetDesignSettingsTool::class)
+ ->assertOk()
+ // The hosting gateway offers the tool under this name.
+ ->assertName('get-design-settings')
+ ->assertStructuredContent(fn (AssertableJson $json) => $json
+ ->where('template', 'messy-0.4.2')
+ ->where(
+ 'groups',
+ fn ($groups) => collect($groups->firstWhere('name', 'generalFontSettings')['settings'])->firstWhere('key', 'color')['value'] === '#ff0000',
+ ));
+ });
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php
index 58865461f..b4413889e 100644
--- a/_api_app/tests/Feature/SiteContentReaderTest.php
+++ b/_api_app/tests/Feature/SiteContentReaderTest.php
@@ -213,3 +213,41 @@
->and($links['items'])->toBe([['icon' => 'x', 'url' => 'https://x.com']]);
});
})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
+
+describe('design settings', function () {
+ beforeEach(function () {
+ // Design settings read the template's setting definitions.
+ linkBertaEngine($this->bertaRoot);
+ });
+
+ it('describes the active template settings with their titles', function () {
+ writeTemplate($this->bertaRoot, 'messy-0.4.2');
+ file_put_contents(
+ $this->bertaRoot . '/storage/settings.messy.xml',
+ ' ',
+ );
+
+ $design = (new SiteContentReader)->designSettings();
+ $groups = collect($design['groups'])->keyBy('name');
+ $color = collect($groups['generalFontSettings']['settings'])->keyBy('key')['color'];
+
+ expect($design['template'])->toBe('messy-0.4.2')
+ ->and($groups['generalFontSettings']['title'])->toBeString()
+ ->and($color['value'])->toBe('#ff0000')
+ ->and($color['title'])->toBeString()
+ // Settings the site never changed come with the template's defaults.
+ ->and($groups['menu']['settings'])->not->toBeEmpty();
+ });
+
+ it('reads the installed template when the site names an unknown one', function (string $template) {
+ writeTemplate($this->bertaRoot, $template);
+
+ $design = (new SiteContentReader)->designSettings();
+
+ expect($design['template'])->toBe('messy-0.4.2')
+ ->and($design['groups'])->not->toBeEmpty();
+ })->with([
+ 'older version' => 'messy-0.3.0',
+ 'not installed' => 'missing-1.0.0',
+ ]);
+})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
From 8f07a1ae63452d0eb1e4dae5cc72b6f02a22aaf5 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Fri, 23 Oct 2026 20:03:17 +0300
Subject: [PATCH 07/22] Run plugin tests from the plugins' own repositories
phpunit.xml and Pest pick up app/Plugins/*/tests, so the AI assistant
plugin's tests move to its repository. Core gets its own test for the
entry title rule, which only the plugin's tests covered.
Co-Authored-By: Claude Opus 5.5
---
_api_app/phpunit.xml | 6 +
...nt_site_settings_in_the_system_prompt.snap | 74 ----
_api_app/tests/Feature/AiAssistantMcpTest.php | 252 -----------
.../Feature/AiAssistantQuotaServiceTest.php | 137 ------
.../tests/Feature/AiChatControllerTest.php | 404 ------------------
.../tests/Feature/GetEntryContentToolTest.php | 53 ---
.../tests/Feature/GetEntryGalleryToolTest.php | 50 ---
.../Feature/ListSectionEntriesToolTest.php | 55 ---
.../tests/Feature/SiteContentReaderTest.php | 253 -----------
_api_app/tests/Pest.php | 3 +-
.../Unit/SectionEntryRenderServiceTest.php | 13 +
11 files changed, 21 insertions(+), 1279 deletions(-)
delete mode 100644 _api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap
delete mode 100644 _api_app/tests/Feature/AiAssistantMcpTest.php
delete mode 100644 _api_app/tests/Feature/AiAssistantQuotaServiceTest.php
delete mode 100644 _api_app/tests/Feature/AiChatControllerTest.php
delete mode 100644 _api_app/tests/Feature/GetEntryContentToolTest.php
delete mode 100644 _api_app/tests/Feature/GetEntryGalleryToolTest.php
delete mode 100644 _api_app/tests/Feature/ListSectionEntriesToolTest.php
delete mode 100644 _api_app/tests/Feature/SiteContentReaderTest.php
create mode 100644 _api_app/tests/Unit/SectionEntryRenderServiceTest.php
diff --git a/_api_app/phpunit.xml b/_api_app/phpunit.xml
index 506b9a38e..a7900cf71 100644
--- a/_api_app/phpunit.xml
+++ b/_api_app/phpunit.xml
@@ -11,11 +11,17 @@
tests/Feature
+
+ app/Plugins/*/tests
+
app
+
+ app/Plugins/*/tests
+
diff --git a/_api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap b/_api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap
deleted file mode 100644
index 8e800383a..000000000
--- a/_api_app/tests/.pest/snapshots/Feature/AiChatControllerTest/it_describes_current_site_settings_in_the_system_prompt.snap
+++ /dev/null
@@ -1,74 +0,0 @@
-berta:
- installed: 1
-siteTexts (Texts in the website):
- siteHeading (Site heading — Name of your site. Visible in all sections.):
-template (Template):
- template (Template — Templates are like skins or themes for your site. You can choose one template from the ones installed in your templates folder. To add a new template to this list, upload it to the templates folder via FTP.): messy-0.4.2
-theme (Theme):
- theme (Choose theme — Themes are ready made sites with predefined design settings.): /themes
-texts (Texts):
- ownerName (Your name — Your name will be put in a meta-tag in the code of your site. You can choose any name ;)):
- pageTitle (Page title (title bar) — Text that appears in the bowser title bar): My site
- metaDescription ( description — Short site description. It should not be longer than one or two sentences.): Personal portfolio built with Berta
- unknownKey: kept
-pageLayout (Page layout):
- favicon (Favicon — Small picture to display in the address bar of the browser. The file must be in PNG format and 180x180 pixels big.):
- gridStep (Grid step — Distance in pixels for snap-to-grid dragging.): 10
- showGrid (Show gridlines — Choose "yes" to show gridlines in background. Available only in Messy template.): no
- gridColor (Gridlines color): black
-navigation (Navigation):
- landingSectionVisible (Is first section visible in menu? — Choose "no" to hide the first section in the main menu. Link from the page title (or header image) will lead to it. NOTE: This setting has no effect, if the section has a submenu; then it is visible at all times.): yes
- landingSectionPageHeadingVisible (Show page heading in first section? — Choose "no" to hide page heading in first section.): yes
- landingSectionMenuVisible (Show menu in first section? — Choose "no" to hide the menu in first section.): yes
- alwaysSelectTag (Always auto-select a submenu item? — Choose "yes" to automatically select the first submenu item when clicking on a menu item. This works only when there is a submenu.): yes
- backToTopEnabled (Enable navigation to page top — Shows navigation button at the bottom when page is scrolled.): no
-entryLayout (Entry layout):
- galleryFullScreenSettings (Gallery fullscreen settings:):
- galleryFullScreenDefault (Is enabled by default — Enables gallery fullscreen mode for new entries.): yes
- galleryFullScreenBackground (Background color — Color of the fullscreen gallery background.): black
- galleryFullScreenImageNumbers (Image numbers — Enables fullscreen gallery slide numbers.): yes
- galleryFullScreenCaptionAlign (Caption alignment — Positioning of the image caption text.): left
- group_gallery (Image gallery appearance:):
- gallerySlideshowAutoRewind (Slideshow loop mode): no
- gallerySlideNumberVisibilityDefault (Show slideshow image numbers — Set the default state of image number visibility in slideshow galleries.): yes
-media (Media):
- imagesSmallWidth (Small image width — Maximum size of a small image (visible if 'Small images' are switched on in the gallery editor). These settings don't affect original image.): 200
- imagesSmallHeight (Small image height): 200
- imagesMediumWidth (Medium image width — Maximum size of a medium image (visible if 'Medium images' are switched on in the gallery editor). These settings don't affect original image.): 400
- imagesMediumHeight (Medium image height): 400
- imagesLargeWidth (Large image width — Maximum size of a large image (visible if 'Large images' are switched on in the gallery editor). These settings don't affect original image.): 600
- imagesLargeHeight (Large image height): 600
-socialMediaLinks (Social media buttons):
- location (Location — Location of social media buttons. Content of additional text or additional footer will be replaced.): footer
-socialMediaButtons (Custom social media buttons):
- socialMediaHTML (HTML code — Paste or write your HTML code here.):
- socialMediaJS (Javascript code — Paste or write your Javascript code here.):
- socialMediaLocation (Location — Location of social media buttons. Content of additional text or additional footer will be replaced.): footer
-banners (Banners):
- banner1_image (Banner image (1) — Banners are images which are visible in all sections. Use it for buttons or social icons in your site. Displayed image will be half of the original size, full size will be used for hi-res displays. ):
- banner1_link (Banner link (1) — Banner link 'https://' should be in front of the address):
- banner2_image (Banner image (2)):
- banner2_link (Banner link (2)):
- banner3_image (Banner image (3)):
- banner3_link (Banner link (3)):
- banner4_image (Banner image (4)):
- banner4_link (Banner link (4)):
- banner5_image (Banner image (5)):
- banner5_link (Banner link (5)):
- banner6_image (Banner image (6)):
- banner6_link (Banner link (6)):
- banner7_image (Banner image (7)):
- banner7_link (Banner link (7)):
- banner8_image (Banner image (8)):
- banner8_link (Banner link (8)):
- banner9_image (Banner image (9)):
- banner9_link (Banner link (9)):
- banner10_image (Banner image (10)):
- banner10_link (Banner link (10)):
-language (Language):
- language (Interface language — Choose language of interface.): en
-settings (Other settings):
- googleAnalyticsId (Google Analytics measurement ID — How to get the measurement ID . Leave blank if Google Tag Manager container ID is filled.):
- googleTagManagerContainerId (Google Tag Manager container ID — How to get the container ID . Leave blank if Google Analytics measurement ID is filled.):
- googleSiteVerification (Google site verification tag — Google ownership verification tag. More info .):
- jsInclude (Javascript include — Javascript code included right before closing </body> element. Learn more how to include JavaScript code .):
\ No newline at end of file
diff --git a/_api_app/tests/Feature/AiAssistantMcpTest.php b/_api_app/tests/Feature/AiAssistantMcpTest.php
deleted file mode 100644
index a387d85fd..000000000
--- a/_api_app/tests/Feature/AiAssistantMcpTest.php
+++ /dev/null
@@ -1,252 +0,0 @@
- '2.0', 'id' => 1, 'method' => 'ping'])
- ->assertStatus(401);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('accepts mcp requests with a site jwt in the X-Authorization header', function () {
- $token = JWT::encode(['iat' => time(), 'exp' => time() + 300, 'uid' => 1], config('app.key'), 'HS256');
-
- postJson('/v1/plugin/ai/mcp', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping'], ['X-Authorization' => "Bearer {$token}"])
- ->assertOk()
- ->assertJsonPath('id', 1);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-describe('list-sections tool', function () {
- beforeEach(function () {
- $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid();
- File::ensureDirectoryExists($this->bertaRoot . '/storage');
- config(['app.old_berta_root' => $this->bertaRoot]);
-
- // SetupMiddleware reconciles the real .env file on every request.
- withoutMiddleware(SetupMiddleware::class);
- });
-
- afterEach(function () {
- File::deleteDirectory($this->bertaRoot);
- });
-
- // Normalization cases are covered in SiteContentReaderTest.
- it('returns the main site sections as structured content', function () {
- file_put_contents(
- $this->bertaRoot . '/storage/sections.xml',
- ''
- . ''
- . ' ',
- );
-
- BertaSiteServer::actingAs(new User)
- ->tool(ListSectionsTool::class)
- ->assertOk()
- // The hosting gateway calls the tool by this name.
- ->assertName('list-sections')
- ->assertStructuredContent(fn (AssertableJson $json) => $json->where('sections', [[
- 'name' => 'shop', 'title' => 'Shop', 'type' => 'shop', 'published' => true, 'entry_count' => 2, 'order' => 0,
- 'seo' => ['title' => '', 'keywords' => '', 'description' => ''],
- ]]));
- });
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-describe('list-entries tool', function () {
- beforeEach(function () {
- $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid();
- File::ensureDirectoryExists($this->bertaRoot . '/storage');
- config(['app.old_berta_root' => $this->bertaRoot]);
- // The default template shows entry titles; the title rule is covered in SiteContentReaderTest.
- linkBertaEngine($this->bertaRoot);
- writeTemplate($this->bertaRoot, 'default');
- file_put_contents(
- $this->bertaRoot . '/storage/sections.xml',
- ' ',
- );
-
- // SetupMiddleware reconciles the real .env file on every request.
- withoutMiddleware(SetupMiddleware::class);
- });
-
- afterEach(function () {
- File::deleteDirectory($this->bertaRoot);
- });
-
- // Normalization cases are covered in SiteContentReaderTest.
- it('returns the entries of a section as structured content', function () {
- file_put_contents(
- $this->bertaRoot . '/storage/blog.works.xml',
- ' '
- . 'Hello]]> ',
- );
-
- BertaSiteServer::actingAs(new User)
- ->tool(ListEntriesTool::class, ['section' => 'works'])
- ->assertOk()
- // The hosting gateway calls the tool by this name.
- ->assertName('list-entries')
- ->assertStructuredContent(fn (AssertableJson $json) => $json
- ->where('section', 'works')
- ->where('entry_titles', true)
- ->where('entries', [[
- 'id' => '1', 'title' => 'First', 'description' => 'Hello
', 'order' => 0,
- 'gallery' => ['type' => null, 'file_count' => 0, 'image_count' => 0, 'video_count' => 0],
- ]]));
- });
-
- it('rejects sections that do not exist', function () {
- BertaSiteServer::actingAs(new User)
- ->tool(ListEntriesTool::class, ['section' => '../sections'])
- ->assertHasErrors(['Section "../sections" not found. Call list-sections to see the sections.']);
- });
-
- it('requires the section argument', function () {
- BertaSiteServer::actingAs(new User)
- ->tool(ListEntriesTool::class, [])
- ->assertHasErrors(['Provide the section name. Call list-sections to see the sections.']);
- });
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-describe('get-entry-gallery tool', function () {
- beforeEach(function () {
- $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid();
- File::ensureDirectoryExists($this->bertaRoot . '/storage');
- config(['app.old_berta_root' => $this->bertaRoot]);
- file_put_contents(
- $this->bertaRoot . '/storage/sections.xml',
- ' ',
- );
- file_put_contents(
- $this->bertaRoot . '/storage/blog.works.xml',
- ' '
- . ' ',
- );
-
- // SetupMiddleware reconciles the real .env file on every request.
- withoutMiddleware(SetupMiddleware::class);
- });
-
- afterEach(function () {
- File::deleteDirectory($this->bertaRoot);
- });
-
- // Gallery normalization is covered in SiteContentReaderTest and GetEntryGalleryToolTest.
- it('returns the gallery of an entry as structured content', function () {
- BertaSiteServer::actingAs(new User)
- ->tool(GetEntryGalleryTool::class, ['section' => 'works', 'entry_id' => '5'])
- ->assertOk()
- // The hosting gateway offers the tool under this name.
- ->assertName('get-entry-gallery')
- ->assertStructuredContent([
- 'section' => 'works',
- 'entry_id' => '5',
- 'settings' => ['type' => 'row'],
- 'file_count' => 1,
- 'image_count' => 1,
- 'video_count' => 0,
- 'files' => [['type' => 'image', 'extension' => 'jpg', 'src' => 'a.jpg', 'caption' => 'Caption']],
- ]);
- });
-
- it('rejects unknown sections and entries', function (array $arguments, string $error) {
- BertaSiteServer::actingAs(new User)
- ->tool(GetEntryGalleryTool::class, $arguments)
- ->assertHasErrors([$error]);
- })->with([
- 'unknown section' => [['section' => '../sections', 'entry_id' => '5'], 'Section "../sections" not found. Call list-sections to see the sections.'],
- 'unknown entry' => [['section' => 'works', 'entry_id' => '99'], 'Entry "99" not found in section "works". Call list-entries to see the entries.'],
- ]);
-
- it('requires the section and entry', function () {
- BertaSiteServer::actingAs(new User)
- ->tool(GetEntryGalleryTool::class, [])
- ->assertHasErrors([
- 'Provide the section name. Call list-sections to see the sections.',
- 'Provide the entry ID. Call list-entries to see the entries of the section.',
- ]);
- });
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-describe('get-site-settings tool', function () {
- beforeEach(function () {
- $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid();
- File::ensureDirectoryExists($this->bertaRoot . '/storage');
- config(['app.old_berta_root' => $this->bertaRoot]);
- linkBertaEngine($this->bertaRoot);
- file_put_contents(
- $this->bertaRoot . '/storage/settings.xml',
- ' ',
- );
-
- // SetupMiddleware reconciles the real .env file on every request.
- withoutMiddleware(SetupMiddleware::class);
- });
-
- afterEach(function () {
- File::deleteDirectory($this->bertaRoot);
- });
-
- // Settings normalization is covered in SiteContentReaderTest.
- it('returns the site settings as structured content', function () {
- BertaSiteServer::actingAs(new User)
- ->tool(GetSiteSettingsTool::class)
- ->assertOk()
- // The hosting gateway offers the tool under this name.
- ->assertName('get-site-settings')
- ->assertStructuredContent(fn (AssertableJson $json) => $json->where(
- 'groups',
- fn ($groups) => $groups->pluck('name')->doesntContain('berta')
- && collect($groups->firstWhere('name', 'texts')['settings'])->firstWhere('key', 'pageTitle')['value'] === 'My site',
- ));
- });
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-describe('get-design-settings tool', function () {
- beforeEach(function () {
- $this->bertaRoot = sys_get_temp_dir() . '/berta_mcp_' . uniqid();
- File::ensureDirectoryExists($this->bertaRoot . '/storage');
- config(['app.old_berta_root' => $this->bertaRoot]);
- linkBertaEngine($this->bertaRoot);
- writeTemplate($this->bertaRoot, 'messy-0.4.2');
- file_put_contents(
- $this->bertaRoot . '/storage/settings.messy.xml',
- ' ',
- );
-
- // SetupMiddleware reconciles the real .env file on every request.
- withoutMiddleware(SetupMiddleware::class);
- });
-
- afterEach(function () {
- File::deleteDirectory($this->bertaRoot);
- });
-
- // Settings normalization is covered in SiteContentReaderTest.
- it('returns the design settings of the active template as structured content', function () {
- BertaSiteServer::actingAs(new User)
- ->tool(GetDesignSettingsTool::class)
- ->assertOk()
- // The hosting gateway offers the tool under this name.
- ->assertName('get-design-settings')
- ->assertStructuredContent(fn (AssertableJson $json) => $json
- ->where('template', 'messy-0.4.2')
- ->where(
- 'groups',
- fn ($groups) => collect($groups->firstWhere('name', 'generalFontSettings')['settings'])->firstWhere('key', 'color')['value'] === '#ff0000',
- ));
- });
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/AiAssistantQuotaServiceTest.php b/_api_app/tests/Feature/AiAssistantQuotaServiceTest.php
deleted file mode 100644
index 4d09ad70e..000000000
--- a/_api_app/tests/Feature/AiAssistantQuotaServiceTest.php
+++ /dev/null
@@ -1,137 +0,0 @@
-makePartial();
- $user->shouldReceive('getPlan')->andReturn($plan);
- $user->plans = $plans ?: [
- ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => ['ai_assistant_daily' => 30]],
- ['id' => '2', 'name' => 'Pro', 'features' => [], 'limits' => ['ai_assistant_daily' => 100]],
- ['id' => '3', 'name' => 'Shop', 'features' => [], 'limits' => ['ai_assistant_daily' => 100]],
- ];
-
- return $user;
-}
-
-it('returns 30 requests per day for basic plan', function () {
- $service = new AiAssistantQuotaService;
-
- expect($service->getDailyLimit(makeUser(1)))->toBe(30);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns 100 requests per day for pro plan', function () {
- $service = new AiAssistantQuotaService;
-
- expect($service->getDailyLimit(makeUser(2)))->toBe(100);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns 100 requests per day for shop plan', function () {
- $service = new AiAssistantQuotaService;
-
- expect($service->getDailyLimit(makeUser(3)))->toBe(100);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('falls back to trial plan (id=0) limit when plan id is not found', function () {
- $service = new AiAssistantQuotaService;
- $user = makeUser(9, [
- ['id' => '0', 'name' => 'Trial', 'features' => [], 'limits' => ['ai_assistant_daily' => 5]],
- ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => ['ai_assistant_daily' => 30]],
- ]);
-
- expect($service->getDailyLimit($user))->toBe(5);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns 0 when plan id is not found and no trial plan exists', function () {
- $service = new AiAssistantQuotaService;
- $user = makeUser(9, [
- ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => ['ai_assistant_daily' => 30]],
- ]);
-
- expect($service->getDailyLimit($user))->toBe(0);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns 0 when plan has no ai_assistant_daily limit configured', function () {
- $service = new AiAssistantQuotaService;
- $user = makeUser(1, [
- ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => []],
- ]);
-
- expect($service->getDailyLimit($user))->toBe(0);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns 0 when plan id is not found in plans list', function () {
- $service = new AiAssistantQuotaService;
- $user = makeUser(9, []);
-
- expect($service->getDailyLimit($user))->toBe(0);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('formats cache key with current date', function () {
- $service = new AiAssistantQuotaService;
-
- expect($service->getCacheKey())->toBe('ai_requests:' . now()->format('Y-m-d'));
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns zero count when no requests have been made', function () {
- Cache::flush();
- $service = new AiAssistantQuotaService;
-
- expect($service->getCount())->toBe(0);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('increments count on each call', function () {
- Cache::flush();
- $service = new AiAssistantQuotaService;
-
- expect($service->increment())->toBe(1)
- ->and($service->increment())->toBe(2)
- ->and($service->getCount())->toBe(2);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('reports limit not reached when count is below limit', function () {
- Cache::flush();
- $service = new AiAssistantQuotaService;
- $user = makeUser(1); // limit = 30
-
- $service->increment(); // count = 1
-
- expect($service->isLimitReached($user))->toBeFalse();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('reports limit reached when count equals the daily limit', function () {
- Cache::flush();
- $service = new AiAssistantQuotaService;
- $user = makeUser(1); // limit = 30
-
- Cache::put($service->getCacheKey(), 30);
-
- expect($service->isLimitReached($user))->toBeTrue();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('reports limit reached when count exceeds the daily limit', function () {
- Cache::flush();
- $service = new AiAssistantQuotaService;
- $user = makeUser(1); // limit = 30
-
- Cache::put($service->getCacheKey(), 31);
-
- expect($service->isLimitReached($user))->toBeTrue();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('uses separate keys for different days', function () {
- Cache::flush();
- $service = new AiAssistantQuotaService;
-
- Cache::put('ai_requests:2024-01-01', 99);
- Cache::put('ai_requests:2024-01-02', 5);
-
- // Only the current day's key is used
- expect($service->getCount())->toBe(0);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/AiChatControllerTest.php b/_api_app/tests/Feature/AiChatControllerTest.php
deleted file mode 100644
index 330de5d7d..000000000
--- a/_api_app/tests/Feature/AiChatControllerTest.php
+++ /dev/null
@@ -1,404 +0,0 @@
- 'Make the background blue',
- 'site' => '',
- 'template' => 'default',
- ])->assertStatus(401);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('parses structured json response from anthropic', function () {
- AssistantAgent::fake([
- '{"reply": "Changed background to blue.", "design_changes": [{"group": "background", "setting": "backgroundColor", "value": "#0000ff"}], "settings_changes": []}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'make background blue']]);
-
- expect($result['reply'])->toBe('Changed background to blue.')
- ->and($result['design_changes'])->toHaveCount(1)
- ->and($result['design_changes'][0]['group'])->toBe('background')
- ->and($result['design_changes'][0]['setting'])->toBe('backgroundColor')
- ->and($result['design_changes'][0]['value'])->toBe('#0000ff')
- ->and($result['settings_changes'])->toBeEmpty();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns empty changes when ai response has no json', function () {
- AssistantAgent::fake([
- 'I cannot help with that.',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'hello']]);
-
- expect($result['reply'])->toBe('I cannot help with that.')
- ->and($result['design_changes'])->toBeEmpty()
- ->and($result['settings_changes'])->toBeEmpty();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns empty changes when json is embedded in prose', function () {
- AssistantAgent::fake([
- 'Sure! Here is my response: {"reply": "Done!", "design_changes": [], "settings_changes": []} — let me know if you need more.',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'reset']]);
-
- expect($result['reply'])->toBe('Done!')
- ->and($result['design_changes'])->toBeEmpty()
- ->and($result['settings_changes'])->toBeEmpty();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('parses site settings changes from ai response', function () {
- AssistantAgent::fake([
- '{"reply": "Updated the page title.", "design_changes": [], "settings_changes": [{"group": "texts", "setting": "pageTitle", "value": "My Site"}]}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'set the page title to My Site']]);
-
- expect($result['reply'])->toBe('Updated the page title.')
- ->and($result['design_changes'])->toBeEmpty()
- ->and($result['settings_changes'])->toHaveCount(1)
- ->and($result['settings_changes'][0]['group'])->toBe('texts')
- ->and($result['settings_changes'][0]['setting'])->toBe('pageTitle')
- ->and($result['settings_changes'][0]['value'])->toBe('My Site');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('parses is_undo from ai response', function () {
- AssistantAgent::fake([
- '{"reply": "Reverted font size.", "is_undo": true, "design_changes": [{"group": "bodyText", "setting": "fontSize", "value": "12px"}], "settings_changes": []}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'undo']]);
-
- expect($result['is_undo'])->toBeTrue()
- ->and($result['reply'])->toBe('Reverted font size.')
- ->and($result['design_changes'])->toHaveCount(1)
- ->and($result['design_changes'][0]['value'])->toBe('12px');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('enriches changes with previous_value', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'enrichChangesWithPreviousValues');
-
- $changes = [
- ['group' => 'bodyText', 'setting' => 'fontSize', 'value' => '16px'],
- ['group' => 'bodyText', 'setting' => 'fontFamily', 'value' => 'Arial'],
- ];
- $currentSettings = [
- 'bodyText' => ['fontSize' => '12px'],
- ];
-
- $result = $method->invoke($controller, $changes, $currentSettings);
-
- expect($result[0]['previous_value'])->toBe('12px')
- ->and($result[1]['previous_value'])->toBeNull();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('includes change history in system prompt', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildChangeHistorySection');
-
- $changeHistory = [
- [
- 'user_message' => 'make the font bigger',
- 'design_changes' => [
- ['group' => 'bodyText', 'setting' => 'fontSize', 'value' => '16px', 'previous_value' => '12px'],
- ],
- 'settings_changes' => [],
- ],
- [
- 'user_message' => 'make background dark',
- 'design_changes' => [
- ['group' => 'background', 'setting' => 'backgroundColor', 'value' => '#000000', 'previous_value' => '#ffffff'],
- ],
- 'settings_changes' => [],
- ],
- ];
-
- $result = $method->invoke($controller, $changeHistory);
-
- expect($result)
- ->toContain('Change History')
- ->toContain('make the font bigger')
- ->toContain('bodyText > fontSize')
- ->toContain('"12px" → "16px"')
- ->toContain('make background dark')
- ->toContain('background > backgroundColor')
- ->toContain('"#ffffff" → "#000000"');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('omits change history section when history is empty', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildChangeHistorySection');
-
- $result = $method->invoke($controller, []);
-
- expect($result)->toBe('');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('parses entry_changes from ai response', function () {
- AssistantAgent::fake([
- '{"reply": "Created an entry.", "is_undo": false, "design_changes": [], "settings_changes": [], "section_changes": [], "entry_changes": [{"operation": "create", "section": "blog", "description": "Hello world
"}]}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'add an entry to blog']]);
-
- expect($result['entry_changes'])->toHaveCount(1)
- ->and($result['entry_changes'][0]['operation'])->toBe('create')
- ->and($result['entry_changes'][0]['section'])->toBe('blog')
- ->and($result['entry_changes'][0]['description'])->toBe('Hello world
');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns empty entry_changes when not in ai response', function () {
- AssistantAgent::fake([
- '{"reply": "Done.", "design_changes": [], "settings_changes": [], "section_changes": []}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'hello']]);
-
- expect($result['entry_changes'])->toBeArray()->toBeEmpty();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('includes entry changes in change history section', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildChangeHistorySection');
-
- $changeHistory = [
- [
- 'user_message' => 'add an entry',
- 'design_changes' => [],
- 'settings_changes' => [],
- 'section_changes' => [],
- 'entry_changes' => [
- ['operation' => 'create', 'section' => 'blog', 'entry_id' => '5', 'description' => 'Hello
'],
- ],
- ],
- [
- 'user_message' => 'update entry description',
- 'design_changes' => [],
- 'settings_changes' => [],
- 'section_changes' => [],
- 'entry_changes' => [
- ['operation' => 'update', 'section' => 'blog', 'entry_id' => '5', 'value' => 'New
', 'previous_value' => 'Old
'],
- ],
- ],
- ];
-
- $result = $method->invoke($controller, $changeHistory);
-
- expect($result)
- ->toContain('add an entry')
- ->toContain('entry: create #5 in "blog"')
- ->toContain('undo: delete entry 5')
- ->toContain('update entry description')
- ->toContain('entry: update #5 in "blog"')
- ->toContain('Old')
- ->toContain('New');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('restricts change_history entry_changes operation to create and update', function () {
- $request = new AiChatRequest;
- $rules = $request->rules();
-
- expect($rules['change_history.*.entry_changes.*.operation'])
- ->toContain('in:create,update');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('includes entries section in system prompt', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildEntriesSection');
-
- $result = $method->invoke($controller);
-
- expect($result)
- ->toContain('Entries')
- ->toContain('list_section_entries')
- ->toContain('get_entry_content')
- ->toContain('description')
- ->toContain('delete')
- ->toContain('manually');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('includes help articles in system prompt', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildHelpArticlesSection');
-
- $result = $method->invoke($controller);
-
- expect($result)
- ->toContain('Help Articles')
- ->toContain('How to Add a Video')
- ->toContain('https://support.berta.me/en/frequently-asked-questions/how-to-add-a-video')
- ->toContain('support.berta.me')
- ->toContain('Domains')
- ->toContain('SSL Certificates and HTTPS');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('parses gallery_changes from ai response', function () {
- AssistantAgent::fake([
- '{"reply": "Updated gallery type.", "is_undo": false, "design_changes": [], "settings_changes": [], "section_changes": [], "entry_changes": [], "gallery_changes": [{"operation": "update_setting", "section": "portfolio", "entry_id": "3", "setting": "type", "value": "row"}]}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'change gallery to row']]);
-
- expect($result['gallery_changes'])->toHaveCount(1)
- ->and($result['gallery_changes'][0]['operation'])->toBe('update_setting')
- ->and($result['gallery_changes'][0]['section'])->toBe('portfolio')
- ->and($result['gallery_changes'][0]['entry_id'])->toBe('3')
- ->and($result['gallery_changes'][0]['setting'])->toBe('type')
- ->and($result['gallery_changes'][0]['value'])->toBe('row');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns empty gallery_changes when not in ai response', function () {
- AssistantAgent::fake([
- '{"reply": "Done.", "design_changes": [], "settings_changes": [], "section_changes": []}',
- ]);
-
- $agent = new AssistantAgent('system prompt');
- $result = $agent->chat([['role' => 'user', 'content' => 'hello']]);
-
- expect($result['gallery_changes'])->toBeArray()->toBeEmpty();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('includes gallery changes in change history section', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildChangeHistorySection');
-
- $changeHistory = [
- [
- 'user_message' => 'change gallery to row',
- 'design_changes' => [],
- 'settings_changes' => [],
- 'section_changes' => [],
- 'entry_changes' => [],
- 'gallery_changes' => [
- ['operation' => 'update_setting', 'section' => 'portfolio', 'entry_id' => '3', 'setting' => 'type', 'value' => 'row', 'previous_value' => 'slideshow'],
- ],
- ],
- [
- 'user_message' => 'update image caption',
- 'design_changes' => [],
- 'settings_changes' => [],
- 'section_changes' => [],
- 'entry_changes' => [],
- 'gallery_changes' => [
- ['operation' => 'update_caption', 'section' => 'portfolio', 'entry_id' => '3', 'file_index' => 0, 'value' => 'New caption
', 'previous_value' => 'Old caption
'],
- ],
- ],
- ];
-
- $result = $method->invoke($controller, $changeHistory);
-
- expect($result)
- ->toContain('gallery: update_setting entry #3 in "portfolio"')
- ->toContain('"slideshow" → "row"')
- ->toContain('gallery: update_caption entry #3 in "portfolio" file[0]')
- ->toContain('"Old caption" → "New caption"');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('restricts change_history gallery_changes operation to update_setting and update_caption', function () {
- $request = new AiChatRequest;
- $rules = $request->rules();
-
- expect($rules['change_history.*.gallery_changes.*.operation'])
- ->toContain('in:update_setting,update_caption');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('includes galleries section in system prompt', function () {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildGalleriesSection');
-
- $result = $method->invoke($controller);
-
- expect($result)
- ->toContain('Galleries')
- ->toContain('get_entry_gallery')
- ->toContain('update_setting')
- ->toContain('update_caption')
- ->toContain('slideshow')
- ->toContain('fullscreen')
- ->toContain('file_index')
- ->toContain('manual');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('describes current sections in the system prompt', function () {
- $bertaRoot = sys_get_temp_dir() . '/berta_ai_sections_' . uniqid();
- File::ensureDirectoryExists($bertaRoot . '/storage');
- config(['app.old_berta_root' => $bertaRoot]);
- file_put_contents(
- $bertaRoot . '/storage/sections.xml',
- ''
- . ''
- . ''
- . ''
- . ' ',
- );
-
- try {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'buildSectionsSection');
-
- $result = $method->invoke($controller, (new SiteContentReader)->sections());
- } finally {
- File::deleteDirectory($bertaRoot);
- }
-
- expect($result)->toContain(
- "### Current Sections\n"
- . " order 0: \"Works\" (name: works, type: default, published: 1)\n"
- . " order 1: \"Shop\" (name: shop, type: shop, published: 0)\n"
- . " SEO — title: \"Buy prints\", keywords: \"prints, art\", description: \"Prints shop\"\n"
- . " order 2: \"About\" (name: about, type: default, published: 1)\n"
- . "\n### Operations",
- );
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('describes current site settings in the system prompt', function () {
- $bertaRoot = sys_get_temp_dir() . '/berta_ai_settings_' . uniqid();
- File::ensureDirectoryExists($bertaRoot . '/storage');
- linkBertaEngine($bertaRoot);
- config(['app.old_berta_root' => $bertaRoot]);
- file_put_contents(
- $bertaRoot . '/storage/settings.xml',
- ''
- . ' '
- . ' '
- . ' '
- . ' ',
- );
-
- try {
- $controller = new AiChatController;
- $method = new ReflectionMethod($controller, 'formatCurrentSettings');
-
- $result = $method->invoke($controller, (new SiteSettingsDataService(''))->getState(), (new SiteSettingsConfigService)->get());
- } finally {
- File::deleteDirectory($bertaRoot);
- }
-
- expect($result)->toContain('texts (')
- ->and($result)->toContain(' pageTitle (')
- ->and($result)->toContain(' unknownKey: kept')
- ->and($result)->not->toContain('https://x.com')
- ->and($result)->toMatchSnapshot();
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/GetEntryContentToolTest.php b/_api_app/tests/Feature/GetEntryContentToolTest.php
deleted file mode 100644
index 18a58f9ea..000000000
--- a/_api_app/tests/Feature/GetEntryContentToolTest.php
+++ /dev/null
@@ -1,53 +0,0 @@
-bertaRoot = sys_get_temp_dir() . '/berta_chat_content_' . uniqid();
- File::ensureDirectoryExists($this->bertaRoot . '/storage');
- config(['app.old_berta_root' => $this->bertaRoot]);
- // The default template shows entry titles.
- linkBertaEngine($this->bertaRoot);
- writeTemplate($this->bertaRoot, 'default');
- file_put_contents(
- $this->bertaRoot . '/storage/sections.xml',
- ' ',
- );
- file_put_contents(
- $this->bertaRoot . '/storage/blog.works.xml',
- ''
- . ' '
- . 'Hello]]> '
- . ' ',
- );
-});
-
-afterEach(function () {
- File::deleteDirectory($this->bertaRoot);
-});
-
-it('returns the content of an entry for the ai chat', function () {
- $result = (new GetEntryContentTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '2']));
-
- expect(json_decode($result, true))->toBe([
- 'id' => '2', 'title' => 'Second', 'description' => 'Hello
', 'order' => 1,
- ]);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('tells the ai chat when the entry does not exist', function () {
- $result = (new GetEntryContentTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '99']));
-
- expect($result)->toBe('Entry not found.');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('leaves out the title where the template has no entry titles', function () {
- writeTemplate($this->bertaRoot, 'messy-0.4.2');
-
- $result = (new GetEntryContentTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '2']));
-
- expect(json_decode($result, true))->toBe(['id' => '2', 'description' => 'Hello
', 'order' => 1]);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/GetEntryGalleryToolTest.php b/_api_app/tests/Feature/GetEntryGalleryToolTest.php
deleted file mode 100644
index 5b4bc0518..000000000
--- a/_api_app/tests/Feature/GetEntryGalleryToolTest.php
+++ /dev/null
@@ -1,50 +0,0 @@
-bertaRoot = sys_get_temp_dir() . '/berta_chat_gallery_' . uniqid();
- File::ensureDirectoryExists($this->bertaRoot . '/storage');
- config(['app.old_berta_root' => $this->bertaRoot]);
- file_put_contents(
- $this->bertaRoot . '/storage/sections.xml',
- ' ',
- );
- file_put_contents(
- $this->bertaRoot . '/storage/blog.works.xml',
- ' '
- . ''
- . 'Sunset]]> '
- . ' '
- . ' ',
- );
-});
-
-afterEach(function () {
- File::deleteDirectory($this->bertaRoot);
-});
-
-it('describes the gallery of an entry for the ai chat', function () {
- $result = (new GetEntryGalleryTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '5']));
-
- expect(json_decode($result, true))->toBe([
- 'settings' => ['type' => 'slideshow', 'autoplay' => '3'],
- 'file_count' => 2,
- 'image_count' => 1,
- 'video_count' => 1,
- 'files' => [
- ['type' => 'image', 'extension' => 'jpg', 'src' => 'Photo.JPG', 'caption' => 'Sunset
'],
- ['type' => 'video', 'extension' => 'mp4', 'src' => 'clip.mp4', 'caption' => ''],
- ],
- ]);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('tells the ai chat when the entry does not exist', function () {
- $result = (new GetEntryGalleryTool(''))->handle(new Request(['section_name' => 'works', 'entry_id' => '99']));
-
- expect($result)->toBe('Entry not found.');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/ListSectionEntriesToolTest.php b/_api_app/tests/Feature/ListSectionEntriesToolTest.php
deleted file mode 100644
index dbe9409a7..000000000
--- a/_api_app/tests/Feature/ListSectionEntriesToolTest.php
+++ /dev/null
@@ -1,55 +0,0 @@
-bertaRoot = sys_get_temp_dir() . '/berta_chat_entries_' . uniqid();
- File::ensureDirectoryExists($this->bertaRoot . '/storage');
- config(['app.old_berta_root' => $this->bertaRoot]);
- // The default template shows entry titles.
- linkBertaEngine($this->bertaRoot);
- writeTemplate($this->bertaRoot, 'default');
- file_put_contents(
- $this->bertaRoot . '/storage/sections.xml',
- ' ',
- );
-});
-
-afterEach(function () {
- File::deleteDirectory($this->bertaRoot);
-});
-
-it('lists section entries for the ai chat', function () {
- file_put_contents(
- $this->bertaRoot . '/storage/blog.works.xml',
- ''
- . ' '
- . ' '
- . 'Hello]]> '
- . ' '
- . ' ',
- );
-
- $result = (new ListSectionEntriesTool(''))->handle(new Request(['section_name' => 'works']));
-
- expect(json_decode($result, true))->toBe([
- [
- 'id' => '1', 'title' => 'First', 'description' => 'Hello
', 'order' => 0,
- 'gallery' => ['type' => 'slideshow', 'file_count' => 3, 'image_count' => 2, 'video_count' => 1],
- ],
- [
- 'id' => '2', 'title' => '', 'description' => '', 'order' => 1,
- 'gallery' => ['type' => null, 'file_count' => 0, 'image_count' => 0, 'video_count' => 0],
- ],
- ]);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('tells the ai chat when a section has no entries', function () {
- $result = (new ListSectionEntriesTool(''))->handle(new Request(['section_name' => 'works']));
-
- expect($result)->toBe('No entries found in this section.');
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Feature/SiteContentReaderTest.php b/_api_app/tests/Feature/SiteContentReaderTest.php
deleted file mode 100644
index b4413889e..000000000
--- a/_api_app/tests/Feature/SiteContentReaderTest.php
+++ /dev/null
@@ -1,253 +0,0 @@
-bertaRoot = sys_get_temp_dir() . '/berta_reader_' . uniqid();
- File::ensureDirectoryExists($this->bertaRoot . '/storage');
- config(['app.old_berta_root' => $this->bertaRoot]);
-});
-
-afterEach(function () {
- File::deleteDirectory($this->bertaRoot);
-});
-
-it('normalizes sections from storage', function () {
- file_put_contents(
- $this->bertaRoot . '/storage/sections.xml',
- ''
- . ''
- . ''
- . ''
- . ' ',
- );
-
- expect((new SiteContentReader)->sections())->toBe([
- [
- 'name' => 'works', 'title' => 'Works', 'type' => 'default', 'published' => true, 'entry_count' => 3, 'order' => 0,
- 'seo' => ['title' => '', 'keywords' => '', 'description' => ''],
- ],
- [
- 'name' => 'shop', 'title' => '', 'type' => 'shop', 'published' => false, 'entry_count' => 2, 'order' => 1,
- 'seo' => ['title' => 'Buy prints', 'keywords' => '', 'description' => ''],
- ],
- [
- 'name' => 'about', 'title' => 'About', 'type' => 'default', 'published' => true, 'entry_count' => 0, 'order' => 2,
- 'seo' => ['title' => '', 'keywords' => '', 'description' => ''],
- ],
- ]);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-it('returns no sections when the site has no sections file', function () {
- expect((new SiteContentReader)->sections())->toBe([]);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-describe('entries', function () {
- beforeEach(function () {
- // Site settings (for the template) read the engine's setting definitions.
- linkBertaEngine($this->bertaRoot);
- writeTemplate($this->bertaRoot, 'default');
- file_put_contents(
- $this->bertaRoot . '/storage/sections.xml',
- ' ',
- );
- });
-
- it('normalizes entries of a section', function () {
- file_put_contents(
- $this->bertaRoot . '/storage/blog.works.xml',
- ''
- . ' '
- . ' '
- . 'Text]]> '
- . ' '
- . ' ',
- );
-
- expect((new SiteContentReader)->entries('works'))->toBe([
- [
- 'id' => '7', 'title' => 'Series', 'description' => 'Text
', 'order' => 0,
- 'gallery' => ['type' => 'row', 'file_count' => 2, 'image_count' => 1, 'video_count' => 1],
- ],
- [
- 'id' => '8', 'title' => '', 'description' => '', 'order' => 1,
- 'gallery' => ['type' => 'slideshow', 'file_count' => 0, 'image_count' => 0, 'video_count' => 0],
- ],
- ]);
- });
-
- it('leaves out entry titles where the template does not show them', function () {
- writeTemplate($this->bertaRoot, 'messy-0.4.2');
- file_put_contents(
- $this->bertaRoot . '/storage/blog.works.xml',
- ' ',
- );
-
- $reader = new SiteContentReader;
-
- expect($reader->showsEntryTitles('works'))->toBeFalse()
- ->and($reader->entries('works')[0])->not->toHaveKey('title');
- });
-
- it('keeps entry titles in portfolio sections on any template', function () {
- writeTemplate($this->bertaRoot, 'messy-0.4.2');
- file_put_contents(
- $this->bertaRoot . '/storage/sections.xml',
- ' ',
- );
- file_put_contents(
- $this->bertaRoot . '/storage/blog.works.xml',
- ' ',
- );
-
- $reader = new SiteContentReader;
-
- expect($reader->showsEntryTitles('works'))->toBeTrue()
- ->and($reader->entries('works')[0]['title'])->toBe('Project');
- });
-
- it('knows which sections exist', function () {
- $reader = new SiteContentReader;
-
- expect($reader->hasSection('works'))->toBeTrue()
- ->and($reader->hasSection('missing'))->toBeFalse()
- ->and($reader->showsEntryTitles('missing'))->toBeFalse();
- });
-
- it('reads one entry like the entry list', function () {
- file_put_contents(
- $this->bertaRoot . '/storage/blog.works.xml',
- ' ',
- );
- $reader = new SiteContentReader;
-
- expect($reader->entryContent('works', '1'))->toBe($reader->entries('works')[0])
- ->and($reader->entryContent('works', '99'))->toBeNull()
- ->and($reader->entryContent('missing', '1'))->toBeNull()
- ->and($reader->entryContent('../storage/blog.works', '1'))->toBeNull();
- });
-
- it('reads no entries for sections that do not exist', function (string $section) {
- file_put_contents($this->bertaRoot . '/storage/blog.missing.xml', '1 ');
-
- expect((new SiteContentReader)->entries($section))->toBe([]);
- })->with(['missing', '../storage/blog.missing', '']);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-// File type, extension and caption normalization is pinned in GetEntryGalleryToolTest.
-describe('entry gallery', function () {
- beforeEach(function () {
- file_put_contents(
- $this->bertaRoot . '/storage/sections.xml',
- ' ',
- );
- file_put_contents(
- $this->bertaRoot . '/storage/blog.works.xml',
- ''
- . ' '
- . ' '
- . ' ',
- );
- });
-
- it('treats files without a type as images', function () {
- $gallery = (new SiteContentReader)->entryGallery('works', '1');
-
- expect($gallery['files'])->toBe([['type' => 'image', 'extension' => 'png', 'src' => 'untyped.png', 'caption' => '']])
- ->and($gallery['image_count'])->toBe(1);
- });
-
- it('describes an entry without gallery as empty', function () {
- expect((new SiteContentReader)->entryGallery('works', '2'))->toBe([
- 'settings' => [], 'file_count' => 0, 'image_count' => 0, 'video_count' => 0, 'files' => [],
- ]);
- });
-
- it('finds no gallery for unknown sections or entries', function (string $section, string $entryId) {
- expect((new SiteContentReader)->entryGallery($section, $entryId))->toBeNull();
- })->with([
- 'unknown entry' => ['works', '99'],
- 'unknown section' => ['missing', '1'],
- 'path outside storage' => ['../storage/blog.works', '1'],
- ]);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-describe('site settings', function () {
- beforeEach(function () {
- // Site settings read the engine's setting definitions.
- linkBertaEngine($this->bertaRoot);
- file_put_contents(
- $this->bertaRoot . '/storage/settings.xml',
- ''
- . ' '
- . ' '
- . ' '
- . ' '
- . ' ',
- );
- });
-
- it('describes settings with their titles and leaves out the internal group', function () {
- $groups = collect((new SiteContentReader)->siteSettings())->keyBy('name');
- $texts = collect($groups['texts']['settings'])->keyBy('key');
-
- expect($groups->keys())->not->toContain('berta')
- // Not on the settings page, but real site content: the site heading and footer texts.
- ->and(collect($groups['siteTexts']['settings'])->keyBy('key')['siteHeading']['value'])->toBe('Acme')
- ->and($groups['texts']['title'])->toBeString()
- ->and($texts['pageTitle']['value'])->toBe('My site')
- ->and($texts['pageTitle']['title'])->toBeString()
- ->and($texts['pageTitle']['items'])->toBeNull()
- ->and($texts['unknownKey'])->toBe(['key' => 'unknownKey', 'title' => null, 'description' => null, 'value' => 'kept', 'items' => null]);
- });
-
- it('lists the entries of list settings as items', function () {
- $groups = collect((new SiteContentReader)->siteSettings())->keyBy('name');
- $links = collect($groups['socialMediaLinks']['settings'])->keyBy('key')['links'];
-
- expect($links['value'])->toBe('')
- ->and($links['items'])->toBe([['icon' => 'x', 'url' => 'https://x.com']]);
- });
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
-
-describe('design settings', function () {
- beforeEach(function () {
- // Design settings read the template's setting definitions.
- linkBertaEngine($this->bertaRoot);
- });
-
- it('describes the active template settings with their titles', function () {
- writeTemplate($this->bertaRoot, 'messy-0.4.2');
- file_put_contents(
- $this->bertaRoot . '/storage/settings.messy.xml',
- ' ',
- );
-
- $design = (new SiteContentReader)->designSettings();
- $groups = collect($design['groups'])->keyBy('name');
- $color = collect($groups['generalFontSettings']['settings'])->keyBy('key')['color'];
-
- expect($design['template'])->toBe('messy-0.4.2')
- ->and($groups['generalFontSettings']['title'])->toBeString()
- ->and($color['value'])->toBe('#ff0000')
- ->and($color['title'])->toBeString()
- // Settings the site never changed come with the template's defaults.
- ->and($groups['menu']['settings'])->not->toBeEmpty();
- });
-
- it('reads the installed template when the site names an unknown one', function (string $template) {
- writeTemplate($this->bertaRoot, $template);
-
- $design = (new SiteContentReader)->designSettings();
-
- expect($design['template'])->toBe('messy-0.4.2')
- ->and($design['groups'])->not->toBeEmpty();
- })->with([
- 'older version' => 'messy-0.3.0',
- 'not installed' => 'missing-1.0.0',
- ]);
-})->skip(! $pluginInstalled, 'AiAssistant plugin not installed');
diff --git a/_api_app/tests/Pest.php b/_api_app/tests/Pest.php
index 70059498c..c3b376fbb 100644
--- a/_api_app/tests/Pest.php
+++ b/_api_app/tests/Pest.php
@@ -16,7 +16,8 @@
|
*/
-pest()->extend(TestCase::class)->in('Feature');
+// Plugins (separate repositories in app/Plugins) keep their own tests in their tests folder.
+pest()->extend(TestCase::class)->in('Feature', '../app/Plugins/*/tests');
/*
|--------------------------------------------------------------------------
diff --git a/_api_app/tests/Unit/SectionEntryRenderServiceTest.php b/_api_app/tests/Unit/SectionEntryRenderServiceTest.php
new file mode 100644
index 000000000..e095d3710
--- /dev/null
+++ b/_api_app/tests/Unit/SectionEntryRenderServiceTest.php
@@ -0,0 +1,13 @@
+toBe($shown);
+})->with([
+ 'default template' => ['default', 'default', true],
+ 'default template, portfolio section' => ['default', 'portfolio', true],
+ 'other template, portfolio section' => ['messy', 'portfolio', true],
+ 'other template' => ['messy', 'default', false],
+ 'other template, no section type' => ['messy', null, false],
+]);
From b9c605b255e694b7dcc4a6e5d61c5bbc48e50d5a Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Tue, 27 Oct 2026 16:20:50 +0200
Subject: [PATCH 08/22] Share the plan features test helper with all tests
givePlanFeatures() puts a test website on a hosting plan with the given
features and limits, as UserModel reads them.
Co-Authored-By: Claude Opus 5.5
---
_api_app/tests/Pest.php | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/_api_app/tests/Pest.php b/_api_app/tests/Pest.php
index c3b376fbb..fa41dee19 100644
--- a/_api_app/tests/Pest.php
+++ b/_api_app/tests/Pest.php
@@ -87,6 +87,22 @@ function linkBertaEngine(string $bertaRoot): void
}
}
+/**
+ * Puts the website in a temporary Berta root on a hosting plan with these features, as UserModel reads them.
+ * Needs linkBertaEngine().
+ *
+ * @param list $features
+ * @param array $limits e.g. ['ai_assistant_daily' => 10]
+ */
+function givePlanFeatures(string $bertaRoot, array $features, array $limits = []): void
+{
+ File::ensureDirectoryExists($bertaRoot . '/engine/config');
+ File::put($bertaRoot . '/engine/config/inc.conf.php', " 'https://berta.me/login', 'plans' => [['id' => '2', 'features' => $features, 'limits' => $limits]]]));
+ File::put($bertaRoot . '/engine/plan', '2');
+}
+
/**
* Sets the main site's template in a temporary Berta root. Needs linkBertaEngine() to be read.
*/
From 94998cf8ac6bd07ee464938542d49c7821612a17 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Tue, 27 Oct 2026 19:45:21 +0200
Subject: [PATCH 09/22] Validate setting values on the server, as the editor
does
The editor's settings and design endpoints saved any value they were sent.
They now check it against the setting's definition with the editor's own
rules (allowed values, colors, CSS units, blank values, validation kinds)
and the plan feature a setting needs, and answer 422 with error_message
otherwise. Entries of list settings are left as before.
Co-Authored-By: Claude Opus 5.5
---
.../Configuration/SettingValueValidator.php | 101 ++++++++++++++++++
.../Sites/Settings/SiteSettingsController.php | 8 ++
.../SiteTemplateSettingsController.php | 8 ++
.../tests/Feature/SettingsValidationTest.php | 69 ++++++++++++
.../tests/Unit/SettingValueValidatorTest.php | 51 +++++++++
5 files changed, 237 insertions(+)
create mode 100644 _api_app/app/Configuration/SettingValueValidator.php
create mode 100644 _api_app/tests/Feature/SettingsValidationTest.php
create mode 100644 _api_app/tests/Unit/SettingValueValidatorTest.php
diff --git a/_api_app/app/Configuration/SettingValueValidator.php b/_api_app/app/Configuration/SettingValueValidator.php
new file mode 100644
index 000000000..d685e8bd7
--- /dev/null
+++ b/_api_app/app/Configuration/SettingValueValidator.php
@@ -0,0 +1,101 @@
+|null $features the website's plan features; read from the account when needed if null
+ */
+ public function __construct(private ?array $features = null) {}
+
+ /**
+ * Why a value sent for a setting path can't be saved, or null when it can. Paths without a definition (e.g.
+ * entries of list settings) and values that aren't text are left to the data service, as before.
+ *
+ * @param mixed $definition the setting's definition, if the path has one
+ */
+ public function errorFor(mixed $value, mixed $definition): ?string
+ {
+ return is_array($definition) && is_scalar($value) ? $this->error(trim((string) $value), $definition) : null;
+ }
+
+ /**
+ * Why the value can't be saved, or null when it can.
+ *
+ * @param array $definition the setting's definition
+ */
+ public function error(string $value, array $definition): ?string
+ {
+ if (isset($definition['requires_feature']) && ! in_array($definition['requires_feature'], $this->features ??= (new UserModel)->features, true)) {
+ return "This setting needs the {$definition['requires_feature']} feature, which the website's plan doesn't include.";
+ }
+
+ return match ($definition['format'] ?? 'text') {
+ 'select', 'fontselect' => $this->selectError($value, $definition),
+ 'color' => $this->colorError($value, $definition),
+ 'text' => $this->textError($value, $definition),
+ default => null,
+ };
+ }
+
+ /**
+ * @param array $definition
+ */
+ private function selectError(string $value, array $definition): ?string
+ {
+ $values = $definition['values'] ?? [];
+ $allowed = array_map('strval', array_is_list($values) ? $values : array_keys($values));
+
+ return in_array($value, $allowed, true) ? null : 'Use one of the allowed values: ' . implode(' | ', $allowed) . '.';
+ }
+
+ /**
+ * The editor's color picker saves rgba(); stored values also use #hex and rgb().
+ *
+ * @param array $definition
+ */
+ private function colorError(string $value, array $definition): ?string
+ {
+ if ($value === '') {
+ return ! empty($definition['allow_blank']) ? null : 'This setting can\'t be empty.';
+ }
+
+ return preg_match('/^(#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\))$/i', $value)
+ ? null
+ : 'Use a color like #ff0000 or rgb(255,0,0).';
+ }
+
+ /**
+ * The rules of the editor's text inputs (validation.service.ts).
+ *
+ * @param array $definition
+ */
+ private function textError(string $value, array $definition): ?string
+ {
+ if ($value === '') {
+ return ! empty($definition['allow_blank']) ? null : 'This setting can\'t be empty.';
+ }
+
+ if (! empty($definition['css_units']) && ! preg_match('/^-?\d*\.?\d+(px|em|rem|vw|vh|pt|%)$/', $value)) {
+ return 'Use a number with a CSS unit: px, em, rem, vw, vh, pt or %.';
+ }
+
+ return match ($definition['validation'] ?? null) {
+ 'positive_integer' => preg_match('/^[1-9]\d*$/', $value) ? null : 'Use a positive whole number.',
+ 'zero_or_positive_integer' => preg_match('/^\d+$/', $value) ? null : 'Use zero or a positive whole number.',
+ 'positive_number' => preg_match('/^(?!0$)(?!0\d+$)\d*\.?\d+$/', $value) ? null : 'Use a positive number.',
+ 'email' => preg_match('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/', $value) ? null : 'Use a valid email address.',
+ default => null,
+ };
+ }
+}
diff --git a/_api_app/app/Sites/Settings/SiteSettingsController.php b/_api_app/app/Sites/Settings/SiteSettingsController.php
index 8418f5444..12517e8a4 100644
--- a/_api_app/app/Sites/Settings/SiteSettingsController.php
+++ b/_api_app/app/Sites/Settings/SiteSettingsController.php
@@ -2,6 +2,8 @@
namespace App\Sites\Settings;
+use App\Configuration\SettingValueValidator;
+use App\Configuration\SiteSettingsConfigService;
use App\Http\Controllers\Controller;
use App\Shared\Helpers;
use Illuminate\Http\Request;
@@ -45,6 +47,12 @@ public function update(Request $request)
$site = $path_arr[0];
$settingsDataService = new SiteSettingsDataService($site);
+ // "/settings//"; deeper paths are entries of list settings
+ $definition = count($path_arr) === 4 && $path_arr[3] !== '_' ? ((new SiteSettingsConfigService)->get()[$path_arr[2]][$path_arr[3]] ?? null) : null;
+ if ($error = (new SettingValueValidator)->errorFor($json['value'], $definition)) {
+ return response()->json(['path' => $json['path'], 'value' => $json['value'], 'error_message' => $error], 422);
+ }
+
$res = $settingsDataService->saveValueByPath($json['path'], $json['value']);
// @@@:TODO: Replace this with something sensible, when migration to redux is done
// `real` returns the user input value
diff --git a/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsController.php b/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsController.php
index 9f243bd98..4ea3f9a4c 100644
--- a/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsController.php
+++ b/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsController.php
@@ -2,6 +2,8 @@
namespace App\Sites\TemplateSettings;
+use App\Configuration\SettingValueValidator;
+use App\Configuration\SiteTemplatesConfigService;
use App\Http\Controllers\Controller;
use App\Shared\Helpers;
use Illuminate\Http\Request;
@@ -17,6 +19,12 @@ public function update(Request $request)
$template = $path_arr[2];
$templateSettingsDataService = new SiteTemplateSettingsDataService($site, $template);
+ // "/template-settings///"
+ $definition = count($path_arr) === 5 && $path_arr[4] !== '_' ? ((new SiteTemplatesConfigService)->get()[$template]['templateConf'][$path_arr[3]][$path_arr[4]] ?? null) : null;
+ if ($error = (new SettingValueValidator)->errorFor($json['value'], $definition)) {
+ return response()->json(['path' => $json['path'], 'value' => $json['value'], 'error_message' => $error], 422);
+ }
+
$res = $templateSettingsDataService->saveValueByPath($json['path'], $json['value']);
// @@@:TODO: Replace this with something sensible, when migration to redux is done
$res['update'] = $res['value'];
diff --git a/_api_app/tests/Feature/SettingsValidationTest.php b/_api_app/tests/Feature/SettingsValidationTest.php
new file mode 100644
index 000000000..4097ae275
--- /dev/null
+++ b/_api_app/tests/Feature/SettingsValidationTest.php
@@ -0,0 +1,69 @@
+bertaRoot = sys_get_temp_dir() . '/berta_settings_validation_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ linkBertaEngine($this->bertaRoot);
+ writeTemplate($this->bertaRoot, 'messy-0.4.2');
+
+ // SetupMiddleware reconciles the real .env file on every request.
+ withoutMiddleware(SetupMiddleware::class);
+ actingAs(new User);
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+describe('design settings', function () {
+ it('saves a valid value', function () {
+ patchJson(route('site_template_settings'), ['path' => '/template-settings/messy-0.4.2/generalFontSettings/fontWeight', 'value' => 'bold'])
+ ->assertOk()
+ ->assertJsonMissingPath('error_message');
+
+ expect(file_get_contents($this->bertaRoot . '/storage/settings.messy.xml'))->toContain('bold');
+ });
+
+ it('refuses a value the editor would not offer, without saving it', function () {
+ patchJson(route('site_template_settings'), ['path' => '/template-settings/messy-0.4.2/generalFontSettings/fontWeight', 'value' => 'heavy'])
+ ->assertStatus(422)
+ ->assertJsonPath('error_message', 'Use one of the allowed values: normal | bold.');
+
+ expect(file_exists($this->bertaRoot . '/storage/settings.messy.xml'))->toBeFalse();
+ });
+});
+
+describe('site settings', function () {
+ it('refuses a setting that needs a feature the plan does not include', function () {
+ givePlanFeatures($this->bertaRoot, ['multisite']);
+
+ patchJson(route('site_settings'), ['path' => '/settings/socialMediaButtons/socialMediaHTML', 'value' => ''])
+ ->assertStatus(422)
+ ->assertJsonPath('error_message', 'This setting needs the custom_javascript feature, which the website\'s plan doesn\'t include.');
+
+ expect(file_get_contents($this->bertaRoot . '/storage/settings.xml'))->not->toContain('', $definition))->toBeNull()
+ ->and((new SettingValueValidator(['multisite']))->error('', $definition))->toContain('custom_javascript feature');
+});
+
+it('leaves paths without a definition and non-text values to the data service', function (mixed $value, mixed $definition) {
+ expect((new SettingValueValidator([]))->errorFor($value, $definition))->toBeNull();
+})->with([
+ 'no definition' => ['anything', null],
+ 'list value' => [['url' => 'x'], ['format' => 'text']],
+]);
From afb1a77f072d9e514de641430c964f64478183af Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Wed, 28 Oct 2026 10:06:27 +0200
Subject: [PATCH 10/22] Keep the real mouse out of the sortable specs'
synthetic drags
The specs used pointerId 1, the real mouse's id in Chrome, so moving the
mouse over a visible test page could join a synthetic drag and, rarely,
stop the auto-scroll spec from scrolling.
Co-Authored-By: Claude Opus 5.5
---
.../preview/sortable/sortable.service.spec.ts | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/editor/src/app/preview/sortable/sortable.service.spec.ts b/editor/src/app/preview/sortable/sortable.service.spec.ts
index 69d8df6c4..4daafe07b 100644
--- a/editor/src/app/preview/sortable/sortable.service.spec.ts
+++ b/editor/src/app/preview/sortable/sortable.service.spec.ts
@@ -14,6 +14,13 @@ interface Point {
* Drives the service with synthetic pointer events against a real
* same-origin iframe, the way the preview uses it.
*/
+/**
+ * The synthetic pointer's id. Not 1, the real mouse's id in Chrome: moving the real mouse over the
+ * test page while a spec drags would otherwise move the synthetic drag too (rare failures in a
+ * visible browser, e.g. auto-scroll stopping).
+ */
+const POINTER_ID = 9;
+
describe('SortableService', () => {
let iframe: HTMLIFrameElement;
let doc: Document;
@@ -103,7 +110,7 @@ describe('SortableService', () => {
bubbles: true,
cancelable: true,
isPrimary: true,
- pointerId: 1,
+ pointerId: POINTER_ID,
button: 0,
clientX: point.x,
clientY: point.y,
@@ -431,7 +438,10 @@ describe('SortableService', () => {
el('.xEntriesList').outerHTML = ENTRIES_HTML(['1', '2', '3']);
// Fired at the document once the capturing list is gone.
doc.dispatchEvent(
- new PointerEvent('lostpointercapture', { bubbles: true, pointerId: 1 }),
+ new PointerEvent('lostpointercapture', {
+ bubbles: true,
+ pointerId: POINTER_ID,
+ }),
);
expect(doc.documentElement.classList).not.toContain('xSorting');
@@ -660,7 +670,7 @@ describe('SortableService', () => {
pointer('pointermove', link, at(el('#tag-b'), 0.8, 0.5));
- expect(capture).toHaveBeenCalledOnceWith(1);
+ expect(capture).toHaveBeenCalledOnceWith(POINTER_ID);
expect(capture.calls.mostRecent().object).toBe(el('.subMenu'));
});
From 6b693fd198b3eba1018b54374457422eca02e822 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Wed, 28 Oct 2026 10:06:27 +0200
Subject: [PATCH 11/22] Keep open editors in sync and renew the session while
it is used
Every editor API response carries the website's latest change
(ChangeMarker, in Laravel's cache). A save from an editor window that
missed a change made elsewhere - in another window or by an AI agent -
is refused with 409, and the editor asks to reload, also when the tab
comes back into view. Login, logout and an empty marker never ask.
The session token is renewed at most hourly while the editor is used,
keeping its claims, for up to 30 days after login and only while the
account hasn't changed since (password change, website transfer).
Co-Authored-By: Claude Opus 5.5
---
.../app/Http/Controllers/AuthController.php | 12 +-
.../app/Http/Controllers/StateController.php | 10 +
_api_app/app/Http/Middleware/EditorSync.php | 114 +++++++
.../app/Http/Middleware/RenewEditorToken.php | 42 +++
_api_app/app/Shared/ChangeMarker.php | 70 +++++
_api_app/app/User/EditorToken.php | 86 ++++++
_api_app/routes/api.php | 17 +-
_api_app/tests/Feature/EditorSyncTest.php | 246 +++++++++++++++
.../tests/Feature/RenewEditorTokenTest.php | 87 ++++++
editor/src/app/app-state/app-state.service.ts | 10 +-
editor/src/app/app.module.ts | 3 +-
editor/src/app/popup/popup.interface.ts | 2 +-
editor/src/app/shared/editor-sync.spec.ts | 288 ++++++++++++++++++
editor/src/app/shared/editor-sync.ts | 206 +++++++++++++
editor/src/app/user/user.actions.ts | 6 +
editor/src/app/user/user.state.ts | 15 +
16 files changed, 1194 insertions(+), 20 deletions(-)
create mode 100644 _api_app/app/Http/Middleware/EditorSync.php
create mode 100644 _api_app/app/Http/Middleware/RenewEditorToken.php
create mode 100644 _api_app/app/Shared/ChangeMarker.php
create mode 100644 _api_app/app/User/EditorToken.php
create mode 100644 _api_app/tests/Feature/EditorSyncTest.php
create mode 100644 _api_app/tests/Feature/RenewEditorTokenTest.php
create mode 100644 editor/src/app/shared/editor-sync.spec.ts
create mode 100644 editor/src/app/shared/editor-sync.ts
diff --git a/_api_app/app/Http/Controllers/AuthController.php b/_api_app/app/Http/Controllers/AuthController.php
index c6a80dafd..9a67855eb 100644
--- a/_api_app/app/Http/Controllers/AuthController.php
+++ b/_api_app/app/Http/Controllers/AuthController.php
@@ -3,8 +3,8 @@
namespace App\Http\Controllers;
use App\Shared\Helpers;
+use App\User\EditorToken;
use App\User\UserModel;
-use Firebase\JWT\JWT;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -12,17 +12,11 @@ class AuthController extends Controller
{
private $bertaSecurity;
- protected static $expiration_time = 86400; // 24 hours = 86400 seconds
+ protected static $expiration_time = EditorToken::LIFETIME_SECONDS;
protected function generateToken()
{
- $app_key = config('app.key');
- $payload = [
- 'iat' => time(), // Time when JWT was issued.
- 'exp' => time() + self::$expiration_time, // Expiration time
- ];
-
- return JWT::encode($payload, $app_key, 'HS256');
+ return (new EditorToken)->issue();
}
public function authenticate(Request $request)
diff --git a/_api_app/app/Http/Controllers/StateController.php b/_api_app/app/Http/Controllers/StateController.php
index c0372418d..ce732a2cb 100644
--- a/_api_app/app/Http/Controllers/StateController.php
+++ b/_api_app/app/Http/Controllers/StateController.php
@@ -4,6 +4,7 @@
use App\Configuration\SiteSettingsConfigService;
use App\Configuration\SiteTemplatesConfigService;
+use App\Shared\ChangeMarker;
use App\Shared\Helpers;
use App\Sites\Sections\Entries\SectionEntriesDataService;
use App\Sites\Sections\SiteSectionsDataService;
@@ -38,6 +39,7 @@ public function get($site = '')
'siteThemeApply' => route('site_theme_apply'),
'siteSwapContentsBetweenSites' => route('site_swap_contents_between_sites'),
'localeSettings' => route('locale_settings'),
+ 'changeMarker' => route('change_marker'),
'siteSections' => route('site_sections'),
'siteSectionsReset' => route('site_sections_reset'),
'siteSectionBackgrounds' => route('site_section_backgrounds'),
@@ -139,6 +141,14 @@ public function getSentryDSN()
return response(env('SENTRY_FRONTEND_DSN', ''))->header('Content-Type', 'text/plain; charset=UTF-8');
}
+ /**
+ * The website's latest change, for an editor window coming back into view (EditorSync also sends it as headers)
+ */
+ public function getChangeMarker(): JsonResponse
+ {
+ return response()->json((new ChangeMarker)->current());
+ }
+
/**
* Returns translated settings for site localization: templates and settings config
*/
diff --git a/_api_app/app/Http/Middleware/EditorSync.php b/_api_app/app/Http/Middleware/EditorSync.php
new file mode 100644
index 000000000..493c203bc
--- /dev/null
+++ b/_api_app/app/Http/Middleware/EditorSync.php
@@ -0,0 +1,114 @@
+header(self::EDITOR_HEADER, '');
+ $isEditorWrite = preg_match('/^[0-9a-f]{16,64}$/', $editor) === 1 && ! $request->isMethodSafe();
+ // Read before the controller reads any data, so an answer never carries a newer change than its data.
+ $latest = $markers->current();
+
+ if ($isEditorWrite && $request->headers->has(self::CHANGE_HEADER) && $this->missedChange($latest, $editor, (string) $request->header(self::CHANGE_HEADER))) {
+ $message = $latest['source'] === ChangeMarker::SOURCE_AGENT
+ ? 'This website was changed by an AI agent. Reload to see the changes.'
+ : 'This website was changed in another editor window. Reload to see the changes.';
+
+ return $this->withMarker(response()->json(['error_message' => $message, 'change_source' => $latest['source']], 409), $latest);
+ }
+
+ $response = $next($request);
+
+ if ($isEditorWrite && $this->saved($response)) {
+ $latest = $this->markSaved($markers, $latest, $editor);
+ }
+
+ return $this->withMarker($response, $latest);
+ }
+
+ /**
+ * Marks this window's save, so other windows learn about it. When someone else marked a change while the save ran,
+ * the window is told about that change instead of its own, so it reloads rather than missing it. When the cache
+ * can't store the mark, the latest known marker stands.
+ *
+ * @param array{token: string, writer: string, source: string} $before the marker when the request came in
+ * @return array{token: string, writer: string, source: string}
+ */
+ private function markSaved(ChangeMarker $markers, array $before, string $editor): array
+ {
+ $now = $markers->current();
+ $changedMeanwhile = $now['token'] !== $before['token'] && $now['writer'] !== $editor;
+ $mine = $markers->touch(ChangeMarker::SOURCE_EDITOR, $editor);
+
+ return $changedMeanwhile ? $now : ($mine ?? $now);
+ }
+
+ /**
+ * Whether the write changed data: Berta's data services also report refused writes as 200 with an error_message.
+ */
+ private function saved(Response $response): bool
+ {
+ return $response->isSuccessful()
+ && ! ($response instanceof JsonResponse && is_array($response->getData(true)) && isset($response->getData(true)['error_message']));
+ }
+
+ /**
+ * Whether someone other than this editor window changed the website since the window saw the given token.
+ * The window's own changes don't count, so its quick saves in a row pass.
+ *
+ * @param array{token: string, writer: string, source: string} $latest
+ */
+ private function missedChange(array $latest, string $editor, string $seenToken): bool
+ {
+ return $latest['token'] !== '' && $latest['token'] !== $seenToken && $latest['writer'] !== $editor;
+ }
+
+ /**
+ * @param array{token: string, writer: string, source: string} $latest
+ */
+ private function withMarker(Response $response, array $latest): Response
+ {
+ $response->headers->set(self::CHANGE_HEADER, $latest['token']);
+ $response->headers->set(self::CHANGE_WRITER_HEADER, $latest['writer']);
+ $response->headers->set(self::CHANGE_SOURCE_HEADER, $latest['source']);
+
+ return $response;
+ }
+}
diff --git a/_api_app/app/Http/Middleware/RenewEditorToken.php b/_api_app/app/Http/Middleware/RenewEditorToken.php
new file mode 100644
index 000000000..e93efd52f
--- /dev/null
+++ b/_api_app/app/Http/Middleware/RenewEditorToken.php
@@ -0,0 +1,42 @@
+isSuccessful() || ! preg_match('/Bearer\s(\S+)/', (string) $request->headers->get('x-authorization'), $matches)) {
+ return $response;
+ }
+
+ $renewed = (new EditorToken)->renewal($matches[1]);
+
+ if ($renewed !== null) {
+ $response->headers->set(self::TOKEN_HEADER, $renewed);
+ // A response carrying a session token must never be stored by a proxy or CDN.
+ $response->headers->set('Cache-Control', 'no-store, private');
+ EditorToken::setCookie($renewed);
+ }
+
+ return $response;
+ }
+}
diff --git a/_api_app/app/Shared/ChangeMarker.php b/_api_app/app/Shared/ChangeMarker.php
new file mode 100644
index 000000000..149eed03e
--- /dev/null
+++ b/_api_app/app/Shared/ChangeMarker.php
@@ -0,0 +1,70 @@
+ '', 'writer' => '', 'source' => ''];
+
+ /**
+ * The latest change: a token that is new with every change, the editor window that made it (empty for agents)
+ * and its source. All empty before the first marked change.
+ *
+ * @return array{token: string, writer: string, source: string}
+ */
+ public function current(): array
+ {
+ try {
+ $marker = Cache::get(self::key());
+ } catch (Throwable) {
+ return self::NONE;
+ }
+
+ // Anything else in the cache counts as no marker.
+ $isMarker = is_array($marker) && array_keys($marker) === array_keys(self::NONE) && array_filter($marker, is_string(...)) === $marker;
+
+ return $isMarker ? $marker : self::NONE;
+ }
+
+ /**
+ * Marks a new change. Returns the marker, or null when the cache can't store it.
+ *
+ * @param string $writer the editor window's id; empty for changes made outside the editor
+ * @return array{token: string, writer: string, source: string}|null
+ */
+ public function touch(string $source, string $writer = ''): ?array
+ {
+ $marker = ['token' => (string) Str::ulid(), 'writer' => $writer, 'source' => $source];
+
+ try {
+ Cache::forever(self::key(), $marker);
+ } catch (Throwable $exception) {
+ report($exception);
+
+ return null;
+ }
+
+ return $marker;
+ }
+
+ private static function key(): string
+ {
+ return 'editor-sync:change-marker:' . md5(config('app.old_berta_root'));
+ }
+}
diff --git a/_api_app/app/User/EditorToken.php b/_api_app/app/User/EditorToken.php
new file mode 100644
index 000000000..daa2670c5
--- /dev/null
+++ b/_api_app/app/User/EditorToken.php
@@ -0,0 +1,86 @@
+ $claims claims to keep, besides the issue and expiry times
+ */
+ public function issue(array $claims = []): string
+ {
+ return JWT::encode([...$claims, 'iat' => time(), 'exp' => time() + self::LIFETIME_SECONDS], config('app.key'), 'HS256');
+ }
+
+ /**
+ * A new token with the same claims when the given valid token is older than RENEW_AFTER_SECONDS, its login not
+ * older than MAX_SESSION_DAYS and not earlier than the account's last change, else null. The login time is kept
+ * as "auth_time"; tokens from a login carry none, so their issue time is the login time.
+ */
+ public function renewal(string $token): ?string
+ {
+ try {
+ $claims = (array) JWT::decode($token, new Key(config('app.key'), 'HS256'));
+ } catch (Throwable) {
+ return null;
+ }
+
+ $authTime = (int) ($claims['auth_time'] ?? $claims['iat'] ?? 0);
+
+ if (
+ time() - (int) ($claims['iat'] ?? 0) < self::RENEW_AFTER_SECONDS
+ || time() - $authTime > self::MAX_SESSION_DAYS * 86400
+ || $authTime < $this->accountChangedAt()
+ ) {
+ return null;
+ }
+
+ return $this->issue([...$claims, 'auth_time' => $authTime]);
+ }
+
+ /**
+ * When the account's login details last changed; PHP_INT_MAX when unknown, so nothing is renewed.
+ */
+ private function accountChangedAt(): int
+ {
+ $file = config('app.old_berta_root') . '/engine/config/inc.conf.php';
+ // A password change rewrites the file earlier in the same request.
+ clearstatcache(true, $file);
+ $changedAt = @filemtime($file);
+
+ return $changedAt === false ? PHP_INT_MAX : $changedAt;
+ }
+
+ /**
+ * Sets the token cookie the editor's login sets, for requests that send no token header.
+ */
+ public static function setCookie(string $token): void
+ {
+ setcookie('token', $token, time() + self::LIFETIME_SECONDS, '/');
+ }
+}
diff --git a/_api_app/routes/api.php b/_api_app/routes/api.php
index 736da609c..8fb798bb4 100644
--- a/_api_app/routes/api.php
+++ b/_api_app/routes/api.php
@@ -3,6 +3,8 @@
use App\Http\Controllers\AuthController;
use App\Http\Controllers\StateController;
use App\Http\Middleware\Authenticate;
+use App\Http\Middleware\EditorSync;
+use App\Http\Middleware\RenewEditorToken;
use App\Http\Middleware\SetupMiddleware;
use App\Setup\ServerRequirementsController;
use App\Sites\Sections\Entries\SectionEntriesController;
@@ -24,17 +26,20 @@
Route::get('v1/requirements', [ServerRequirementsController::class, 'index'])->name('requirements');
});
-Route::middleware([SetupMiddleware::class, Authenticate::class])->prefix('v1')->group(function () {
- Route::patch('user/changepassword', [AuthController::class, 'changePassword']);
+Route::middleware([SetupMiddleware::class, Authenticate::class, RenewEditorToken::class, EditorSync::class])->prefix('v1')->group(function () {
+ // Changes no site data, so other editor windows aren't asked to reload (EditorSync).
+ Route::patch('user/changepassword', [AuthController::class, 'changePassword'])->name('user_change_password')->withoutMiddleware(EditorSync::class);
Route::get('state/{site?}', [StateController::class, 'get']);
Route::get('locale-settings', [StateController::class, 'getLocaleSettings'])->name('locale_settings');
+ Route::get('change-marker', [StateController::class, 'getChangeMarker'])->name('change_marker');
// Sites
Route::post('sites', [SitesController::class, 'create'])->name('sites');
Route::patch('sites', [SitesController::class, 'update']);
Route::put('sites', [SitesController::class, 'order']);
Route::delete('sites', [SitesController::class, 'delete']);
- Route::post('sites/theme-preview', [SitesController::class, 'themePreview'])->name('site_theme_preview');
+ // Builds a preview copy, not the live site (EditorSync).
+ Route::post('sites/theme-preview', [SitesController::class, 'themePreview'])->name('site_theme_preview')->withoutMiddleware(EditorSync::class);
Route::put('sites/theme-apply', [SitesController::class, 'themeApply'])->name('site_theme_apply');
Route::put('sites/swap-contents-between-sites', [SitesController::class, 'swapContentsBetweenSites'])->name('site_swap_contents_between_sites');
Route::get('sites/render-menu/{site?}', [SitesController::class, 'renderMenu']);
@@ -59,7 +64,7 @@
])->name('site_template_settings_reset_to_defaults');
});
-Route::middleware([SetupMiddleware::class, Authenticate::class])->prefix('v1/sites')->group(function () {
+Route::middleware([SetupMiddleware::class, Authenticate::class, RenewEditorToken::class, EditorSync::class])->prefix('v1/sites')->group(function () {
Route::post('sections', [SiteSectionsController::class, 'create'])->name('site_sections');
Route::patch('sections', [SiteSectionsController::class, 'update']);
Route::patch('sections/reset', [SiteSectionsController::class, 'reset'])->name('site_sections_reset');
@@ -89,7 +94,7 @@
Route::put('sections/tags', [SectionTagsController::class, 'order'])->name('section_tags');
});
-Route::middleware([SetupMiddleware::class, Authenticate::class])->prefix('v1/sites/sections')->group(function () {
+Route::middleware([SetupMiddleware::class, Authenticate::class, RenewEditorToken::class, EditorSync::class])->prefix('v1/sites/sections')->group(function () {
Route::patch('entries', [SectionEntriesController::class, 'update'])->name('section_entries');
Route::post('entries', [SectionEntriesController::class, 'create']);
Route::put('entries', [SectionEntriesController::class, 'order']);
@@ -108,7 +113,7 @@
]);
});
-Route::middleware([SetupMiddleware::class, Authenticate::class])->prefix('v1/plugin')->group(function () {
+Route::middleware([SetupMiddleware::class, Authenticate::class, RenewEditorToken::class])->prefix('v1/plugin')->group(function () {
$app_path = app_path();
foreach (scandir("{$app_path}/Plugins") as $fileOrDir) {
if (in_array($fileOrDir, ['.', '..'])) {
diff --git a/_api_app/tests/Feature/EditorSyncTest.php b/_api_app/tests/Feature/EditorSyncTest.php
new file mode 100644
index 000000000..0b110c5b7
--- /dev/null
+++ b/_api_app/tests/Feature/EditorSyncTest.php
@@ -0,0 +1,246 @@
+bertaRoot = sys_get_temp_dir() . '/berta_editor_sync_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ linkBertaEngine($this->bertaRoot);
+ writeTemplate($this->bertaRoot, 'messy-0.4.2');
+
+ // SetupMiddleware reconciles the real .env file on every request.
+ withoutMiddleware(SetupMiddleware::class);
+ actingAs(new User);
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+/**
+ * Saves the font weight as an editor window that has seen the given change token.
+ */
+function saveFontWeight(string $value, string $editor, string $seenToken): TestResponse
+{
+ return patchJson(
+ route('site_template_settings'),
+ ['path' => '/template-settings/messy-0.4.2/generalFontSettings/fontWeight', 'value' => $value],
+ ['X-Berta-Editor' => $editor, 'X-Berta-Change' => $seenToken],
+ );
+}
+
+function savedFontWeight(): string
+{
+ return (new SiteTemplateSettingsDataService('', 'messy-0.4.2'))->getState()['generalFontSettings']['fontWeight'];
+}
+
+it('tells the editor the latest change', function () {
+ $marker = (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+
+ getJson(route('change_marker'))
+ ->assertOk()
+ ->assertExactJson($marker)
+ ->assertHeader('X-Berta-Change', $marker['token'])
+ ->assertHeader('X-Berta-Change-Source', 'agent');
+});
+
+it('marks a change for each save of an editor window', function () {
+ $response = saveFontWeight('bold', EDITOR_WINDOW_A, '')->assertOk();
+ $marker = (new ChangeMarker)->current();
+
+ expect($marker)->toMatchArray(['writer' => EDITOR_WINDOW_A, 'source' => 'editor'])
+ ->and($marker['token'])->not->toBe('')
+ ->and($response->headers->get('X-Berta-Change'))->toBe($marker['token']);
+});
+
+it('lets a window save again over its own changes', function () {
+ saveFontWeight('normal', EDITOR_WINDOW_A, '')->assertOk();
+
+ // The editor sends quick saves before the previous response is back.
+ saveFontWeight('bold', EDITOR_WINDOW_A, '')->assertOk();
+
+ expect(savedFontWeight())->toBe('bold');
+});
+
+it('refuses a save from a window that missed a change in another window', function () {
+ saveFontWeight('normal', EDITOR_WINDOW_A, '')->assertOk();
+
+ saveFontWeight('bold', EDITOR_WINDOW_B, '')
+ ->assertStatus(409)
+ ->assertJsonPath('error_message', 'This website was changed in another editor window. Reload to see the changes.')
+ ->assertJsonPath('change_source', 'editor');
+
+ expect(savedFontWeight())->toBe('normal');
+});
+
+it('refuses saves from every window that missed an agent change', function () {
+ $seen = saveFontWeight('normal', EDITOR_WINDOW_A, '')->headers->get('X-Berta-Change');
+ (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+
+ saveFontWeight('bold', EDITOR_WINDOW_A, $seen)
+ ->assertStatus(409)
+ ->assertJsonPath('error_message', 'This website was changed by an AI agent. Reload to see the changes.')
+ ->assertHeader('X-Berta-Change-Source', 'agent');
+
+ expect(savedFontWeight())->toBe('normal');
+});
+
+it('saves from a window that has seen the latest change', function () {
+ $marker = (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+
+ saveFontWeight('bold', EDITOR_WINDOW_A, $marker['token'])->assertOk();
+
+ expect(savedFontWeight())->toBe('bold');
+});
+
+it('saves unchecked from a window that has not seen a change yet', function () {
+ (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+
+ // E.g. a save retried right after login, before the fresh data has arrived.
+ patchJson(
+ route('site_template_settings'),
+ ['path' => '/template-settings/messy-0.4.2/generalFontSettings/fontWeight', 'value' => 'bold'],
+ ['X-Berta-Editor' => EDITOR_WINDOW_A],
+ )->assertOk();
+
+ expect(savedFontWeight())->toBe('bold')
+ ->and((new ChangeMarker)->current())->toMatchArray(['writer' => EDITOR_WINDOW_A, 'source' => 'editor']);
+});
+
+it('leaves requests without an editor window as before', function () {
+ (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+ $before = (new ChangeMarker)->current();
+
+ patchJson(route('site_template_settings'), ['path' => '/template-settings/messy-0.4.2/generalFontSettings/fontWeight', 'value' => 'bold'])
+ ->assertOk();
+
+ expect(savedFontWeight())->toBe('bold')
+ ->and((new ChangeMarker)->current())->toBe($before);
+});
+
+it('lets saves through while no change is marked yet, e.g. right after an update', function () {
+ getJson(route('change_marker'))->assertHeader('X-Berta-Change', '');
+
+ saveFontWeight('bold', EDITOR_WINDOW_A, '')->assertOk();
+
+ expect(savedFontWeight())->toBe('bold');
+});
+
+it('works as before when the cache fails, e.g. on the first request after updating an old install', function () {
+ Cache::shouldReceive('get')->andThrow(new RuntimeException('No cache table.'));
+ Cache::shouldReceive('forever')->andThrow(new RuntimeException('No cache table.'));
+
+ saveFontWeight('bold', EDITOR_WINDOW_A, 'some-token')
+ ->assertOk()
+ ->assertHeader('X-Berta-Change', '');
+
+ expect(savedFontWeight())->toBe('bold');
+});
+
+it('ignores window ids the editor does not make', function () {
+ (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+ $before = (new ChangeMarker)->current();
+
+ saveFontWeight('bold', 'window-a', 'stale')
+ ->assertOk()
+ ->assertHeader('X-Berta-Change-Writer', '');
+
+ expect((new ChangeMarker)->current())->toBe($before);
+});
+
+it('marks nothing when a save is refused with an error message', function () {
+ // A subsite without a settings file: the data service answers 200 with error_message.
+ patchJson(
+ route('site_settings'),
+ ['path' => 'missing-site/settings/texts/pageTitle', 'value' => 'Studio'],
+ ['X-Berta-Editor' => EDITOR_WINDOW_A, 'X-Berta-Change' => ''],
+ )->assertOk()->assertJsonPath('error_message', 'Settings file not found in storage!');
+
+ expect((new ChangeMarker)->current()['token'])->toBe('');
+});
+
+it('leaves out requests that change no site data', function (string $route) {
+ expect(Route::getRoutes()->getByName($route)->excludedMiddleware())->toContain(EditorSync::class);
+})->with(['site_theme_preview', 'user_change_password']);
+
+it('lets the setup wizard of a new site save every step', function () {
+ $seen = '';
+ $steps = [
+ ['siteTexts/siteHeading', 'Studio'],
+ ['texts/ownerName', 'Ann'],
+ ['texts/metaDescription', 'Portfolio'],
+ ['siteTexts/siteFooter', 'Ann © '],
+ ['texts/pageTitle', 'Studio'],
+ ['berta/installed', '1'],
+ ];
+
+ foreach ($steps as [$path, $value]) {
+ $seen = patchJson(route('site_settings'), ['path' => "/settings/{$path}", 'value' => $value], ['X-Berta-Editor' => EDITOR_WINDOW_A, 'X-Berta-Change' => $seen])
+ ->assertOk()
+ ->assertJsonMissingPath('error_message')
+ ->headers->get('X-Berta-Change');
+ }
+
+ $settings = (new SiteSettingsDataService)->getState();
+
+ expect($settings['siteTexts']['siteFooter'])->toBe('Ann ©')
+ ->and($settings['berta']['installed'])->toBe('1')
+ ->and(array_column((new SiteSectionsDataService)->get(), 'name'))->toContain('home')
+ ->and((new ChangeMarker)->current())->toMatchArray(['token' => $seen, 'writer' => EDITOR_WINDOW_A]);
+});
+
+it('tells a window about a change someone else made while its save ran', function () {
+ $request = Request::create('/_api/v1/sites/settings', 'PATCH', server: ['HTTP_X_BERTA_EDITOR' => EDITOR_WINDOW_A, 'HTTP_X_BERTA_CHANGE' => '']);
+ $agentChange = null;
+
+ $response = (new EditorSync)->handle($request, function () use (&$agentChange) {
+ $agentChange = (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT);
+
+ return response()->json([]);
+ });
+
+ // The window reloads to see the agent's change; other windows learn about this save.
+ expect($response->headers->get('X-Berta-Change'))->toBe($agentChange['token'])
+ ->and($response->headers->get('X-Berta-Change-Source'))->toBe('agent')
+ ->and((new ChangeMarker)->current())->toMatchArray(['writer' => EDITOR_WINDOW_A, 'source' => 'editor']);
+});
+
+it('keeps the earlier change when the cache cannot store the save', function () {
+ Cache::shouldReceive('get')->andReturn(['token' => 'earlier', 'writer' => EDITOR_WINDOW_B, 'source' => 'editor']);
+ Cache::shouldReceive('forever')->andThrow(new RuntimeException('Read-only cache.'));
+
+ saveFontWeight('bold', EDITOR_WINDOW_A, 'earlier')
+ ->assertOk()
+ ->assertHeader('X-Berta-Change', 'earlier');
+});
+
+it('treats anything else in the cache as no marker', function (mixed $cached) {
+ Cache::shouldReceive('get')->andReturn($cached);
+
+ expect((new ChangeMarker)->current())->toBe(['token' => '', 'writer' => '', 'source' => '']);
+})->with([
+ 'text' => ['garbage'],
+ 'missing keys' => [['token' => 'x']],
+ 'not text' => [['token' => 1, 'writer' => '', 'source' => '']],
+]);
diff --git a/_api_app/tests/Feature/RenewEditorTokenTest.php b/_api_app/tests/Feature/RenewEditorTokenTest.php
new file mode 100644
index 000000000..c7a3643a7
--- /dev/null
+++ b/_api_app/tests/Feature/RenewEditorTokenTest.php
@@ -0,0 +1,87 @@
+bertaRoot = sys_get_temp_dir() . '/berta_editor_token_' . uniqid();
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ linkBertaEngine($this->bertaRoot);
+ givePlanFeatures($this->bertaRoot, []);
+ // The account's login details last changed long before the tokens here were issued.
+ touch($this->bertaRoot . '/engine/config/inc.conf.php', time() - 40 * 86400);
+
+ // SetupMiddleware reconciles the real .env file on every request.
+ withoutMiddleware(SetupMiddleware::class);
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+/**
+ * An editor token as Berta or berta.me hosting issues it, the given number of seconds ago.
+ *
+ * @param array $claims
+ */
+function editorTokenIssuedAgo(int $seconds, array $claims = []): string
+{
+ return JWT::encode([...$claims, 'iat' => time() - $seconds, 'exp' => time() - $seconds + EditorToken::LIFETIME_SECONDS], config('app.key'), 'HS256');
+}
+
+it('renews a token older than an hour, keeping its claims', function () {
+ $response = getJson(route('change_marker'), ['X-Authorization' => 'Bearer ' . editorTokenIssuedAgo(2 * 3600, ['uid' => 42])])
+ ->assertOk()
+ ->assertHeader('X-Berta-Token');
+
+ // No proxy or CDN may store a response carrying a session token.
+ expect($response->headers->get('Cache-Control'))->toContain('no-store');
+
+ $renewed = (array) JWT::decode($response->headers->get('X-Berta-Token'), new Key(config('app.key'), 'HS256'));
+
+ // The hosting user's id, used by the support chat.
+ expect($renewed['uid'])->toBe(42)
+ ->and($renewed['exp'])->toBeGreaterThanOrEqual(time() + EditorToken::LIFETIME_SECONDS - 5);
+});
+
+it('leaves a recent token as it is', function () {
+ getJson(route('change_marker'), ['X-Authorization' => 'Bearer ' . editorTokenIssuedAgo(600)])
+ ->assertOk()
+ ->assertHeaderMissing('X-Berta-Token');
+});
+
+it('does not renew an expired token', function () {
+ getJson(route('change_marker'), ['X-Authorization' => 'Bearer ' . editorTokenIssuedAgo(EditorToken::LIFETIME_SECONDS + 3600)])
+ ->assertUnauthorized()
+ ->assertHeaderMissing('X-Berta-Token');
+});
+
+it('keeps the login time through renewals', function () {
+ $response = getJson(route('change_marker'), ['X-Authorization' => 'Bearer ' . editorTokenIssuedAgo(2 * 3600, ['auth_time' => time() - 5 * 86400])])
+ ->assertOk();
+
+ $renewed = (array) JWT::decode($response->headers->get('X-Berta-Token'), new Key(config('app.key'), 'HS256'));
+
+ expect($renewed['auth_time'])->toBeLessThanOrEqual(time() - 5 * 86400);
+});
+
+it('stops renewing a month after the login, so a leaked token ends', function () {
+ getJson(route('change_marker'), ['X-Authorization' => 'Bearer ' . editorTokenIssuedAgo(2 * 3600, ['auth_time' => time() - (EditorToken::MAX_SESSION_DAYS + 1) * 86400])])
+ ->assertOk()
+ ->assertHeaderMissing('X-Berta-Token');
+});
+
+it('stops renewing sessions that started before the account changed, e.g. a password change or website transfer', function () {
+ touch($this->bertaRoot . '/engine/config/inc.conf.php', time() - 3600);
+
+ getJson(route('change_marker'), ['X-Authorization' => 'Bearer ' . editorTokenIssuedAgo(2 * 3600)])
+ ->assertOk()
+ ->assertHeaderMissing('X-Berta-Token');
+});
diff --git a/editor/src/app/app-state/app-state.service.ts b/editor/src/app/app-state/app-state.service.ts
index 794fb0cce..cc8110a7c 100644
--- a/editor/src/app/app-state/app-state.service.ts
+++ b/editor/src/app/app-state/app-state.service.ts
@@ -120,9 +120,13 @@ export class AppStateService {
}),
tap(() => this.hideLoading()),
catchError((error) => {
- // 429 responses are handled by the specific feature (e.g. AI chat panel),
- // so skip the generic error notification to avoid a duplicate message.
- if (!(error instanceof HttpErrorResponse) || error.status !== 429) {
+ // 429 responses are handled by the specific feature (e.g. AI chat panel), and 409 (the
+ // website was changed elsewhere) by the editor sync popup, so skip the generic error
+ // notification to avoid a duplicate message.
+ if (
+ !(error instanceof HttpErrorResponse) ||
+ ![409, 429].includes(error.status)
+ ) {
this.showError(error, data);
}
this.hideLoading();
diff --git a/editor/src/app/app.module.ts b/editor/src/app/app.module.ts
index ff422f011..d749a5585 100644
--- a/editor/src/app/app.module.ts
+++ b/editor/src/app/app.module.ts
@@ -50,6 +50,7 @@ import * as Sentry from '@sentry/angular';
import { Router } from '@angular/router';
import { sentryInitFactory } from './sentry/sentry-init.factory';
import { pendingRequestsInterceptor } from './shared/pending-requests';
+import { editorSyncInterceptor } from './shared/editor-sync';
@NgModule({
declarations: [
@@ -122,7 +123,7 @@ import { pendingRequestsInterceptor } from './shared/pending-requests';
{ provide: APP_BASE_HREF, useValue: '/engine/' },
provideHttpClient(
withInterceptorsFromDi(),
- withInterceptors([pendingRequestsInterceptor]),
+ withInterceptors([pendingRequestsInterceptor, editorSyncInterceptor]),
),
],
})
diff --git a/editor/src/app/popup/popup.interface.ts b/editor/src/app/popup/popup.interface.ts
index cd955ff49..0a546b59e 100644
--- a/editor/src/app/popup/popup.interface.ts
+++ b/editor/src/app/popup/popup.interface.ts
@@ -1,7 +1,7 @@
import { PopupService } from './popup.service';
export interface PopupState {
- type: 'error' | 'warning' | 'info' | 'success' | Object;
+ type: 'error' | 'warn' | 'info' | 'success' | Object;
content: any;
showOverlay?: boolean;
isModal?: boolean;
diff --git a/editor/src/app/shared/editor-sync.spec.ts b/editor/src/app/shared/editor-sync.spec.ts
new file mode 100644
index 000000000..4da7f5506
--- /dev/null
+++ b/editor/src/app/shared/editor-sync.spec.ts
@@ -0,0 +1,288 @@
+import { TestBed } from '@angular/core/testing';
+import {
+ HttpClient,
+ provideHttpClient,
+ withInterceptors,
+} from '@angular/common/http';
+import {
+ HttpTestingController,
+ provideHttpClientTesting,
+} from '@angular/common/http/testing';
+import { ActionStatus, Actions, Store } from '@ngxs/store';
+import { Subject } from 'rxjs';
+
+import { EditorSyncService, editorSyncInterceptor } from './editor-sync';
+import { PopupService } from '../popup/popup.service';
+import {
+ UserLoginAction,
+ UserLogoutAction,
+ UserTokenRenewedAction,
+} from '../user/user.actions';
+
+describe('editorSyncInterceptor', () => {
+ let http: HttpClient;
+ let backend: HttpTestingController;
+ let sync: EditorSyncService;
+ let popupService: PopupService;
+ let store: jasmine.SpyObj;
+ let actions$: Subject<{ action: unknown; status: ActionStatus }>;
+
+ /** Answers a request with the latest change, as the API's EditorSync middleware does. */
+ function answer(
+ url: string,
+ change: { token: string; writer?: string; source?: string },
+ status = 200,
+ ) {
+ backend.expectOne(url).flush(
+ {},
+ {
+ status,
+ statusText: status === 200 ? 'OK' : 'Conflict',
+ headers: {
+ 'X-Berta-Change': change.token,
+ 'X-Berta-Change-Writer': change.writer ?? '',
+ 'X-Berta-Change-Source': change.source ?? '',
+ },
+ },
+ );
+ }
+
+ beforeEach(() => {
+ popupService = new PopupService();
+ spyOn(popupService, 'showPopup').and.callThrough();
+ store = jasmine.createSpyObj('Store', ['dispatch', 'select']);
+ actions$ = new Subject();
+
+ TestBed.configureTestingModule({
+ providers: [
+ provideHttpClient(withInterceptors([editorSyncInterceptor])),
+ provideHttpClientTesting(),
+ { provide: PopupService, useValue: popupService },
+ { provide: Store, useValue: store },
+ { provide: Actions, useValue: actions$ },
+ ],
+ });
+ http = TestBed.inject(HttpClient);
+ backend = TestBed.inject(HttpTestingController);
+ sync = TestBed.inject(EditorSyncService);
+ });
+
+ afterEach(() => backend.verify());
+
+ it('sends the window and the latest change it has seen to the editor API', () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-1', source: 'agent' });
+
+ http.patch('/_api/v1/sites/settings', {}).subscribe();
+ const save = backend.expectOne('/_api/v1/sites/settings');
+
+ expect(save.request.headers.get('X-Berta-Editor')).toBe(sync.editorId);
+ expect(save.request.headers.get('X-Berta-Change')).toBe('change-1');
+ save.flush({});
+ });
+
+ it('sends no change before the window has seen one', () => {
+ http.get('/_api/v1/state').subscribe();
+ const request = backend.expectOne('/_api/v1/state');
+
+ expect(request.request.headers.get('X-Berta-Editor')).toBe(sync.editorId);
+ expect(request.request.headers.has('X-Berta-Change')).toBeFalse();
+ request.flush({});
+ });
+
+ it('starts over after logout, since the login loads all data fresh', () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-1', source: 'agent' });
+
+ actions$.next({
+ action: new UserLogoutAction(),
+ status: ActionStatus.Dispatched,
+ });
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-2', source: 'agent' });
+
+ expect(sync.seenChange).toBe('change-2');
+ expect(popupService.showPopup).not.toHaveBeenCalled();
+ });
+
+ it('ignores an answer still on its way at logout once logged in again', () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-1', source: 'agent' });
+ http.get('/_api/v1/sites/render-menu').subscribe();
+
+ actions$.next({
+ action: new UserLogoutAction(),
+ status: ActionStatus.Dispatched,
+ });
+ answer('/_api/v1/sites/render-menu', {
+ token: 'change-1',
+ source: 'agent',
+ });
+ actions$.next({
+ action: new UserLoginAction({ token: 'new-token' }),
+ status: ActionStatus.Dispatched,
+ });
+ // The login reloads the data, with a change made while logged out.
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-2', source: 'agent' });
+
+ expect(sync.seenChange).toBe('change-2');
+ expect(popupService.showPopup).not.toHaveBeenCalled();
+ });
+
+ it('shows nothing after an update, before any change is marked', () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: '' });
+
+ http.patch('/_api/v1/sites/settings', {}).subscribe();
+ answer('/_api/v1/sites/settings', {
+ token: 'change-1',
+ writer: sync.editorId,
+ source: 'editor',
+ });
+
+ expect(sync.seenChange).toBe('change-1');
+ expect(popupService.showPopup).not.toHaveBeenCalled();
+ });
+
+ it('shows nothing when the answer has no sync headers', () => {
+ // An expired session (401) answers before the API's sync check; a proxy may drop empty headers.
+ http.get('/_api/v1/state').subscribe({ error: () => {} });
+ backend
+ .expectOne('/_api/v1/state')
+ .flush({}, { status: 401, statusText: 'Unauthorized' });
+ http.get('/_api/v1/sites/render-menu').subscribe();
+ backend.expectOne('/_api/v1/sites/render-menu').flush({});
+
+ http.patch('/_api/v1/sites/settings', {}).subscribe();
+ const save = backend.expectOne('/_api/v1/sites/settings');
+
+ expect(save.request.headers.has('X-Berta-Change')).toBeFalse();
+ expect(popupService.showPopup).not.toHaveBeenCalled();
+ save.flush({});
+ });
+
+ it('leaves requests to other sites alone', () => {
+ http.get('https://example.com/data').subscribe();
+ const request = backend.expectOne('https://example.com/data');
+
+ expect(request.request.headers.has('X-Berta-Editor')).toBeFalse();
+ request.flush({});
+ });
+
+ it("follows the window's own changes, also when answers come out of order", () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-1', source: 'agent' });
+
+ http.get('/_api/v1/sites/render-menu').subscribe();
+ http.patch('/_api/v1/sites/settings', {}).subscribe();
+ answer('/_api/v1/sites/settings', {
+ token: 'change-2',
+ writer: sync.editorId,
+ source: 'editor',
+ });
+ answer('/_api/v1/sites/render-menu', {
+ token: 'change-1',
+ source: 'agent',
+ });
+
+ expect(sync.seenChange).toBe('change-2');
+ expect(popupService.showPopup).not.toHaveBeenCalled();
+ });
+
+ it('asks once to reload when someone else changed the website', () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', {
+ token: 'change-1',
+ source: 'editor',
+ writer: 'other',
+ });
+
+ // The API refuses the stale save.
+ http.patch('/_api/v1/sites/settings', {}).subscribe({ error: () => {} });
+ answer(
+ '/_api/v1/sites/settings',
+ { token: 'change-2', source: 'agent' },
+ 409,
+ );
+ http.get('/_api/v1/sites/render-menu').subscribe();
+ answer('/_api/v1/sites/render-menu', {
+ token: 'change-2',
+ source: 'agent',
+ });
+
+ expect(popupService.showPopup).toHaveBeenCalledTimes(1);
+ expect(
+ (popupService.showPopup as jasmine.Spy).calls.mostRecent().args[0],
+ ).toEqual(
+ jasmine.objectContaining({
+ type: 'warn',
+ content:
+ 'This website was changed by an AI agent. Reload to see the changes.',
+ isModal: true,
+ }),
+ );
+ expect(sync.seenChange).toBe('change-1');
+ });
+
+ it('ignores an empty change after a real one, e.g. after the cache was cleared', () => {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-1', source: 'agent' });
+
+ http.get('/_api/v1/sites/render-menu').subscribe();
+ answer('/_api/v1/sites/render-menu', { token: '' });
+
+ expect(sync.seenChange).toBe('change-1');
+ expect(popupService.showPopup).not.toHaveBeenCalled();
+ });
+
+ /** Makes the API answer with a change by an AI agent, which asks to reload. */
+ function changeByAgent() {
+ http.get('/_api/v1/state').subscribe();
+ answer('/_api/v1/state', { token: 'change-1', source: 'agent' });
+ http.get('/_api/v1/sites/render-menu').subscribe();
+ answer('/_api/v1/sites/render-menu', {
+ token: 'change-2',
+ source: 'agent',
+ });
+ }
+
+ it('asks to reload again after another popup replaced the request and closed', async () => {
+ changeByAgent();
+ const reloadPopup = popupService.popupState$.value;
+ expect(reloadPopup).not.toBeNull();
+
+ // An error message replaces it for a moment.
+ popupService.showPopup({
+ type: 'error',
+ content: 'Oops unexpected error!',
+ });
+ popupService.closePopup();
+ await Promise.resolve();
+
+ expect(popupService.popupState$.value).toBe(reloadPopup);
+ });
+
+ it('closes the request to reload on login, which loads all data fresh', async () => {
+ changeByAgent();
+
+ actions$.next({
+ action: new UserLoginAction({ token: 'new-token' }),
+ status: ActionStatus.Dispatched,
+ });
+ await Promise.resolve();
+
+ expect(popupService.popupState$.value).toBeNull();
+ });
+
+ it('stores a renewed session token', () => {
+ http.get('/_api/v1/state').subscribe();
+ backend
+ .expectOne('/_api/v1/state')
+ .flush({}, { headers: { 'X-Berta-Token': 'renewed-token' } });
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new UserTokenRenewedAction('renewed-token'),
+ );
+ });
+});
diff --git a/editor/src/app/shared/editor-sync.ts b/editor/src/app/shared/editor-sync.ts
new file mode 100644
index 000000000..f3dfc562b
--- /dev/null
+++ b/editor/src/app/shared/editor-sync.ts
@@ -0,0 +1,206 @@
+import {
+ HttpClient,
+ HttpErrorResponse,
+ HttpHeaders,
+ HttpInterceptorFn,
+ HttpResponse,
+} from '@angular/common/http';
+import { Injectable, Injector, inject } from '@angular/core';
+import { Actions, Store, ofActionDispatched } from '@ngxs/store';
+import { filter, take, tap } from 'rxjs/operators';
+
+import { PopupState } from '../popup/popup.interface';
+import { PopupService } from '../popup/popup.service';
+import {
+ UserLoginAction,
+ UserLogoutAction,
+ UserTokenRenewedAction,
+} from '../user/user.actions';
+
+/** How often coming back to the editor tab checks for changes made elsewhere, at most. */
+const FOCUS_CHECK_INTERVAL_MS = 10 * 1000;
+
+/**
+ * Keeps this editor window in step with changes made elsewhere: in another editor window (tab,
+ * browser or device) or by an AI agent. The API marks every change (EditorSync middleware) and
+ * sends the latest one with every response. When this window sees a change it didn't make, it asks
+ * the user to reload, because its values are stale; the API also refuses its saves until then, so
+ * it can't overwrite that change. The request to reload stays until the window reloads, since
+ * other popups (e.g. error messages) may replace it for a moment. Checks again whenever the tab
+ * comes back into view. Logout and login start over, since the login loads all data fresh: answers
+ * to requests still on their way at logout must not count as news afterwards.
+ */
+@Injectable({ providedIn: 'root' })
+export class EditorSyncService {
+ /** This window's id, so the API can tell its own changes from others'. */
+ readonly editorId = randomId();
+
+ /** The latest change this window has seen; null until the first API response after loading or login. */
+ seenChange: string | null = null;
+
+ private knownChanges = new Set();
+ /** The request to reload, while one is pending. */
+ private reloadPopup: PopupState | null = null;
+ private lastFocusCheck = 0;
+
+ constructor(
+ private injector: Injector,
+ private popupService: PopupService,
+ private store: Store,
+ actions$: Actions,
+ ) {
+ actions$
+ .pipe(ofActionDispatched(UserLogoutAction, UserLoginAction))
+ .subscribe(() => this.startOver());
+
+ // Another popup replaced the request to reload and has closed: show it again. Deferred, so the
+ // popup's other subscribers have seen it close first.
+ popupService.popupState$
+ .pipe(filter((state) => state === null && this.reloadPopup !== null))
+ .subscribe(() =>
+ queueMicrotask(() => {
+ if (this.reloadPopup && !this.popupService.popupState$.value) {
+ this.popupService.showPopup(this.reloadPopup);
+ }
+ }),
+ );
+
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') {
+ this.checkForChanges();
+ }
+ });
+ }
+
+ /** Reads the latest change and a renewed session token from an API response. */
+ readResponse(headers: HttpHeaders) {
+ const renewedToken = headers.get('X-Berta-Token');
+ if (renewedToken) {
+ this.store.dispatch(new UserTokenRenewedAction(renewedToken));
+ }
+
+ // Responses can arrive out of order, so an older change this window already knows is no news.
+ // Neither is an empty one once the window has seen a change: the API has no marker then (e.g. its
+ // cache was cleared), which says nothing about changes.
+ const change = headers.get('X-Berta-Change');
+ if (
+ change === null ||
+ this.knownChanges.has(change) ||
+ (change === '' && this.seenChange !== null)
+ ) {
+ return;
+ }
+
+ if (
+ this.seenChange === null ||
+ headers.get('X-Berta-Change-Writer') === this.editorId
+ ) {
+ this.seenChange = change;
+ this.knownChanges.add(change);
+ return;
+ }
+
+ this.showChangedElsewhere(headers.get('X-Berta-Change-Source'));
+ }
+
+ private checkForChanges() {
+ const now = Date.now();
+ if (now - this.lastFocusCheck < FOCUS_CHECK_INTERVAL_MS) {
+ return;
+ }
+ this.lastFocusCheck = now;
+
+ this.store
+ .select((state) => [state.app.urls?.changeMarker, state.user.token])
+ .pipe(
+ take(1),
+ filter(([url, token]) => !!url && !!token),
+ )
+ .subscribe(([url, token]) => {
+ // The interceptor reads the answer; a failed check simply waits for the next one.
+ this.injector
+ .get(HttpClient)
+ .get(url, { headers: { 'X-Authorization': 'Bearer ' + token } })
+ .subscribe({ error: () => {} });
+ });
+ }
+
+ private startOver() {
+ this.seenChange = null;
+ this.knownChanges.clear();
+
+ const reloadPopup = this.reloadPopup;
+ this.reloadPopup = null;
+ if (reloadPopup && this.popupService.popupState$.value === reloadPopup) {
+ this.popupService.closePopup();
+ }
+ }
+
+ private showChangedElsewhere(source: string | null) {
+ if (this.reloadPopup) {
+ return;
+ }
+
+ this.reloadPopup = {
+ type: 'warn',
+ content:
+ source === 'agent'
+ ? 'This website was changed by an AI agent. Reload to see the changes.'
+ : 'This website was changed in another editor window. Reload to see the changes.',
+ showOverlay: true,
+ isModal: true,
+ actions: [
+ {
+ label: 'Reload',
+ type: 'primary',
+ callback: () => window.location.reload(),
+ },
+ ],
+ };
+ this.popupService.showPopup(this.reloadPopup);
+ }
+}
+
+/**
+ * Sends this window's id and the latest change it has seen with every request to the editor's own
+ * API, and reads the answer's sync headers, also of failed requests (a refused save says what
+ * changed).
+ */
+export const editorSyncInterceptor: HttpInterceptorFn = (request, next) => {
+ if (
+ new URL(request.url, window.location.href).origin !== window.location.origin
+ ) {
+ return next(request);
+ }
+
+ const sync = inject(EditorSyncService);
+
+ // Until the window has seen a change, the API leaves its saves unchecked: e.g. a save retried
+ // right after login, before the fresh data has arrived.
+ const headers: Record = { 'X-Berta-Editor': sync.editorId };
+ if (sync.seenChange !== null) {
+ headers['X-Berta-Change'] = sync.seenChange;
+ }
+
+ return next(request.clone({ setHeaders: headers })).pipe(
+ tap({
+ next: (event) => {
+ if (event instanceof HttpResponse) {
+ sync.readResponse(event.headers);
+ }
+ },
+ error: (error) => {
+ if (error instanceof HttpErrorResponse) {
+ sync.readResponse(error.headers);
+ }
+ },
+ }),
+ );
+};
+
+function randomId(): string {
+ // crypto.randomUUID() needs HTTPS; local Berta sites run on plain HTTP.
+ return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) =>
+ byte.toString(16).padStart(2, '0'),
+ ).join('');
+}
diff --git a/editor/src/app/user/user.actions.ts b/editor/src/app/user/user.actions.ts
index ed47edf14..c331ccfff 100644
--- a/editor/src/app/user/user.actions.ts
+++ b/editor/src/app/user/user.actions.ts
@@ -9,6 +9,12 @@ export class UserLoginAction {
) {}
}
+/** The API renewed the session token while the editor is used. */
+export class UserTokenRenewedAction {
+ static readonly type = 'USER:TOKEN_RENEWED';
+ constructor(public token: string) {}
+}
+
export class UserLogoutAction {
static readonly type = 'USER:LOGOUT';
constructor(public saveNextUrl = false) {}
diff --git a/editor/src/app/user/user.state.ts b/editor/src/app/user/user.state.ts
index 9131eff5b..cb0b39fb6 100644
--- a/editor/src/app/user/user.state.ts
+++ b/editor/src/app/user/user.state.ts
@@ -13,6 +13,7 @@ import { UserStateModel } from './user.state.model';
import {
UserLoginAction,
UserLogoutAction,
+ UserTokenRenewedAction,
SetUserNextUrlAction,
} from './user.actions';
import { AppStateService } from '../app-state/app-state.service';
@@ -129,6 +130,20 @@ export class UserState implements NgxsOnInit {
);
}
+ @Action(UserTokenRenewedAction)
+ renewToken(
+ { getState, patchState }: StateContext,
+ action: UserTokenRenewedAction,
+ ) {
+ // A response may arrive after logout; it must not log the user back in.
+ if (!getState().token) {
+ return;
+ }
+
+ window.localStorage.setItem('token', action.token);
+ patchState({ token: action.token });
+ }
+
@Action(UserLogoutAction)
logout(
{ dispatch, setState }: StateContext,
From b248e555227bfce4399dfbaae0e4a5c12ba0bc1a Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Wed, 28 Oct 2026 20:53:09 +0200
Subject: [PATCH 12/22] Add an HTML cleaner that removes what can run scripts
Removes script elements, event handler attributes, iframe documents and
javascript:/vbscript: links, as the editor's rich text field does, and keeps
everything else (forms, embeds, styling). HTML with nothing to remove is
returned unchanged. Used for HTML that AI agents write.
Co-Authored-By: Claude Opus 5.5
---
_api_app/app/Shared/HtmlCleaner.php | 76 +++++++++++++++++++++++++
_api_app/tests/Unit/HtmlCleanerTest.php | 28 +++++++++
2 files changed, 104 insertions(+)
create mode 100644 _api_app/app/Shared/HtmlCleaner.php
create mode 100644 _api_app/tests/Unit/HtmlCleanerTest.php
diff --git a/_api_app/app/Shared/HtmlCleaner.php b/_api_app/app/Shared/HtmlCleaner.php
new file mode 100644
index 000000000..d50daf24c
--- /dev/null
+++ b/_api_app/app/Shared/HtmlCleaner.php
@@ -0,0 +1,76 @@
+' . $html, LIBXML_NOERROR);
+ $removed = false;
+
+ foreach ($document->body->querySelectorAll('script') as $script) {
+ $script->remove();
+ $removed = true;
+ }
+
+ foreach ($document->body->querySelectorAll('*') as $element) {
+ $removed = $this->cleanAttributes($element) || $removed;
+ }
+
+ return $removed ? $document->body->innerHTML : $html;
+ }
+
+ /**
+ * Removes the element's attributes that can run scripts; true when any was removed.
+ */
+ private function cleanAttributes(Element $element): bool
+ {
+ $unsafe = [];
+
+ foreach ($element->attributes as $attribute) {
+ $name = strtolower($attribute->name);
+
+ if (str_starts_with($name, 'on') || $name === 'srcdoc' || (in_array($name, self::URL_ATTRIBUTES, true) && $this->isScriptUrl($attribute->value))) {
+ $unsafe[] = $attribute->name;
+ }
+ }
+
+ foreach ($unsafe as $name) {
+ $element->removeAttribute($name);
+ }
+
+ return $unsafe !== [];
+ }
+
+ /**
+ * Whether a link runs a script. Browsers ignore whitespace and control characters in the scheme.
+ */
+ private function isScriptUrl(string $url): bool
+ {
+ $scheme = strtolower((string) preg_replace('/[\x00-\x20]+/', '', $url));
+
+ return str_starts_with($scheme, 'javascript:') || str_starts_with($scheme, 'vbscript:');
+ }
+}
diff --git a/_api_app/tests/Unit/HtmlCleanerTest.php b/_api_app/tests/Unit/HtmlCleanerTest.php
new file mode 100644
index 000000000..508324f77
--- /dev/null
+++ b/_api_app/tests/Unit/HtmlCleanerTest.php
@@ -0,0 +1,28 @@
+clean($html))->toBe($cleaned);
+})->with([
+ 'script element' => ['Hi
', 'Hi
'],
+ 'script in svg' => [' ', ' '],
+ 'event handler' => ['Hi
', 'Hi
'],
+ 'event handler on an image' => [' ', ' '],
+ 'javascript link' => ['Hi ', 'Hi '],
+ 'javascript link with hidden characters' => ['Hi ', 'Hi '],
+ 'vbscript link' => ['Hi ', 'Hi '],
+ 'javascript form action' => ['', ''],
+ 'iframe document' => ['', ''],
+]);
+
+it('keeps what the editor allows unchanged', function (string $html) {
+ expect((new HtmlCleaner)->clean($html))->toBe($html);
+})->with([
+ 'formatting and links' => ['Released Velvet Blood Noir from the publisher .
'],
+ 'video embed' => [''],
+ 'shop button' => [''],
+ 'styles' => ['Hi
'],
+ 'plain text' => ['Studio'],
+ 'empty' => [''],
+]);
From bed8203b426267b0e687717caf573bd63ef93af7 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Thu, 29 Oct 2026 10:36:29 +0200
Subject: [PATCH 13/22] Name the section types that can hold entries in one
place
Co-Authored-By: Claude Opus 5.5
---
.../Sections/Entries/SectionEntryRenderService.php | 11 +++++++++--
_api_app/tests/Unit/SectionEntryRenderServiceTest.php | 10 ++++++++++
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php b/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php
index 1a2c4c67f..08d25b995 100644
--- a/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php
+++ b/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php
@@ -43,6 +43,14 @@ public static function showsEntryTitles(string $templateName, ?string $sectionTy
return $sectionType == 'portfolio' || $templateName == 'default';
}
+ /**
+ * Whether entries can be moved into a section of this type: not into external links or the shopping cart.
+ */
+ public static function acceptsEntries(?string $sectionType): bool
+ {
+ return ! in_array($sectionType, ['external_link', 'shopping_cart'], true);
+ }
+
/**
* Prepare data for view
*/
@@ -181,9 +189,8 @@ public function getViewData(
// Exclude current section, external link and shopping cart
$sections = array_filter($sections, function ($s) use ($section) {
$isCurrentSection = $s['name'] === $section['name'];
- $validSectionType = empty($s['@attributes']['type']) ? true : ! in_array($s['@attributes']['type'], ['external_link', 'shopping_cart']);
- return ! $isCurrentSection && $validSectionType;
+ return ! $isCurrentSection && self::acceptsEntries($s['@attributes']['type'] ?? null);
});
$entryContents = view('Sites/Sections/Entries/_entryEditor', [
diff --git a/_api_app/tests/Unit/SectionEntryRenderServiceTest.php b/_api_app/tests/Unit/SectionEntryRenderServiceTest.php
index e095d3710..8dfc7f697 100644
--- a/_api_app/tests/Unit/SectionEntryRenderServiceTest.php
+++ b/_api_app/tests/Unit/SectionEntryRenderServiceTest.php
@@ -11,3 +11,13 @@
'other template' => ['messy', 'default', false],
'other template, no section type' => ['messy', null, false],
]);
+
+it('accepts entries in sections other than external links and the shopping cart', function (?string $sectionType, bool $accepts) {
+ expect(SectionEntryRenderService::acceptsEntries($sectionType))->toBe($accepts);
+})->with([
+ 'no section type' => [null, true],
+ 'default' => ['default', true],
+ 'portfolio' => ['portfolio', true],
+ 'external link' => ['external_link', false],
+ 'shopping cart' => ['shopping_cart', false],
+]);
From 251c4af81f58faf251891d5858986ee712cbb692 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Thu, 29 Oct 2026 12:38:22 +0200
Subject: [PATCH 14/22] Keep gallery files safe when entries and sections are
copied, moved, renamed or deleted
Co-Authored-By: Claude Opus 5.5
---
_api_app/app/Shared/Storage.php | 73 +++++++-
.../Entries/SectionEntriesDataService.php | 114 ++++++++----
.../Sites/Sections/SiteSectionsController.php | 4 +
.../Sections/SiteSectionsDataService.php | 42 +++--
_api_app/tests/Feature/MediaFoldersTest.php | 174 ++++++++++++++++++
5 files changed, 351 insertions(+), 56 deletions(-)
create mode 100644 _api_app/tests/Feature/MediaFoldersTest.php
diff --git a/_api_app/app/Shared/Storage.php b/_api_app/app/Shared/Storage.php
index 6525f2d52..e05ef55d5 100644
--- a/_api_app/app/Shared/Storage.php
+++ b/_api_app/app/Shared/Storage.php
@@ -305,30 +305,93 @@ protected function xmlFile2array($xml_file)
}
/**
+ * Copies a folder with its subfolders. Returns false when a folder can't be made or a file can't be copied,
+ * so callers can keep the original; a missing source has nothing to copy and returns true.
+ *
* @param list $skipFolders Folders at the top of $src left out of the copy
*/
- protected function copyFolder($src, $dst, array $skipFolders = [])
+ protected function copyFolder($src, $dst, array $skipFolders = []): bool
{
if (! is_dir($src)) {
- return;
+ return true;
+ }
+
+ if (! is_dir($dst) && ! @mkdir($dst, 0777, true)) {
+ return false;
}
$dir = opendir($src);
- @mkdir($dst, 0777, true);
+ $copied = true;
while (false !== ($file = readdir($dir))) {
if (($file != '.') && ($file != '..')) {
if (is_dir($src . '/' . $file)) {
if (! in_array($file, ['-sites', $this->PREVIEW_FOLDER, ...$skipFolders])) {
- self::copyFolder($src . '/' . $file, $dst . '/' . $file);
+ $copied = self::copyFolder($src . '/' . $file, $dst . '/' . $file) && $copied;
}
} else {
- copy($src . '/' . $file, $dst . '/' . $file);
+ $copied = copy($src . '/' . $file, $dst . '/' . $file) && $copied;
}
}
}
closedir($dir);
+
+ return $copied;
+ }
+
+ /**
+ * The site's media folders, each with the entry galleries ([section, entry id]) and section backgrounds
+ * ([section, null]) that refer to it. Sites made by older Berta versions can have folders that more than one refers to.
+ *
+ * @return array>
+ */
+ protected function mediaFolderReferences(): array
+ {
+ $root = dirname($this->MEDIA_ROOT);
+ $references = [];
+
+ foreach (glob($root . '/blog.*.xml') ?: [] as $file) {
+ $section = substr(basename($file), strlen('blog.'), -strlen('.xml'));
+ $blog = $this->xmlFile2array($file);
+
+ foreach ($this->asList($blog['entry'] ?? []) as $entry) {
+ if (is_array($entry) && ! empty($entry['mediafolder'])) {
+ $references[$entry['mediafolder']][] = [$section, (string) ($entry['id'] ?? '')];
+ }
+ }
+ }
+
+ $sections = $this->xmlFile2array($root . '/sections.xml');
+
+ foreach ($this->asList($sections['section'] ?? []) as $section) {
+ if (is_array($section) && ! empty($section['mediafolder'])) {
+ $references[$section['mediafolder']][] = [(string) ($section['name'] ?? ''), null];
+ }
+ }
+
+ return $references;
+ }
+
+ /**
+ * Media folders that something other than the given entry refers to, or, without an entry, something outside the
+ * given section and its entries. Deleting keeps these, so a shared folder never loses another gallery's files.
+ *
+ * @return array
+ */
+ protected function mediaFoldersReferencedOutside(string $section, ?string $entryId = null): array
+ {
+ $referencedOutside = [];
+
+ foreach ($this->mediaFolderReferences() as $folder => $references) {
+ foreach ($references as [$referencingSection, $referencingEntryId]) {
+ if ($referencingSection !== $section || ($entryId !== null && $referencingEntryId !== $entryId)) {
+ $referencedOutside[$folder] = true;
+ }
+ }
+ }
+
+ return $referencedOutside;
}
public function moveFolderContents($src, $dst, $ignore = [])
diff --git a/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php b/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php
index 178123e1b..305dedf71 100644
--- a/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php
+++ b/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php
@@ -506,22 +506,35 @@ public function create($name = null)
$blog['@attributes']['section'] = $this->SECTION_NAME;
if (isset($blog[self::$ROOT_LIST_ELEMENT])) {
+ $referencedFolders = $this->mediaFolderReferences();
+ $copiedFolders = [];
+
foreach ($blog[self::$ROOT_LIST_ELEMENT] as $idx => $entry) {
$blog[self::$ROOT_LIST_ELEMENT][$idx]['uniqid'] = uniqid();
$blog[self::$ROOT_LIST_ELEMENT][$idx]['date'] = date('d.m.Y H:i:s');
$blog[self::$ROOT_LIST_ELEMENT][$idx]['updated'] = date('d.m.Y H:i:s');
- if (isset($entry['mediafolder'])) {
- $blog[self::$ROOT_LIST_ELEMENT][$idx]['mediafolder'] = str_replace(
- $name,
- $this->SECTION_NAME,
- $entry['mediafolder']
- );
+ if (! empty($entry['mediafolder'])) {
+ // A new folder for each copy: an entry's folder isn't always named after its section, and a
+ // folder shared with the original would lose its files when either one is deleted.
+ $mediafolder = $this->newMediaFolder($referencedFolders);
- $this->copyFolder(
- realpath($this->MEDIA_ROOT) . '/' . $entry['mediafolder'],
- realpath($this->MEDIA_ROOT) . '/' . $blog[self::$ROOT_LIST_ELEMENT][$idx]['mediafolder']
- );
+ if ($mediafolder !== null) {
+ $copiedFolders[] = $mediafolder;
+ }
+
+ if ($mediafolder === null || ! $this->copyFolder(
+ $this->MEDIA_ROOT . '/' . $entry['mediafolder'],
+ $this->MEDIA_ROOT . '/' . $mediafolder
+ )) {
+ foreach ($copiedFolders as $copiedFolder) {
+ $this->delFolder($this->MEDIA_ROOT . '/' . $copiedFolder);
+ }
+
+ return ['error_message' => 'Can\'t copy the media files of the entries! Check permissions and free disk space.'];
+ }
+
+ $blog[self::$ROOT_LIST_ELEMENT][$idx]['mediafolder'] = $mediafolder;
}
}
}
@@ -598,14 +611,17 @@ public function rename($new_name, $new_title)
$entries['@attributes']['section'] = $new_name;
if (isset($entries[self::$ROOT_LIST_ELEMENT])) {
+ $referencedFolders = $this->mediaFolderReferences();
+
foreach ($entries[self::$ROOT_LIST_ELEMENT] as $key => $entry) {
- if (isset($entry['mediafolder'])) {
+ if (! empty($entry['mediafolder'])) {
+ $mediafolder = $new_name . $entry['id'];
$old_media = realpath($this->MEDIA_ROOT) . '/' . $entry['mediafolder'];
- $new_name = $new_name . $entry['id'];
- $new_media = realpath($this->MEDIA_ROOT) . '/' . $new_name;
+ $new_media = realpath($this->MEDIA_ROOT) . '/' . $mediafolder;
- if (@rename($old_media, $new_media)) {
- $entries[self::$ROOT_LIST_ELEMENT][$key]['mediafolder'] = $new_name;
+ // Skip a name that exists or another gallery refers to: the entry keeps its folder instead of sharing one.
+ if (! isset($referencedFolders[$mediafolder]) && ! file_exists($new_media) && @rename($old_media, $new_media)) {
+ $entries[self::$ROOT_LIST_ELEMENT][$key]['mediafolder'] = $mediafolder;
}
}
}
@@ -622,8 +638,10 @@ public function delete()
// delete media files
if (array_key_exists(self::$ROOT_LIST_ELEMENT, $entries) and ! empty($entries[self::$ROOT_LIST_ELEMENT])) {
+ $referencedOutside = $this->mediaFoldersReferencedOutside($this->SECTION_NAME);
+
foreach ($entries[self::$ROOT_LIST_ELEMENT] as $entry) {
- if (! empty($entry['mediafolder'])) {
+ if (! empty($entry['mediafolder']) && ! isset($referencedOutside[$entry['mediafolder']])) {
$mediaFolder = $this->MEDIA_ROOT . '/' . $entry['mediafolder'];
if (file_exists($mediaFolder)) {
@@ -665,14 +683,6 @@ public function delete()
*/
public function createEntry($newEntry, $beforeEntry, $tag)
{
- $mediafolder = $this->SECTION_NAME;
- $counter = 1;
- do {
- $new_mediafolder = $mediafolder . $counter;
- $counter++;
- } while (file_exists($this->MEDIA_ROOT . '/' . $new_mediafolder));
- $mediafolder = $new_mediafolder;
-
$siteSettingsDataService = new SiteSettingsDataService($this->SITE);
$siteSettings = $siteSettingsDataService->getState();
$template = $siteSettings['template']['template'];
@@ -690,12 +700,13 @@ public function createEntry($newEntry, $beforeEntry, $tag)
$galleryFullScreen = $siteSettings['entryLayout']['galleryFullScreenDefault'];
}
- if (! @mkdir($this->MEDIA_ROOT . '/' . $mediafolder, 0777)) {
- $ret['value'] = $mediafolder;
- $ret['error_message'] = 'Can\'t create media folder! Check permissions.';
- $ret['status_code'] = 400;
+ $mediafolder = $this->newMediaFolder();
- return $ret;
+ if ($mediafolder === null) {
+ return [
+ 'error_message' => 'Can\'t create media folder! Check permissions.',
+ 'status_code' => 400,
+ ];
}
$entries = $this->get();
@@ -706,11 +717,16 @@ public function createEntry($newEntry, $beforeEntry, $tag)
// In case creating entry from existing entry from other section
// Update references
if ($newEntry) {
- if (isset($newEntry['mediafolder'])) {
- $this->copyFolder(
- $this->XML_ROOT . '/' . $this->MEDIA_FOLDER . '/' . $newEntry['mediafolder'],
- $this->XML_ROOT . '/' . $this->MEDIA_FOLDER . '/' . $mediafolder
- );
+ if (! empty($newEntry['mediafolder']) && ! $this->copyFolder(
+ $this->XML_ROOT . '/' . $this->MEDIA_FOLDER . '/' . $newEntry['mediafolder'],
+ $this->XML_ROOT . '/' . $this->MEDIA_FOLDER . '/' . $mediafolder
+ )) {
+ $this->delFolder($this->MEDIA_ROOT . '/' . $mediafolder);
+
+ return [
+ 'error_message' => 'Can\'t copy the media files of the entry! Check permissions and free disk space.',
+ 'status_code' => 400,
+ ];
}
$newEntry = array_replace_recursive($newEntry, [
@@ -850,6 +866,11 @@ public function moveEntry($entryId, $toSection)
$toSectionEntriesDS = new self($this->SITE, $toSection);
$data = $toSectionEntriesDS->createEntry($entry, null, null);
+ // Without a copy in the other section, deleting the entry here would lose it and its files.
+ if (isset($data['error_message'])) {
+ return $data;
+ }
+
$deletedEntry = $this->deleteEntry($entryId);
$data['deleted_entry'] = $deletedEntry;
@@ -869,8 +890,9 @@ public function deleteEntry($entry_id)
$entry = $entries['entry'][$entry_order];
- // Delete entry media folder
- if (isset($entry['mediafolder']) && ! empty($entry['mediafolder'])) {
+ // Delete entry media folder, unless another gallery refers to it too
+ if (! empty($entry['mediafolder'])
+ && ! isset($this->mediaFoldersReferencedOutside($this->SECTION_NAME, (string) $entry['id'])[$entry['mediafolder']])) {
$this->delFolder($this->MEDIA_ROOT . '/' . $entry['mediafolder']);
}
@@ -939,6 +961,26 @@ private function setTitle($title)
}
}
+ /**
+ * Creates an empty media folder for an entry of this section, named after the section, and returns its name;
+ * null when it can't be created. Names another gallery refers to are skipped even when their folder is missing,
+ * so no two galleries end up sharing one.
+ *
+ * @param array|null $referencedFolders The site's media folder references, when the caller already has them
+ */
+ private function newMediaFolder(?array $referencedFolders = null): ?string
+ {
+ $referencedFolders ??= $this->mediaFolderReferences();
+ $counter = 1;
+
+ do {
+ $mediafolder = $this->SECTION_NAME . $counter;
+ $counter++;
+ } while (isset($referencedFolders[$mediafolder]) || file_exists($this->MEDIA_ROOT . '/' . $mediafolder));
+
+ return @mkdir($this->MEDIA_ROOT . '/' . $mediafolder, 0777) ? $mediafolder : null;
+ }
+
public function galleryOrder($section_name, $entry_id, $new_files)
{
$entries = $this->get();
diff --git a/_api_app/app/Sites/Sections/SiteSectionsController.php b/_api_app/app/Sites/Sections/SiteSectionsController.php
index eab3b6314..ea1af96be 100644
--- a/_api_app/app/Sites/Sections/SiteSectionsController.php
+++ b/_api_app/app/Sites/Sections/SiteSectionsController.php
@@ -36,6 +36,10 @@ public function create(Request $request)
);
}
+ if (isset($section['error_message'])) {
+ return response()->json($section);
+ }
+
$tags = $cloneFrom ? new SectionTagsDataService($json['site'], $section['name']) : null;
$entries = $cloneFrom ? new SectionEntriesDataService($json['site'], $section['name']) : null;
diff --git a/_api_app/app/Sites/Sections/SiteSectionsDataService.php b/_api_app/app/Sites/Sections/SiteSectionsDataService.php
index 69e9cdc9b..8f4fb97ac 100644
--- a/_api_app/app/Sites/Sections/SiteSectionsDataService.php
+++ b/_api_app/app/Sites/Sections/SiteSectionsDataService.php
@@ -259,6 +259,10 @@ public function cloneSection(?string $name = null, ?string $title = null)
$entries = new SectionEntriesDataService($this->SITE, 'clone-of-' . $name, 'clone of ' . $title);
$section_entries = $entries->create($name);
+ if (isset($section_entries['error_message'])) {
+ return $section_entries;
+ }
+
$section = $sections[$section_order];
$section['name'] = $section_entries['name'];
$section['title'] = $section_entries['title'];
@@ -268,10 +272,15 @@ public function cloneSection(?string $name = null, ?string $title = null)
if (isset($section['mediafolder'])) {
$section['mediafolder'] = $section_entries['name'] . '-background';
- $this->copyFolder(
+ if (! $this->copyFolder(
realpath($this->MEDIA_ROOT) . '/' . $sections[$section_order]['mediafolder'],
realpath($this->MEDIA_ROOT) . '/' . $section['mediafolder']
- );
+ )) {
+ $this->delFolder(realpath($this->MEDIA_ROOT) . '/' . $section['mediafolder']);
+ (new SectionEntriesDataService($this->SITE, $section_entries['name']))->delete();
+
+ return ['error_message' => 'Can\'t copy the background files of the section! Check permissions and free disk space.'];
+ }
}
$section_order = count($sections);
@@ -341,19 +350,21 @@ public function saveValueByPath($path, $value)
);
// Rename section background path
- if (isset($sections['section'][$order]['mediafolder'])) {
+ if (! empty($sections['section'][$order]['mediafolder'])) {
$mediafolder = $new_name . '-background';
-
- @rename(
- realpath($this->MEDIA_ROOT) . '/' . $sections['section'][$order]['mediafolder'],
- realpath($this->MEDIA_ROOT) . '/' . $mediafolder
- );
-
- $this->setValueByPath(
- $sections,
- 'section/' . $order . '/mediafolder',
- $mediafolder
- );
+ $old_media = realpath($this->MEDIA_ROOT) . '/' . $sections['section'][$order]['mediafolder'];
+ $new_media = realpath($this->MEDIA_ROOT) . '/' . $mediafolder;
+
+ // Keep pointing at the files when the folder can't be renamed, or the new name exists or another gallery refers to it
+ if (! isset($this->mediaFolderReferences()[$mediafolder])
+ && ! file_exists($new_media)
+ && (! file_exists($old_media) || @rename($old_media, $new_media))) {
+ $this->setValueByPath(
+ $sections,
+ 'section/' . $order . '/mediafolder',
+ $mediafolder
+ );
+ }
}
$entries = new SectionEntriesDataService($this->SITE, $old_name, $old_title);
@@ -453,7 +464,8 @@ public function delete($name)
// delete section media
$section = $sections['section'][$section_idx];
- if (array_key_exists('mediafolder', $section) and ! empty($section['mediafolder'])) {
+ if (array_key_exists('mediafolder', $section) and ! empty($section['mediafolder'])
+ and ! isset($this->mediaFoldersReferencedOutside($name)[$section['mediafolder']])) {
$mediaFolder = $this->MEDIA_ROOT . '/' . $section['mediafolder'];
if (file_exists($mediaFolder)) {
diff --git a/_api_app/tests/Feature/MediaFoldersTest.php b/_api_app/tests/Feature/MediaFoldersTest.php
new file mode 100644
index 000000000..ddb90fc7f
--- /dev/null
+++ b/_api_app/tests/Feature/MediaFoldersTest.php
@@ -0,0 +1,174 @@
+bertaRoot = sys_get_temp_dir() . '/berta_media_folders_' . uniqid();
+ $this->storage = $this->bertaRoot . '/storage';
+ File::ensureDirectoryExists($this->storage);
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ linkBertaEngine($this->bertaRoot);
+ writeTemplate($this->bertaRoot, 'messy');
+
+ // "photographs" has a gallery in a folder named after another section, as on sites from older Berta versions,
+ // and a background; "news" has two entries with their own folders.
+ file_put_contents(
+ $this->storage . '/sections.xml',
+ ''
+ . ''
+ . ''
+ . ' ',
+ );
+ writeBlog($this->storage, 'photographs', ['1' => 'news1']);
+ writeBlog($this->storage, 'news', ['1' => 'news2', '2' => 'news-old']);
+ writeMediaFile($this->storage, 'news1/sky.jpg');
+ writeMediaFile($this->storage, 'news2/sea.jpg');
+ writeMediaFile($this->storage, 'news-old/field.jpg');
+ writeMediaFile($this->storage, 'photographs-background/bg.jpg');
+
+ Event::fake([SectionUpdated::class]);
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+/**
+ * Writes a section's entries file, with each entry's media folder.
+ *
+ * @param array $mediaFolders entry id => media folder
+ */
+function writeBlog(string $storage, string $section, array $mediaFolders): void
+{
+ $entries = '';
+
+ foreach ($mediaFolders as $id => $mediaFolder) {
+ $entries .= " "
+ . ' ';
+ }
+
+ file_put_contents($storage . "/blog.{$section}.xml", "{$entries} ");
+}
+
+function writeMediaFile(string $storage, string $path): void
+{
+ File::ensureDirectoryExists(dirname($storage . '/media/' . $path));
+ file_put_contents($storage . '/media/' . $path, 'image');
+}
+
+/**
+ * The media folder of each entry of a section, by entry id.
+ *
+ * @return array
+ */
+function mediaFolders(string $section): array
+{
+ $entries = (new SectionEntriesDataService('', $section))->get()['entry'];
+
+ return array_combine(array_column($entries, 'id'), array_column($entries, 'mediafolder'));
+}
+
+it('copies a section into new media folders, also where a folder is not named after its section', function () {
+ $copy = (new SiteSectionsDataService)->cloneSection('photographs', 'Photographs');
+
+ $copiedFolder = mediaFolders($copy['name'])['1'];
+ expect($copy['name'])->toBe('clone-of-photographs')
+ ->and($copiedFolder)->not->toBe('news1')
+ ->and($this->storage . "/media/{$copiedFolder}/sky.jpg")->toBeFile()
+ ->and($this->storage . '/media/clone-of-photographs-background/bg.jpg')->toBeFile();
+
+ (new SiteSectionsDataService)->delete($copy['name']);
+
+ expect($this->storage . "/media/{$copiedFolder}")->not->toBeDirectory()
+ ->and($this->storage . '/media/news1/sky.jpg')->toBeFile()
+ ->and($this->storage . '/media/photographs-background/bg.jpg')->toBeFile();
+});
+
+it('moves an entry with its gallery into a folder no other gallery uses', function () {
+ $moved = (new SectionEntriesDataService('', 'photographs'))->moveEntry('1', 'news');
+
+ // news1 is still taken by the moving entry and news2 by another entry, so the copy gets news3.
+ expect($moved)->not->toHaveKey('error_message')
+ ->and(mediaFolders('news')['3'])->toBe('news3')
+ ->and($this->storage . '/media/news3/sky.jpg')->toBeFile()
+ ->and($this->storage . '/media/news1')->not->toBeDirectory()
+ ->and($this->storage . '/media/news2/sea.jpg')->toBeFile();
+});
+
+it('keeps the entry and its files when the move can not copy them', function () {
+ if (function_exists('posix_getuid') && posix_getuid() === 0) {
+ $this->markTestSkipped('Root can write to a read-only folder.');
+ }
+
+ chmod($this->storage . '/media', 0555);
+
+ try {
+ $moved = (new SectionEntriesDataService('', 'photographs'))->moveEntry('1', 'news');
+ } finally {
+ chmod($this->storage . '/media', 0755);
+ }
+
+ expect($moved)->toHaveKey('error_message')
+ ->and(mediaFolders('photographs'))->toBe(['1' => 'news1'])
+ ->and(mediaFolders('news'))->toHaveCount(2)
+ ->and($this->storage . '/media/news1/sky.jpg')->toBeFile();
+});
+
+it('names the media folders after a renamed section, one per entry', function () {
+ (new SiteSectionsDataService)->saveValueByPath('/section/1/title', 'Archive');
+
+ expect(mediaFolders('archive'))->toBe(['1' => 'archive1', '2' => 'archive2'])
+ ->and($this->storage . '/media/archive1/sea.jpg')->toBeFile()
+ ->and($this->storage . '/media/archive2/field.jpg')->toBeFile();
+});
+
+it('keeps an entry in its folder when the new folder name is in use', function () {
+ File::ensureDirectoryExists($this->storage . '/media/archive1');
+
+ (new SiteSectionsDataService)->saveValueByPath('/section/1/title', 'Archive');
+
+ expect(mediaFolders('archive'))->toBe(['1' => 'news2', '2' => 'archive2'])
+ ->and($this->storage . '/media/news2/sea.jpg')->toBeFile();
+});
+
+it('keeps the background where it is when its folder can not be renamed', function () {
+ writeMediaFile($this->storage, 'gallery-background/other.jpg');
+
+ $saved = (new SiteSectionsDataService)->saveValueByPath('/section/0/title', 'Gallery');
+
+ expect($saved['section']['name'])->toBe('gallery')
+ ->and($saved['section']['mediafolder'])->toBe('photographs-background')
+ ->and($this->storage . '/media/photographs-background/bg.jpg')->toBeFile();
+});
+
+it('keeps a media folder that another entry uses when an entry is deleted', function (string $sharingSection) {
+ writeBlog($this->storage, 'news', ['1' => 'news2', '2' => 'news-old', '3' => $sharingSection === 'news' ? 'news2' : 'news1']);
+
+ (new SectionEntriesDataService('', $sharingSection === 'news' ? 'news' : 'photographs'))->deleteEntry('1');
+
+ expect($this->storage . ($sharingSection === 'news' ? '/media/news2/sea.jpg' : '/media/news1/sky.jpg'))->toBeFile();
+})->with([
+ 'in the same section' => 'news',
+ 'in another section' => 'photographs',
+]);
+
+it('still deletes the media folder of a deleted entry that no other gallery uses', function () {
+ (new SectionEntriesDataService('', 'news'))->deleteEntry('2');
+
+ expect($this->storage . '/media/news-old')->not->toBeDirectory();
+});
+
+it('keeps media folders that other sections use when a section is deleted', function () {
+ writeBlog($this->storage, 'news', ['1' => 'news2', '2' => 'news1']);
+
+ (new SiteSectionsDataService)->delete('photographs');
+
+ expect($this->storage . '/media/news1/sky.jpg')->toBeFile()
+ ->and($this->storage . '/media/photographs-background')->not->toBeDirectory()
+ ->and($this->storage . '/blog.photographs.xml')->not->toBeFile();
+});
From c312f9aade529d6ae2262c7f4de89dec06c2b099 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Thu, 29 Oct 2026 14:44:45 +0200
Subject: [PATCH 15/22] Keep a section's name when only the case of its title
changes
Co-Authored-By: Claude Opus 5.5
---
.../app/Sites/Sections/SiteSectionsDataService.php | 10 +++++++---
_api_app/tests/Feature/MediaFoldersTest.php | 8 ++++++++
2 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/_api_app/app/Sites/Sections/SiteSectionsDataService.php b/_api_app/app/Sites/Sections/SiteSectionsDataService.php
index 8f4fb97ac..df2652529 100644
--- a/_api_app/app/Sites/Sections/SiteSectionsDataService.php
+++ b/_api_app/app/Sites/Sections/SiteSectionsDataService.php
@@ -331,7 +331,7 @@ public function saveValueByPath($path, $value)
if ($prop === 'title') {
$old_name = $sectionName;
$old_title = isset($sections['section'][$order]['title']) ? $sections['section'][$order]['title'] : '';
- $new_name = $this->getUniqueSlug($value);
+ $new_name = $this->getUniqueSlug($value, $sectionName);
if (empty($value)) {
$ret['value'] = $old_title;
@@ -697,7 +697,11 @@ public function backgroundGalleryUpload($path, $file)
* Private methods
************************************************************/
- private function getUniqueSlug($title)
+ /**
+ * A section name for the title that no other section has. $currentName is the renamed section's own name,
+ * which it may keep, e.g. when only the title's case changes.
+ */
+ private function getUniqueSlug($title, ?string $currentName = null)
{
$sections['section'] = $this->get();
$title = trim($title);
@@ -708,7 +712,7 @@ private function getUniqueSlug($title)
$slug = Helpers::slugify($title, '-', '\._-', true);
$slug = $slug ?: '_';
- $names = array_values(array_column($sections['section'], 'name'));
+ $names = array_values(array_diff(array_column($sections['section'], 'name'), [$currentName]));
$notUnique = true;
$i = 1;
diff --git a/_api_app/tests/Feature/MediaFoldersTest.php b/_api_app/tests/Feature/MediaFoldersTest.php
index ddb90fc7f..7b01b0e41 100644
--- a/_api_app/tests/Feature/MediaFoldersTest.php
+++ b/_api_app/tests/Feature/MediaFoldersTest.php
@@ -136,6 +136,14 @@ function mediaFolders(string $section): array
->and($this->storage . '/media/news2/sea.jpg')->toBeFile();
});
+it('keeps the section name and its folders when only the title case changes', function () {
+ $saved = (new SiteSectionsDataService)->saveValueByPath('/section/1/title', 'NEWS');
+
+ expect($saved['section']['name'])->toBe('news')
+ ->and($saved['section']['title'])->toBe('NEWS')
+ ->and(mediaFolders('news'))->toBe(['1' => 'news2', '2' => 'news-old']);
+});
+
it('keeps the background where it is when its folder can not be renamed', function () {
writeMediaFile($this->storage, 'gallery-background/other.jpg');
From ea7a12b7e10d02bc70180ff98614df68f0509b4b Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Tue, 3 Nov 2026 17:25:09 +0200
Subject: [PATCH 16/22] Keep deleted files for undo out of reach, and remove
them after 30 days
- UndoFiles: one folder per change in storage/_undo_files, where the AI plugin keeps what an agent deletes
- Every web request removes kept files older than 30 days, at most once a day; without kept files the check is
a single stat call
- .htaccess and LocalValetDriver.php refuse the folder, and site clones and previews skip it
Co-Authored-By: Claude Opus 5.5
---
.htaccess | 3 +-
LocalValetDriver.php | 1 +
_api_app/app/Providers/AppServiceProvider.php | 6 ++
_api_app/app/Shared/UndoFiles.php | 65 +++++++++++++++++++
_api_app/app/Sites/SitesDataService.php | 7 +-
_api_app/tests/Feature/UndoFilesTest.php | 57 ++++++++++++++++
_api_app/tests/Unit/WebAccessRulesTest.php | 10 +++
7 files changed, 145 insertions(+), 4 deletions(-)
create mode 100644 _api_app/app/Shared/UndoFiles.php
create mode 100644 _api_app/tests/Feature/UndoFilesTest.php
diff --git a/.htaccess b/.htaccess
index fae6d760a..648195c5e 100644
--- a/.htaccess
+++ b/.htaccess
@@ -47,8 +47,9 @@ RewriteCond %{REQUEST_URI} !^/engine/editor.*
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ /engine/dist/index.html [L]
-# A site's private shop files: invoices and payment records
+# A site's private shop files: invoices and payment records; and deleted files kept for undo
RewriteCond %{REQUEST_URI} ^/storage/(-sites/[^/]+/)?_shop_files/ [OR]
+RewriteCond %{REQUEST_URI} ^/storage/_undo_files/ [OR]
RewriteCond %{REQUEST_URI} ^/storage.*\.(xml|sqlite|db|log|php)$ [OR]
RewriteCond %{REQUEST_URI} ^/storage/$ [OR]
RewriteCond %{REQUEST_URI} ^/_templates/$
diff --git a/LocalValetDriver.php b/LocalValetDriver.php
index 416be1970..5d9e78923 100644
--- a/LocalValetDriver.php
+++ b/LocalValetDriver.php
@@ -112,6 +112,7 @@ private function resolve(string $sitePath, string $uri): array
if (
preg_match('#^/storage/(-sites/[^/]+/)?_shop_files/#', $uri)
+ || preg_match('#^/storage/_undo_files/#', $uri)
|| preg_match('#^/storage.*\.(xml|sqlite|db|log|php)$#', $uri)
|| $uri === '/storage/'
|| $uri === '/_templates/'
diff --git a/_api_app/app/Providers/AppServiceProvider.php b/_api_app/app/Providers/AppServiceProvider.php
index ee04ba199..86f11e782 100644
--- a/_api_app/app/Providers/AppServiceProvider.php
+++ b/_api_app/app/Providers/AppServiceProvider.php
@@ -3,6 +3,7 @@
namespace App\Providers;
use App\Shared\ImageHelpers;
+use App\Shared\UndoFiles;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;
@@ -46,5 +47,10 @@ public function boot(): void
Validator::extend('not_corrupted_image', function ($attribute, $value, $parameters) {
return ! ImageHelpers::isCorrupted($value);
});
+
+ // Public pages boot the app without terminating it, so a shutdown function is what runs on every web request.
+ if (! $this->app->runningInConsole()) {
+ register_shutdown_function(fn () => rescue(fn () => (new UndoFiles)->cleanUpIfDue()));
+ }
}
}
diff --git a/_api_app/app/Shared/UndoFiles.php b/_api_app/app/Shared/UndoFiles.php
new file mode 100644
index 000000000..d22c1776a
--- /dev/null
+++ b/_api_app/app/Shared/UndoFiles.php
@@ -0,0 +1,65 @@
+XML_MAIN_ROOT . '/' . self::FOLDER . ($changeId !== '' ? '/' . $changeId : '');
+ }
+
+ /**
+ * Removes the kept files older than KEEP_DAYS, unless that was done in the last CLEAN_UP_INTERVAL seconds.
+ * Only the time of each change's kept.json is read; the files themselves are never walked.
+ */
+ public function cleanUpIfDue(): void
+ {
+ $folder = $this->path();
+
+ if (! is_dir($folder)) {
+ return;
+ }
+
+ $marker = $folder . '/' . self::CLEANED_UP_MARKER;
+
+ if (is_file($marker) && filemtime($marker) > time() - self::CLEAN_UP_INTERVAL) {
+ return;
+ }
+
+ // Touched first, so requests arriving meanwhile skip the clean-up.
+ touch($marker);
+ $oldest = time() - self::KEEP_DAYS * 86400;
+
+ foreach (glob($folder . '/*', GLOB_ONLYDIR) ?: [] as $changeFolder) {
+ $dataFile = $changeFolder . '/' . self::DATA_FILE;
+
+ if ((is_file($dataFile) ? filemtime($dataFile) : filemtime($changeFolder)) < $oldest) {
+ $this->delFolder($changeFolder);
+ }
+ }
+ }
+}
diff --git a/_api_app/app/Sites/SitesDataService.php b/_api_app/app/Sites/SitesDataService.php
index 03d1bb572..118f4dfe8 100644
--- a/_api_app/app/Sites/SitesDataService.php
+++ b/_api_app/app/Sites/SitesDataService.php
@@ -8,6 +8,7 @@
use App\Plugins\Shop\ShopProductsDataService;
use App\Shared\Helpers;
use App\Shared\Storage;
+use App\Shared\UndoFiles;
use App\Sites\Sections\SiteSectionsDataService;
use App\Sites\Settings\SiteSettingsDataService;
use App\Sites\TemplateSettings\SiteTemplateSettingsDataService;
@@ -166,8 +167,8 @@ public function create(Request $request, ?string $cloneFrom = null)
}
$dir = $this->XML_SITES_ROOT . '/' . $name;
- // The shop files belong to the source's orders, which the clone doesn't keep
- $this->copyFolder($src, $dir, [self::SHOP_FILES_FOLDER]);
+ // The shop files belong to the source's orders, and the undo files to its changes, which the clone doesn't keep
+ $this->copyFolder($src, $dir, [self::SHOP_FILES_FOLDER, UndoFiles::FOLDER]);
} else {
@mkdir($dir, 0777, true);
}
@@ -282,7 +283,7 @@ public function createPrieview($themeName)
// generate new preview content
// for now just copy all the existing contents to preview
- $this->copyFolder($this->XML_STORAGE_ROOT, $this->XML_PREVIEW_ROOT, [self::SHOP_FILES_FOLDER]);
+ $this->copyFolder($this->XML_STORAGE_ROOT, $this->XML_PREVIEW_ROOT, [self::SHOP_FILES_FOLDER, UndoFiles::FOLDER]);
// Merge site settings
$siteSettingsDS = new SiteSettingsDataService($this->SITE, $this->XML_PREVIEW_ROOT);
diff --git a/_api_app/tests/Feature/UndoFilesTest.php b/_api_app/tests/Feature/UndoFilesTest.php
new file mode 100644
index 000000000..e8be42536
--- /dev/null
+++ b/_api_app/tests/Feature/UndoFilesTest.php
@@ -0,0 +1,57 @@
+bertaRoot = sys_get_temp_dir() . '/berta_undo_files_' . uniqid();
+ $this->undoFiles = $this->bertaRoot . '/storage/' . UndoFiles::FOLDER;
+ File::ensureDirectoryExists($this->bertaRoot . '/storage');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+/**
+ * A change's kept files, with its kept.json written the given number of days ago.
+ */
+function keepUndoFiles(string $undoFiles, string $changeId, int $daysAgo): void
+{
+ File::ensureDirectoryExists("{$undoFiles}/{$changeId}/media/news3");
+ file_put_contents("{$undoFiles}/{$changeId}/media/news3/sky.jpg", 'image');
+ file_put_contents("{$undoFiles}/{$changeId}/" . UndoFiles::DATA_FILE, '{}');
+ touch("{$undoFiles}/{$changeId}/" . UndoFiles::DATA_FILE, time() - $daysAgo * 86400);
+}
+
+it('does nothing without kept files', function () {
+ (new UndoFiles)->cleanUpIfDue();
+
+ expect($this->undoFiles)->not->toBeDirectory();
+});
+
+it('removes kept files older than the days they are kept for, and keeps newer ones', function () {
+ keepUndoFiles($this->undoFiles, '3', UndoFiles::KEEP_DAYS + 1);
+ keepUndoFiles($this->undoFiles, '8', UndoFiles::KEEP_DAYS - 1);
+
+ (new UndoFiles)->cleanUpIfDue();
+
+ expect("{$this->undoFiles}/3")->not->toBeDirectory()
+ ->and("{$this->undoFiles}/8/media/news3/sky.jpg")->toBeFile();
+});
+
+it('cleans up at most once a day', function () {
+ keepUndoFiles($this->undoFiles, '3', 1);
+ (new UndoFiles)->cleanUpIfDue();
+ keepUndoFiles($this->undoFiles, '3', UndoFiles::KEEP_DAYS + 1);
+
+ (new UndoFiles)->cleanUpIfDue();
+
+ expect("{$this->undoFiles}/3")->toBeDirectory();
+
+ touch("{$this->undoFiles}/.cleaned-up", time() - 86401);
+ (new UndoFiles)->cleanUpIfDue();
+
+ expect("{$this->undoFiles}/3")->not->toBeDirectory();
+});
diff --git a/_api_app/tests/Unit/WebAccessRulesTest.php b/_api_app/tests/Unit/WebAccessRulesTest.php
index 8167663bb..2d2d622bc 100644
--- a/_api_app/tests/Unit/WebAccessRulesTest.php
+++ b/_api_app/tests/Unit/WebAccessRulesTest.php
@@ -112,6 +112,14 @@ function putWebFile(string $sitePath, string $uri): void
'a multisite site payment record' => '/storage/-sites/second/_shop_files/paypal/5O190127TN364715T.json',
]);
+it('refuses deleted files kept for undo', function () {
+ $uri = '/storage/_undo_files/12/media/news3/photo.jpg';
+ putWebFile($this->sitePath, $uri);
+
+ expect(htaccessRefuses($uri))->toBeTrue()
+ ->and(valetDriverRoute($this->sitePath, $uri))->toBe('not_found');
+});
+
it('serves media, also in folders named like the shop files folder', function (string $uri) {
putWebFile($this->sitePath, $uri);
@@ -122,6 +130,8 @@ function putWebFile(string $sitePath, string $uri): void
'multisite site media' => '/storage/-sites/second/media/photo.jpg',
'a media folder sharing the name' => '/storage/media/_shop_files/photo.jpg',
'a site named like the folder' => '/storage/-sites/_shop_files/media/photo.jpg',
+ 'a media folder named like the undo files folder' => '/storage/media/_undo_files/photo.jpg',
+ 'a site named like the undo files folder' => '/storage/-sites/_undo_files/media/photo.jpg',
]);
it('redirects a page to its address with the trailing slash', function (string $uri) {
From 43ea8b943ee41e62fda7b3634d2bde8db8bd3ce5 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Tue, 3 Nov 2026 19:05:38 +0200
Subject: [PATCH 17/22] Define entry gallery settings on the server, and keep
shared gallery files when one is deleted
- EntryGallerySettings mirrors the editor's gallery editor: each setting's values, input rules and the gallery
types and templates it's shown for, plus the file fields (caption, video autoplay)
- SettingValueValidator checks toggles against their values, like selects
- Deleting a gallery file whose folder another gallery refers to keeps the file, so that gallery doesn't lose it
Co-Authored-By: Claude Opus 5.5
---
.../Configuration/SettingValueValidator.php | 2 +-
.../Galleries/EntryGallerySettings.php | 134 ++++++++++++++++++
.../Entries/SectionEntriesDataService.php | 6 +-
_api_app/tests/Feature/MediaFoldersTest.php | 14 ++
.../tests/Unit/SettingValueValidatorTest.php | 2 +
5 files changed, 155 insertions(+), 3 deletions(-)
create mode 100644 _api_app/app/Sites/Sections/Entries/Galleries/EntryGallerySettings.php
diff --git a/_api_app/app/Configuration/SettingValueValidator.php b/_api_app/app/Configuration/SettingValueValidator.php
index d685e8bd7..e7977591a 100644
--- a/_api_app/app/Configuration/SettingValueValidator.php
+++ b/_api_app/app/Configuration/SettingValueValidator.php
@@ -41,7 +41,7 @@ public function error(string $value, array $definition): ?string
}
return match ($definition['format'] ?? 'text') {
- 'select', 'fontselect' => $this->selectError($value, $definition),
+ 'select', 'fontselect', 'toggle' => $this->selectError($value, $definition),
'color' => $this->colorError($value, $definition),
'text' => $this->textError($value, $definition),
default => null,
diff --git a/_api_app/app/Sites/Sections/Entries/Galleries/EntryGallerySettings.php b/_api_app/app/Sites/Sections/Entries/Galleries/EntryGallerySettings.php
new file mode 100644
index 000000000..d3df53890
--- /dev/null
+++ b/_api_app/app/Sites/Sections/Entries/Galleries/EntryGallerySettings.php
@@ -0,0 +1,134 @@
+ [
+ 'title' => 'Gallery type',
+ 'format' => 'select',
+ 'values' => ['slideshow' => 'Slideshow', 'row' => 'Row', 'column' => 'Column', 'pile' => 'Pile', 'link' => 'Link', 'grid' => 'Grid'],
+ ],
+ 'autoplay' => [
+ 'title' => 'Autoplay seconds',
+ 'format' => 'text',
+ 'validation' => 'zero_or_positive_integer',
+ 'default' => '0',
+ 'types' => ['slideshow'],
+ ],
+ 'slide_numbers_visible' => [
+ 'title' => 'Show image numbers',
+ 'format' => 'toggle',
+ 'values' => ['yes', 'no'],
+ 'types' => ['slideshow'],
+ ],
+ 'gallery_width_by_widest_slide' => [
+ 'title' => 'Width by widest slide',
+ 'format' => 'toggle',
+ 'values' => ['yes', 'no'],
+ 'default' => 'no',
+ 'types' => ['slideshow'],
+ 'templates' => ['messy'],
+ ],
+ 'row_gallery_padding' => [
+ 'title' => 'Image padding',
+ 'format' => 'text',
+ 'types' => ['row'],
+ ],
+ 'link_address' => [
+ 'title' => 'Link address',
+ 'format' => 'url',
+ 'types' => ['link'],
+ ],
+ 'linkTarget' => [
+ 'title' => 'Open in',
+ 'format' => 'select',
+ 'values' => ['_self' => 'Same window', '_blank' => 'New window'],
+ 'default' => '_self',
+ 'types' => ['link'],
+ ],
+ 'grid_columns_mobile' => [
+ 'title' => 'Columns (mobile)',
+ 'format' => 'text',
+ 'validation' => 'positive_integer',
+ 'default' => '1',
+ 'types' => ['grid'],
+ ],
+ 'grid_columns_desktop' => [
+ 'title' => 'Columns (desktop)',
+ 'format' => 'text',
+ 'validation' => 'positive_integer',
+ 'default' => '2',
+ 'types' => ['grid'],
+ ],
+ 'grid_columns_large_desktop' => [
+ 'title' => 'Columns (large desktop)',
+ 'format' => 'text',
+ 'validation' => 'positive_integer',
+ 'default' => '3',
+ 'types' => ['grid'],
+ ],
+ 'grid_show_captions' => [
+ 'title' => 'Show captions',
+ 'format' => 'toggle',
+ 'values' => ['yes', 'no'],
+ 'default' => 'yes',
+ 'types' => ['grid'],
+ ],
+ 'grid_gap' => [
+ 'title' => 'Gap between images',
+ 'format' => 'text',
+ 'allow_blank' => true,
+ 'css_units' => true,
+ 'types' => ['grid'],
+ ],
+ 'fullscreen' => [
+ 'title' => 'Fullscreen mode',
+ 'format' => 'toggle',
+ 'values' => ['yes', 'no'],
+ ],
+ 'size' => [
+ 'title' => 'File size',
+ 'format' => 'select',
+ 'values' => ['large' => 'Large', 'medium' => 'Medium', 'small' => 'Small'],
+ 'default' => 'large',
+ ],
+ ];
+
+ /**
+ * Fields of each file: its caption, and for videos whether they play by themselves.
+ */
+ public const FILE_FIELDS = [
+ 'caption' => [
+ 'title' => 'Caption',
+ 'format' => 'richtext',
+ ],
+ 'autoplay' => [
+ 'title' => 'Autoplay',
+ 'format' => 'toggle',
+ 'values' => ['0', '1'],
+ 'default' => '0',
+ 'file_types' => ['video'],
+ ],
+ ];
+
+ /**
+ * The settings the editor shows for a gallery of this type on this template (its name without version).
+ *
+ * @return array>
+ */
+ public static function shownFor(string $type, string $template): array
+ {
+ return array_filter(self::DEFINITIONS, fn (array $definition): bool => in_array($type, $definition['types'] ?? [$type], true)
+ && in_array($template, $definition['templates'] ?? [$template], true));
+ }
+}
diff --git a/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php b/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php
index 305dedf71..e41219f65 100644
--- a/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php
+++ b/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php
@@ -1230,8 +1230,10 @@ public function galleryDelete($section_name, $entry_id, $file)
return ['error_message' => 'File "' . $file . '" not found!'];
}
- $mediafolder = $this->MEDIA_ROOT . '/' . $entry['mediafolder'] . '/';
- $this->deleteMedia($mediafolder, $file);
+ // Keep the file when another gallery refers to the folder, as it may show the file too
+ if (! isset($this->mediaFoldersReferencedOutside($this->SECTION_NAME, (string) $entry['id'])[$entry['mediafolder']])) {
+ $this->deleteMedia($this->MEDIA_ROOT . '/' . $entry['mediafolder'] . '/', $file);
+ }
$file = current(array_splice($files, $file_order, 1));
diff --git a/_api_app/tests/Feature/MediaFoldersTest.php b/_api_app/tests/Feature/MediaFoldersTest.php
index 7b01b0e41..c88d7d8e3 100644
--- a/_api_app/tests/Feature/MediaFoldersTest.php
+++ b/_api_app/tests/Feature/MediaFoldersTest.php
@@ -180,3 +180,17 @@ function mediaFolders(string $section): array
->and($this->storage . '/media/photographs-background')->not->toBeDirectory()
->and($this->storage . '/blog.photographs.xml')->not->toBeFile();
});
+
+it('keeps a gallery file in a folder another gallery refers to, and still deletes one in its own folder', function () {
+ // photographs entry 1 has its gallery in news1, which news entry 2 refers to as well.
+ writeBlog($this->storage, 'news', ['1' => 'news2', '2' => 'news1']);
+ writeMediaFile($this->storage, 'news1/x.jpg');
+ writeMediaFile($this->storage, 'news2/x.jpg');
+
+ (new SectionEntriesDataService('', 'photographs'))->galleryDelete('photographs', '1', 'x.jpg');
+ (new SectionEntriesDataService('', 'news'))->galleryDelete('news', '1', 'x.jpg');
+
+ expect($this->storage . '/media/news1/x.jpg')->toBeFile()
+ ->and($this->storage . '/media/news2/x.jpg')->not->toBeFile()
+ ->and((new SectionEntriesDataService('', 'photographs'))->get()['entry'][0]['mediaCacheData']['file'])->toBe([]);
+});
diff --git a/_api_app/tests/Unit/SettingValueValidatorTest.php b/_api_app/tests/Unit/SettingValueValidatorTest.php
index 6a5acb74f..1fd245bf0 100644
--- a/_api_app/tests/Unit/SettingValueValidatorTest.php
+++ b/_api_app/tests/Unit/SettingValueValidatorTest.php
@@ -6,6 +6,7 @@
expect((new SettingValueValidator([]))->error($value, $definition))->toBeNull();
})->with([
'select value' => ['bold', ['format' => 'select', 'values' => ['normal', 'bold']]],
+ 'toggle value' => ['yes', ['format' => 'toggle', 'values' => ['yes', 'no']]],
'font by its CSS value' => ['"Comic Sans MS", cursive', ['format' => 'fontselect', 'values' => ['"Comic Sans MS", cursive' => 'Comic Sans MS']]],
'hex color' => ['#00ff00', ['format' => 'color']],
'short hex color' => ['#0f0', ['format' => 'color']],
@@ -28,6 +29,7 @@
expect((new SettingValueValidator([]))->error($value, $definition))->toContain($error);
})->with([
'value not allowed' => ['heavy', ['format' => 'select', 'values' => ['normal', 'bold']], 'normal | bold'],
+ 'toggle value not allowed' => ['maybe', ['format' => 'toggle', 'values' => ['yes', 'no']], 'yes | no'],
'font by its name' => ['Comic Sans MS', ['format' => 'fontselect', 'values' => ['"Comic Sans MS", cursive' => 'Comic Sans MS']], 'allowed values'],
'not a color' => ['red', ['format' => 'color'], 'color'],
'missing css unit' => ['12', ['format' => 'text', 'css_units' => true], 'CSS unit'],
From f8bdadf0917a1f4571a1f187806d1224fb9a0a8c Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Wed, 4 Nov 2026 07:52:26 +0200
Subject: [PATCH 18/22] Define background gallery settings on the server, and
delete only an image's own copies
- SectionBackgroundSettings mirrors the editor's background gallery editor: each setting's values, input rules
and the templates that show backgrounds (messy), which the background renderer now uses too
- Deleting a background image whose folder another gallery refers to keeps the file, as for entry galleries
- One exact rule for an image's resized copies (Storage::resizedCopies()): deleting 1.jpg no longer deletes the
thumbnails and sizes of 11.jpg, nor a.jpg those of banana.jpg, in galleries, backgrounds and settings images
- Deleting a video also deletes its poster image, unless another file of the gallery uses it
Co-Authored-By: Claude Opus 5.5
---
_api_app/app/Shared/Storage.php | 79 ++++++++++++++---
.../Entries/SectionEntriesDataService.php | 34 +++-----
.../SectionBackgroundGalleryRenderService.php | 6 +-
.../Sections/SectionBackgroundSettings.php | 85 +++++++++++++++++++
.../Sections/SiteSectionsDataService.php | 28 +-----
_api_app/tests/Feature/MediaFoldersTest.php | 84 ++++++++++++++++++
6 files changed, 254 insertions(+), 62 deletions(-)
create mode 100644 _api_app/app/Sites/Sections/SectionBackgroundSettings.php
diff --git a/_api_app/app/Shared/Storage.php b/_api_app/app/Shared/Storage.php
index e05ef55d5..564943b43 100644
--- a/_api_app/app/Shared/Storage.php
+++ b/_api_app/app/Shared/Storage.php
@@ -3,7 +3,6 @@
namespace App\Shared;
use Illuminate\Support\Facades\Config;
-use Illuminate\Support\Str;
use Swaggest\JsonSchema\Schema;
class Storage
@@ -217,19 +216,63 @@ protected function getUniqueFileName($dir, $fileName)
return $name . '_' . ($maxFileIdx + 1) . '.' . $extension;
}
- public static function removeImageWithThumbnails($dir, $fileName)
+ /**
+ * The names of the media files gallery file records use: each file's src and a video's poster image.
+ *
+ * @param array> $files raw file records
+ * @return list
+ */
+ public static function galleryFileNames(array $files): array
{
- if (file_exists($dir . '/' . $fileName)) {
- unlink($dir . '/' . $fileName);
- }
+ $names = [];
- // Remove thumbnails
- foreach (scandir($dir) as $file) {
- if (! is_file($dir . '/' . $file) || substr($file, 0, 1) !== '_' || ! Str::endsWith($file, $fileName)) {
- continue;
+ foreach ($files as $file) {
+ foreach (['src', 'poster_frame'] as $attribute) {
+ if ((string) ($file['@attributes'][$attribute] ?? '') !== '') {
+ $names[] = (string) $file['@attributes'][$attribute];
+ }
}
- unlink($dir . '/' . $file);
}
+
+ return $names;
+ }
+
+ /**
+ * Removes an image and its resizedCopies().
+ */
+ public static function removeImageWithThumbnails(string $dir, string $fileName): void
+ {
+ $dir = rtrim($dir, '/');
+
+ if (is_file($dir . '/' . $fileName)) {
+ @unlink($dir . '/' . $fileName);
+ }
+
+ foreach (self::resizedCopies($dir, $fileName) as $copy) {
+ @unlink($dir . '/' . $copy);
+ }
+ }
+
+ /**
+ * The names of an image's resized copies in its folder: the editor's thumbnail, the background and grid images
+ * (config app.*_prefix) and the sizes made on demand (_x_, ImageHelpers::getResizedSrc()). Only
+ * copies of exactly this file: those of 11.jpg aren't copies of 1.jpg.
+ *
+ * @return list
+ */
+ public static function resizedCopies(string $dir, string $fileName): array
+ {
+ $dir = rtrim($dir, '/');
+ $prefixes = array_map(
+ fn (string $prefix): string => preg_quote($prefix, '/'),
+ [config('app.small_thumb_prefix'), config('app.bg_image_prefix'), config('app.grid_image_prefix')],
+ );
+ $pattern = '/^(?:' . implode('|', $prefixes) . '|_\d+x\d+_)' . preg_quote($fileName, '/') . '$/';
+
+ return array_values(array_filter(
+ is_dir($dir) ? scandir($dir) : [],
+ fn (string $file): bool => preg_match($pattern, $file) === 1 && is_file($dir . '/' . $file),
+ ));
}
/**
@@ -394,6 +437,22 @@ protected function mediaFoldersReferencedOutside(string $section, ?string $entry
return $referencedOutside;
}
+ /**
+ * Whether a gallery other than the given one refers to the media folder: other than the entry's gallery, or,
+ * without an entry id, other than the section's background. Deleting a gallery file keeps it in such a folder,
+ * as the other gallery may show it too.
+ */
+ protected function isMediaFolderReferencedElsewhere(string $folder, string $section, ?string $entryId = null): bool
+ {
+ foreach ($this->mediaFolderReferences()[$folder] ?? [] as $reference) {
+ if ($reference !== [$section, $entryId]) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
public function moveFolderContents($src, $dst, $ignore = [])
{
if (! is_dir($src)) {
diff --git a/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php b/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php
index e41219f65..0d4d84cff 100644
--- a/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php
+++ b/_api_app/app/Sites/Sections/Entries/SectionEntriesDataService.php
@@ -1230,13 +1230,19 @@ public function galleryDelete($section_name, $entry_id, $file)
return ['error_message' => 'File "' . $file . '" not found!'];
}
- // Keep the file when another gallery refers to the folder, as it may show the file too
- if (! isset($this->mediaFoldersReferencedOutside($this->SECTION_NAME, (string) $entry['id'])[$entry['mediafolder']])) {
- $this->deleteMedia($this->MEDIA_ROOT . '/' . $entry['mediafolder'] . '/', $file);
- }
-
+ $poster = (string) ($files[$file_order]['@attributes']['poster_frame'] ?? '');
$file = current(array_splice($files, $file_order, 1));
+ if (! $this->isMediaFolderReferencedElsewhere((string) $entry['mediafolder'], $this->SECTION_NAME, (string) $entry['id'])) {
+ $folder = $this->MEDIA_ROOT . '/' . $entry['mediafolder'];
+ $this->removeImageWithThumbnails($folder, $file['@attributes']['src']);
+
+ // A video's poster goes with it, unless another file of the gallery uses that image too.
+ if ($poster !== '' && ! in_array($poster, self::galleryFileNames($files), true)) {
+ $this->removeImageWithThumbnails($folder, $poster);
+ }
+ }
+
$entries['entry'][$entry_order]['mediaCacheData']['file'] = array_filter($files, function ($f) use ($file) {
return $f['@attributes']['src'] != $file;
});
@@ -1254,24 +1260,6 @@ public function galleryDelete($section_name, $entry_id, $file)
return ['error_message' => 'Entry with ID "' . $entry_id . '" not found!'];
}
- // @todo video poster should also be removed
- private function deleteMedia($folder, $file = '')
- {
- @unlink($folder . $file);
-
- if ($handle = opendir($folder)) {
- while (false !== ($f = readdir($handle))) {
- if (! $file || strpos($f, $file) !== false) {
- if (substr($f, 0, 1) == '_') {
- @unlink($folder . $f);
- }
- }
- }
-
- closedir($handle);
- }
- }
-
/**
* Copy all section entries gallery files to destination folder
*
diff --git a/_api_app/app/Sites/Sections/SectionBackgroundGalleryRenderService.php b/_api_app/app/Sites/Sections/SectionBackgroundGalleryRenderService.php
index 770b4151c..2eb77a7d7 100644
--- a/_api_app/app/Sites/Sections/SectionBackgroundGalleryRenderService.php
+++ b/_api_app/app/Sites/Sections/SectionBackgroundGalleryRenderService.php
@@ -7,8 +7,6 @@
class SectionBackgroundGalleryRenderService
{
- private $USED_IN_TEMPLATES = ['messy'];
-
private function getWrapperAttributes($currentSection, $isEditMode)
{
$attributes['id'] = 'xBackground';
@@ -123,9 +121,7 @@ public function render(
$request,
$isEditMode
) {
- $templateName = explode('-', $siteSettings['template']['template'])[0];
-
- if (! in_array($templateName, $this->USED_IN_TEMPLATES)) {
+ if (! SectionBackgroundSettings::shownOn($siteSettings['template']['template'])) {
return '';
}
diff --git a/_api_app/app/Sites/Sections/SectionBackgroundSettings.php b/_api_app/app/Sites/Sections/SectionBackgroundSettings.php
new file mode 100644
index 000000000..269dc1b53
--- /dev/null
+++ b/_api_app/app/Sites/Sections/SectionBackgroundSettings.php
@@ -0,0 +1,85 @@
+ [
+ 'title' => 'Image size',
+ 'format' => 'select',
+ 'values' => ['large' => 'Large', 'medium' => 'Medium', 'small' => 'Small'],
+ 'default' => 'medium',
+ ],
+ 'autoplay' => [
+ 'title' => 'Autoplay seconds',
+ 'format' => 'text',
+ 'validation' => 'zero_or_positive_integer',
+ 'default' => '0',
+ ],
+ 'sectionBgColor' => [
+ 'title' => 'Background color',
+ 'format' => 'color',
+ 'allow_blank' => true,
+ 'on_section' => true,
+ ],
+ 'caption_color' => [
+ 'title' => 'Caption text color',
+ 'format' => 'color',
+ 'allow_blank' => true,
+ ],
+ 'caption_bg_color' => [
+ 'title' => 'Caption background color',
+ 'format' => 'color',
+ 'allow_blank' => true,
+ ],
+ 'hide_navigation' => [
+ 'title' => 'Hide navigation arrows',
+ 'format' => 'toggle',
+ 'values' => ['yes', 'no'],
+ 'default' => 'no',
+ ],
+ 'animation' => [
+ 'title' => 'Animation',
+ 'format' => 'toggle',
+ 'values' => ['enabled', 'disabled'],
+ 'default' => 'enabled',
+ ],
+ 'fade_content' => [
+ 'title' => 'Fade content',
+ 'format' => 'toggle',
+ 'values' => ['enabled', 'disabled'],
+ 'default' => 'disabled',
+ ],
+ ];
+
+ /**
+ * Fields of each file: its caption. Background galleries hold images only.
+ */
+ public const FILE_FIELDS = [
+ 'caption' => [
+ 'title' => 'Caption',
+ 'format' => 'richtext',
+ ],
+ ];
+
+ /**
+ * Whether the template (its name, with or without version) shows background galleries.
+ */
+ public static function shownOn(string $template): bool
+ {
+ return in_array(explode('-', $template)[0], self::TEMPLATES, true);
+ }
+}
diff --git a/_api_app/app/Sites/Sections/SiteSectionsDataService.php b/_api_app/app/Sites/Sections/SiteSectionsDataService.php
index df2652529..c114f7b14 100644
--- a/_api_app/app/Sites/Sections/SiteSectionsDataService.php
+++ b/_api_app/app/Sites/Sections/SiteSectionsDataService.php
@@ -534,11 +534,7 @@ public function backgroundGalleryDelete($name, $file)
if ($section_order !== false) {
$section = &$sections['section'][$section_order];
- if (! isset($section['mediaCacheData'])) {
- return ['error_message' => 'File "' . $file . '" not found!'];
- }
-
- $files = $this->asList($section['mediaCacheData']['file']);
+ $files = $this->asList($section['mediaCacheData']['file'] ?? []);
$file_order = array_search(
$file,
array_column(
@@ -554,8 +550,9 @@ public function backgroundGalleryDelete($name, $file)
return ['error_message' => 'File "' . $file . '" not found!'];
}
- $mediafolder = $this->MEDIA_ROOT . '/' . $section['mediafolder'] . '/';
- $this->deleteMedia($mediafolder, $file);
+ if (! $this->isMediaFolderReferencedElsewhere((string) $section['mediafolder'], $name)) {
+ $this->removeImageWithThumbnails($this->MEDIA_ROOT . '/' . $section['mediafolder'], $file);
+ }
$file = current(array_splice($files, $file_order, 1));
@@ -731,23 +728,6 @@ private function getUniqueSlug($title, ?string $currentName = null)
return $slug;
}
- private function deleteMedia($folder, $file = '')
- {
- @unlink($folder . $file);
-
- if ($handle = opendir($folder)) {
- while (false !== ($f = readdir($handle))) {
- if (! $file || strpos($f, $file) !== false) {
- if (substr($f, 0, 1) == '_') {
- @unlink($folder . $f);
- }
- }
- }
-
- closedir($handle);
- }
- }
-
/**
* Merge site sections from other source folder
*
diff --git a/_api_app/tests/Feature/MediaFoldersTest.php b/_api_app/tests/Feature/MediaFoldersTest.php
index c88d7d8e3..6046af5c7 100644
--- a/_api_app/tests/Feature/MediaFoldersTest.php
+++ b/_api_app/tests/Feature/MediaFoldersTest.php
@@ -194,3 +194,87 @@ function mediaFolders(string $section): array
->and($this->storage . '/media/news2/x.jpg')->not->toBeFile()
->and((new SectionEntriesDataService('', 'photographs'))->get()['entry'][0]['mediaCacheData']['file'])->toBe([]);
});
+
+it('keeps a background file in a folder an entry refers to, and still deletes one in its own folder', function () {
+ // The news background is in news2, the folder of news entry 1.
+ file_put_contents(
+ $this->storage . '/sections.xml',
+ ''
+ . ''
+ . ''
+ . ' ',
+ );
+
+ (new SiteSectionsDataService)->backgroundGalleryDelete('photographs', 'bg.jpg');
+ (new SiteSectionsDataService)->backgroundGalleryDelete('news', 'sea.jpg');
+
+ expect($this->storage . '/media/photographs-background/bg.jpg')->not->toBeFile()
+ ->and($this->storage . '/media/news2/sea.jpg')->toBeFile()
+ ->and((new SiteSectionsDataService)->get('news')['mediaCacheData']['file'] ?? [])->toBe([]);
+});
+
+it('deletes a gallery file with its own resized copies, not those of files with similar names', function (string $gallery, string $folder) {
+ file_put_contents(
+ $this->storage . '/sections.xml',
+ ''
+ . ''
+ . ''
+ . ' ',
+ );
+ $copies = ['_smallthumb_x.jpg', '_bg_x.jpg', '_grid_x.jpg', '_600x400_x.jpg'];
+ $others = ['ax.jpg', '_smallthumb_ax.jpg', '_600x400_ax.jpg', '_bg_x.jpg.png', 'x.jpg.png'];
+
+ foreach (['x.jpg', ...$copies, ...$others] as $file) {
+ writeMediaFile($this->storage, "{$folder}/{$file}");
+ }
+
+ if ($gallery === 'entry') {
+ (new SectionEntriesDataService('', 'news'))->galleryDelete('news', '1', 'x.jpg');
+ } else {
+ (new SiteSectionsDataService)->backgroundGalleryDelete('photographs', 'x.jpg');
+ }
+
+ expect(array_values(array_diff(scandir($this->storage . "/media/{$folder}"), ['.', '..'])))
+ ->toEqualCanonicalizing($gallery === 'entry' ? [...$others, 'sea.jpg'] : [...$others, 'bg.jpg']);
+})->with([
+ 'entry gallery' => ['entry', 'news2'],
+ 'background' => ['background', 'photographs-background'],
+]);
+
+it('removes a settings image with its own resized copies only, in the shared media folder', function () {
+ foreach (['logo.png', '_180x180_logo.png', '_16x16_logo.png', 'mylogo.png', '_180x180_mylogo.png', '_smallthumb_mylogo.png'] as $file) {
+ writeMediaFile($this->storage, $file);
+ }
+
+ SiteSectionsDataService::removeImageWithThumbnails($this->storage . '/media', 'logo.png');
+
+ expect(collect(['logo.png', '_180x180_logo.png', '_16x16_logo.png'])->map(fn (string $file): bool => is_file($this->storage . "/media/{$file}"))->all())
+ ->toBe([false, false, false])
+ ->and(collect(['mylogo.png', '_180x180_mylogo.png', '_smallthumb_mylogo.png'])->map(fn (string $file): bool => is_file($this->storage . "/media/{$file}"))->all())
+ ->toBe([true, true, true]);
+});
+
+it('deletes a video with its poster image, unless another file of the gallery uses that image', function () {
+ file_put_contents(
+ $this->storage . '/blog.news.xml',
+ ' '
+ . ''
+ . ' '
+ . ' '
+ . ' '
+ . ' ',
+ );
+
+ foreach (['a.mp4', 'a.jpg', '_smallthumb_a.jpg', '_600x400_a.jpg', 'b.mp4', 'shared.jpg', '_smallthumb_shared.jpg'] as $file) {
+ writeMediaFile($this->storage, "news2/{$file}");
+ }
+
+ (new SectionEntriesDataService('', 'news'))->galleryDelete('news', '1', 'a.mp4');
+ (new SectionEntriesDataService('', 'news'))->galleryDelete('news', '1', 'b.mp4');
+
+ expect(array_values(array_diff(scandir($this->storage . '/media/news2'), ['.', '..'])))
+ ->toEqualCanonicalizing(['sea.jpg', 'shared.jpg', '_smallthumb_shared.jpg']);
+});
From 5733ead2d117295f70f7b47d553e59b72e83ae34 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Wed, 4 Nov 2026 11:00:09 +0200
Subject: [PATCH 19/22] Restore deleted subsites through core, and give new
sites their settings file right away
- SitesDataService::restore() adds back the record of a deleted subsite whose folder is in place again, the
counterpart of delete(), so undoing a subsite delete is a core operation
- delete() refuses the main site, whose empty name would point at the folder of all subsites
- A new, empty site gets settings.xml with the default template when it is created, as the engine wrote it on
first load, so its setup can be finished without opening it first
Co-Authored-By: Claude Opus 5.5
---
.../Settings/SiteSettingsDataService.php | 20 +++++++
_api_app/app/Sites/SitesDataService.php | 33 +++++++++++
_api_app/tests/Feature/SitesTest.php | 58 +++++++++++++++++++
3 files changed, 111 insertions(+)
create mode 100644 _api_app/tests/Feature/SitesTest.php
diff --git a/_api_app/app/Sites/Settings/SiteSettingsDataService.php b/_api_app/app/Sites/Settings/SiteSettingsDataService.php
index 73ed2b4d0..dec4a2ec4 100644
--- a/_api_app/app/Sites/Settings/SiteSettingsDataService.php
+++ b/_api_app/app/Sites/Settings/SiteSettingsDataService.php
@@ -317,6 +317,26 @@ public function getDefaultSettings()
return $this->SITE_SETTINGS;
}
+ /**
+ * Writes a new site's settings file with the default template, as the engine does when it first loads a site
+ * without one, so its settings can be saved right away. Does nothing when the file exists.
+ */
+ public function createDefaultFile(): void
+ {
+ if (file_exists($this->XML_FILE)) {
+ return;
+ }
+
+ $settings = [];
+
+ foreach (self::$DEFAULT_SITE_SETTINGS as $path => $value) {
+ $this->setValueByPath($settings, $path, str_contains($path, 'lastUpdated') ? gmdate($value, time()) . ' GMT' : $value);
+ }
+
+ $this->array2xmlFile($settings, $this->XML_FILE, $this->ROOT_ELEMENT);
+ $this->SITE_SETTINGS = [];
+ }
+
/**
* Returns settings of site as an array
*
diff --git a/_api_app/app/Sites/SitesDataService.php b/_api_app/app/Sites/SitesDataService.php
index 118f4dfe8..aa07a2556 100644
--- a/_api_app/app/Sites/SitesDataService.php
+++ b/_api_app/app/Sites/SitesDataService.php
@@ -171,6 +171,7 @@ public function create(Request $request, ?string $cloneFrom = null)
$this->copyFolder($src, $dir, [self::SHOP_FILES_FOLDER, UndoFiles::FOLDER]);
} else {
@mkdir($dir, 0777, true);
+ (new SiteSettingsDataService($name))->createDefaultFile();
}
$site = [
@@ -318,6 +319,11 @@ public function delete($name)
$sites['site'] = $this->get();
$order = array_search($name, array_column($sites['site'], 'name'));
+ // The main site has no folder of its own: its name would point at the folder of all subsites.
+ if ((string) $name === '') {
+ return ['error_message' => 'The main site can not be deleted!'];
+ }
+
if ($order !== false) {
$dir = $this->XML_SITES_ROOT . '/' . $name;
$this->delFolder($dir);
@@ -330,6 +336,33 @@ public function delete($name)
return ['error_message' => 'Site "' . $name . '" not found!'];
}
+ /**
+ * Adds back the record of a deleted subsite whose storage folder is in place again, at the end: the counterpart
+ * of delete(), for undoing it.
+ *
+ * @param array $record the site's record as delete() returned it
+ * @return array the record, or error_message
+ */
+ public function restore(array $record): array
+ {
+ $name = (string) ($record['name'] ?? '');
+ $sites['site'] = $this->get();
+
+ if ($name === '' || in_array($name, array_map('strval', array_column($sites['site'], 'name')), true)) {
+ return ['error_message' => 'Site "' . $name . '" can not be restored: a site with this name exists.'];
+ }
+
+ if (! is_dir($this->XML_SITES_ROOT . '/' . $name)) {
+ return ['error_message' => 'Site "' . $name . '" can not be restored: its storage folder is missing.'];
+ }
+
+ $sites['site'][] = $record;
+ $this->array2xmlFile($sites, $this->XML_FILE, $this->ROOT_ELEMENT);
+ $this->SITES = [];
+
+ return $record;
+ }
+
/**
* Reorder sites and save to XML file
*
diff --git a/_api_app/tests/Feature/SitesTest.php b/_api_app/tests/Feature/SitesTest.php
new file mode 100644
index 000000000..a11c85dc8
--- /dev/null
+++ b/_api_app/tests/Feature/SitesTest.php
@@ -0,0 +1,58 @@
+bertaRoot = sys_get_temp_dir() . '/berta_sites_' . uniqid();
+ $this->storage = $this->bertaRoot . '/storage';
+ File::ensureDirectoryExists($this->storage . '/-sites/other');
+ config(['app.old_berta_root' => $this->bertaRoot]);
+ file_put_contents(
+ $this->storage . '/-sites/sites.xml',
+ ' '
+ . ' ',
+ );
+ file_put_contents($this->storage . '/-sites/other/sections.xml', 'sections');
+});
+
+afterEach(function () {
+ File::deleteDirectory($this->bertaRoot);
+});
+
+it('adds back a deleted subsite whose folder is in place again, at the end', function () {
+ $record = (new SitesDataService)->delete('other');
+ File::ensureDirectoryExists($this->storage . '/-sites/other');
+
+ expect((new SitesDataService)->restore($record))->toBe($record)
+ ->and(array_column((new SitesDataService)->get(), 'name'))->toBe(['', 'other'])
+ ->and((new SitesDataService)->get()[1]['title'])->toBe('Other');
+});
+
+it('does not restore a subsite whose name is taken or whose folder is missing', function () {
+ $record = (new SitesDataService)->get()[1];
+
+ expect((new SitesDataService)->restore($record))->toHaveKey('error_message');
+
+ (new SitesDataService)->delete('other');
+
+ expect((new SitesDataService)->restore($record))->toHaveKey('error_message')
+ ->and(array_column((new SitesDataService)->get(), 'name'))->toBe(['']);
+});
+
+it('never deletes the main site, whose name would point at the folder of all subsites', function () {
+ expect((new SitesDataService)->delete(''))->toHaveKey('error_message')
+ ->and($this->storage . '/-sites/other/sections.xml')->toBeFile();
+});
+
+it('gives a new site a settings file with the default template, and a copy the settings of its source', function () {
+ linkBertaEngine($this->bertaRoot);
+ file_put_contents($this->storage . '/-sites/other/settings.xml', ' ');
+
+ $new = (new SitesDataService)->create(Request::create('/'));
+ $copy = (new SitesDataService)->create(Request::create('/'), 'other');
+
+ expect(file_get_contents($this->storage . "/-sites/{$new['name']}/settings.xml"))->toContain('messy-0.4.2')
+ ->and(file_get_contents($this->storage . "/-sites/{$copy['name']}/settings.xml"))->toContain('white-0.3.5');
+});
From 91e0d2ca9ed63a4555597889cbf5ef349a0005b5 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Wed, 4 Nov 2026 13:36:39 +0200
Subject: [PATCH 20/22] Let routes mark their own changes for open editors, and
give the editor the chat's undo address
- EditorSync takes a MARKED_BY_HANDLER parameter for routes whose handler marks the changes it makes, e.g. the AI
chat: a window that missed a change is still refused, but the route marks no change of its own, and the answer
carries the latest marker
- The state lists the AI chat's undo address (aiUndo) when the plugin offers it
Co-Authored-By: Claude Opus 5.5
---
.../app/Http/Controllers/StateController.php | 4 ++
_api_app/app/Http/Middleware/EditorSync.php | 14 ++++++-
_api_app/tests/Feature/EditorSyncTest.php | 41 +++++++++++++++++++
3 files changed, 57 insertions(+), 2 deletions(-)
diff --git a/_api_app/app/Http/Controllers/StateController.php b/_api_app/app/Http/Controllers/StateController.php
index ce732a2cb..7bfcc902e 100644
--- a/_api_app/app/Http/Controllers/StateController.php
+++ b/_api_app/app/Http/Controllers/StateController.php
@@ -55,6 +55,10 @@ public function get($site = '')
$state['urls']['aiChat'] = route('ai_chat');
}
+ if (Route::has('ai_undo')) {
+ $state['urls']['aiUndo'] = route('ai_undo');
+ }
+
if (Route::has('ai_quota')) {
$state['urls']['aiQuota'] = route('ai_quota');
}
diff --git a/_api_app/app/Http/Middleware/EditorSync.php b/_api_app/app/Http/Middleware/EditorSync.php
index 493c203bc..1bc3db6f5 100644
--- a/_api_app/app/Http/Middleware/EditorSync.php
+++ b/_api_app/app/Http/Middleware/EditorSync.php
@@ -15,6 +15,9 @@
* new change. Requests without the editor window header (other clients, older editors) pass unchecked, and so do
* writes of a window that hasn't seen a change yet (no change header), e.g. a save retried right after login.
* Routes that change no site data opt out with withoutMiddleware(), so other windows aren't asked to reload for them.
+ * Routes whose handler marks the changes it makes itself (e.g. the AI chat, whose agent may change nothing or many
+ * things) use the MARKED_BY_HANDLER parameter: the write is still refused when the window missed a change, but isn't
+ * marked as a change of its own, and the answer carries the latest marker, the handler's changes included.
*/
class EditorSync
{
@@ -33,12 +36,17 @@ class EditorSync
public const CHANGE_SOURCE_HEADER = 'X-Berta-Change-Source';
+ /**
+ * Route middleware parameter, as EditorSync::class . ':' . EditorSync::MARKED_BY_HANDLER.
+ */
+ public const MARKED_BY_HANDLER = 'marked-by-handler';
+
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
- public function handle(Request $request, Closure $next): Response
+ public function handle(Request $request, Closure $next, string $mode = ''): Response
{
$markers = new ChangeMarker;
$editor = (string) $request->header(self::EDITOR_HEADER, '');
@@ -56,7 +64,9 @@ public function handle(Request $request, Closure $next): Response
$response = $next($request);
- if ($isEditorWrite && $this->saved($response)) {
+ if ($mode === self::MARKED_BY_HANDLER) {
+ $latest = $markers->current();
+ } elseif ($isEditorWrite && $this->saved($response)) {
$latest = $this->markSaved($markers, $latest, $editor);
}
diff --git a/_api_app/tests/Feature/EditorSyncTest.php b/_api_app/tests/Feature/EditorSyncTest.php
index 0b110c5b7..c14b963ec 100644
--- a/_api_app/tests/Feature/EditorSyncTest.php
+++ b/_api_app/tests/Feature/EditorSyncTest.php
@@ -226,6 +226,47 @@ function savedFontWeight(): string
->and((new ChangeMarker)->current())->toMatchArray(['writer' => EDITOR_WINDOW_A, 'source' => 'editor']);
});
+describe('routes whose handler marks its own changes', function () {
+ it('marks nothing itself, and answers with the change the handler marked', function () {
+ $request = Request::create('/_api/v1/plugin/ai/chat', 'POST', server: ['HTTP_X_BERTA_EDITOR' => EDITOR_WINDOW_A, 'HTTP_X_BERTA_CHANGE' => '']);
+ $handlerChange = null;
+
+ $response = (new EditorSync)->handle($request, function () use (&$handlerChange) {
+ $handlerChange = (new ChangeMarker)->touch(ChangeMarker::SOURCE_AGENT, EDITOR_WINDOW_A);
+
+ return response()->json([]);
+ }, EditorSync::MARKED_BY_HANDLER);
+
+ expect($response->headers->get('X-Berta-Change'))->toBe($handlerChange['token'])
+ ->and($response->headers->get('X-Berta-Change-Writer'))->toBe(EDITOR_WINDOW_A)
+ ->and((new ChangeMarker)->current())->toBe($handlerChange);
+ });
+
+ it('marks no change when the handler made none', function () {
+ $request = Request::create('/_api/v1/plugin/ai/chat', 'POST', server: ['HTTP_X_BERTA_EDITOR' => EDITOR_WINDOW_A, 'HTTP_X_BERTA_CHANGE' => '']);
+
+ (new EditorSync)->handle($request, fn () => response()->json([]), EditorSync::MARKED_BY_HANDLER);
+
+ expect((new ChangeMarker)->current()['token'])->toBe('');
+ });
+
+ it('still refuses a window that missed a change', function () {
+ $marker = (new ChangeMarker)->touch(ChangeMarker::SOURCE_EDITOR, EDITOR_WINDOW_B);
+ $request = Request::create('/_api/v1/plugin/ai/chat', 'POST', server: ['HTTP_X_BERTA_EDITOR' => EDITOR_WINDOW_A, 'HTTP_X_BERTA_CHANGE' => 'older']);
+ $handled = false;
+
+ $response = (new EditorSync)->handle($request, function () use (&$handled) {
+ $handled = true;
+
+ return response()->json([]);
+ }, EditorSync::MARKED_BY_HANDLER);
+
+ expect($response->getStatusCode())->toBe(409)
+ ->and($handled)->toBeFalse()
+ ->and($response->headers->get('X-Berta-Change'))->toBe($marker['token']);
+ });
+});
+
it('keeps the earlier change when the cache cannot store the save', function () {
Cache::shouldReceive('get')->andReturn(['token' => 'earlier', 'writer' => EDITOR_WINDOW_B, 'source' => 'editor']);
Cache::shouldReceive('forever')->andThrow(new RuntimeException('Read-only cache.'));
From cfc4b1984deb27403c3c34b104871668beb81e07 Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Wed, 4 Nov 2026 15:05:09 +0200
Subject: [PATCH 21/22] Upgrade laravel/mcp to 1.1.2 and laravel/ai to 1.3.0
Co-Authored-By: Claude Opus 5.5
---
_api_app/composer.lock | 175 +++++++++++++++++++++--------------------
1 file changed, 88 insertions(+), 87 deletions(-)
diff --git a/_api_app/composer.lock b/_api_app/composer.lock
index 13b3e855d..d112189b8 100644
--- a/_api_app/composer.lock
+++ b/_api_app/composer.lock
@@ -1570,16 +1570,16 @@
},
{
"name": "laravel/ai",
- "version": "v1.0.1",
+ "version": "v1.3.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/ai.git",
- "reference": "127fce89bc620fcf942252837669c2fc0895f4b3"
+ "reference": "6837b4c557f67e04e8a13d882fe7724032278c27"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/ai/zipball/127fce89bc620fcf942252837669c2fc0895f4b3",
- "reference": "127fce89bc620fcf942252837669c2fc0895f4b3",
+ "url": "https://api.github.com/repos/laravel/ai/zipball/6837b4c557f67e04e8a13d882fe7724032278c27",
+ "reference": "6837b4c557f67e04e8a13d882fe7724032278c27",
"shasum": ""
},
"require": {
@@ -1592,7 +1592,8 @@
"illuminate/support": "^12.0|^13.0",
"laravel/prompts": "^0.3.6",
"laravel/serializable-closure": "^2.0",
- "php": "^8.3"
+ "php": "^8.3",
+ "symfony/yaml": "^7.2|^8.0"
},
"conflict": {
"aws/aws-sdk-php": "<3.369.1",
@@ -1647,7 +1648,7 @@
"issues": "https://github.com/laravel/ai/issues",
"source": "https://github.com/laravel/ai"
},
- "time": "2026-09-29T18:50:17+00:00"
+ "time": "2026-10-19T16:50:18+00:00"
},
{
"name": "laravel/framework",
@@ -1882,16 +1883,16 @@
},
{
"name": "laravel/mcp",
- "version": "v1.1.1",
+ "version": "v1.1.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
- "reference": "e77af70cd009aaa31455626bd463b0e7e39cfcf9"
+ "reference": "ab9238ce6c89095679651dd3d19d67641d73a436"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/mcp/zipball/e77af70cd009aaa31455626bd463b0e7e39cfcf9",
- "reference": "e77af70cd009aaa31455626bd463b0e7e39cfcf9",
+ "url": "https://api.github.com/repos/laravel/mcp/zipball/ab9238ce6c89095679651dd3d19d67641d73a436",
+ "reference": "ab9238ce6c89095679651dd3d19d67641d73a436",
"shasum": ""
},
"require": {
@@ -1953,7 +1954,7 @@
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
- "time": "2026-10-21T11:50:42+00:00"
+ "time": "2026-10-26T17:48:48+00:00"
},
{
"name": "laravel/prompts",
@@ -7406,6 +7407,82 @@
],
"time": "2026-09-03T16:02:27+00:00"
},
+ {
+ "name": "symfony/yaml",
+ "version": "v8.1.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/yaml.git",
+ "reference": "030e96eed0303223bfca3af9109e9e64830b6ec3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/030e96eed0303223bfca3af9109e9e64830b6ec3",
+ "reference": "030e96eed0303223bfca3af9109e9e64830b6ec3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4.1",
+ "symfony/polyfill-ctype": "^1.8"
+ },
+ "conflict": {
+ "symfony/console": "<7.4"
+ },
+ "require-dev": {
+ "symfony/console": "^7.4|^8.0",
+ "yaml/yaml-test-suite": "*"
+ },
+ "bin": [
+ "Resources/bin/yaml-lint"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Yaml\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Loads and dumps YAML files",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/yaml/tree/v8.1.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-09-26T06:33:49+00:00"
+ },
{
"name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0",
@@ -11127,82 +11204,6 @@
],
"time": "2024-10-20T05:08:20+00:00"
},
- {
- "name": "symfony/yaml",
- "version": "v8.1.8",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/yaml.git",
- "reference": "030e96eed0303223bfca3af9109e9e64830b6ec3"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/030e96eed0303223bfca3af9109e9e64830b6ec3",
- "reference": "030e96eed0303223bfca3af9109e9e64830b6ec3",
- "shasum": ""
- },
- "require": {
- "php": ">=8.4.1",
- "symfony/polyfill-ctype": "^1.8"
- },
- "conflict": {
- "symfony/console": "<7.4"
- },
- "require-dev": {
- "symfony/console": "^7.4|^8.0",
- "yaml/yaml-test-suite": "*"
- },
- "bin": [
- "Resources/bin/yaml-lint"
- ],
- "type": "library",
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\Yaml\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Loads and dumps YAML files",
- "homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/yaml/tree/v8.1.8"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://github.com/nicolas-grekas",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2026-09-26T06:33:49+00:00"
- },
{
"name": "ta-tikoma/phpunit-architecture-test",
"version": "0.8.7",
From 5d15a306f4e453ddb6848552234f18dc6992ae3f Mon Sep 17 00:00:00 2001
From: uldisrudzitis
Date: Wed, 4 Nov 2026 15:05:09 +0200
Subject: [PATCH 22/22] Rebuild the editor's AI chat on the server's change
log, and mirror its changes natively
- The chat sends the message, the conversation and the site; each reply keeps the changes it made, with an Undo
button that undoes them on the server without the AI, and says why when they were changed since
- AiChangesMirrorService swaps the fresh data the server sends into the store and re-renders the preview with the
triggers the editor's own edits use, reloading it only where they do; the old client-side applying is removed
- New store actions replace a site's sections and a site's section entries; SiteContentRefreshedAction re-renders
the menus and entries in all four templates
Co-Authored-By: Claude Opus 5.5
---
.../app/ai-assistant/ai-assistant.actions.ts | 24 +-
.../ai-assistant/ai-assistant.component.ts | 63 +-
.../app/ai-assistant/ai-assistant.service.ts | 97 ++-
.../ai-assistant/ai-assistant.state.spec.ts | 173 +++++
.../app/ai-assistant/ai-assistant.state.ts | 734 +++++-------------
.../ai-changes-mirror.service.spec.ts | 195 +++++
.../ai-assistant/ai-changes-mirror.service.ts | 265 +++++++
.../default-template-rerender.service.ts | 6 +
.../mashup-template-rerender.service.ts | 6 +
.../messy/messy-template-rerender.service.ts | 6 +
.../template-rerender.service.spec.ts | 22 +
.../app/rerender/template-rerender.service.ts | 35 +-
.../white/white-template-rerender.service.ts | 6 +
.../entries-state/section-entries.actions.ts | 13 +
.../entries-state/section-entries.state.ts | 22 +
.../sections/replace-from-server.spec.ts | 88 +++
.../sections-state/site-sections.actions.ts | 11 +
.../sections-state/site-sections.state.ts | 42 +-
.../app/sites/sites-state/sites.actions.ts | 9 +
19 files changed, 1201 insertions(+), 616 deletions(-)
create mode 100644 editor/src/app/ai-assistant/ai-assistant.state.spec.ts
create mode 100644 editor/src/app/ai-assistant/ai-changes-mirror.service.spec.ts
create mode 100644 editor/src/app/ai-assistant/ai-changes-mirror.service.ts
create mode 100644 editor/src/app/sites/sections/replace-from-server.spec.ts
diff --git a/editor/src/app/ai-assistant/ai-assistant.actions.ts b/editor/src/app/ai-assistant/ai-assistant.actions.ts
index 4f7761c33..e3189f787 100644
--- a/editor/src/app/ai-assistant/ai-assistant.actions.ts
+++ b/editor/src/app/ai-assistant/ai-assistant.actions.ts
@@ -1,3 +1,5 @@
+import { AiChange } from './ai-assistant.service';
+
export class ToggleAiAssistantAction {
static readonly type = 'AI_ASSISTANT:TOGGLE';
}
@@ -7,19 +9,20 @@ export class SendAiMessageAction {
constructor(public message: string) {}
}
-export class AiMessageReceivedAction {
- static readonly type = 'AI_ASSISTANT:MESSAGE_RECEIVED';
+export class AiReplyReceivedAction {
+ static readonly type = 'AI_ASSISTANT:REPLY_RECEIVED';
constructor(
public reply: string,
- public designChanges: { group: string; setting: string; value: string; previous_value?: string | null }[],
- public settingsChanges: { group: string; setting: string; value: string; previous_value?: string | null }[],
- public sectionChanges: { operation: string; name?: string; title?: string; property?: string; value?: string; previous_value?: string | null; order?: number }[],
- public isUndo: boolean = false,
- public entryChanges: { operation: 'create' | 'update' | 'delete'; section?: string; entry_id?: string; value?: string; previous_value?: string | null; description?: string }[] = [],
- public galleryChanges: { operation: 'update_setting' | 'update_caption'; section?: string; entry_id?: string; setting?: string; file_index?: number; value?: string; previous_value?: string | null }[] = [],
+ public changes: AiChange[],
) {}
}
+/** Undoes the changes of the assistant message at this index. */
+export class UndoAiMessageAction {
+ static readonly type = 'AI_ASSISTANT:UNDO_MESSAGE';
+ constructor(public messageIndex: number) {}
+}
+
export class ClearAiChatAction {
static readonly type = 'AI_ASSISTANT:CLEAR';
}
@@ -35,5 +38,8 @@ export class AiQuotaFetchedAction {
export class SubmitAiFeedbackAction {
static readonly type = 'AI_ASSISTANT:SUBMIT_FEEDBACK';
- constructor(public messageIndex: number, public vote: 'up' | 'down') {}
+ constructor(
+ public messageIndex: number,
+ public vote: 'up' | 'down',
+ ) {}
}
diff --git a/editor/src/app/ai-assistant/ai-assistant.component.ts b/editor/src/app/ai-assistant/ai-assistant.component.ts
index e11cad7ba..ab4608f6d 100644
--- a/editor/src/app/ai-assistant/ai-assistant.component.ts
+++ b/editor/src/app/ai-assistant/ai-assistant.component.ts
@@ -15,6 +15,7 @@ import {
ClearAiChatAction,
ToggleAiAssistantAction,
SubmitAiFeedbackAction,
+ UndoAiMessageAction,
} from './ai-assistant.actions';
import { AppState } from '../app-state/app.state';
@@ -100,6 +101,28 @@ const EXAMPLE_PROMPTS = [
>
@if (msg.role === 'assistant') {
+ @if (msg.changes?.length || msg.undone) {
+
+ @if (msg.changes?.length) {
+
+ {{
+ (undoingIndex$ | async) === $index
+ ? 'Undoing…'
+ : 'Undo'
+ }}
+
+ } @else {
+
Undone
+ }
+ @if (msg.undoRefusal) {
+
{{ msg.undoRefusal }}
+ }
+
+ }
@if (hasFeedback$ | async) {
@if (dailyUsage$ | async; as usage) {
- {{ usage.count }} / {{ usage.limit }} daily limit
+ {{ usage.count }} / {{ usage.limit }} daily limit
}
@@ -471,6 +496,36 @@ const EXAMPLE_PROMPTS = [
font-weight: 600;
}
+ .ai-undo {
+ margin-top: 6px;
+ }
+
+ .ai-undo-btn {
+ background: none;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ padding: 2px 8px;
+ font-size: 12px;
+ cursor: pointer;
+ color: #555;
+ }
+
+ .ai-undo-btn:disabled {
+ cursor: default;
+ opacity: 0.6;
+ }
+
+ .ai-undo-done {
+ font-size: 12px;
+ color: #777;
+ }
+
+ .ai-undo-refusal {
+ margin: 4px 0 0;
+ font-size: 12px;
+ color: #a33;
+ }
+
.ai-feedback {
display: flex;
gap: 4px;
@@ -508,6 +563,7 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy {
isOpen$: Observable;
messages$: Observable;
isLoading$: Observable;
+ undoingIndex$: Observable;
dailyLimitMessage$: Observable;
dailyUsage$: Observable<{ count: number; limit: number } | null>;
hasFeedback$: Observable;
@@ -527,6 +583,7 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy {
this.isOpen$ = this.store.select(AiAssistantState.isOpen);
this.messages$ = this.store.select(AiAssistantState.messages);
this.isLoading$ = this.store.select(AiAssistantState.isLoading);
+ this.undoingIndex$ = this.store.select(AiAssistantState.undoingIndex);
this.dailyLimitMessage$ = this.store.select(
AiAssistantState.dailyLimitMessage,
);
@@ -592,6 +649,10 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy {
this.store.dispatch(new ClearAiChatAction());
}
+ undo(messageIndex: number) {
+ this.store.dispatch(new UndoAiMessageAction(messageIndex));
+ }
+
submitFeedback(messageIndex: number, vote: 'up' | 'down') {
this.store.dispatch(new SubmitAiFeedbackAction(messageIndex, vote));
}
diff --git a/editor/src/app/ai-assistant/ai-assistant.service.ts b/editor/src/app/ai-assistant/ai-assistant.service.ts
index 20ac44fa2..3b8b67f3c 100644
--- a/editor/src/app/ai-assistant/ai-assistant.service.ts
+++ b/editor/src/app/ai-assistant/ai-assistant.service.ts
@@ -1,55 +1,62 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
+
import { AppStateService } from '../app-state/app-state.service';
+import { SiteStateModel } from '../sites/sites-state/site-state.model';
+import { SiteSettingsSiteResponse } from '../sites/settings/site-settings.interface';
+import { SitesTemplateSettingsSiteResponse } from '../sites/template-settings/site-template-settings.interface';
+import { SiteSectionStateModel } from '../sites/sections/sections-state/site-sections-state.model';
+import { SectionEntry } from '../sites/sections/entries/entries-state/section-entries-state.model';
+import { SectionTagsInterface } from '../sites/sections/tags/section-tags-state.model';
+/** One changed value of a change, as the server's change log records it. */
export interface AiChangeItem {
group: string;
- setting: string;
+ key: string;
+ previous: string;
value: string;
- previous_value?: string | null;
}
-export interface AiSectionChangeItem {
- operation: 'create' | 'clone' | 'update' | 'delete' | 'reorder';
- name?: string;
- title?: string;
- property?: string;
- value?: string;
- previous_value?: string | null;
- order?: number;
+/** A change the chat made, logged on the server, so it can be undone. */
+export interface AiChange {
+ change_id: string;
+ tool: string;
+ subsite: string;
+ kind: string;
+ action: string;
+ items: AiChangeItem[];
}
-export interface AiEntryChangeItem {
- operation: 'create' | 'update' | 'delete';
- section?: string;
- entry_id?: string;
- value?: string;
- previous_value?: string | null;
- description?: string;
+/** Fresh data of a site's parts that changes touched, in the shapes the state endpoint gives them. */
+export interface AiSiteData {
+ settings?: SiteSettingsSiteResponse;
+ template_settings?: SitesTemplateSettingsSiteResponse;
+ sections?: SiteSectionStateModel[];
+ entries?: { [sectionName: string]: SectionEntry[] };
+ tags?: { section: SectionTagsInterface[] };
}
-export interface AiGalleryChangeItem {
- operation: 'update_setting' | 'update_caption';
- section?: string;
- entry_id?: string;
- setting?: string;
- file_index?: number;
- value?: string;
- previous_value?: string | null;
+/** The parts of the editor's data that changes touched, read fresh by the server. */
+export interface AiRefresh {
+ sites?: SiteStateModel[];
+ site_data: { [siteName: string]: AiSiteData };
}
export interface AiChatResponse {
reply: string;
- is_undo: boolean;
- design_changes: AiChangeItem[];
- settings_changes: AiChangeItem[];
- section_changes: AiSectionChangeItem[];
- entry_changes: AiEntryChangeItem[];
- gallery_changes: AiGalleryChangeItem[];
+ changes: AiChange[];
+ refresh: AiRefresh;
daily_usage?: { count: number; limit: number };
}
+export interface AiUndoResponse {
+ undone: string[];
+ refusal: string | null;
+ changes: AiChange[];
+ refresh: AiRefresh;
+}
+
@Injectable({
providedIn: 'root',
})
@@ -60,18 +67,27 @@ export class AiAssistantService {
message: string,
history: { role: string; content: string }[],
site: string,
- template: string,
- changeHistory: { user_message: string; design_changes: AiChangeItem[]; settings_changes: AiChangeItem[]; section_changes: AiSectionChangeItem[]; entry_changes: AiEntryChangeItem[]; gallery_changes: AiGalleryChangeItem[] }[] = [],
): Observable {
return this.appStateService
- .sync('aiChat', { message, history, site, template, change_history: changeHistory }, 'POST')
+ .sync('aiChat', { message, history, site }, 'POST')
.pipe(map((response: any) => response.data as AiChatResponse));
}
+ /** Undoes changes of a reply on the server, without the AI. */
+ undo(changeIds: string[]): Observable {
+ return this.appStateService
+ .sync('aiUndo', { change_ids: changeIds }, 'POST')
+ .pipe(map((response: any) => response.data as AiUndoResponse));
+ }
+
getQuota(): Observable<{ count: number; limit: number }> {
return this.appStateService
.sync('aiQuota', {}, 'GET')
- .pipe(map((response: any) => response.data as { count: number; limit: number }));
+ .pipe(
+ map(
+ (response: any) => response.data as { count: number; limit: number },
+ ),
+ );
}
feedback(
@@ -81,7 +97,16 @@ export class AiAssistantService {
conversation: { role: string; content: string }[],
): Observable {
return this.appStateService
- .sync('aiFeedback', { vote, user_message: userMessage, assistant_message: assistantMessage, conversation }, 'POST')
+ .sync(
+ 'aiFeedback',
+ {
+ vote,
+ user_message: userMessage,
+ assistant_message: assistantMessage,
+ conversation,
+ },
+ 'POST',
+ )
.pipe(map(() => undefined));
}
}
diff --git a/editor/src/app/ai-assistant/ai-assistant.state.spec.ts b/editor/src/app/ai-assistant/ai-assistant.state.spec.ts
new file mode 100644
index 000000000..61dfd1465
--- /dev/null
+++ b/editor/src/app/ai-assistant/ai-assistant.state.spec.ts
@@ -0,0 +1,173 @@
+import { StateContext, Store } from '@ngxs/store';
+import { of, throwError } from 'rxjs';
+
+import { AppState } from '../app-state/app.state';
+import {
+ AiReplyReceivedAction,
+ SendAiMessageAction,
+ UndoAiMessageAction,
+} from './ai-assistant.actions';
+import { AiAssistantService, AiChange } from './ai-assistant.service';
+import { AiAssistantState, AiAssistantStateModel } from './ai-assistant.state';
+import { AiChangesMirrorService } from './ai-changes-mirror.service';
+
+describe('AiAssistantState', () => {
+ const CHANGE: AiChange = {
+ change_id: '7',
+ tool: 'create-section',
+ subsite: '',
+ kind: 'section',
+ action: 'create',
+ items: [],
+ };
+ const REFRESH = { site_data: { '': { sections: [] } } };
+
+ let model: AiAssistantStateModel;
+ let context: StateContext;
+ let service: jasmine.SpyObj;
+ let mirror: jasmine.Spy;
+ let state: AiAssistantState;
+
+ beforeEach(() => {
+ localStorage.clear();
+ model = {
+ isOpen: true,
+ messages: [],
+ isLoading: false,
+ undoingIndex: null,
+ loadedSite: '',
+ dailyLimitMessage: null,
+ dailyUsage: null,
+ pendingInput: null,
+ };
+ service = jasmine.createSpyObj('AiAssistantService', ['chat', 'undo']);
+ mirror = jasmine.createSpy('mirror').and.returnValue(of(null));
+ state = new AiAssistantState(
+ {
+ selectSnapshot: (selector: unknown) =>
+ selector === AppState.getSite ? '' : undefined,
+ } as unknown as Store,
+ service,
+ { mirror } as unknown as AiChangesMirrorService,
+ );
+ context = {
+ getState: () => model,
+ patchState: (patch: Partial) => {
+ model = { ...model, ...patch };
+ return model;
+ },
+ dispatch: (action: unknown) => {
+ if (action instanceof AiReplyReceivedAction) {
+ state.replyReceived(context, action);
+ }
+ return of(null);
+ },
+ } as unknown as StateContext;
+ });
+
+ it('sends the message with the conversation, and adds the reply with the changes it made', () => {
+ model.messages = [{ role: 'assistant', content: 'Hi', changes: [CHANGE] }];
+ service.chat.and.returnValue(
+ of({ reply: 'Added.', changes: [CHANGE], refresh: REFRESH }),
+ );
+
+ state
+ .sendMessage(context, new SendAiMessageAction('Add a section'))
+ .subscribe();
+
+ expect(service.chat).toHaveBeenCalledWith(
+ 'Add a section',
+ [{ role: 'assistant', content: 'Hi' }],
+ '',
+ );
+ expect(mirror).toHaveBeenCalledWith([CHANGE], REFRESH);
+ expect(model.messages[2]).toEqual({
+ role: 'assistant',
+ content: 'Added.',
+ changes: [CHANGE],
+ });
+ expect(model.isLoading).toBeFalse();
+ expect(
+ JSON.parse(localStorage.getItem('berta_ai_chat_')!).messages.length,
+ ).toBe(3);
+ });
+
+ it('adds a reply without changes as plain text', () => {
+ service.chat.and.returnValue(
+ of({
+ reply: 'You have one section.',
+ changes: [],
+ refresh: { site_data: {} },
+ }),
+ );
+
+ state
+ .sendMessage(context, new SendAiMessageAction('What sections?'))
+ .subscribe();
+
+ expect(model.messages[1]).toEqual({
+ role: 'assistant',
+ content: 'You have one section.',
+ });
+ });
+
+ it('puts the message back when the daily limit is reached', () => {
+ service.chat.and.returnValue(
+ throwError(() => ({ status: 429, error: { message: 'Limit reached.' } })),
+ );
+
+ state.sendMessage(context, new SendAiMessageAction('Hello')).subscribe();
+
+ expect(model.messages).toEqual([]);
+ expect(model.pendingInput).toBe('Hello');
+ expect(model.dailyLimitMessage).toBe('Limit reached.');
+ });
+
+ it("undoes a reply's changes and shows them in the editor", () => {
+ model.messages = [
+ { role: 'assistant', content: 'Added.', changes: [CHANGE] },
+ ];
+ const undoChange = { ...CHANGE, change_id: '8', action: 'remove' };
+ service.undo.and.returnValue(
+ of({
+ undone: ['7'],
+ refusal: null,
+ changes: [undoChange],
+ refresh: REFRESH,
+ }),
+ );
+
+ state.undoMessage(context, new UndoAiMessageAction(0)).subscribe();
+
+ expect(service.undo).toHaveBeenCalledWith(['7']);
+ expect(mirror).toHaveBeenCalledWith([undoChange], REFRESH);
+ expect(model.messages[0]).toEqual(
+ jasmine.objectContaining({ undone: true, changes: [] }),
+ );
+ expect(model.undoingIndex).toBeNull();
+ });
+
+ it('says why changes could not be undone, and keeps them to try again', () => {
+ model.messages = [
+ { role: 'assistant', content: 'Added.', changes: [CHANGE] },
+ ];
+ service.undo.and.returnValue(
+ of({
+ undone: [],
+ refusal: 'It was changed since.',
+ changes: [],
+ refresh: { site_data: {} },
+ }),
+ );
+
+ state.undoMessage(context, new UndoAiMessageAction(0)).subscribe();
+
+ expect(model.messages[0]).toEqual(
+ jasmine.objectContaining({
+ undone: false,
+ undoRefusal: 'It was changed since.',
+ changes: [CHANGE],
+ }),
+ );
+ });
+});
diff --git a/editor/src/app/ai-assistant/ai-assistant.state.ts b/editor/src/app/ai-assistant/ai-assistant.state.ts
index 1f869ef96..898b06675 100644
--- a/editor/src/app/ai-assistant/ai-assistant.state.ts
+++ b/editor/src/app/ai-assistant/ai-assistant.state.ts
@@ -1,96 +1,42 @@
import { Injectable } from '@angular/core';
-import { State, Action, StateContext, Selector } from '@ngxs/store';
-import { tap, catchError, concatMap } from 'rxjs/operators';
-import { EMPTY, from, concat } from 'rxjs';
+import { State, Action, StateContext, Selector, Store } from '@ngxs/store';
+import { EMPTY } from 'rxjs';
+import { catchError, concatMap, finalize, tap } from 'rxjs/operators';
-import { Store } from '@ngxs/store';
import { AppState } from '../app-state/app.state';
import { UpdateAppStateAction } from '../app-state/app.actions';
-import { SiteSettingsState } from '../sites/settings/site-settings.state';
-import { SiteSectionsState } from '../sites/sections/sections-state/site-sections.state';
-import { UpdateSiteTemplateSettingsAction } from '../sites/template-settings/site-template-settings.actions';
-import { UpdateSiteSettingsAction } from '../sites/settings/site-settings.actions';
-import {
- CreateSectionAction,
- UpdateSiteSectionAction,
- RenameSiteSectionAction,
- DeleteSiteSectionAction,
- ReOrderSiteSectionsAction,
-} from '../sites/sections/sections-state/site-sections.actions';
-import {
- AddSectionEntryFromSyncAction,
- UpdateSectionEntryFromSyncAction,
- DeleteSectionEntryFromSyncAction,
-} from '../sites/sections/entries/entries-state/section-entries.actions';
-import { SectionEntriesState } from '../sites/sections/entries/entries-state/section-entries.state';
-import { AiAssistantService, AiEntryChangeItem as AiEntryChangeResponseItem, AiGalleryChangeItem as AiGalleryChangeResponseItem, AiSectionChangeItem as AiSectionChangeResponseItem } from './ai-assistant.service';
+import { UserLoginAction } from '../user/user.actions';
+import { AiAssistantService, AiChange } from './ai-assistant.service';
+import { AiChangesMirrorService } from './ai-changes-mirror.service';
import {
ToggleAiAssistantAction,
SendAiMessageAction,
- AiMessageReceivedAction,
+ AiReplyReceivedAction,
+ UndoAiMessageAction,
ClearAiChatAction,
SubmitAiFeedbackAction,
FetchAiQuotaAction,
AiQuotaFetchedAction,
} from './ai-assistant.actions';
-import { UserLoginAction } from '../user/user.actions';
export interface AiMessage {
role: 'user' | 'assistant';
content: string;
vote?: 'up' | 'down';
-}
-
-export interface AiChangeItem {
- group: string;
- setting: string;
- value: string;
- previousValue: string | null;
-}
-
-export interface AiSectionChangeItem {
- operation: string;
- name?: string;
- title?: string;
- property?: string;
- value?: string;
- previousValue?: string | null;
- order?: number;
-}
-
-export interface AiEntryChangeItem {
- operation: string;
- section?: string;
- entryId?: string;
- value?: string;
- previousValue?: string | null;
- description?: string;
-}
-
-export interface AiGalleryChangeItem {
- operation: string;
- section?: string;
- entryId?: string;
- setting?: string;
- fileIndex?: number;
- value?: string;
- previousValue?: string | null;
-}
-
-export interface AiChangeHistoryItem {
- userMessage: string;
- designChanges: AiChangeItem[];
- settingsChanges: AiChangeItem[];
- sectionChanges: AiSectionChangeItem[];
- entryChanges: AiEntryChangeItem[];
- galleryChanges: AiGalleryChangeItem[];
+ /** The changes an assistant reply made on the server, which its Undo button undoes. */
+ changes?: AiChange[];
+ /** Whether those changes were undone. */
+ undone?: boolean;
+ /** Why they couldn't be undone, e.g. because they were changed since. */
+ undoRefusal?: string;
}
export interface AiAssistantStateModel {
isOpen: boolean;
messages: AiMessage[];
isLoading: boolean;
- changeHistory: AiChangeHistoryItem[];
+ /** The index of the message whose changes are being undone. */
+ undoingIndex: number | null;
loadedSite: string | null;
dailyLimitMessage: string | null;
dailyUsage: { count: number; limit: number } | null;
@@ -103,13 +49,19 @@ const defaults: AiAssistantStateModel = {
isOpen: false,
messages: [],
isLoading: false,
- changeHistory: [],
+ undoingIndex: null,
loadedSite: null,
dailyLimitMessage: null,
dailyUsage: null,
pendingInput: null,
};
+/**
+ * The editor's AI chat. The server's agent reads and changes the site with the same tools AI apps use over MCP;
+ * each reply comes with the changes it made and the fresh data of what they touched, which AiChangesMirrorService
+ * shows in the editor and the preview. A reply's changes are undone on the server, without the AI. Each site's
+ * conversation is kept in the browser.
+ */
@State({
name: 'aiAssistant',
defaults,
@@ -131,6 +83,11 @@ export class AiAssistantState {
return state.isLoading;
}
+ @Selector()
+ static undoingIndex(state: AiAssistantStateModel) {
+ return state.undoingIndex;
+ }
+
@Selector()
static dailyLimitMessage(state: AiAssistantStateModel) {
return state.dailyLimitMessage;
@@ -149,46 +106,38 @@ export class AiAssistantState {
constructor(
private store: Store,
private aiAssistantService: AiAssistantService,
+ private changesMirror: AiChangesMirrorService,
) {}
- private saveToStorage(site: string, messages: AiMessage[], changeHistory: AiChangeHistoryItem[]) {
- try {
- localStorage.setItem(STORAGE_KEY_PREFIX + site, JSON.stringify({ messages, changeHistory }));
- } catch {}
- }
-
@Action(UpdateAppStateAction)
onAppStateUpdate(
{ patchState, getState }: StateContext,
action: UpdateAppStateAction,
) {
const site = action.payload?.site;
- if (site == null) return;
- if (getState().loadedSite === site) return;
-
- try {
- const raw = localStorage.getItem(STORAGE_KEY_PREFIX + site);
- if (raw) {
- const { messages, changeHistory } = JSON.parse(raw);
- patchState({ messages, changeHistory, loadedSite: site });
- } else {
- patchState({ messages: [], changeHistory: [], loadedSite: site });
- }
- } catch {
- patchState({ messages: [], changeHistory: [], loadedSite: site });
+ if (site == null || getState().loadedSite === site) {
+ return;
}
+
+ patchState({ messages: this.loadMessages(site), loadedSite: site });
}
@Action(UserLoginAction)
onLogin({ setState }: StateContext) {
- Object.keys(localStorage)
- .filter(key => key.startsWith(STORAGE_KEY_PREFIX))
- .forEach(key => localStorage.removeItem(key));
+ try {
+ Object.keys(localStorage)
+ .filter((key) => key.startsWith(STORAGE_KEY_PREFIX))
+ .forEach((key) => localStorage.removeItem(key));
+ } catch {}
setState(defaults);
}
@Action(ToggleAiAssistantAction)
- toggle({ patchState, getState, dispatch }: StateContext) {
+ toggle({
+ patchState,
+ getState,
+ dispatch,
+ }: StateContext) {
const willOpen = !getState().isOpen;
patchState({ isOpen: willOpen });
if (willOpen) {
@@ -205,7 +154,10 @@ export class AiAssistantState {
}
@Action(AiQuotaFetchedAction)
- quotaFetched({ patchState }: StateContext, action: AiQuotaFetchedAction) {
+ quotaFetched(
+ { patchState }: StateContext,
+ action: AiQuotaFetchedAction,
+ ) {
patchState({ dailyUsage: action.usage });
}
@@ -214,85 +166,51 @@ export class AiAssistantState {
{ patchState, getState, dispatch }: StateContext,
action: SendAiMessageAction,
) {
- const state = getState();
- const userMessage: AiMessage = { role: 'user', content: action.message };
+ const history = getState().messages.map(({ role, content }) => ({
+ role,
+ content,
+ }));
patchState({
- messages: [...state.messages, userMessage],
+ messages: [
+ ...getState().messages,
+ { role: 'user', content: action.message },
+ ],
isLoading: true,
pendingInput: null,
});
- const site = this.store.selectSnapshot(AppState.getSite) || '';
- const template =
- this.store.selectSnapshot(SiteSettingsState.getCurrentSiteTemplate) ||
- '';
- const history = state.messages.map((m) => ({
- role: m.role,
- content: m.content,
- }));
-
- const changeHistoryPayload = state.changeHistory.map((entry) => ({
- user_message: entry.userMessage,
- design_changes: entry.designChanges.map((c) => ({
- group: c.group,
- setting: c.setting,
- value: c.value,
- previous_value: c.previousValue,
- })),
- settings_changes: entry.settingsChanges.map((c) => ({
- group: c.group,
- setting: c.setting,
- value: c.value,
- previous_value: c.previousValue,
- })),
- section_changes: entry.sectionChanges.map((c) => ({
- operation: c.operation as AiSectionChangeResponseItem['operation'],
- name: c.name,
- title: c.title,
- property: c.property,
- value: c.value,
- previous_value: c.previousValue,
- order: c.order,
- })),
- entry_changes: entry.entryChanges.map((c) => ({
- operation: c.operation as AiEntryChangeResponseItem['operation'],
- section: c.section,
- entry_id: c.entryId,
- value: c.value,
- previous_value: c.previousValue,
- })),
- gallery_changes: entry.galleryChanges.map((c) => ({
- operation: c.operation as AiGalleryChangeResponseItem['operation'],
- section: c.section,
- entry_id: c.entryId,
- setting: c.setting,
- file_index: c.fileIndex,
- value: c.value,
- previous_value: c.previousValue,
- })),
- }));
-
return this.aiAssistantService
- .chat(action.message, history, site, template, changeHistoryPayload)
+ .chat(action.message, history, this.site())
.pipe(
tap((response) => {
if (response.daily_usage) {
patchState({ dailyUsage: response.daily_usage });
}
- dispatch(
- new AiMessageReceivedAction(response.reply, response.design_changes, response.settings_changes, response.section_changes ?? [], response.is_undo, response.entry_changes ?? [], response.gallery_changes ?? []),
- );
}),
+ concatMap((response) =>
+ this.changesMirror
+ .mirror(response.changes ?? [], response.refresh)
+ .pipe(
+ finalize(() =>
+ dispatch(
+ new AiReplyReceivedAction(
+ response.reply,
+ response.changes ?? [],
+ ),
+ ),
+ ),
+ ),
+ ),
catchError((error) => {
console.error('AI assistant error:', error);
if (error?.status === 429) {
- const currentMessages = getState().messages;
- const lastUserMessage = [...currentMessages].reverse().find(m => m.role === 'user');
+ const messages = getState().messages;
patchState({
isLoading: false,
- dailyLimitMessage: error?.error?.message ?? 'Daily limit reached.',
- messages: currentMessages.slice(0, -1),
- pendingInput: lastUserMessage?.content ?? null,
+ dailyLimitMessage:
+ error?.error?.message ?? 'Daily limit reached.',
+ messages: messages.slice(0, -1),
+ pendingInput: action.message,
});
setTimeout(() => patchState({ dailyLimitMessage: null }), 10000);
} else {
@@ -303,398 +221,75 @@ export class AiAssistantState {
);
}
- @Action(AiMessageReceivedAction)
- messageReceived(
- { patchState, getState, dispatch }: StateContext,
- action: AiMessageReceivedAction,
+ @Action(AiReplyReceivedAction)
+ replyReceived(
+ { patchState, getState }: StateContext,
+ action: AiReplyReceivedAction,
) {
- const state = getState();
- const assistantMessage: AiMessage = {
- role: 'assistant',
- content: action.reply,
- };
-
- const site = this.store.selectSnapshot(AppState.getSite) || '';
- const currentSections = this.store.selectSnapshot(SiteSectionsState.getCurrentSiteSections);
-
- let changeHistory: AiChangeHistoryItem[];
- if (action.isUndo) {
- changeHistory = state.changeHistory.slice(0, -1);
- } else {
- const lastUserMessage = [...state.messages].reverse().find((m) => m.role === 'user');
- const newItem: AiChangeHistoryItem = {
- userMessage: lastUserMessage?.content ?? '',
- designChanges: action.designChanges.map((c) => ({
- group: c.group,
- setting: c.setting,
- value: c.value,
- previousValue: c.previous_value ?? null,
- })),
- settingsChanges: action.settingsChanges.map((c) => ({
- group: c.group,
- setting: c.setting,
- value: c.value,
- previousValue: c.previous_value ?? null,
- })),
- sectionChanges: action.sectionChanges.map((c) => {
- if (c.operation === 'reorder') {
- // Capture previousValue from frontend state (reliable) before the reorder dispatch
- const section = currentSections.find((s) => s.name === c.name);
- return {
- operation: c.operation,
- name: c.name,
- previousValue: section != null ? String(section.order) : (c.previous_value ?? null),
- order: c.order,
- };
- }
- return {
- operation: c.operation,
- name: c.name,
- title: c.title,
- property: c.property,
- value: c.value,
- previousValue: c.previous_value ?? null,
- order: c.order,
- };
- }),
- entryChanges: action.entryChanges.map((c) => ({
- operation: c.operation,
- section: c.section,
- entryId: c.entry_id,
- value: c.value,
- previousValue: c.previous_value ?? null,
- description: c.description,
- })),
- galleryChanges: action.galleryChanges.map((c) => ({
- operation: c.operation,
- section: c.section,
- entryId: c.entry_id,
- setting: c.setting,
- fileIndex: c.file_index,
- value: c.value,
- previousValue: c.previous_value ?? null,
- })),
- };
- const hasChanges =
- newItem.designChanges.length > 0 ||
- newItem.settingsChanges.length > 0 ||
- newItem.sectionChanges.length > 0 ||
- newItem.entryChanges.length > 0 ||
- newItem.galleryChanges.length > 0;
- changeHistory = hasChanges ? [...state.changeHistory, newItem] : state.changeHistory;
- }
-
- const updatedMessages = [...state.messages, assistantMessage];
- patchState({
- messages: updatedMessages,
- isLoading: false,
- changeHistory,
- });
+ const messages: AiMessage[] = [
+ ...getState().messages,
+ {
+ role: 'assistant',
+ content: action.reply,
+ ...(action.changes.length ? { changes: action.changes } : {}),
+ },
+ ];
+ patchState({ messages, isLoading: false });
+ this.saveMessages(messages);
+ }
- this.saveToStorage(site, updatedMessages, changeHistory);
+ @Action(UndoAiMessageAction)
+ undoMessage(
+ { patchState, getState }: StateContext,
+ action: UndoAiMessageAction,
+ ) {
+ const message = getState().messages[action.messageIndex];
- for (const change of action.designChanges) {
- dispatch(
- new UpdateSiteTemplateSettingsAction(change.group, {
- [change.setting]: change.value,
- }),
- );
+ if (
+ !message?.changes?.length ||
+ message.undone ||
+ getState().undoingIndex !== null
+ ) {
+ return EMPTY;
}
- for (const change of action.settingsChanges) {
- dispatch(
- new UpdateSiteSettingsAction(change.group, {
- [change.setting]: change.value,
+ patchState({ undoingIndex: action.messageIndex });
+
+ return this.aiAssistantService
+ .undo(message.changes.map((change) => change.change_id))
+ .pipe(
+ concatMap((response) =>
+ this.changesMirror
+ .mirror(response.changes ?? [], response.refresh)
+ .pipe(
+ finalize(() => {
+ const undone =
+ response.undone.length === message.changes.length;
+ const messages = getState().messages.map((m, index) =>
+ index !== action.messageIndex
+ ? m
+ : {
+ ...m,
+ undone,
+ undoRefusal: response.refusal ?? undefined,
+ // Changes undone before a refusal stay undone: only the rest can be undone again.
+ changes: (m.changes ?? []).filter(
+ (change) =>
+ !response.undone.includes(change.change_id),
+ ),
+ },
+ );
+ patchState({ messages, undoingIndex: null });
+ this.saveMessages(messages);
+ }),
+ ),
+ ),
+ catchError((error) => {
+ console.error('AI assistant undo error:', error);
+ patchState({ undoingIndex: null });
+ return EMPTY;
}),
);
- }
-
- const lastHistoryItem = action.isUndo
- ? state.changeHistory[state.changeHistory.length - 1]
- : null;
-
- const createChanges = action.sectionChanges.filter((c) => c.operation === 'create');
- const cloneChanges = action.sectionChanges.filter((c) => c.operation === 'clone');
- const reorderChanges = action.sectionChanges.filter((c) => c.operation === 'reorder');
-
- for (const change of action.sectionChanges) {
- const { operation, name, property, value } = change;
-
- if (operation === 'create') {
- // handled in createChain below
- } else if (operation === 'update') {
- let section = currentSections.find((s) => s.name === name);
-
- // Fallback for rename: after rename the section slug changes, so the old
- // name no longer matches. Search change history for a prior rename of this
- // name and find the section by its current title instead.
- if (!section && property === 'title') {
- for (let i = state.changeHistory.length - 1; i >= 0; i--) {
- const histChange = state.changeHistory[i].sectionChanges.find(
- (c) => c.operation === 'update' && c.property === 'title' && c.name === name,
- );
- if (histChange?.value) {
- section = currentSections.find((s) => s.title === histChange.value);
- if (section) break;
- }
- }
- }
-
- if (section && property) {
- if (property === 'title') {
- dispatch(new RenameSiteSectionAction(section, section.order, { title: value ?? '' }));
- } else if (property === 'published') {
- // Normalize to '0'/'1': AI may return 'yes'/'no' due to ambiguous prompt instructions
- const publishedValue = (value === '1' || value === 'yes' || value === 'true') ? '1' : '0';
- dispatch(new UpdateSiteSectionAction(site, section.order, { '@attributes': { published: publishedValue } }));
- } else if (property === 'type') {
- dispatch(new UpdateSiteSectionAction(site, section.order, { '@attributes': { [property]: value ?? '' } }));
- } else {
- dispatch(new UpdateSiteSectionAction(site, section.order, { [property]: value ?? '' }));
- }
- }
- } else if (operation === 'delete') {
- let section = currentSections.find((s) => s.name === name);
-
- // Fallback: on undo of a create, the server assigns its own slug (e.g. untitled-1).
- // After creation we store that server-assigned name in c.value. Try by value first,
- // then fall back to matching by title.
- if (!section && action.isUndo && lastHistoryItem) {
- const histChange = lastHistoryItem.sectionChanges.find(
- (c) => c.operation === 'create' && (c.name === name || c.value === name),
- );
- if (histChange?.value) {
- section = currentSections.find((s) => s.name === histChange.value);
- }
- if (!section && histChange?.title) {
- section = currentSections.find((s) => s.title === histChange.title);
- }
- }
-
- // Fallback: on undo of a clone, find the cloned section by the name stored in history
- if (!section && action.isUndo && lastHistoryItem) {
- const cloneHistChange = lastHistoryItem.sectionChanges.find(
- (c) => c.operation === 'clone' && (c.name === name || c.value === name),
- );
- if (cloneHistChange?.value) {
- section = currentSections.find((s) => s.name === cloneHistChange.value);
- }
- }
-
- if (section) {
- dispatch(new DeleteSiteSectionAction(section));
- }
- }
- }
-
- // Map from AI-proposed section name → server-assigned slug, populated during createChain.
- // Used by createEntryChain to target the correct section even when the slug differs.
- const sectionNameMap: Record = {};
-
- // Create dispatches — sequential, capturing the server-assigned name for undo support
- const createChain = from(createChanges).pipe(
- concatMap((change) => {
- const sectionsBefore = this.store.selectSnapshot(SiteSectionsState.getCurrentSiteSections);
- return dispatch(new CreateSectionAction({ name: null, title: change.title ?? change.name } as any)).pipe(
- tap(() => {
- const sectionsAfter = this.store.selectSnapshot(SiteSectionsState.getCurrentSiteSections);
- const newSection = sectionsAfter.find((s) => !sectionsBefore.some((b) => b.name === s.name));
- if (newSection) {
- // Record mapping so createEntryChain can resolve the correct section name
- sectionNameMap[change.name] = newSection.name;
-
- if (!action.isUndo) {
- const currentHistory = getState().changeHistory;
- patchState({
- changeHistory: currentHistory.map((entry, idx) => {
- if (idx !== currentHistory.length - 1) return entry;
- return {
- ...entry,
- sectionChanges: entry.sectionChanges.map((c) =>
- c.operation === 'create' && c.name === change.name
- ? { ...c, value: newSection.name }
- : c,
- ),
- };
- }),
- });
- const updated = getState();
- this.saveToStorage(site, updated.messages, updated.changeHistory);
- }
- }
- }),
- );
- }),
- );
-
- // Clone dispatches — sequential, capturing the server-assigned name for undo support.
- // Dispatch CreateSectionAction directly (not CloneSectionAction) because CloneSectionAction
- // doesn't return the inner Observable, causing dispatch() to complete before the HTTP call
- // finishes and making the tap() snapshot miss the newly created section.
- const cloneChain = from(cloneChanges).pipe(
- concatMap((change) => {
- const sectionsBefore = this.store.selectSnapshot(SiteSectionsState.getCurrentSiteSections);
- const sourceSection = sectionsBefore.find((s) => s.name === change.name);
- if (!sourceSection) return EMPTY;
-
- return dispatch(new CreateSectionAction(sourceSection)).pipe(
- tap(() => {
- if (!action.isUndo) {
- // Detect the newly created section and store its name in history for undo
- const sectionsAfter = this.store.selectSnapshot(SiteSectionsState.getCurrentSiteSections);
- const newSection = sectionsAfter.find((s) => !sectionsBefore.some((b) => b.name === s.name));
- if (newSection) {
- const currentHistory = getState().changeHistory;
- patchState({
- changeHistory: currentHistory.map((entry, idx) => {
- if (idx !== currentHistory.length - 1) return entry;
- return {
- ...entry,
- sectionChanges: entry.sectionChanges.map((c) =>
- c.operation === 'clone' && c.name === change.name
- ? { ...c, value: newSection.name }
- : c,
- ),
- };
- }),
- });
- const updated = getState();
- this.saveToStorage(site, updated.messages, updated.changeHistory);
- }
- }
- }),
- );
- }),
- );
-
- const reorderChain = from(reorderChanges).pipe(
- concatMap((change) => {
- // Snapshot live state at THIS moment so we see updates from prior reorders
- const liveSection = this.store
- .selectSnapshot(SiteSectionsState.getCurrentSiteSections)
- .find((s) => s.name === change.name);
-
- if (!liveSection) return EMPTY;
-
- let targetOrder: number | undefined = change.order;
- if (action.isUndo && lastHistoryItem) {
- const histChange = lastHistoryItem.sectionChanges.find(
- (c) => c.operation === 'reorder' && c.name === change.name,
- );
- if (histChange?.previousValue != null) {
- targetOrder = parseInt(histChange.previousValue, 10);
- }
- }
-
- if (targetOrder === undefined) return EMPTY;
-
- return dispatch(new ReOrderSiteSectionsAction(liveSection.order, targetOrder));
- }),
- );
-
- // Entry: update dispatches — each update targets content/description
- const updateEntryChanges = action.entryChanges.filter((c) => c.operation === 'update');
- for (const change of updateEntryChanges) {
- if (change.entry_id && change.section) {
- dispatch(
- new UpdateSectionEntryFromSyncAction(
- `${site}/entry/${change.section}/${change.entry_id}/content/description`,
- change.value ?? '',
- ),
- );
- }
- }
-
- // Entry: delete dispatches (used for undo of creates)
- const deleteEntryChanges = action.entryChanges.filter((c) => c.operation === 'delete');
- for (const change of deleteEntryChanges) {
- if (change.entry_id && change.section) {
- dispatch(new DeleteSectionEntryFromSyncAction(site, change.section, change.entry_id));
- }
- }
-
- // Gallery: update gallery settings and file captions
- for (const change of action.galleryChanges) {
- if (!change.entry_id || !change.section) continue;
-
- if (change.operation === 'update_setting' && change.setting) {
- dispatch(
- new UpdateSectionEntryFromSyncAction(
- `${site}/entry/${change.section}/${change.entry_id}/mediaCacheData/@attributes/${change.setting}`,
- change.value ?? '',
- ),
- );
- } else if (change.operation === 'update_caption' && change.file_index != null) {
- dispatch(
- new UpdateSectionEntryFromSyncAction(
- `${site}/entry/${change.section}/${change.entry_id}/mediaCacheData/file/${change.file_index}/@value`,
- change.value ?? '',
- ),
- );
- }
- }
-
- // Entry: create dispatches — sequential, capturing server-assigned entry ID for undo
- const createEntryChanges = action.entryChanges.filter((c) => c.operation === 'create');
- const createEntryChain = from(createEntryChanges).pipe(
- concatMap((change) => {
- if (!change.section) return EMPTY;
- // Resolve the actual server-assigned section name (in case it was just created above)
- const targetSection = sectionNameMap[change.section] ?? change.section;
- const entriesBefore = this.store
- .selectSnapshot(SectionEntriesState.getCurrentSiteEntries)
- .filter((e) => e.sectionName === targetSection);
-
- return dispatch(
- new AddSectionEntryFromSyncAction(site, targetSection, { tag: null, before_entry: null }),
- ).pipe(
- concatMap(() => {
- const newEntry = this.store
- .selectSnapshot(SectionEntriesState.getCurrentSiteEntries)
- .find(
- (e) =>
- e.sectionName === targetSection &&
- !entriesBefore.some((b) => b.id === e.id),
- );
-
- if (!newEntry) return EMPTY;
-
- // Store the server-assigned entry ID in change history for undo
- if (!action.isUndo) {
- const currentHistory = getState().changeHistory;
- patchState({
- changeHistory: currentHistory.map((entry, idx) => {
- if (idx !== currentHistory.length - 1) return entry;
- return {
- ...entry,
- entryChanges: entry.entryChanges.map((c) =>
- c.operation === 'create' && c.section === change.section && !c.entryId
- ? { ...c, entryId: String(newEntry.id) }
- : c,
- ),
- };
- }),
- });
- if (site) {
- const updated = getState();
- this.saveToStorage(site, updated.messages, updated.changeHistory);
- }
- }
-
- if (!change.description) return EMPTY;
-
- return dispatch(
- new UpdateSectionEntryFromSyncAction(
- `${site}/entry/${targetSection}/${newEntry.id}/content/description`,
- change.description,
- ),
- );
- }),
- );
- }),
- );
-
- return concat(createChain, cloneChain, reorderChain, createEntryChain);
}
@Action(SubmitAiFeedbackAction)
@@ -702,25 +297,29 @@ export class AiAssistantState {
{ getState, patchState }: StateContext,
action: SubmitAiFeedbackAction,
) {
- const state = getState();
- const messages = state.messages;
+ const messages = getState().messages;
const assistantMessage = messages[action.messageIndex];
if (!assistantMessage || assistantMessage.role !== 'assistant') {
return EMPTY;
}
- const userMessage = [...messages.slice(0, action.messageIndex)].reverse().find((m) => m.role === 'user');
- const site = this.store.selectSnapshot(AppState.getSite) || '';
-
+ const userMessage = [...messages.slice(0, action.messageIndex)]
+ .reverse()
+ .find((m) => m.role === 'user');
const updatedMessages = messages.map((m, i) =>
i === action.messageIndex ? { ...m, vote: action.vote } : m,
);
patchState({ messages: updatedMessages });
- this.saveToStorage(site, updatedMessages, state.changeHistory);
+ this.saveMessages(updatedMessages);
return this.aiAssistantService
- .feedback(action.vote, userMessage?.content ?? '', assistantMessage.content, messages.map((m) => ({ role: m.role, content: m.content })))
+ .feedback(
+ action.vote,
+ userMessage?.content ?? '',
+ assistantMessage.content,
+ messages.map((m) => ({ role: m.role, content: m.content })),
+ )
.pipe(
catchError((error) => {
console.error('AI feedback error:', error);
@@ -731,10 +330,39 @@ export class AiAssistantState {
@Action(ClearAiChatAction)
clearChat({ patchState }: StateContext) {
- const site = this.store.selectSnapshot(AppState.getSite);
- if (site != null) {
- localStorage.removeItem(STORAGE_KEY_PREFIX + site);
+ try {
+ localStorage.removeItem(STORAGE_KEY_PREFIX + this.site());
+ } catch {}
+ patchState({
+ messages: [],
+ isLoading: false,
+ undoingIndex: null,
+ dailyLimitMessage: null,
+ pendingInput: null,
+ });
+ }
+
+ private site(): string {
+ return this.store.selectSnapshot(AppState.getSite) || '';
+ }
+
+ private loadMessages(site: string): AiMessage[] {
+ try {
+ const stored = JSON.parse(
+ localStorage.getItem(STORAGE_KEY_PREFIX + site) ?? 'null',
+ );
+ return Array.isArray(stored?.messages) ? stored.messages : [];
+ } catch {
+ return [];
}
- patchState({ messages: [], changeHistory: [], isLoading: false, dailyLimitMessage: null, pendingInput: null });
+ }
+
+ private saveMessages(messages: AiMessage[]) {
+ try {
+ localStorage.setItem(
+ STORAGE_KEY_PREFIX + this.site(),
+ JSON.stringify({ messages }),
+ );
+ } catch {}
}
}
diff --git a/editor/src/app/ai-assistant/ai-changes-mirror.service.spec.ts b/editor/src/app/ai-assistant/ai-changes-mirror.service.spec.ts
new file mode 100644
index 000000000..c2e939d57
--- /dev/null
+++ b/editor/src/app/ai-assistant/ai-changes-mirror.service.spec.ts
@@ -0,0 +1,195 @@
+import { Router } from '@angular/router';
+import { Store } from '@ngxs/store';
+import { of } from 'rxjs';
+
+import { AppState } from '../app-state/app.state';
+import { PreviewService } from '../preview/preview.service';
+import {
+ CreateSiteSettingsAction,
+ DeleteSiteSettingsAction,
+ HandleSiteSettingsChildrenChangesAction,
+} from '../sites/settings/site-settings.actions';
+import {
+ CreateSiteTemplateSettingsAction,
+ HandleSiteTemplateSettingsAction,
+} from '../sites/template-settings/site-template-settings.actions';
+import { ReplaceSiteSectionsAction } from '../sites/sections/sections-state/site-sections.actions';
+import { ReplaceSectionEntriesAction } from '../sites/sections/entries/entries-state/section-entries.actions';
+import {
+ InitSitesAction,
+ SiteContentRefreshedAction,
+} from '../sites/sites-state/sites.actions';
+import { AiChangesMirrorService } from './ai-changes-mirror.service';
+import { AiChange, AiRefresh } from './ai-assistant.service';
+
+describe('AiChangesMirrorService', () => {
+ let dispatched: object[];
+ let reloadIframe: jasmine.Spy;
+ let navigate: jasmine.Spy;
+ let currentSite: string;
+ let mirror: AiChangesMirrorService;
+
+ function change(
+ kind: string,
+ items: { group: string; key: string; value: string }[],
+ subsite = '',
+ ): AiChange {
+ return {
+ change_id: '1',
+ tool: 'tool',
+ subsite,
+ kind,
+ action: 'update',
+ items: items.map((item) => ({ ...item, previous: '' })),
+ };
+ }
+
+ function run(changes: AiChange[], refresh: AiRefresh) {
+ mirror.mirror(changes, refresh).subscribe();
+ }
+
+ function dispatchedOf(
+ type: new (...args: any[]) => T,
+ ): T[] {
+ return dispatched.filter((action): action is T => action instanceof type);
+ }
+
+ beforeEach(() => {
+ dispatched = [];
+ currentSite = '';
+ reloadIframe = jasmine.createSpy('reloadIframe');
+ navigate = jasmine.createSpy('navigate');
+ const store = {
+ dispatch: (actions: object | object[]) => {
+ dispatched.push(...(Array.isArray(actions) ? actions : [actions]));
+ return of(null);
+ },
+ selectSnapshot: (selector: unknown) =>
+ selector === AppState.getSite
+ ? currentSite
+ : [{ name: '' }, { name: 'archive' }],
+ } as unknown as Store;
+
+ mirror = new AiChangesMirrorService(
+ store,
+ { reloadIframe } as unknown as PreviewService,
+ { navigate } as unknown as Router,
+ );
+ });
+
+ it('does nothing for a reply without changes', () => {
+ run([], { site_data: {} });
+
+ expect(dispatched).toEqual([]);
+ });
+
+ it('swaps the fresh parts into the store, then re-renders the content', () => {
+ const sections = [{ name: 'works', site_name: '' }] as any;
+ const entries = { works: [{ id: '1', sectionName: 'works' }] } as any;
+
+ run([change('entry', [{ group: 'works', key: '1.title', value: 'One' }])], {
+ site_data: { '': { sections, entries } },
+ });
+
+ const replacedSections = dispatchedOf(ReplaceSiteSectionsAction)[0];
+ const replacedEntries = dispatchedOf(ReplaceSectionEntriesAction)[0];
+
+ expect(replacedSections.sections).toBe(sections);
+ expect(replacedEntries.entriesBySection).toBe(entries);
+ expect(replacedEntries.sectionNames).toEqual(['works']);
+ expect(dispatched[dispatched.length - 1]).toEqual(
+ new SiteContentRefreshedAction(''),
+ );
+ expect(reloadIframe).not.toHaveBeenCalled();
+ });
+
+ it('re-renders settings and design with the triggers the editor uses for its own edits', () => {
+ run(
+ [
+ change('settings', [
+ { group: 'siteTexts', key: 'siteHeading', value: 'Studio' },
+ ]),
+ change('design', [
+ { group: 'heading', key: 'color', value: '#f00' },
+ { group: 'background', key: 'backgroundColor', value: '#fff' },
+ ]),
+ ],
+ {
+ site_data: {
+ '': { settings: {} as any, template_settings: {} as any },
+ },
+ },
+ );
+
+ expect(dispatchedOf(CreateSiteSettingsAction).length).toBe(1);
+ expect(dispatchedOf(CreateSiteTemplateSettingsAction).length).toBe(1);
+ expect(dispatchedOf(HandleSiteSettingsChildrenChangesAction)).toEqual([
+ new HandleSiteSettingsChildrenChangesAction('siteTexts', {
+ siteHeading: 'Studio',
+ }),
+ ]);
+ // Styles follow the store: only groups the templates re-render get a trigger.
+ expect(dispatchedOf(HandleSiteTemplateSettingsAction)).toEqual([
+ new HandleSiteTemplateSettingsAction('heading', { color: '#f00' }),
+ ]);
+ expect(dispatchedOf(SiteContentRefreshedAction)).toEqual([]);
+ });
+
+ it('reloads the preview where the editor reloads it for its own edits', () => {
+ run(
+ [
+ change('section', [
+ { group: 'works', key: 'type', value: 'portfolio' },
+ ]),
+ ],
+ {
+ site_data: { '': { sections: [] } },
+ },
+ );
+
+ expect(reloadIframe).toHaveBeenCalled();
+ });
+
+ it('leaves the preview of another site alone', () => {
+ run(
+ [
+ change(
+ 'settings',
+ [{ group: 'siteTexts', key: 'siteHeading', value: 'x' }],
+ 'archive',
+ ),
+ ],
+ {
+ site_data: { archive: { settings: {} as any } },
+ },
+ );
+
+ expect(dispatchedOf(CreateSiteSettingsAction).length).toBe(1);
+ expect(dispatchedOf(HandleSiteSettingsChildrenChangesAction)).toEqual([]);
+ });
+
+ it('drops the data of a deleted subsite, and opens the main site when it was open', () => {
+ currentSite = 'archive';
+
+ run(
+ [
+ change('subsite', [
+ { group: 'archive', key: 'subsite', value: 'deleted' },
+ ]),
+ ],
+ {
+ sites: [{ name: '' }] as any,
+ site_data: {},
+ },
+ );
+
+ expect(dispatchedOf(DeleteSiteSettingsAction)).toEqual([
+ new DeleteSiteSettingsAction('archive'),
+ ]);
+ expect(dispatchedOf(InitSitesAction).length).toBe(1);
+ expect(navigate).toHaveBeenCalledWith([], {
+ queryParams: { site: null },
+ queryParamsHandling: 'merge',
+ });
+ });
+});
diff --git a/editor/src/app/ai-assistant/ai-changes-mirror.service.ts b/editor/src/app/ai-assistant/ai-changes-mirror.service.ts
new file mode 100644
index 000000000..e4d3497ea
--- /dev/null
+++ b/editor/src/app/ai-assistant/ai-changes-mirror.service.ts
@@ -0,0 +1,265 @@
+import { Injectable } from '@angular/core';
+import { Router } from '@angular/router';
+import { Store } from '@ngxs/store';
+import { concat, defer, Observable, of } from 'rxjs';
+import { tap } from 'rxjs/operators';
+
+import { AppState } from '../app-state/app.state';
+import { PreviewService } from '../preview/preview.service';
+import {
+ CreateSiteSettingsAction,
+ DeleteSiteSettingsAction,
+ HandleSiteSettingsChildrenChangesAction,
+ UpdateNavigationSiteSettingsAction,
+} from '../sites/settings/site-settings.actions';
+import {
+ CreateSiteTemplateSettingsAction,
+ DeleteSiteTemplateSettingsAction,
+ HandleSiteTemplateSettingsAction,
+} from '../sites/template-settings/site-template-settings.actions';
+import {
+ DeleteSiteSectionsAction,
+ ReplaceSiteSectionsAction,
+} from '../sites/sections/sections-state/site-sections.actions';
+import {
+ DeleteSiteSectionsEntriesAction,
+ ReplaceSectionEntriesAction,
+} from '../sites/sections/entries/entries-state/section-entries.actions';
+import {
+ AddSiteSectionsTagsAction,
+ DeleteSiteSectionsTagsAction,
+} from '../sites/sections/tags/section-tags.actions';
+import {
+ InitSitesAction,
+ SiteContentRefreshedAction,
+} from '../sites/sites-state/sites.actions';
+import { SiteStateModel } from '../sites/sites-state/site-state.model';
+import { AiChange, AiRefresh } from './ai-assistant.service';
+
+/**
+ * Shows changes the AI chat made on the server as the editor shows its own edits: the touched parts of the store are
+ * swapped for the fresh data the server sent, then the preview is re-rendered with the same triggers the editor's
+ * own edits use, and reloaded only where the editor reloads it for its own edits too
+ * (TemplateRerenderService.handleIframeHardReload).
+ */
+@Injectable({ providedIn: 'root' })
+export class AiChangesMirrorService {
+ /** Site settings groups whose components the templates re-render (SiteSettingsState.updateSiteSettings). */
+ private static readonly SETTINGS_RERENDER_GROUPS = [
+ 'siteTexts',
+ 'socialMediaLinks',
+ 'socialMediaButtons',
+ 'banners',
+ 'settings',
+ 'entryLayout',
+ 'pageLayout',
+ ];
+
+ /** Design groups the templates re-render; the rest are styles, which the preview updates from the store. */
+ private static readonly DESIGN_RERENDER_GROUPS = [
+ 'pageLayout',
+ 'pageHeading',
+ 'entryLayout',
+ 'sideBar',
+ 'heading',
+ 'menu',
+ 'tagsMenu',
+ 'css',
+ ];
+
+ /** Section fields after which the editor reloads the preview. */
+ private static readonly RELOADING_SECTION_FIELDS = [
+ 'type',
+ 'params.columns',
+ 'params.entryMaxWidth',
+ 'params.entryPadding',
+ 'params.backgroundVideoEmbed',
+ 'params.backgroundVideoRatio',
+ 'params.marked_items_imageselect',
+ 'params.marked_items_count',
+ ];
+
+ constructor(
+ private store: Store,
+ private previewService: PreviewService,
+ private router: Router,
+ ) {}
+
+ /** Swaps the fresh data into the store and updates the preview. */
+ mirror(changes: AiChange[], refresh: AiRefresh): Observable {
+ if (changes.length === 0) {
+ return of(null);
+ }
+
+ const { triggers, reload } = this.previewUpdates(changes);
+
+ return concat(
+ defer(() => this.store.dispatch(this.storeUpdates(refresh))),
+ defer(() => (triggers.length ? this.store.dispatch(triggers) : of(null))),
+ ).pipe(
+ tap({
+ complete: () => {
+ this.leaveRemovedSite(refresh);
+
+ if (reload) {
+ this.previewService.reloadIframe();
+ }
+ },
+ }),
+ );
+ }
+
+ /** The actions that swap the fresh parts into the store. */
+ private storeUpdates(refresh: AiRefresh): object[] {
+ const actions: object[] = [];
+
+ if (refresh.sites) {
+ const names = refresh.sites.map((site) => site.name);
+
+ this.knownSites()
+ .filter((name) => !names.includes(name))
+ .forEach((name) =>
+ actions.push(
+ new DeleteSiteSettingsAction(name),
+ new DeleteSiteTemplateSettingsAction(name),
+ new DeleteSiteSectionsAction(name),
+ new DeleteSiteSectionsEntriesAction(name),
+ new DeleteSiteSectionsTagsAction(name),
+ ),
+ );
+ actions.push(new InitSitesAction(refresh.sites));
+ }
+
+ for (const [name, data] of Object.entries(refresh.site_data ?? {})) {
+ const site = { name } as SiteStateModel;
+
+ if (data.settings) {
+ actions.push(new CreateSiteSettingsAction(site, data.settings as any));
+ }
+ if (data.template_settings) {
+ actions.push(
+ new CreateSiteTemplateSettingsAction(site, data.template_settings),
+ );
+ }
+ if (data.sections) {
+ actions.push(new ReplaceSiteSectionsAction(name, data.sections));
+ }
+ if (data.entries) {
+ actions.push(
+ new ReplaceSectionEntriesAction(
+ name,
+ data.entries,
+ data.sections ? data.sections.map((section) => section.name) : null,
+ ),
+ );
+ }
+ if (data.tags) {
+ actions.push(new AddSiteSectionsTagsAction(site, data.tags as any));
+ }
+ }
+
+ return actions;
+ }
+
+ /**
+ * The re-render triggers the changes of the site open in the editor need, and whether the preview reloads.
+ */
+ private previewUpdates(changes: AiChange[]): {
+ triggers: object[];
+ reload: boolean;
+ } {
+ const site = this.store.selectSnapshot(AppState.getSite) ?? '';
+ const triggers: object[] = [];
+ let content = false;
+ let reload = false;
+
+ for (const change of changes) {
+ if (change.kind === 'subsite') {
+ content = true;
+ continue;
+ }
+
+ if (change.subsite !== site) {
+ continue;
+ }
+
+ for (const item of change.items) {
+ const payload = { [item.key]: item.value };
+
+ switch (change.kind) {
+ case 'settings':
+ if (item.group === 'media') {
+ reload = true;
+ } else if (item.group === 'navigation') {
+ triggers.push(new UpdateNavigationSiteSettingsAction(payload));
+ } else if (
+ AiChangesMirrorService.SETTINGS_RERENDER_GROUPS.includes(
+ item.group,
+ )
+ ) {
+ triggers.push(
+ new HandleSiteSettingsChildrenChangesAction(
+ item.group,
+ payload,
+ ),
+ );
+ }
+ break;
+ case 'design':
+ if (
+ AiChangesMirrorService.DESIGN_RERENDER_GROUPS.includes(item.group)
+ ) {
+ triggers.push(
+ new HandleSiteTemplateSettingsAction(item.group, payload),
+ );
+ }
+ break;
+ case 'section':
+ content = true;
+ reload ||= AiChangesMirrorService.RELOADING_SECTION_FIELDS.includes(
+ item.key,
+ );
+ break;
+ case 'entry':
+ content = true;
+ break;
+ case 'gallery':
+ content = true;
+ reload ||=
+ item.key.startsWith('background.') ||
+ item.key.endsWith('.gallery.size');
+ break;
+ }
+ }
+ }
+
+ if (content) {
+ triggers.push(new SiteContentRefreshedAction(site));
+ }
+
+ return { triggers, reload };
+ }
+
+ /** Opens the main site when the site open in the editor is gone, e.g. deleted or given a new address. */
+ private leaveRemovedSite(refresh: AiRefresh) {
+ const site = this.store.selectSnapshot(AppState.getSite) ?? '';
+
+ if (
+ refresh.sites &&
+ site !== '' &&
+ !refresh.sites.some((known) => known.name === site)
+ ) {
+ this.router.navigate([], {
+ queryParams: { site: null },
+ queryParamsHandling: 'merge',
+ });
+ }
+ }
+
+ private knownSites(): string[] {
+ return (
+ this.store.selectSnapshot((state) => state.sites as SiteStateModel[]) ??
+ []
+ ).map((site) => site.name);
+ }
+}
diff --git a/editor/src/app/rerender/default-template-rerender.service.ts b/editor/src/app/rerender/default-template-rerender.service.ts
index 735a94bf4..809dc32ba 100644
--- a/editor/src/app/rerender/default-template-rerender.service.ts
+++ b/editor/src/app/rerender/default-template-rerender.service.ts
@@ -42,6 +42,11 @@ export class DefaultTemplateRerenderService extends TemplateRerenderService {
const sitesMenuSubscr = this.handleSitesMenuRerender(iframe);
+ // menus and entries after fresh content from the server, e.g. AI chat changes
+
+ const siteContentRefreshSubscr =
+ this.handleSiteContentRefreshRerender(iframe);
+
// sites sections crud
const siteSectionSubscr = this.handleSiteSectionsRerender(iframe);
@@ -145,6 +150,7 @@ export class DefaultTemplateRerenderService extends TemplateRerenderService {
});
this.unsubscribe(win, [
+ siteContentRefreshSubscr,
iframeHardReloadSubscr,
sitesMenuSubscr,
siteSectionSubscr,
diff --git a/editor/src/app/rerender/mashup/mashup-template-rerender.service.ts b/editor/src/app/rerender/mashup/mashup-template-rerender.service.ts
index 24e73be7a..62c8eaed8 100644
--- a/editor/src/app/rerender/mashup/mashup-template-rerender.service.ts
+++ b/editor/src/app/rerender/mashup/mashup-template-rerender.service.ts
@@ -43,6 +43,11 @@ export class MashupTemplateRerenderService extends TemplateRerenderService {
const sitesMenuSubscr = this.handleSitesMenuRerender(iframe);
+ // menus and entries after fresh content from the server, e.g. AI chat changes
+
+ const siteContentRefreshSubscr =
+ this.handleSiteContentRefreshRerender(iframe);
+
// sites sections crud
const siteSectionSubscr = this.handleSiteSectionsRerender(iframe);
@@ -85,6 +90,7 @@ export class MashupTemplateRerenderService extends TemplateRerenderService {
});
this.unsubscribe(win, [
+ siteContentRefreshSubscr,
iframeHardReloadSubscr,
sitesMenuSubscr,
siteSectionSubscr,
diff --git a/editor/src/app/rerender/messy/messy-template-rerender.service.ts b/editor/src/app/rerender/messy/messy-template-rerender.service.ts
index 0a59683dd..0996311e4 100644
--- a/editor/src/app/rerender/messy/messy-template-rerender.service.ts
+++ b/editor/src/app/rerender/messy/messy-template-rerender.service.ts
@@ -44,6 +44,11 @@ export class MessyTemplateRerenderService extends TemplateRerenderService {
const sitesMenuSubscr = this.handleSitesMenuRerender(iframe);
+ // menus and entries after fresh content from the server, e.g. AI chat changes
+
+ const siteContentRefreshSubscr =
+ this.handleSiteContentRefreshRerender(iframe);
+
// sites sections crud
const siteSectionSubscr = this.handleSiteSectionsRerender(iframe);
@@ -112,6 +117,7 @@ export class MessyTemplateRerenderService extends TemplateRerenderService {
});
this.unsubscribe(win, [
+ siteContentRefreshSubscr,
iframeHardReloadSubscr,
sitesMenuSubscr,
siteSectionSubscr,
diff --git a/editor/src/app/rerender/template-rerender.service.spec.ts b/editor/src/app/rerender/template-rerender.service.spec.ts
index f46f15b41..9d94caf83 100644
--- a/editor/src/app/rerender/template-rerender.service.spec.ts
+++ b/editor/src/app/rerender/template-rerender.service.spec.ts
@@ -6,6 +6,7 @@ import { TemplateRenderService } from '../render/template-render.service';
import { AddSectionEntryFromSyncAction } from '../sites/sections/entries/entries-state/section-entries.actions';
import { HandleSiteSettingsChildrenChangesAction } from '../sites/settings/site-settings.actions';
import { SiteSettingChildrenHandler } from './types/components';
+import { SiteContentRefreshedAction } from '../sites/sites-state/sites.actions';
describe('TemplateRerenderService', () => {
const ENTRY =
@@ -77,6 +78,27 @@ describe('TemplateRerenderService', () => {
});
});
+ it('rerenders the menus and entries after fresh content came from the server', () => {
+ doc.body.innerHTML =
+ ' ';
+ viewData.sitesMenu = '';
+ viewData.sectionsMenu = '';
+ viewData.entries = ENTRY;
+ viewData.portfolioThumbnails = '';
+ service.handleSiteContentRefreshRerender(iframe);
+
+ succeed(new SiteContentRefreshedAction(''));
+
+ expect(doc.querySelector('#sitesMenu .sites')).not.toBeNull();
+ expect(doc.querySelector('#sectionsMenu .sections')).not.toBeNull();
+ expect(doc.querySelectorAll('#pageEntries .xEntry').length).toBe(1);
+ expect(dispatchedEvents()).toEqual([
+ 'sitesMenuRerendered',
+ 'sectionsMenuRerendered',
+ 'entriesRerendered',
+ ]);
+ });
+
it('rerenders site settings on a page without entries', () => {
doc.body.innerHTML = '
';
viewData.siteBanners = '
';
diff --git a/editor/src/app/rerender/template-rerender.service.ts b/editor/src/app/rerender/template-rerender.service.ts
index 53c2da732..5ca961f3e 100644
--- a/editor/src/app/rerender/template-rerender.service.ts
+++ b/editor/src/app/rerender/template-rerender.service.ts
@@ -41,6 +41,7 @@ import {
import {
DeleteSiteAction,
ReOrderSitesAction,
+ SiteContentRefreshedAction,
SwapContentsSitesAction,
UpdateSiteAction,
} from '../sites/sites-state/sites.actions';
@@ -92,7 +93,9 @@ export class TemplateRerenderService {
compList = info.socialMediaComp;
break;
case TemplateRerenderService.SITE_TEXTS:
- if (info.siteTexts) { compList.push(info.siteTexts); }
+ if (info.siteTexts) {
+ compList.push(info.siteTexts);
+ }
break;
}
@@ -216,6 +219,36 @@ export class TemplateRerenderService {
});
}
+ /**
+ * Re-renders the menus, entries and portfolio thumbnails after the store got fresh content of a site from the
+ * server, e.g. after AI chat changes.
+ */
+ public handleSiteContentRefreshRerender(
+ iframe: HTMLIFrameElement,
+ ): Subscription {
+ return this.actions$
+ .pipe(ofActionSuccessful(SiteContentRefreshedAction))
+ .subscribe(() => {
+ const viewData = this.renderService.getViewData();
+
+ replaceContent(iframe.contentDocument, 'sitesMenu', viewData.sitesMenu);
+ replaceContent(
+ iframe.contentDocument,
+ 'sectionsMenu',
+ viewData.sectionsMenu,
+ );
+ replaceContent(iframe.contentDocument, 'pageEntries', viewData.entries);
+ replaceContent(
+ iframe.contentDocument,
+ 'portfolioThumbnails',
+ viewData.portfolioThumbnails,
+ );
+ iframe.contentWindow.dispatchEvent(new Event('sitesMenuRerendered'));
+ iframe.contentWindow.dispatchEvent(new Event('sectionsMenuRerendered'));
+ notifyEntriesRerendered(iframe);
+ });
+ }
+
public handleSiteSectionsRerender(iframe: HTMLIFrameElement): Subscription {
return this.actions$
.pipe(
diff --git a/editor/src/app/rerender/white/white-template-rerender.service.ts b/editor/src/app/rerender/white/white-template-rerender.service.ts
index 6efcb05bb..2abdbdb7b 100644
--- a/editor/src/app/rerender/white/white-template-rerender.service.ts
+++ b/editor/src/app/rerender/white/white-template-rerender.service.ts
@@ -43,6 +43,11 @@ export class WhiteTemplateRerenderService extends TemplateRerenderService {
const sitesMenuSubscr = this.handleSitesMenuRerender(iframe);
+ // menus and entries after fresh content from the server, e.g. AI chat changes
+
+ const siteContentRefreshSubscr =
+ this.handleSiteContentRefreshRerender(iframe);
+
// sites sections crud
const siteSectionSubscr = this.handleSiteSectionsRerender(iframe);
@@ -85,6 +90,7 @@ export class WhiteTemplateRerenderService extends TemplateRerenderService {
});
this.unsubscribe(win, [
+ siteContentRefreshSubscr,
iframeHardReloadSubscr,
sitesMenuSubscr,
siteSectionSubscr,
diff --git a/editor/src/app/sites/sections/entries/entries-state/section-entries.actions.ts b/editor/src/app/sites/sections/entries/entries-state/section-entries.actions.ts
index 840da900a..c554e1ad9 100644
--- a/editor/src/app/sites/sections/entries/entries-state/section-entries.actions.ts
+++ b/editor/src/app/sites/sections/entries/entries-state/section-entries.actions.ts
@@ -53,6 +53,19 @@ export class DeleteSiteSectionsEntriesAction {
constructor(public siteName: string) {}
}
+/**
+ * Replaces the entries of the given sections of a site with fresh ones from the server. With the site's section
+ * names, the entries of sections no longer among them are dropped too.
+ */
+export class ReplaceSectionEntriesAction {
+ static readonly type = 'SECTION_ENTRIES:REPLACE';
+ constructor(
+ public siteName: string,
+ public entriesBySection: { [sectionName: string]: SectionEntry[] },
+ public sectionNames: string[] | null = null,
+ ) {}
+}
+
export class RenameSectionEntriesAction {
static readonly type = 'SECTION_ENTRIES:RENAME';
constructor(
diff --git a/editor/src/app/sites/sections/entries/entries-state/section-entries.state.ts b/editor/src/app/sites/sections/entries/entries-state/section-entries.state.ts
index 397ebbd82..b7fba3389 100644
--- a/editor/src/app/sites/sections/entries/entries-state/section-entries.state.ts
+++ b/editor/src/app/sites/sections/entries/entries-state/section-entries.state.ts
@@ -43,6 +43,7 @@ import {
UpdateEntryGalleryVideoPosterAction,
UpdateEntryGalleryImageCropAction,
SwapContentsSitesSectionsEntriesAction,
+ ReplaceSectionEntriesAction,
} from './section-entries.actions';
import { UserLoginAction } from '../../../../user/user.actions';
import { UpdateSiteSectionAction } from '../../sections-state/site-sections.actions';
@@ -374,6 +375,27 @@ export class SectionEntriesState implements NgxsOnInit {
setState(newState);
}
+ @Action(ReplaceSectionEntriesAction)
+ replaceSectionEntries(
+ { getState, patchState }: StateContext,
+ action: ReplaceSectionEntriesAction,
+ ) {
+ const replaced = Object.keys(action.entriesBySection);
+ const kept = (getState()[action.siteName] ?? []).filter(
+ (entry) =>
+ !replaced.includes(entry.sectionName) &&
+ (action.sectionNames === null ||
+ action.sectionNames.includes(entry.sectionName)),
+ );
+
+ patchState({
+ [action.siteName]: [
+ ...kept,
+ ...replaced.flatMap((section) => action.entriesBySection[section]),
+ ],
+ });
+ }
+
@Action(ResetSectionEntriesAction)
resetSectionEntries({ setState }: StateContext) {
setState({});
diff --git a/editor/src/app/sites/sections/replace-from-server.spec.ts b/editor/src/app/sites/sections/replace-from-server.spec.ts
new file mode 100644
index 000000000..393447193
--- /dev/null
+++ b/editor/src/app/sites/sections/replace-from-server.spec.ts
@@ -0,0 +1,88 @@
+import { StateContext } from '@ngxs/store';
+
+import { SiteSectionsState } from './sections-state/site-sections.state';
+import { ReplaceSiteSectionsAction } from './sections-state/site-sections.actions';
+import { SiteSectionStateModel } from './sections-state/site-sections-state.model';
+import { SectionEntriesState } from './entries/entries-state/section-entries.state';
+import { ReplaceSectionEntriesAction } from './entries/entries-state/section-entries.actions';
+import { SectionEntriesStateModel } from './entries/entries-state/section-entries-state.model';
+
+/** A context over a plain value, as NGXS gives a state's action handlers. */
+function contextOf(initial: T): {
+ context: StateContext;
+ value: () => T;
+} {
+ let value = initial;
+
+ return {
+ context: {
+ getState: () => value,
+ setState: (next: T) => (value = next),
+ patchState: (patch: Partial) => (value = { ...value, ...patch }),
+ } as unknown as StateContext,
+ value: () => value,
+ };
+}
+
+describe('replacing store data with fresh data from the server', () => {
+ it("replaces one site's sections, giving each a type, and keeps the other sites' sections", () => {
+ const other = {
+ name: 'news',
+ site_name: 'archive',
+ } as SiteSectionStateModel;
+ const { context, value } = contextOf([
+ { name: 'works', site_name: '' } as SiteSectionStateModel,
+ other,
+ ]);
+ const state = new SiteSectionsState(
+ undefined as any,
+ undefined as any,
+ undefined as any,
+ undefined as any,
+ );
+
+ state.replaceSiteSections(
+ context,
+ new ReplaceSiteSectionsAction('', [
+ { name: 'prints', site_name: '' } as SiteSectionStateModel,
+ ]),
+ );
+
+ expect(value()).toEqual([
+ other,
+ {
+ name: 'prints',
+ site_name: '',
+ '@attributes': { type: 'default' },
+ } as any,
+ ]);
+ });
+
+ it('replaces the entries of the given sections and drops those of sections that are gone', () => {
+ const { context, value } = contextOf({
+ '': [
+ { id: '1', sectionName: 'works' },
+ { id: '2', sectionName: 'news' },
+ { id: '3', sectionName: 'old' },
+ ] as any,
+ archive: [{ id: '9', sectionName: 'works' }] as any,
+ });
+ const state = new SectionEntriesState(
+ undefined as any,
+ undefined as any,
+ undefined as any,
+ );
+
+ state.replaceSectionEntries(
+ context,
+ new ReplaceSectionEntriesAction(
+ '',
+ { works: [{ id: '4', sectionName: 'works' }] as any },
+ ['works', 'news'],
+ ),
+ );
+
+ expect(value()[''].map((entry) => entry.id)).toEqual(['2', '4']);
+ expect(value()['archive'].map((entry) => entry.id)).toEqual(['9']);
+ });
+});
diff --git a/editor/src/app/sites/sections/sections-state/site-sections.actions.ts b/editor/src/app/sites/sections/sections-state/site-sections.actions.ts
index bbd7d6b56..cf0fb3eb4 100644
--- a/editor/src/app/sites/sections/sections-state/site-sections.actions.ts
+++ b/editor/src/app/sites/sections/sections-state/site-sections.actions.ts
@@ -130,6 +130,17 @@ export class DeleteSiteSectionsAction {
constructor(public siteName: string) {}
}
+/**
+ * Replaces a site's sections with the given ones, fresh from the server.
+ */
+export class ReplaceSiteSectionsAction {
+ static readonly type = 'SITE_SECTIONS:REPLACE';
+ constructor(
+ public siteName: string,
+ public sections: SiteSectionStateModel[],
+ ) {}
+}
+
export class ResetSiteSectionsAction {
static readonly type = 'SITE_SECTIONS:RESET';
}
diff --git a/editor/src/app/sites/sections/sections-state/site-sections.state.ts b/editor/src/app/sites/sections/sections-state/site-sections.state.ts
index 8523163f7..f2cefa85f 100644
--- a/editor/src/app/sites/sections/sections-state/site-sections.state.ts
+++ b/editor/src/app/sites/sections/sections-state/site-sections.state.ts
@@ -31,6 +31,7 @@ import {
AddSiteSectionsAction,
ResetSiteSectionsAction,
InitSiteSectionsAction,
+ ReplaceSiteSectionsAction,
UpdateSiteSectionBackgroundFromSyncAction,
AddSiteSectionAction,
ReOrderSiteSectionsAction,
@@ -779,23 +780,32 @@ export class SiteSectionsState implements NgxsOnInit {
{ setState }: StateContext,
action: InitSiteSectionsAction,
) {
- let sections = action.payload;
+ setState(
+ action.payload instanceof Array
+ ? action.payload.map((section) => SiteSectionsState.withType(section))
+ : action.payload,
+ );
+ }
- if (action.payload instanceof Array) {
- sections = action.payload.map((section) => {
- if (!section['@attributes']) {
- section = { ...section, '@attributes': {} };
- }
- if (!section['@attributes']['type']) {
- section['@attributes'] = {
- ...section['@attributes'],
- type: 'default',
- };
- }
- return section;
- });
- }
+ @Action(ReplaceSiteSectionsAction)
+ replaceSiteSections(
+ { getState, setState }: StateContext,
+ action: ReplaceSiteSectionsAction,
+ ) {
+ setState([
+ ...getState().filter((section) => section.site_name !== action.siteName),
+ ...action.sections.map((section) => SiteSectionsState.withType(section)),
+ ]);
+ }
+
+ /** The section with its type, "default" when the server stores none. */
+ private static withType(
+ section: SiteSectionStateModel,
+ ): SiteSectionStateModel {
+ const attributes = section['@attributes'] ?? {};
- setState(sections);
+ return attributes['type']
+ ? { ...section, '@attributes': attributes }
+ : { ...section, '@attributes': { ...attributes, type: 'default' } };
}
}
diff --git a/editor/src/app/sites/sites-state/sites.actions.ts b/editor/src/app/sites/sites-state/sites.actions.ts
index 5382ca0f4..42c3a6436 100644
--- a/editor/src/app/sites/sites-state/sites.actions.ts
+++ b/editor/src/app/sites/sites-state/sites.actions.ts
@@ -63,3 +63,12 @@ export class SwapContentsSitesAction {
static readonly type = 'SITE:SWAP_CONTENTS';
constructor(public payload: { siteSlugFrom: string; siteSlugTo: string }) {}
}
+
+/**
+ * The store got fresh content of a site from the server (e.g. after AI chat changes): its sites list, sections,
+ * entries or tags. The preview re-renders its menus and entries, as it does after the editor's own edits.
+ */
+export class SiteContentRefreshedAction {
+ static readonly type = 'SITE:CONTENT_REFRESHED';
+ constructor(public siteName: string) {}
+}