diff --git a/panel/src/components/Views/PreviewView.vue b/panel/src/components/Views/PreviewView.vue index a20c4e4fdf..27df5f55aa 100644 --- a/panel/src/components/Views/PreviewView.vue +++ b/panel/src/components/Views/PreviewView.vue @@ -91,10 +91,16 @@ - {{ $t("lock.unsaved.empty") }} - - {{ $t("edit") }} - + + @@ -223,6 +229,8 @@ export default { flex-grow: 1; justify-content: center; flex-direction: column; + text-align: center; + padding-inline: var(--spacing-3); gap: var(--spacing-6); --button-color-text: var(--color-text); } diff --git a/src/Api/Controller/Changes.php b/src/Api/Controller/Changes.php index 7ebe9cdf68..99e207ca88 100644 --- a/src/Api/Controller/Changes.php +++ b/src/Api/Controller/Changes.php @@ -3,7 +3,9 @@ namespace Kirby\Api\Controller; use Kirby\Cms\ModelWithContent; +use Kirby\Content\Lock; use Kirby\Content\VersionId; +use Kirby\Filesystem\F; use Kirby\Form\Form; /** @@ -18,6 +20,20 @@ */ class Changes { + /** + * Cleans up legacy lock files. The `discard`, `publish` and `save` actions + * are perfect for this cleanup job. They will be stopped early if + * the lock is still active and otherwise, we can use them to clean + * up outdated .lock files to keep the content folders clean. This + * can be removed as soon as old .lock files should no longer be around. + * + * @todo Remove in 6.0.0 + */ + protected static function cleanup(ModelWithContent $model): void + { + F::remove(Lock::legacyFile($model)); + } + /** * Discards unsaved changes by deleting the changes version */ @@ -25,6 +41,10 @@ public static function discard(ModelWithContent $model): array { $model->version(VersionId::changes())->delete('current'); + // Removes the old .lock file when it is no longer needed + // @todo Remove in 6.0.0 + static::cleanup($model); + return [ 'status' => 'ok' ]; @@ -41,6 +61,10 @@ public static function publish(ModelWithContent $model, array $input): array input: $input ); + // Removes the old .lock file when it is no longer needed + // @todo Remove in 6.0.0 + static::cleanup($model); + // get the changes version $changes = $model->version(VersionId::changes()); @@ -77,6 +101,10 @@ public static function save(ModelWithContent $model, array $input): array $changes = $model->version(VersionId::changes()); $latest = $model->version(VersionId::latest()); + // Removes the old .lock file when it is no longer needed + // @todo Remove in 6.0.0 + static::cleanup($model); + // combine the new field changes with the // last published state $changes->save( diff --git a/src/Content/Lock.php b/src/Content/Lock.php index c0de6d74fa..5d70c26c15 100644 --- a/src/Content/Lock.php +++ b/src/Content/Lock.php @@ -5,7 +5,9 @@ use Kirby\Cms\App; use Kirby\Cms\Language; use Kirby\Cms\Languages; +use Kirby\Cms\ModelWithContent; use Kirby\Cms\User; +use Kirby\Data\Data; use Kirby\Toolkit\Str; /** @@ -27,6 +29,7 @@ class Lock public function __construct( protected User|null $user = null, protected int|null $modified = null, + protected bool $legacy = false ) { } @@ -39,6 +42,11 @@ public static function for( Version $version, Language|string $language = 'default' ): static { + + if ($legacy = static::legacy($version->model())) { + return $legacy; + } + // wildcard to search for a lock in any language // the first locked one will be preferred if ($language === '*') { @@ -87,13 +95,21 @@ public function isActive(): bool } /** - * Check if content locking is enabled at all + * Checks if content locking is enabled at all */ public static function isEnabled(): bool { return App::instance()->option('content.locking', true) !== false; } + /** + * Checks if the lock is coming from an old .lock file + */ + public function isLegacy(): bool + { + return $this->legacy; + } + /** * Checks if the lock is actually locked */ @@ -124,6 +140,52 @@ public function isLocked(): bool return true; } + /** + * Looks for old .lock files and tries to create a + * usable lock instance from them + */ + public static function legacy(ModelWithContent $model): static|null + { + $kirby = $model->kirby(); + $file = static::legacyFile($model); + $id = '/' . $model->id(); + + // no legacy lock file? no lock. + if (file_exists($file) === false) { + return null; + } + + $data = Data::read($file, 'yml', fail: false)[$id] ?? []; + + // no valid lock entry? no lock. + if (isset($data['lock']) === false) { + return null; + } + + // has the lock been unlocked? no lock. + if (isset($data['unlock']) === true) { + return null; + } + + return new static( + user: $kirby->user($data['lock']['user']), + modified: $data['lock']['time'], + legacy: true + ); + } + + /** + * Returns the absolute path to a legacy lock file + */ + public static function legacyFile(ModelWithContent $model): string + { + $root = match ($model::CLASS_ALIAS) { + 'file' => dirname($model->root()), + default => $model->root() + }; + return $root . '/.lock'; + } + /** * Returns the timestamp when the locked content has * been updated. You can pass a format to get a useful, @@ -147,6 +209,7 @@ public function modified( public function toArray(): array { return [ + 'isLegacy' => $this->isLegacy(), 'isLocked' => $this->isLocked(), 'modified' => $this->modified('c'), 'user' => [ diff --git a/tests/Content/LockTest.php b/tests/Content/LockTest.php index 0055d3cbd1..d4871a87f4 100644 --- a/tests/Content/LockTest.php +++ b/tests/Content/LockTest.php @@ -5,6 +5,7 @@ use Kirby\Cms\App; use Kirby\Cms\Language; use Kirby\Cms\User; +use Kirby\Data\Data; /** * @coversDefaultClass \Kirby\Content\Lock @@ -155,6 +156,28 @@ public function testForWithLanguageWildcard() $this->assertSame('admin', $lock->user()->id()); } + /** + * @covers ::for + */ + public function testForWithLegacyLock() + { + $page = $this->app->page('test'); + $file = $page->root() . '/.lock'; + + Data::write($file, [ + '/' . $page->id() => [ + 'lock' => [ + 'user' => 'editor', + 'time' => $time = time() + ] + ] + ], 'yml'); + + $lock = Lock::for($page->version('changes')); + $this->assertInstanceOf(Lock::class, $lock); + $this->assertTrue($lock->isLocked()); + } + /** * @covers ::isActive */ @@ -215,6 +238,18 @@ public function testIsEnabledWhenDisabled() $this->assertFalse(Lock::isEnabled()); } + /** + * @covers ::isLegacy + */ + public function testIsLegacy() + { + $lock = new Lock(); + $this->assertFalse($lock->isLegacy()); + + $lock = new Lock(legacy: true); + $this->assertTrue($lock->isLegacy()); + } + /** * @covers ::isLocked */ @@ -292,6 +327,102 @@ public function testIsLockedWithDifferentUserAndOldTimestamp() $this->assertFalse($lock->isLocked()); } + /** + * @covers ::legacy + */ + public function testLegacy() + { + $page = $this->app->page('test'); + $file = $page->root() . '/.lock'; + + Data::write($file, [ + '/' . $page->id() => [ + 'lock' => [ + 'user' => 'editor', + 'time' => $time = time() + ] + ] + ], 'yml'); + + $lock = Lock::legacy($page); + + $this->assertInstanceOf(Lock::class, $lock); + $this->assertTrue($lock->isLocked()); + $this->assertTrue($lock->isLegacy()); + $this->assertSame($this->app->user('editor'), $lock->user()); + $this->assertSame($time, $lock->modified()); + } + + /** + * @covers ::legacy + */ + public function testLegacyWithoutLockInfo() + { + $page = $this->app->page('test'); + $file = $page->root() . '/.lock'; + + Data::write($file, [], 'yml'); + + $lock = Lock::legacy($page); + $this->assertNull($lock); + } + + /** + * @covers ::legacy + */ + public function testLegacyWithOutdatedFile() + { + $page = $this->app->page('test'); + $file = $page->root() . '/.lock'; + + Data::write($file, [ + '/' . $page->id() => [ + 'lock' => [ + 'user' => 'editor', + 'time' => time() - 60 * 60 * 24 + ], + ] + ], 'yml'); + + $lock = Lock::legacy($page); + + $this->assertInstanceOf(Lock::class, $lock); + $this->assertFalse($lock->isLocked()); + } + + /** + * @covers ::legacy + */ + public function testLegacyWithUnlockedFile() + { + $page = $this->app->page('test'); + $file = $page->root() . '/.lock'; + + Data::write($file, [ + '/' . $page->id() => [ + 'lock' => [ + 'user' => 'editor', + 'time' => time() + ], + 'unlock' => ['admin'] + ] + ], 'yml'); + + $lock = Lock::legacy($page); + $this->assertNull($lock); + } + + /** + * @covers ::legacyFile + */ + public function testLegacyFile() + { + $page = $this->app->page('test'); + $expected = $page->root() . '/.lock'; + + $this->assertSame($expected, Lock::legacyFile($page)); + } + /** * @covers ::modified */ @@ -319,6 +450,7 @@ public function testToArray() ); $this->assertSame([ + 'isLegacy' => false, 'isLocked' => true, 'modified' => date('c', $modified), 'user' => [ diff --git a/tests/Panel/Areas/SiteTest.php b/tests/Panel/Areas/SiteTest.php index 05957c5989..d2f576f7ba 100644 --- a/tests/Panel/Areas/SiteTest.php +++ b/tests/Panel/Areas/SiteTest.php @@ -38,6 +38,7 @@ public function testPage(): void $this->assertSame('default', $props['blueprint']); $this->assertSame([ + 'isLegacy' => false, 'isLocked' => false, 'modified' => null, 'user' => [ @@ -95,6 +96,7 @@ public function testPageFile(): void $this->assertSame('image', $props['blueprint']); $this->assertSame([ + 'isLegacy' => false, 'isLocked' => false, 'modified' => null, 'user' => [ @@ -190,6 +192,7 @@ public function testSiteFile(): void $this->assertSame('image', $props['blueprint']); $this->assertSame([ + 'isLegacy' => false, 'isLocked' => false, 'modified' => null, 'user' => [