* @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license */ abstract class ModelWithContent implements Identifiable { /** * Each model must define a CLASS_ALIAS * which will be used in template queries. * The CLASS_ALIAS is a short human-readable * version of the class name, i.e. page. */ public const CLASS_ALIAS = null; /** * Cached array of valid blueprints * that could be used for the model */ public array|null $blueprints = null; public Content|null $content; public static App $kirby; protected Site|null $site; protected ContentStorage $storage; public Collection|null $translations = null; /** * Store values used to initilaize object */ protected array $propertyData = []; public function __construct(array $props = []) { $this->site = $props['site'] ?? null; $this->setContent($props['content'] ?? null); $this->setTranslations($props['translations'] ?? null); $this->propertyData = $props; } /** * Returns the blueprint of the model */ abstract public function blueprint(): Blueprint; /** * Returns an array with all blueprints that are available */ public function blueprints(string $inSection = null): array { // helper function $toBlueprints = function (array $sections): array { $blueprints = []; foreach ($sections as $section) { if ($section === null) { continue; } foreach ((array)$section->blueprints() as $blueprint) { $blueprints[$blueprint['name']] = $blueprint; } } return array_values($blueprints); }; $blueprint = $this->blueprint(); // no caching for when collecting for specific section if ($inSection !== null) { return $toBlueprints([$blueprint->section($inSection)]); } return $this->blueprints ??= $toBlueprints($blueprint->sections()); } /** * Creates a new instance with the same * initial properties * * @todo eventually refactor without need of propertyData */ public function clone(array $props = []): static { return new static(array_replace_recursive($this->propertyData, $props)); } /** * Executes any given model action */ abstract protected function commit( string $action, array $arguments, Closure $callback ): mixed; /** * Returns the content * * @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist */ public function content(string|null $languageCode = null): Content { // single language support if ($this->kirby()->multilang() === false) { if ($this->content instanceof Content) { return $this->content; } // don't normalize field keys (already handled by the `Data` class) return $this->content = new Content($this->readContent(), $this, false); } // get the targeted language $language = $this->kirby()->language($languageCode); // stop if the language does not exist if ($language === null) { throw new InvalidArgumentException('Invalid language: ' . $languageCode); } // only fetch from cache for the current language if ($languageCode === null && $this->content instanceof Content) { return $this->content; } // get the translation by code $translation = $this->translation($language->code()); // don't normalize field keys (already handled by the `ContentTranslation` class) $content = new Content($translation->content(), $this, false); // only store the content for the current language if ($languageCode === null) { $this->content = $content; } return $content; } /** * Returns the absolute path to the content file; * NOTE: only supports the published content file * (use `$model->storage()->contentFile()` for other versions) * @internal * @deprecated 4.0.0 * @todo Remove in v5 * @codeCoverageIgnore * * @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist */ public function contentFile( string $languageCode = null, bool $force = false ): string { Helpers::deprecated('The internal $model->contentFile() method has been deprecated. You can use $model->storage()->contentFile() instead, however please note that this method is also internal and may be removed in the future.', 'model-content-file'); return $this->storage()->contentFile( $this->storage()->defaultVersion(), $languageCode, $force ); } /** * Returns an array with all content files; * NOTE: only supports the published content file * (use `$model->storage()->contentFiles()` for other versions) * @deprecated 4.0.0 * @todo Remove in v5 * @codeCoverageIgnore */ public function contentFiles(): array { Helpers::deprecated('The internal $model->contentFiles() method has been deprecated. You can use $model->storage()->contentFiles() instead, however please note that this method is also internal and may be removed in the future.', 'model-content-file'); return $this->storage()->contentFiles( $this->storage()->defaultVersion() ); } /** * Prepares the content that should be written * to the text file * @internal */ public function contentFileData( array $data, string $languageCode = null ): array { return $data; } /** * Returns the absolute path to the * folder in which the content file is * located * @internal * @deprecated 4.0.0 * @todo Remove in v5 * @codeCoverageIgnore */ public function contentFileDirectory(): string|null { Helpers::deprecated('The internal $model->contentFileDirectory() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); return $this->root(); } /** * Returns the extension of the content file * @internal * @deprecated 4.0.0 * @todo Remove in v5 * @codeCoverageIgnore */ public function contentFileExtension(): string { Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); return $this->kirby()->contentExtension(); } /** * Needs to be declared by the final model * @internal * @deprecated 4.0.0 * @todo Remove in v5 * @codeCoverageIgnore */ abstract public function contentFileName(): string; /** * Converts model to new blueprint * incl. its content for all translations */ protected function convertTo(string $blueprint): static { // first close object with new blueprint as template $new = $this->clone(['template' => $blueprint]); // temporary compatibility change (TODO: also convert changes) $identifier = $this->storage()->defaultVersion(); // for multilang, we go through all translations and // covnert the content for each of them, remove the content file // to rewrite it with converted content afterwards if ($this->kirby()->multilang() === true) { $translations = []; foreach ($this->kirby()->languages()->codes() as $code) { if ($this->translation($code)?->exists() === true) { $content = $this->content($code)->convertTo($blueprint); // delete the old text file $this->storage()->delete( $identifier, $code ); // save to re-create the translation content file // with the converted/updated content $new->save($content, $code); } $translations[] = [ 'code' => $code, 'content' => $content ?? null ]; } // cloning the object with the new translations content ensures // that `propertyData` prop does not hold any old translations // content that could surface on subsequent cloning return $new->clone(['translations' => $translations]); } // for single language setups, we do the same, // just once for the main content $content = $this->content()->convertTo($blueprint); // delete the old text file $this->storage()->delete($identifier, 'default'); return $new->save($content); } /** * Decrement a given field value */ public function decrement( string $field, int $by = 1, int $min = 0 ): static { $value = (int)$this->content()->get($field)->value() - $by; if ($value < $min) { $value = $min; } return $this->update([$field => $value]); } /** * Returns all content validation errors */ public function errors(): array { $errors = []; foreach ($this->blueprint()->sections() as $section) { $errors = array_merge($errors, $section->errors()); } return $errors; } /** * Creates a clone and fetches all * lazy-loaded getters to get a full copy */ public function hardcopy(): static { $clone = $this->clone(); foreach (get_object_vars($clone) as $name => $default) { if (method_exists($clone, $name) === true) { $clone->$name(); } } return $clone; } /** * Each model must return a unique id */ public function id(): string|null { return null; } /** * Increment a given field value */ public function increment( string $field, int $by = 1, int $max = null ): static { $value = (int)$this->content()->get($field)->value() + $by; if ($max && $value > $max) { $value = $max; } return $this->update([$field => $value]); } /** * Checks if the model is locked for the current user */ public function isLocked(): bool { $lock = $this->lock(); return $lock && $lock->isLocked() === true; } /** * Checks if the data has any errors */ public function isValid(): bool { return Form::for($this)->isValid() === true; } /** * Returns the parent Kirby instance */ public function kirby(): App { return static::$kirby ??= App::instance(); } /** * Returns the lock object for this model * * Only if a content directory exists, * virtual pages will need to overwrite this method */ public function lock(): ContentLock|null { $dir = $this->root(); if ($this::CLASS_ALIAS === 'file') { $dir = dirname($dir); } if ( $this->kirby()->option('content.locking', true) && is_string($dir) === true && file_exists($dir) === true ) { return new ContentLock($this); } return null; } /** * Returns the panel info of the model * @since 3.6.0 */ abstract public function panel(): Model; /** * Must return the permissions object for the model */ abstract public function permissions(): ModelPermissions; /** * Clean internal caches * * @return $this */ public function purge(): static { $this->blueprints = null; $this->content = null; $this->translations = null; return $this; } /** * Creates a string query, starting from the model * @internal */ public function query( string $query = null, string $expect = null ): mixed { if ($query === null) { return null; } try { $result = Str::query($query, [ 'kirby' => $this->kirby(), 'site' => $this instanceof Site ? $this : $this->site(), 'model' => $this, static::CLASS_ALIAS => $this ]); } catch (Throwable) { return null; } if ($expect !== null && $result instanceof $expect === false) { return null; } return $result; } /** * Read the content from the content file * @internal */ public function readContent(string $languageCode = null): array { try { return $this->storage()->read( $this->storage()->defaultVersion(), $languageCode ); } catch (NotFoundException) { // only if the content file really does not exist, it's ok // to return empty content. Otherwise this could lead to // content loss in case of file reading issues return []; } } /** * Returns the absolute path to the model */ abstract public function root(): string|null; /** * Stores the content on disk * @internal */ public function save( array|null $data = null, string|null $languageCode = null, bool $overwrite = false ): static { if ($this->kirby()->multilang() === true) { return $this->saveTranslation($data, $languageCode, $overwrite); } return $this->saveContent($data, $overwrite); } /** * Save the single language content */ protected function saveContent( array $data = null, bool $overwrite = false ): static { // create a clone to avoid modifying the original $clone = $this->clone(); // merge the new data with the existing content $clone->content()->update($data, $overwrite); // send the full content array to the writer $clone->writeContent($clone->content()->toArray()); return $clone; } /** * Save a translation * * @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist */ protected function saveTranslation( array $data = null, string $languageCode = null, bool $overwrite = false ): static { // create a clone to not touch the original $clone = $this->clone(); // fetch the matching translation and update all the strings $translation = $clone->translation($languageCode); if ($translation === null) { throw new InvalidArgumentException('Invalid language: ' . $languageCode); } // get the content to store $content = $translation->update($data, $overwrite)->content(); $kirby = $this->kirby(); $languageCode = $kirby->languageCode($languageCode); // remove all untranslatable fields if ($languageCode !== $kirby->defaultLanguage()->code()) { foreach ($this->blueprint()->fields() as $field) { if (($field['translate'] ?? true) === false) { $content[strtolower($field['name'])] = null; } } // remove UUID for non-default languages if (Uuids::enabled() === true && isset($content['uuid']) === true) { $content['uuid'] = null; } // merge the translation with the new data $translation->update($content, true); } // send the full translation array to the writer $clone->writeContent($translation->content(), $languageCode); // reset the content object $clone->content = null; // return the updated model return $clone; } /** * Sets the Content object * * @return $this */ protected function setContent(array $content = null): static { if ($content !== null) { $content = new Content($content, $this); } $this->content = $content; return $this; } /** * Create the translations collection from an array * * @return $this */ protected function setTranslations(array $translations = null): static { if ($translations !== null) { $this->translations = new Collection(); foreach ($translations as $props) { $props['parent'] = $this; $translation = new ContentTranslation($props); $this->translations->data[$translation->code()] = $translation; } } else { $this->translations = null; } return $this; } /** * Returns the parent Site instance */ public function site(): Site { return $this->site ??= $this->kirby()->site(); } /** * Returns the content storage handler * @internal */ public function storage(): ContentStorage { return $this->storage ??= new ContentStorage( model: $this, handler: PlainTextContentStorageHandler::class ); } /** * Convert the model to a simple array */ public function toArray(): array { return [ 'content' => $this->content()->toArray(), 'translations' => $this->translations()->toArray() ]; } /** * String template builder with automatic HTML escaping * @since 3.6.0 * * @param string|null $template Template string or `null` to use the model ID * @param string|null $fallback Fallback for tokens in the template that cannot be replaced * (`null` to keep the original token) */ public function toSafeString( string $template = null, array $data = [], string|null $fallback = '' ): string { return $this->toString($template, $data, $fallback, 'safeTemplate'); } /** * String template builder * * @param string|null $template Template string or `null` to use the model ID * @param string|null $fallback Fallback for tokens in the template that cannot be replaced * (`null` to keep the original token) * @param string $handler For internal use */ public function toString( string $template = null, array $data = [], string|null $fallback = '', string $handler = 'template' ): string { if ($template === null) { return $this->id() ?? ''; } if ($handler !== 'template' && $handler !== 'safeTemplate') { throw new InvalidArgumentException('Invalid toString handler'); // @codeCoverageIgnore } $result = Str::$handler($template, array_replace([ 'kirby' => $this->kirby(), 'site' => $this instanceof Site ? $this : $this->site(), 'model' => $this, static::CLASS_ALIAS => $this, ], $data), ['fallback' => $fallback]); return $result; } /** * Makes it possible to convert the entire model * to a string. Mostly useful for debugging */ public function __toString(): string { return $this->id(); } /** * Returns a single translation by language code * If no code is specified the current translation is returned */ public function translation( string $languageCode = null ): ContentTranslation|null { if ($language = $this->kirby()->language($languageCode)) { return $this->translations()->find($language->code()); } return null; } /** * Returns the translations collection */ public function translations(): Collection { if ($this->translations !== null) { return $this->translations; } $this->translations = new Collection(); foreach ($this->kirby()->languages() as $language) { $translation = new ContentTranslation([ 'parent' => $this, 'code' => $language->code(), ]); $this->translations->data[$translation->code()] = $translation; } return $this->translations; } /** * Updates the model data * * @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values */ public function update( array $input = null, string $languageCode = null, bool $validate = false ): static { $form = Form::for($this, [ 'ignoreDisabled' => $validate === false, 'input' => $input, 'language' => $languageCode, ]); // validate the input if ($validate === true && $form->isInvalid() === true) { throw new InvalidArgumentException([ 'fallback' => 'Invalid form with errors', 'details' => $form->errors() ]); } return $this->commit( 'update', [ static::CLASS_ALIAS => $this, 'values' => $form->data(), 'strings' => $form->strings(), 'languageCode' => $languageCode ], fn ($model, $values, $strings, $languageCode) => $model->save($strings, $languageCode, true) ); } /** * Returns the model's UUID * @since 3.8.0 */ public function uuid(): Uuid|null { return Uuid::for($this); } /** * Low level data writer method * to store the given data on disk or anywhere else * @internal */ public function writeContent(array $data, string $languageCode = null): bool { $data = $this->contentFileData($data, $languageCode); $id = $this->storage()->defaultVersion(); try { // we can only update if the version already exists $this->storage()->update($id, $languageCode, $data); } catch (NotFoundException) { // otherwise create a new version $this->storage()->create($id, $languageCode, $data); } return true; } }