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/Configuration/SettingValueValidator.php b/_api_app/app/Configuration/SettingValueValidator.php
new file mode 100644
index 000000000..e7977591a
--- /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', 'toggle' => $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/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..d0c9e97ef 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'),
@@ -53,6 +55,14 @@ 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_approve')) {
+ $state['urls']['aiApprove'] = route('ai_approve');
+ }
+
if (Route::has('ai_quota')) {
$state['urls']['aiQuota'] = route('ai_quota');
}
@@ -139,6 +149,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..1bc3db6f5
--- /dev/null
+++ b/_api_app/app/Http/Middleware/EditorSync.php
@@ -0,0 +1,124 @@
+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 ($mode === self::MARKED_BY_HANDLER) {
+ $latest = $markers->current();
+ } elseif ($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/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/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/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/app/Shared/Storage.php b/_api_app/app/Shared/Storage.php
index 6525f2d52..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),
+ ));
}
/**
@@ -305,30 +348,109 @@ 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;
+ }
+
+ /**
+ * 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 = [])
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/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 178123e1b..0d4d84cff 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();
@@ -1188,11 +1230,19 @@ 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);
-
+ $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;
});
@@ -1210,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/Entries/SectionEntryRenderService.php b/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php
index d1464c5bb..08d25b995 100644
--- a/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php
+++ b/_api_app/app/Sites/Sections/Entries/SectionEntryRenderService.php
@@ -35,6 +35,22 @@ 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';
+ }
+
+ /**
+ * 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
*/
@@ -57,7 +73,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([
@@ -173,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/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/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..c114f7b14 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);
@@ -322,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;
@@ -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)) {
@@ -522,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(
@@ -542,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));
@@ -685,7 +694,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);
@@ -696,7 +709,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;
@@ -715,23 +728,6 @@ private function getUniqueSlug($title)
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/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/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 03d1bb572..aa07a2556 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,10 +167,11 @@ 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);
+ (new SiteSettingsDataService($name))->createDefaultFile();
}
$site = [
@@ -282,7 +284,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);
@@ -317,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);
@@ -329,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/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/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/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..d112189b8 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",
@@ -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",
@@ -1880,6 +1881,81 @@
},
"time": "2026-09-29T12:46:21+00:00"
},
+ {
+ "name": "laravel/mcp",
+ "version": "v1.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/mcp.git",
+ "reference": "ab9238ce6c89095679651dd3d19d67641d73a436"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/mcp/zipball/ab9238ce6c89095679651dd3d19d67641d73a436",
+ "reference": "ab9238ce6c89095679651dd3d19d67641d73a436",
+ "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-26T17:48:48+00:00"
+ },
{
"name": "laravel/prompts",
"version": "v0.3.24",
@@ -7331,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",
@@ -8161,80 +8313,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",
@@ -11126,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",
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/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 28c6ca78a..000000000
--- a/_api_app/tests/Feature/AiChatControllerTest.php
+++ /dev/null
@@ -1,337 +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');
diff --git a/_api_app/tests/Feature/EditorSyncTest.php b/_api_app/tests/Feature/EditorSyncTest.php
new file mode 100644
index 000000000..c14b963ec
--- /dev/null
+++ b/_api_app/tests/Feature/EditorSyncTest.php
@@ -0,0 +1,287 @@
+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']);
+});
+
+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.'));
+
+ 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/MediaFoldersTest.php b/_api_app/tests/Feature/MediaFoldersTest.php
new file mode 100644
index 000000000..6046af5c7
--- /dev/null
+++ b/_api_app/tests/Feature/MediaFoldersTest.php
@@ -0,0 +1,280 @@
+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 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');
+
+ $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();
+});
+
+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([]);
+});
+
+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']);
+});
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/_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('
', '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' => [''],
+]);
diff --git a/_api_app/tests/Unit/SectionEntryRenderServiceTest.php b/_api_app/tests/Unit/SectionEntryRenderServiceTest.php
new file mode 100644
index 000000000..8dfc7f697
--- /dev/null
+++ b/_api_app/tests/Unit/SectionEntryRenderServiceTest.php
@@ -0,0 +1,23 @@
+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],
+]);
+
+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],
+]);
diff --git a/_api_app/tests/Unit/SettingValueValidatorTest.php b/_api_app/tests/Unit/SettingValueValidatorTest.php
new file mode 100644
index 000000000..1fd245bf0
--- /dev/null
+++ b/_api_app/tests/Unit/SettingValueValidatorTest.php
@@ -0,0 +1,53 @@
+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']],
+ 'rgb color' => ['rgb(0, 44, 255)', ['format' => 'color']],
+ 'color picker rgba' => ['rgba(0,0,0,0.5)', ['format' => 'color']],
+ 'css units' => ['12px', ['format' => 'text', 'css_units' => true]],
+ 'percent' => ['50%', ['format' => 'text', 'css_units' => true]],
+ 'blank when allowed' => ['', ['format' => 'text', 'allow_blank' => true]],
+ 'positive integer' => ['3', ['format' => 'text', 'validation' => 'positive_integer']],
+ 'zero' => ['0', ['format' => 'text', 'validation' => 'zero_or_positive_integer']],
+ 'positive number' => ['1.5', ['format' => 'text', 'validation' => 'positive_number']],
+ 'email' => ['me@example.com', ['format' => 'text', 'validation' => 'email']],
+ // The editor doesn't validate these inputs either.
+ 'blank long text' => ['', ['format' => 'longtext']],
+ 'any url' => ['example.com', ['format' => 'url']],
+ 'image file name' => ['photo.jpg', ['format' => 'image']],
+]);
+
+it('refuses values the editor refuses', function (string $value, array $definition, string $error) {
+ 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'],
+ 'blank' => ['', ['format' => 'text'], 'can\'t be empty'],
+ 'not a positive integer' => ['0', ['format' => 'text', 'validation' => 'positive_integer'], 'positive whole number'],
+ 'not an email' => ['me@', ['format' => 'text', 'validation' => 'email'], 'email'],
+]);
+
+it('allows settings that need a plan feature only when the plan includes it', function () {
+ $definition = ['format' => 'longtext', 'requires_feature' => 'custom_javascript'];
+
+ expect((new SettingValueValidator(['custom_javascript']))->error('', $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']],
+]);
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) {
diff --git a/editor/src/app/ai-assistant/ai-assistant.actions.ts b/editor/src/app/ai-assistant/ai-assistant.actions.ts
index 4f7761c33..2cd7d06eb 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 { AiApproval, AiChange } from './ai-assistant.service';
+
export class ToggleAiAssistantAction {
static readonly type = 'AI_ASSISTANT:TOGGLE';
}
@@ -7,19 +9,31 @@ 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[],
+ public approvals: AiApproval[] = [],
+ ) {}
+}
+
+/** Approves or rejects a delete the assistant message at this index waits on. */
+export class ResolveAiApprovalAction {
+ static readonly type = 'AI_ASSISTANT:RESOLVE_APPROVAL';
+ constructor(
+ public messageIndex: number,
+ public approvalId: string,
+ public approve: boolean,
) {}
}
+/** 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 +49,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..e11dab550 100644
--- a/editor/src/app/ai-assistant/ai-assistant.component.ts
+++ b/editor/src/app/ai-assistant/ai-assistant.component.ts
@@ -15,6 +15,8 @@ import {
ClearAiChatAction,
ToggleAiAssistantAction,
SubmitAiFeedbackAction,
+ UndoAiMessageAction,
+ ResolveAiApprovalAction,
} from './ai-assistant.actions';
import { AppState } from '../app-state/app.state';
@@ -92,7 +94,11 @@ const EXAMPLE_PROMPTS = [
}
- @for (msg of messages$ | async; track $index) {
+ @for (
+ msg of messages$ | async;
+ track $index;
+ let msgIndex = $index
+ ) {
@if (msg.role === 'assistant') {
+ @for (approval of msg.approvals ?? []; track approval.id) {
+
+
{{
+ approval.summary
+ }}
+ @switch (approval.status) {
+ @case ('pending') {
+
+
+
+
+ }
+ @case ('approved') {
+
Deleted
+ }
+ @case ('rejected') {
+
Kept
+ }
+ @case ('expired') {
+
This request expired; ask again.
+ }
+ @case ('failed') {
+
{{ approval.error }}
+ }
+ }
+
+ }
+ @if (msg.changes?.length || msg.undone) {
+
+ @if (msg.changes?.length) {
+
+ } @else {
+
Undone
+ }
+ @if (msg.undoRefusal) {
+
{{ msg.undoRefusal }}
+ }
+
+ }
@if (hasFeedback$ | async) {
@@ -471,6 +558,87 @@ const EXAMPLE_PROMPTS = [
font-weight: 600;
}
+ .ai-approval {
+ margin-top: 8px;
+ padding: 6px 8px;
+ border: 1px solid #e3c9c9;
+ border-radius: 4px;
+ background: #fdf6f6;
+ font-size: 12px;
+ }
+
+ .ai-approval-actions {
+ display: flex;
+ gap: 6px;
+ margin-top: 6px;
+ }
+
+ .ai-approve-btn,
+ .ai-reject-btn {
+ border-radius: 3px;
+ padding: 2px 10px;
+ font-size: 12px;
+ cursor: pointer;
+ }
+
+ .ai-approve-btn {
+ border: 1px solid #a33;
+ background: #a33;
+ color: #fff;
+ }
+
+ .ai-reject-btn {
+ border: 1px solid #ccc;
+ background: none;
+ color: #555;
+ }
+
+ .ai-approve-btn:disabled,
+ .ai-reject-btn:disabled {
+ cursor: default;
+ opacity: 0.6;
+ }
+
+ .ai-approval-status {
+ display: block;
+ margin-top: 4px;
+ color: #777;
+ }
+
+ .ai-approval-status--error {
+ color: #a33;
+ }
+
+ .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 +676,8 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy {
isOpen$: Observable;
messages$: Observable;
isLoading$: Observable;
+ undoingIndex$: Observable;
+ resolvingApprovalId$: Observable;
dailyLimitMessage$: Observable;
dailyUsage$: Observable<{ count: number; limit: number } | null>;
hasFeedback$: Observable;
@@ -527,6 +697,10 @@ 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.resolvingApprovalId$ = this.store.select(
+ AiAssistantState.resolvingApprovalId,
+ );
this.dailyLimitMessage$ = this.store.select(
AiAssistantState.dailyLimitMessage,
);
@@ -592,6 +766,16 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy {
this.store.dispatch(new ClearAiChatAction());
}
+ resolveApproval(messageIndex: number, approvalId: string, approve: boolean) {
+ this.store.dispatch(
+ new ResolveAiApprovalAction(messageIndex, approvalId, approve),
+ );
+ }
+
+ 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..8c051ca5b 100644
--- a/editor/src/app/ai-assistant/ai-assistant.service.ts
+++ b/editor/src/app/ai-assistant/ai-assistant.service.ts
@@ -1,55 +1,78 @@
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[];
+}
+
+/** 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 AiEntryChangeItem {
- operation: 'create' | 'update' | 'delete';
- section?: string;
- entry_id?: string;
- value?: string;
- previous_value?: string | null;
- description?: string;
+/** 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 AiGalleryChangeItem {
- operation: 'update_setting' | 'update_caption';
- section?: string;
- entry_id?: string;
- setting?: string;
- file_index?: number;
- value?: string;
- previous_value?: string | null;
+/** A delete the chat parked on the server until the user approves or rejects it. */
+export interface AiApproval {
+ id: string;
+ tool: string;
+ summary: string;
+}
+
+export interface AiApproveResponse {
+ approved: boolean;
+ /** The tool's message when it refused, e.g. because the item is gone. */
+ error?: string | null;
+ changes?: AiChange[];
+ refresh?: AiRefresh;
}
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[];
+ approvals?: AiApproval[];
+ 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 +83,34 @@ 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));
+ }
+
+ /** Runs or drops a delete the chat parked for approval, without the AI. */
+ approve(id: string, approve: boolean): Observable {
+ return this.appStateService
+ .sync('aiApprove', { id, approve }, 'POST')
+ .pipe(map((response: any) => response.data as AiApproveResponse));
+ }
+
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 +120,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..4ae33f30f
--- /dev/null
+++ b/editor/src/app/ai-assistant/ai-assistant.state.spec.ts
@@ -0,0 +1,288 @@
+import { StateContext, Store } from '@ngxs/store';
+import { of, throwError } from 'rxjs';
+
+import { AppState } from '../app-state/app.state';
+import {
+ AiReplyReceivedAction,
+ SendAiMessageAction,
+ UndoAiMessageAction,
+ ResolveAiApprovalAction,
+} 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,
+ resolvingApprovalId: null,
+ loadedSite: '',
+ dailyLimitMessage: null,
+ dailyUsage: null,
+ pendingInput: null,
+ };
+ service = jasmine.createSpyObj('AiAssistantService', [
+ 'chat',
+ 'undo',
+ 'approve',
+ ]);
+ 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],
+ }),
+ );
+ });
+
+ describe('deletes waiting for approval', () => {
+ const APPROVAL = {
+ id: 'A1',
+ tool: 'delete-section',
+ summary: 'Delete section "works"',
+ };
+ const DELETE: AiChange = {
+ ...CHANGE,
+ change_id: '9',
+ tool: 'delete-section',
+ action: 'delete',
+ };
+
+ beforeEach(() => {
+ model.messages = [
+ {
+ role: 'assistant',
+ content: 'Approve below.',
+ approvals: [{ ...APPROVAL, status: 'pending' }],
+ },
+ ];
+ });
+
+ it('keeps the approvals of a reply as pending', () => {
+ model.messages = [];
+ service.chat.and.returnValue(
+ of({
+ reply: 'Approve below.',
+ changes: [],
+ approvals: [APPROVAL],
+ refresh: { site_data: {} },
+ }),
+ );
+
+ state
+ .sendMessage(context, new SendAiMessageAction('Delete works'))
+ .subscribe();
+
+ expect(model.messages[1].approvals).toEqual([
+ { ...APPROVAL, status: 'pending' },
+ ]);
+ });
+
+ it('deletes on approval, shows it in the editor, and lets the reply undo it', () => {
+ service.approve.and.returnValue(
+ of({
+ approved: true,
+ error: null,
+ changes: [DELETE],
+ refresh: REFRESH,
+ }),
+ );
+
+ state
+ .resolveApproval(context, new ResolveAiApprovalAction(0, 'A1', true))
+ .subscribe();
+
+ expect(service.approve).toHaveBeenCalledWith('A1', true);
+ expect(mirror).toHaveBeenCalledWith([DELETE], REFRESH);
+ expect(model.messages[0].approvals![0].status).toBe('approved');
+ expect(model.messages[0].changes).toEqual([DELETE]);
+ expect(model.resolvingApprovalId).toBeNull();
+ });
+
+ it('keeps everything on rejection', () => {
+ service.approve.and.returnValue(of({ approved: false }));
+
+ state
+ .resolveApproval(context, new ResolveAiApprovalAction(0, 'A1', false))
+ .subscribe();
+
+ expect(mirror).not.toHaveBeenCalled();
+ expect(model.messages[0].approvals![0].status).toBe('rejected');
+ expect(model.messages[0].changes).toBeUndefined();
+ });
+
+ it('tells when the approval expired', () => {
+ service.approve.and.returnValue(throwError(() => ({ status: 404 })));
+
+ state
+ .resolveApproval(context, new ResolveAiApprovalAction(0, 'A1', true))
+ .subscribe();
+
+ expect(model.messages[0].approvals![0].status).toBe('expired');
+ });
+
+ it('shows why an approved delete failed', () => {
+ service.approve.and.returnValue(
+ of({
+ approved: true,
+ error: 'Section "works" not found.',
+ changes: [],
+ refresh: { site_data: {} },
+ }),
+ );
+
+ state
+ .resolveApproval(context, new ResolveAiApprovalAction(0, 'A1', true))
+ .subscribe();
+
+ expect(model.messages[0].approvals![0]).toEqual(
+ jasmine.objectContaining({
+ status: 'failed',
+ error: 'Section "works" not found.',
+ }),
+ );
+ });
+ });
+});
diff --git a/editor/src/app/ai-assistant/ai-assistant.state.ts b/editor/src/app/ai-assistant/ai-assistant.state.ts
index 1f869ef96..43d4f2bde 100644
--- a/editor/src/app/ai-assistant/ai-assistant.state.ts
+++ b/editor/src/app/ai-assistant/ai-assistant.state.ts
@@ -1,96 +1,58 @@
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 { UserLoginAction } from '../user/user.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';
+ AiApproval,
+ AiAssistantService,
+ AiChange,
+} from './ai-assistant.service';
+import { AiChangesMirrorService } from './ai-changes-mirror.service';
import {
ToggleAiAssistantAction,
SendAiMessageAction,
- AiMessageReceivedAction,
+ AiReplyReceivedAction,
+ UndoAiMessageAction,
+ ResolveAiApprovalAction,
ClearAiChatAction,
SubmitAiFeedbackAction,
FetchAiQuotaAction,
AiQuotaFetchedAction,
} from './ai-assistant.actions';
-import { UserLoginAction } from '../user/user.actions';
+
+/** A delete an assistant reply waits on, and what became of it. */
+export interface AiMessageApproval extends AiApproval {
+ status: 'pending' | 'approved' | 'rejected' | 'expired' | 'failed';
+ /** Why the approved delete failed, e.g. because the item is gone. */
+ error?: string;
+}
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;
+ /** Deletes the reply waits on; approving one adds its changes to the reply's. */
+ approvals?: AiMessageApproval[];
}
export interface AiAssistantStateModel {
isOpen: boolean;
messages: AiMessage[];
isLoading: boolean;
- changeHistory: AiChangeHistoryItem[];
+ /** The index of the message whose changes are being undone. */
+ undoingIndex: number | null;
+ /** The id of the approval being resolved. */
+ resolvingApprovalId: string | null;
loadedSite: string | null;
dailyLimitMessage: string | null;
dailyUsage: { count: number; limit: number } | null;
@@ -103,13 +65,21 @@ const defaults: AiAssistantStateModel = {
isOpen: false,
messages: [],
isLoading: false,
- changeHistory: [],
+ undoingIndex: null,
+ resolvingApprovalId: 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. Deletes wait for
+ * the user's approval: a reply lists them, and approving runs one on the server, without the AI. Each site's
+ * conversation is kept in the browser.
+ */
@State({
name: 'aiAssistant',
defaults,
@@ -131,6 +101,16 @@ export class AiAssistantState {
return state.isLoading;
}
+ @Selector()
+ static undoingIndex(state: AiAssistantStateModel) {
+ return state.undoingIndex;
+ }
+
+ @Selector()
+ static resolvingApprovalId(state: AiAssistantStateModel) {
+ return state.resolvingApprovalId;
+ }
+
@Selector()
static dailyLimitMessage(state: AiAssistantStateModel) {
return state.dailyLimitMessage;
@@ -149,46 +129,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 +177,10 @@ export class AiAssistantState {
}
@Action(AiQuotaFetchedAction)
- quotaFetched({ patchState }: StateContext, action: AiQuotaFetchedAction) {
+ quotaFetched(
+ { patchState }: StateContext,
+ action: AiQuotaFetchedAction,
+ ) {
patchState({ dailyUsage: action.usage });
}
@@ -214,85 +189,52 @@ 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 ?? [],
+ response.approvals ?? [],
+ ),
+ ),
+ ),
+ ),
+ ),
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 +245,164 @@ 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 } : {}),
+ ...(action.approvals.length
+ ? {
+ approvals: action.approvals.map((approval) => ({
+ ...approval,
+ status: 'pending' as const,
+ })),
+ }
+ : {}),
+ },
+ ];
+ 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));
- }),
+ @Action(ResolveAiApprovalAction)
+ resolveApproval(
+ { patchState, getState }: StateContext,
+ action: ResolveAiApprovalAction,
+ ) {
+ const approval = getState().messages[action.messageIndex]?.approvals?.find(
+ (pending) => pending.id === action.approvalId,
);
- // 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 ?? '',
- ),
- );
- }
+ if (
+ approval?.status !== 'pending' ||
+ getState().resolvingApprovalId !== null
+ ) {
+ return EMPTY;
}
- // 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);
- }
- }
+ patchState({ resolvingApprovalId: action.approvalId });
+
+ const settle = (
+ update: Partial,
+ changes: AiChange[] = [],
+ ) => {
+ const messages = getState().messages.map((message, index) =>
+ index !== action.messageIndex
+ ? message
+ : {
+ ...message,
+ // The approved delete can be undone with the reply's other changes.
+ ...(changes.length
+ ? {
+ changes: [...(message.changes ?? []), ...changes],
+ undone: false,
+ }
+ : {}),
+ approvals: (message.approvals ?? []).map((pending) =>
+ pending.id === action.approvalId
+ ? { ...pending, ...update }
+ : pending,
+ ),
+ },
+ );
+ patchState({ messages, resolvingApprovalId: null });
+ this.saveMessages(messages);
+ };
- if (!change.description) return EMPTY;
+ return this.aiAssistantService
+ .approve(action.approvalId, action.approve)
+ .pipe(
+ concatMap((response) => {
+ if (!response.approved) {
+ settle({ status: 'rejected' });
+ return EMPTY;
+ }
- return dispatch(
- new UpdateSectionEntryFromSyncAction(
- `${site}/entry/${targetSection}/${newEntry.id}/content/description`,
- change.description,
+ const changes = response.changes ?? [];
+
+ return this.changesMirror
+ .mirror(changes, response.refresh ?? { site_data: {} })
+ .pipe(
+ finalize(() =>
+ settle(
+ response.error
+ ? { status: 'failed', error: response.error }
+ : { status: 'approved' },
+ changes,
+ ),
),
);
- }),
- );
- }),
- );
-
- return concat(createChain, cloneChain, reorderChain, createEntryChain);
+ }),
+ catchError((error) => {
+ if (error?.status === 404) {
+ settle({ status: 'expired' });
+ } else {
+ console.error('AI assistant approval error:', error);
+ patchState({ resolvingApprovalId: null });
+ }
+ return EMPTY;
+ }),
+ );
}
@Action(SubmitAiFeedbackAction)
@@ -702,25 +410,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 +443,40 @@ 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,
+ resolvingApprovalId: 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/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/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'));
});
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/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/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) {}
+}
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,