classes/ModelWithKhulan.php
<?php
declare(strict_types=1);
namespace Bnomei;
use DateTime;
use Exception;
use Kirby\Cms\Blueprint;
use Kirby\Cms\File;
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
use Kirby\Filesystem\F;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use MongoDB\BSON\ObjectId;
use MongoDB\BSON\UTCDateTime;
trait ModelWithKhulan
{
private bool $khulanCacheWillBeDeleted = false;
public function hasKhulan(): bool
{
if ($this instanceof File) {
/** @var \Bnomei\KhulanPage|\Bnomei\KhulanUser $parent */
$parent = $this->parent();
return $parent->hasKhulan() === true;
}
return true;
}
public function setKhulanCacheWillBeDeleted(bool $value): void
{
$this->khulanCacheWillBeDeleted = $value;
}
public function keyKhulan(?string $languageCode = null): string
{
$key = $this->id(); // can not use UUID since content not loaded yet
if (! $languageCode) {
$languageCode = kirby()->languages()->count() ? kirby()->language()?->code() : null;
}
if ($languageCode) {
$key = $key.'-'.$languageCode;
}
// mongodb _id must be 24 chars long
return substr(hash('md5', $key), 0, 24);
}
public function readContentCache(?string $languageCode = null): ?array
{
$document = khulan()->findOne([
'_id' => $this->keyKhulan($languageCode),
]);
return $document ? iterator_to_array($document) : null;
}
public function readContent(?string $languageCode = null): array
{
// read from boostedCache if exists
$data = option('bnomei.mongodb.khulan.read') === false || option('debug') ? null : $this->readContentCache($languageCode);
$data = $this->decodeKhulan($data);
// read from file and update boostedCache
if (! $data) {
$data = parent::readContent($languageCode);
if ($data && $this->khulanCacheWillBeDeleted !== true) {
$this->writeKhulan($data, $languageCode);
}
}
return $data;
}
public function writeKhulan(?array $data = null, ?string $languageCode = null): bool
{
if (option('bnomei.mongodb.khulan.write') === false) {
return true;
}
/*
$modified = null;
if ($this instanceof Site) {
// site()->modified() does crawl index but not return change of its content file
$siteFile = site()->storage()->read(
site()->storage()->defaultVersion(),
kirby()->defaultLanguage()->code()
)[0]; // TODO: this might be a bug
$modified = $modified.filemtime($siteFile);
} else {
$modified = $this->modified();
}
*/
$modified = $this->modified();
// in rare case file does not exists or is not readable
if ($modified === false || empty($modified)) {
$this->deleteKhulan(); // whatever was in the cache is no longer valid
return false; // try again another time
}
// kirby can return a timestamp as string
if (is_string($modified) && is_numeric($modified)) {
$modified = (int) $modified;
}
// failed
if (! is_int($modified)) {
$this->deleteKhulan(); // whatever was in the cache is no longer valid
return false; // try again another time
}
$modelType = 'page';
if ($this instanceof Site) {
$modelType = 'site';
} elseif ($this instanceof User) {
$modelType = 'user';
} elseif ($this instanceof File) {
$modelType = 'file';
}
$meta = [
'id' => $this->id(),
'modified' => $modified,
'modified{}' => new UTCDateTime($modified * 1000),
'class' => $this::class,
'language' => $languageCode,
'modelType' => $modelType,
];
$this->id();
if ($this instanceof Page) {
$slug = explode('/', $this->id());
$meta['num'] = $this->num() ? (int) $this->num() : null;
$meta['slug'] = $this->id() ? array_pop($slug) : null;
$meta['template'] = $this->intendedTemplate()->name();
$meta['status'] = $this->status();
} elseif ($this instanceof File) {
// can not use $file->content() since it would trigger a loop
$meta['sort'] = A::get($data, 'sort') ? (int) A::get($data, 'sort') : null;
$meta['filename'] = $this->filename();
$meta['template'] = A::get($data, 'template');
$meta['mimeType'] = $this->root() ? F::mime($this->root()) : null;
/** @var \Bnomei\KhulanPage|\Bnomei\KhulanUser $parent */
$parent = $this->parent();
$meta['parent{}'] = new ObjectId($parent->keyKhulan($languageCode));
} elseif ($this instanceof User) {
$meta['email'] = $this->email();
$meta['name'] = $this->name()->isNotEmpty() ? $this->name()->value() : null;
$meta['role'] = $this->role()->name();
}
$data = array_merge($this->encodeKhulan($data, $languageCode), $meta);
// _id is not allowed as data key
if (array_key_exists('_id', $data)) {
unset($data['_id']);
}
$status = khulan()->findOneAndUpdate(
['_id' => new ObjectId($this->keyKhulan($languageCode))],
['$set' => $data],
['upsert' => true]
);
return $status != null;
}
public function writeContent(array $data, ?string $languageCode = null): bool
{
// write to file and cache
return parent::writeContent($data, $languageCode) &&
$this->writeKhulan($data, $languageCode);
}
public function deleteKhulan(): bool
{
if (option('bnomei.mongodb.khulan.write') === false) {
return true;
}
$this->setKhulanCacheWillBeDeleted(true);
// using many and by id to delete all language versions
// as well as the version without a language code
khulan()->deleteMany([
'id' => $this->id(),
]);
return true;
}
public function delete(bool $force = false): bool
{
$success = parent::delete($force); // @phpstan-ignore-line
$this->deleteKhulan();
return $success;
}
public function encodeKhulan(?array $data = null, ?string $languageCode = null): array
{
if (! $data) {
return [];
}
$blueprint = null;
if ($this instanceof Page) {
$blueprint = $this->blueprint()->fields();
} elseif ($this instanceof File) {
// $blueprint = $this->blueprint();
// does not work as that would trigger a loop reading the content
// but it can be read manually
$blueprint = Blueprint::find('files/'.A::get($data, 'template', 'default'));
$blueprint = A::get($blueprint, 'fields');
}
if (! $blueprint) {
return $data;
}
// foreach each key value pairs
$copy = $data;
foreach ($data as $key => $value) {
$field = A::get($blueprint, $key);
if (! $field) {
continue;
}
if (is_string($value)) {
$type = A::get($field, 'type');
// if it is a comma separated list unroll it to an array. validate if it is with a regex but allow for spaces chars after the comma
if (preg_match('/^[\w\s-]+(,\s*[\w\s-]+)*$/', $value) && in_array(
$type, ['tags', 'select', 'multiselect', 'radio', 'checkbox']
)) {
$tags = explode(',', $value);
$tags = array_map('trim', $tags);
$tags = array_filter($tags, function ($value) {
return ! empty($value);
});
$copy[$key.'[,]'] = $tags;
continue; // process next key value pair
}
if (in_array(
$type, ['date']
)) {
// convert iso date to mongodb date
$copy[$key.'{}'] = new UTCDateTime((new DateTime($value))->getTimestamp() * 1000);
}
// if it is a valid yaml string, convert it to an array
if (in_array(
$type, ['pages', 'files', 'users']
)) {
try {
$v = Yaml::decode($value);
if (is_array($v)) {
$copy[$key.'[]'] = $v;
$copy[$key.'{}'] = [];
// resolve each and set objectid
foreach ($v as $vv) {
$modelType = null;
if (Str::startsWith($vv, 'page://')) {
$modelType = 'page';
} elseif (Str::startsWith($vv, 'file://')) {
$modelType = 'file';
} elseif (Str::startsWith($vv, 'user://')) {
$modelType = 'user';
} elseif (Str::startsWith($vv, 'site://')) {
$modelType = 'site';
}
if (! $modelType) {
continue;
}
$vv = str_replace($modelType.'://', '', $vv);
$query = [
'$or' => [
['id' => $vv],
['uuid' => $vv],
],
];
if (kirby()->multilang() && $languageCode) {
$query = [
'$and' => [
$query,
['language' => $languageCode],
],
];
}
$document = khulan()->findOne($query);
if ($document) {
$copy[$key.'{}'][] = $document['_id'];
}
}
}
} catch (Exception $e) {
// do nothing
// ray($e->getMessage());
}
}
// if it is a valid yaml string, convert it to an array
if (in_array(
$type, ['object', 'structure']
)) {
try {
$v = Yaml::decode($value);
if (is_array($v)) {
$copy[$key.'[]'] = $v;
}
} catch (Exception $e) {
// do nothing
}
}
}
}
return $copy;
}
public function decodeKhulan(?array $data = []): array
{
if (empty($data)) {
return [];
}
// flatten to array
$data = iterator_to_array($data);
// and remove any mongodb objects
$json_encode = json_encode($data);
if (! $json_encode) {
throw new Exception('Could not encode data to JSON.');
}
$data = json_decode($json_encode, true);
if (! is_array($data)) {
throw new Exception('Could not decode JSON to array.');
}
$copy = $data;
// remove any empty values
$copy = array_filter($copy, function ($value) {
return ! empty($value);
});
// remove meta
$meta = [
'id',
'modified',
'modified{}',
'class',
'language',
'modelType',
];
if ($this instanceof Page) {
$meta[] = 'num';
$meta[] = 'slug';
$meta[] = 'status';
$meta[] = 'template';
} elseif ($this instanceof File) {
$meta[] = 'sort';
$meta[] = 'filename';
$meta[] = 'mimeType';
$meta[] = 'template';
} elseif ($this instanceof User) {
$meta[] = 'email';
$meta[] = 'name';
$meta[] = 'role';
}
foreach ($meta as $key) {
if (array_key_exists($key, $copy)) {
unset($copy[$key]);
}
}
// remove dynamic keys
foreach ($data as $key => $value) {
if (is_array($value) && (
Str::endsWith($key, '[,]') ||
Str::endsWith($key, '[]') ||
Str::endsWith($key, '{}')
)
) {
unset($copy[$key]);
}
}
return $copy;
}
}