Initial commit
This commit is contained in:
commit
efa5624dab
687 changed files with 162710 additions and 0 deletions
687
kirby/src/Content/Version.php
Normal file
687
kirby/src/Content/Version.php
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\Languages;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Site;
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Form\Fields;
|
||||
use Kirby\Http\Uri;
|
||||
|
||||
/**
|
||||
* The Version class handles all actions for a single
|
||||
* version and is identified by a VersionId instance
|
||||
*
|
||||
* @package Kirby Content
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class Version
|
||||
{
|
||||
public function __construct(
|
||||
protected ModelWithContent $model,
|
||||
protected VersionId $id
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Content object for the given language
|
||||
*/
|
||||
public function content(Language|string $language = 'default'): Content
|
||||
{
|
||||
$language = Language::ensure($language);
|
||||
$fields = $this->read($language) ?? [];
|
||||
|
||||
// This is where we merge content from the default language
|
||||
// to provide a fallback for missing/untranslated fields.
|
||||
//
|
||||
// @todo This is the critical point that needs to be removed/refactored
|
||||
// in the future, to provide multi-language support with truly
|
||||
// individual versions of pages and no longer enforce the fallback.
|
||||
if ($language->isDefault() === false) {
|
||||
// merge the fields with the default language
|
||||
$fields = [
|
||||
...$this->read('default') ?? [],
|
||||
...$fields
|
||||
];
|
||||
}
|
||||
|
||||
// remove fields that should not be used for the Content object
|
||||
unset($fields['lock']);
|
||||
|
||||
return new Content(
|
||||
parent: $this->model,
|
||||
data: $fields,
|
||||
normalize: false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides simplified access to the absolute content file path.
|
||||
* This should stay an internal method and be removed as soon as
|
||||
* the dependency on file storage methods is resolved more clearly.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public function contentFile(Language|string $language = 'default'): string
|
||||
{
|
||||
return $this->model->storage()->contentFile(
|
||||
$this->id,
|
||||
Language::ensure($language)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that all field names are converted to lower
|
||||
* case to be able to merge and filter them properly
|
||||
*/
|
||||
protected function convertFieldNamesToLowerCase(array $fields): array
|
||||
{
|
||||
return array_change_key_case($fields, CASE_LOWER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new version for the given language
|
||||
* @todo Convert to a static method that creates the version initially with all relevant languages
|
||||
*
|
||||
* @param array<string, string> $fields Content fields
|
||||
*/
|
||||
public function create(
|
||||
array $fields,
|
||||
Language|string $language = 'default'
|
||||
): void {
|
||||
$language = Language::ensure($language);
|
||||
|
||||
// check if creating is allowed
|
||||
VersionRules::create($this, $fields, $language);
|
||||
|
||||
// track the changes
|
||||
if ($this->id->is('changes') === true) {
|
||||
(new Changes())->track($this->model);
|
||||
}
|
||||
|
||||
$this->model->storage()->create(
|
||||
versionId: $this->id,
|
||||
language: $language,
|
||||
fields: $this->prepareFieldsBeforeWrite($fields, $language)
|
||||
);
|
||||
|
||||
// make sure that an older version does not exist in the cache
|
||||
VersionCache::remove($this, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a version for a specific language
|
||||
*/
|
||||
public function delete(Language|string $language = 'default'): void
|
||||
{
|
||||
if ($language === '*') {
|
||||
foreach (Languages::ensure() as $language) {
|
||||
$this->delete($language);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$language = Language::ensure($language);
|
||||
|
||||
// check if deleting is allowed
|
||||
VersionRules::delete($this, $language);
|
||||
|
||||
$this->model->storage()->delete($this->id, $language);
|
||||
|
||||
// untrack the changes if the version does no longer exist
|
||||
// in any of the available languages
|
||||
if (
|
||||
$this->id->is('changes') === true &&
|
||||
$this->exists('*') === false
|
||||
) {
|
||||
(new Changes())->untrack($this->model);
|
||||
}
|
||||
|
||||
// Remove the version from the cache
|
||||
VersionCache::remove($this, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all validation errors for the given language
|
||||
*/
|
||||
public function errors(Language|string $language = 'default'): array
|
||||
{
|
||||
$fields = Fields::for($this->model, $language);
|
||||
$fields->fill(
|
||||
input: $this->content($language)->toArray()
|
||||
);
|
||||
|
||||
return $fields->errors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a version exists for the given language
|
||||
*/
|
||||
public function exists(Language|string $language = 'default'): bool
|
||||
{
|
||||
// go through all possible languages to check if this
|
||||
// version exists in any language
|
||||
if ($language === '*') {
|
||||
foreach (Languages::ensure() as $language) {
|
||||
if ($this->exists($language) === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->model->storage()->exists(
|
||||
$this->id,
|
||||
Language::ensure($language)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the VersionId instance for this version
|
||||
*/
|
||||
public function id(): VersionId
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the content of both versions
|
||||
* is identical
|
||||
*/
|
||||
public function isIdentical(
|
||||
Version|VersionId|string $version,
|
||||
Language|string $language = 'default'
|
||||
): bool {
|
||||
if (is_string($version) === true) {
|
||||
$version = VersionId::from($version);
|
||||
}
|
||||
|
||||
if ($version instanceof VersionId) {
|
||||
$version = $this->sibling($version);
|
||||
}
|
||||
|
||||
if ($version->id()->is($this->id) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$language = Language::ensure($language);
|
||||
$fields = Fields::for($this->model, $language);
|
||||
|
||||
// read fields low-level from storage
|
||||
$a = $this->read($language) ?? [];
|
||||
$b = $version->read($language) ?? [];
|
||||
|
||||
// remove fields that should not be
|
||||
// considered in the comparison
|
||||
unset(
|
||||
$a['lock'],
|
||||
$b['lock'],
|
||||
$a['uuid'],
|
||||
$b['uuid']
|
||||
);
|
||||
|
||||
$a = $fields->reset()->fill(input: $a)->toFormValues();
|
||||
$b = $fields->reset()->fill(input: $b)->toFormValues();
|
||||
|
||||
ksort($a);
|
||||
ksort($b);
|
||||
|
||||
return $a === $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the version is the latest version
|
||||
*/
|
||||
public function isLatest(): bool
|
||||
{
|
||||
return $this->id->is('latest');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the version is locked for the current user
|
||||
*/
|
||||
public function isLocked(Language|string $language = 'default'): bool
|
||||
{
|
||||
return $this->lock($language)->isLocked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there are any validation errors for the given language
|
||||
*/
|
||||
public function isValid(Language|string $language = 'default'): bool
|
||||
{
|
||||
return $this->errors($language) === [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the lock object for the version
|
||||
*/
|
||||
public function lock(Language|string $language = 'default'): Lock
|
||||
{
|
||||
return Lock::for($this, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent model
|
||||
*/
|
||||
public function model(): ModelWithContent
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the modification timestamp of a version
|
||||
* if it exists
|
||||
*/
|
||||
public function modified(
|
||||
Language|string $language = 'default'
|
||||
): int|null {
|
||||
if ($this->exists($language) === true) {
|
||||
return $this->model->storage()->modified(
|
||||
$this->id,
|
||||
Language::ensure($language)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the version to a new language and/or version
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the version does not exist
|
||||
*/
|
||||
public function move(
|
||||
Language|string $fromLanguage,
|
||||
VersionId|null $toVersionId = null,
|
||||
Language|string|null $toLanguage = null,
|
||||
Storage|null $toStorage = null
|
||||
): void {
|
||||
$fromVersion = $this;
|
||||
$fromLanguage = Language::ensure($fromLanguage);
|
||||
$toLanguage = Language::ensure($toLanguage ?? $fromLanguage);
|
||||
$toVersion = $this->sibling($toVersionId ?? $this->id);
|
||||
|
||||
// check if moving is allowed
|
||||
VersionRules::move(
|
||||
fromVersion: $fromVersion,
|
||||
fromLanguage: $fromLanguage,
|
||||
toVersion: $toVersion,
|
||||
toLanguage: $toLanguage
|
||||
);
|
||||
|
||||
$this->model->storage()->move(
|
||||
fromVersionId: $fromVersion->id(),
|
||||
fromLanguage: $fromLanguage,
|
||||
toVersionId: $toVersion->id(),
|
||||
toLanguage: $toLanguage,
|
||||
toStorage: $toStorage
|
||||
);
|
||||
|
||||
// remove both versions from the cache
|
||||
VersionCache::remove($fromVersion, $fromLanguage);
|
||||
VersionCache::remove($toVersion, $toLanguage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare fields to be written by removing unwanted fields
|
||||
* depending on the language or model and by cleaning the field names
|
||||
*/
|
||||
protected function prepareFieldsBeforeWrite(
|
||||
array $fields,
|
||||
Language $language
|
||||
): array {
|
||||
// convert all field names to lower case
|
||||
$fields = $this->convertFieldNamesToLowerCase($fields);
|
||||
|
||||
// make sure to store the right fields for the model
|
||||
$fields = $this->model->contentFileData($fields, $language);
|
||||
|
||||
// add the editing user
|
||||
if (
|
||||
Lock::isEnabled() === true &&
|
||||
$this->id->is('changes') === true
|
||||
) {
|
||||
$fields['lock'] = $this->model->kirby()->user()?->id();
|
||||
|
||||
// remove the lock field for any other version or
|
||||
// if locking is disabled
|
||||
} else {
|
||||
unset($fields['lock']);
|
||||
}
|
||||
|
||||
// the default language stores all fields
|
||||
if ($language->isDefault() === true) {
|
||||
return $fields;
|
||||
}
|
||||
|
||||
// remove all untranslatable fields
|
||||
foreach ($this->model->blueprint()->fields() as $field) {
|
||||
if (($field['translate'] ?? true) === false) {
|
||||
unset($fields[strtolower($field['name'])]);
|
||||
}
|
||||
}
|
||||
|
||||
// remove UUID for non-default languages
|
||||
unset($fields['uuid']);
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that reading from storage will always
|
||||
* return a usable set of fields with clean field names
|
||||
*/
|
||||
protected function prepareFieldsAfterRead(array $fields, Language $language): array
|
||||
{
|
||||
$fields = $this->convertFieldNamesToLowerCase($fields);
|
||||
|
||||
// ignore all fields with null values
|
||||
return array_filter($fields, fn ($field) => $field !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a verification token for the authentication
|
||||
* of draft and version previews
|
||||
* @unstable
|
||||
*/
|
||||
public function previewToken(): string
|
||||
{
|
||||
if ($this->model instanceof Site) {
|
||||
// the site itself does not render; its preview is the home page
|
||||
$homePage = $this->model->homePage();
|
||||
|
||||
if ($homePage === null) {
|
||||
throw new NotFoundException('The home page does not exist');
|
||||
}
|
||||
|
||||
return $homePage->version($this->id)->previewToken();
|
||||
}
|
||||
|
||||
if (($this->model instanceof Page) === false) {
|
||||
throw new LogicException('Invalid model type');
|
||||
}
|
||||
|
||||
return $this->previewTokenFromUrl($this->model->url());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a verification token for the authentication
|
||||
* of draft and version previews from a raw URL
|
||||
*/
|
||||
protected function previewTokenFromUrl(string $url): string
|
||||
{
|
||||
// get rid of all modifiers after the path
|
||||
$uri = new Uri($url);
|
||||
$uri->fragment = null;
|
||||
$uri->params = null;
|
||||
$uri->query = null;
|
||||
|
||||
$data = [
|
||||
'url' => $uri->toString(),
|
||||
'versionId' => $this->id->value()
|
||||
];
|
||||
|
||||
$token = $this->model->kirby()->contentToken(
|
||||
null,
|
||||
json_encode($data, JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
|
||||
return substr($token, 0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method can only be applied to the "changes" version.
|
||||
* It will copy all fields over to the "latest" version and delete
|
||||
* this version afterwards.
|
||||
*/
|
||||
public function publish(Language|string $language = 'default'): void
|
||||
{
|
||||
$language = Language::ensure($language);
|
||||
|
||||
// check if publishing is allowed
|
||||
VersionRules::publish($this, $language);
|
||||
|
||||
$latest = $this->sibling('latest')->read($language) ?? [];
|
||||
$changes = $this->read($language) ?? [];
|
||||
|
||||
// overwrite all fields that are not in the `changes` version
|
||||
// with a null value. The ModelWithContent::update method will merge
|
||||
// the input with the existing content fields and setting null values
|
||||
// for removed fields will take care of not inheriting old values.
|
||||
foreach ($latest as $key => $value) {
|
||||
if (isset($changes[$key]) === false) {
|
||||
$changes[$key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// update the latest version
|
||||
$this->model = $this->model->update(
|
||||
input: $changes,
|
||||
languageCode: $language->code(),
|
||||
validate: true
|
||||
);
|
||||
|
||||
// delete the changes
|
||||
$this->delete($language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored content fields
|
||||
*
|
||||
* @return array<string, string>|null
|
||||
*/
|
||||
public function read(Language|string $language = 'default'): array|null
|
||||
{
|
||||
$language = Language::ensure($language);
|
||||
|
||||
try {
|
||||
// make sure that the version exists
|
||||
VersionRules::read($this, $language);
|
||||
|
||||
$fields = VersionCache::get($this, $language);
|
||||
|
||||
if ($fields === null) {
|
||||
$fields = $this->model->storage()->read($this->id, $language);
|
||||
$fields = $this->prepareFieldsAfterRead($fields, $language);
|
||||
|
||||
if ($fields !== null) {
|
||||
VersionCache::set($this, $language, $fields);
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
} catch (NotFoundException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the content of the current version with the given fields
|
||||
*
|
||||
* @param array<string, string> $fields Content fields
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the version does not exist
|
||||
*/
|
||||
public function replace(
|
||||
array $fields,
|
||||
Language|string $language = 'default'
|
||||
): void {
|
||||
$language = Language::ensure($language);
|
||||
|
||||
// check if replacing is allowed
|
||||
VersionRules::replace($this, $fields, $language);
|
||||
|
||||
$this->model->storage()->update(
|
||||
versionId: $this->id,
|
||||
language: $language,
|
||||
fields: $this->prepareFieldsBeforeWrite($fields, $language)
|
||||
);
|
||||
|
||||
// remove the version from the cache to read
|
||||
// a fresh version next time
|
||||
VersionCache::remove($this, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper around ::create, ::replace and ::update.
|
||||
*/
|
||||
public function save(
|
||||
array $fields,
|
||||
Language|string $language = 'default',
|
||||
bool $overwrite = false
|
||||
): void {
|
||||
if ($this->exists($language) === false) {
|
||||
$this->create($fields, $language);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($overwrite === true) {
|
||||
$this->replace($fields, $language);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->update($fields, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a sibling version for the same model
|
||||
*/
|
||||
public function sibling(VersionId|string $id): Version
|
||||
{
|
||||
return new Version(
|
||||
model: $this->model,
|
||||
id: VersionId::from($id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the modification timestamp of an existing version
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the version does not exist
|
||||
*/
|
||||
public function touch(Language|string $language = 'default'): void
|
||||
{
|
||||
$language = Language::ensure($language);
|
||||
|
||||
VersionRules::touch($this, $language);
|
||||
|
||||
$this->model->storage()->touch($this->id, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the content fields of an existing version
|
||||
*
|
||||
* @param array<string, string> $fields Content fields
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the version does not exist
|
||||
*/
|
||||
public function update(
|
||||
array $fields,
|
||||
Language|string $language = 'default'
|
||||
): void {
|
||||
$language = Language::ensure($language);
|
||||
|
||||
// check if updating is allowed
|
||||
VersionRules::update($this, $fields, $language);
|
||||
|
||||
// merge the previous state with the new state to always
|
||||
// update to a complete version
|
||||
$fields = [
|
||||
...$this->read($language),
|
||||
...$fields
|
||||
];
|
||||
|
||||
$this->model->storage()->update(
|
||||
versionId: $this->id,
|
||||
language: $language,
|
||||
fields: $this->prepareFieldsBeforeWrite($fields, $language)
|
||||
);
|
||||
|
||||
// remove the version from the cache to read
|
||||
// a fresh version next time
|
||||
VersionCache::remove($this, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preview URL with authentication for drafts and versions
|
||||
* @unstable
|
||||
*/
|
||||
public function url(): string|null
|
||||
{
|
||||
if (
|
||||
($this->model instanceof Page || $this->model instanceof Site) === false
|
||||
) {
|
||||
throw new LogicException('Only pages and the site have a content preview URL');
|
||||
}
|
||||
|
||||
$url = $this->model->blueprint()->preview();
|
||||
|
||||
// preview was disabled
|
||||
if ($url === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// we only need to add a token for draft and changes previews
|
||||
if (
|
||||
($this->model instanceof Site || $this->model->isDraft() === false) &&
|
||||
$this->id->is('changes') === false
|
||||
) {
|
||||
return match (true) {
|
||||
is_string($url) => $url,
|
||||
default => $this->model->url()
|
||||
};
|
||||
}
|
||||
|
||||
// check if the URL was customized
|
||||
if (is_string($url) === true) {
|
||||
return $this->urlFromOption($url);
|
||||
}
|
||||
|
||||
// it wasn't, use the safer/more reliable model-based preview token
|
||||
return $this->urlWithQueryParams($this->model->url(), $this->previewToken());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preview URL based on an arbitrary URL from
|
||||
* the blueprint option
|
||||
*/
|
||||
protected function urlFromOption(string $url): string
|
||||
{
|
||||
// try to determine a token for a local preview
|
||||
// (we cannot determine the token for external previews)
|
||||
if ($token = $this->previewTokenFromUrl($url)) {
|
||||
return $this->urlWithQueryParams($url, $token);
|
||||
}
|
||||
|
||||
// fall back to the URL as defined in the blueprint
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles the preview URL with the added `_token` and `_version`
|
||||
* query params, no matter if the base URL already contains query params
|
||||
*/
|
||||
protected function urlWithQueryParams(string $baseUrl, string $token): string
|
||||
{
|
||||
$uri = new Uri($baseUrl);
|
||||
$uri->query->_token = $token;
|
||||
|
||||
if ($this->id->is('changes') === true) {
|
||||
$uri->query->_version = 'changes';
|
||||
}
|
||||
|
||||
return $uri->toString();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue