classes/Lapse.php
<?php
declare(strict_types=1);
namespace Bnomei;
use Exception;
use Kirby\Cache\Cache;
use Kirby\Content\Field;
use Kirby\Toolkit\A;
final class Lapse
{
/*
* @var string
*/
private const SALT = 'L4P$e';
/*
* @var string
*/
private const INDEX = 'LAPSE_INDEX';
/*
* @var int
*/
private const INDEX_LIMIT = 500;
/*
* @var \Kirby\Cache\Cache
*/
private $cache;
private function cache(): Cache
{
if (!$this->cache) {
$this->cache = kirby()->cache('bnomei.lapse');
}
return $this->cache;
}
/*
* @var array
*/
private $options;
public function __construct(array $options = [])
{
$this->options = array_merge([
'expires' => option('bnomei.lapse.expires', 0),
'debug' => option('debug'),
'languageCode' => kirby()->language() ? kirby()->language()->code() : null,
'indexLimit' => option('bnomei.lapse.indexLimit', null),
'autoid' => function_exists('autoid') && function_exists('modified'),
'boost' => function_exists('boost') && function_exists('modified'),
], $options);
if ($this->option('debug')) {
$this->flush();
}
}
public function option(?string $key = null)
{
if ($key) {
return A::get($this->options, $key);
}
return $this->options;
}
public function set($key, $value = null, $expires = null)
{
return $this->getAndSetIfMissingOrExpired($key, $value, $expires);
}
public function getAndSetIfMissingOrExpired($key, $value = null, $expires = null)
{
if ($this->option('debug')) {
try {
return $this->serialize($value);
} catch (LapseCancelException $e) {
return null;
}
}
if (!is_string($key)) {
$key = $this->keyFromObject($key);
$key = $this->hashKey($key);
}
$response = $this->cache()->get($key);
if ($response || !$value) {
return $response;
}
try {
$response = $this->serialize($value); // might throw LapseCancelException
$expires = $expires ?? $this->option('expires');
$this->cache()->set(strval($key), $response, intval($expires));
$this->updateIndex($key, $this->option('indexLimit'));
} catch (LapseCancelException $e) {
// do not cache exceptions
return null;
}
return $response;
}
private static function isCallable($value): bool
{
// do not call global helpers just methods or closures
return !is_string($value) && is_callable($value);
}
public function get($key)
{
if (!is_string($key)) {
$key = $this->keyFromObject($key);
$key = $this->hashKey($key);
}
return $this->cache()->get($key, null);
}
/**
* Removes a single cache file
*
* @param $key
*
* @return bool
*/
public function remove($key): bool
{
if (!is_string($key)) {
$key = $this->keyFromObject($key);
$key = $this->hashKey($key);
}
if ($this->option('indexLimit')) {
$index = $this->cache()->get(self::INDEX, []);
$idx = array_search($key, array_column($index, 0));
if ($idx !== false) {
unset($index[$idx]);
}
$this->cache()->set(self::INDEX, $index);
}
return $this->cache()->remove($key);
}
/**
* @param $value
* @return mixed
*/
public function serialize($value)
{
if (! $value) {
return null;
}
$value = self::isCallable($value) ? $value() : $value;
if (is_array($value)) {
$items = [];
foreach ($value as $key => $item) {
$items[$key] = $this->serialize($item);
}
return $items;
}
if (is_a($value, 'Kirby\Content\Field')) {
return $value->value();
}
return $value;
}
/**
* @param $key
* @return string
*/
public function keyFromObject($key): string
{
if (is_string($key)) {
return $key;
}
if (is_int($key) || is_bool($key) || is_numeric($key)) {
return strval($key);
}
if (is_array($key) || $key instanceof \Kirby\Toolkit\Iterator) {
$items = [];
foreach ($key as $item) {
$items[] = $this->keyFromObject($item);
}
return implode($items);
}
if (is_object($key) && (
$key instanceof \Kirby\Cms\Site ||
$key instanceof \Kirby\Cms\Page ||
$key instanceof \Kirby\Cms\File ||
$key instanceof \Kirby\Cms\FileVersion
)
) {
$modified = '';
// lookup modified zero-cost...
// do NOT read file from disk: && $key->autoid()->isNotEmpty()
if ($this->option('autoid') || $this->option('boost')) {
// @codeCoverageIgnoreStart
// use obj not string so autoid can index if needed
$modified = modified($key);
// autoid will check file on disk if needed
/*
if (!$modified) {
$modified = $key->modified();
}
*/
// @codeCoverageIgnoreEnd
} else {
// ... or check file on disk now
if ($key instanceof \Kirby\Cms\Site) {
// site->modified() would be ALL content files
$modified = site()->modifiedTimestamp();
} else {
$modified = $key->modified();
}
}
// also factor in modified for default language in case there are non-translatable fields
// BUT do not use as key but concat so it creates a caches for each language.
// this has nothing to do with uuids or autoid or boost but only with what one would expect
// the automatic key of lapse per object to be.
if (kirby()->multilang()) {
if ($key instanceof \Kirby\Cms\Site) {
$siteFile = site()->storage()->contentFile(
site()->storage()->defaultVersion(),
kirby()->defaultLanguage()->code()
)[0];
$modified = $modified . filemtime($siteFile);
} else {
$modified = $modified . $key->modified(kirby()->defaultLanguage()->code());
}
}
return $key->id() . $modified;
}
if (is_object($key) && in_array(get_class($key), [Field::class])) {
return $key->key() . hash('xxh3', $key->value());
}
return strval($key);
}
/**
* @param string $key
* @return string
*/
public function hashKey(string $key): string
{
$hash = strval(hash('xxh3', $key . self::SALT));
if ($lang = $this->option('languageCode')) {
$hash .= '-' . $lang;
}
return $hash;
}
/**
* @param string|null $key
* @param null $indexLimit
* @return int|null
*/
public function updateIndex(?string $key = null, $indexLimit = null): ?int
{
if (!$indexLimit) {
return null;
}
$index = $this->cache()->get(self::INDEX, []);
if ($key) {
array_push($index, [$key, microtime(true)]);
}
if (count($index) > $indexLimit) {
// sort by time
array_multisort(array_column($index, 1), SORT_DESC, $index);
// get keys to remove
$remove = array_column(array_slice($index, $indexLimit), 0);
foreach ($remove as $keyToRemove) {
$this->cache()->remove($keyToRemove);
}
// keep those not removed
$index = array_slice($index, 0, $indexLimit);
}
$this->cache()->set(self::INDEX, $index);
return count($index);
}
/**
* @return bool
*/
public function prune(): bool
{
return $this->updateIndex(null, self::INDEX_LIMIT) <= self::INDEX_LIMIT;
}
/**
* Removes all cache files created by this plugin
* @return bool
*/
public function flush(): bool
{
$success = false;
try {
$success = $this->cache()->flush();
} catch (Exception $e) {
//
}
return $success;
}
/*
* @var \Bnomei\Lapse
*/
private static $singleton;
public static function singleton()
{
if (! self::$singleton) {
self::$singleton = new self();
}
return self::$singleton;
}
/**
* @param $key
* @param null $value
* @param null $expires
* @return array|mixed|null
*/
public static function io($key, $value = null, $expires = null)
{
return self::singleton()->getAndSetIfMissingOrExpired($key, $value, $expires);
}
public static function gt($key)
{
return self::singleton()->get($key);
}
public static function rm($key)
{
return self::singleton()->remove($key);
}
/**
* @param $key
* @return string
*/
public static function hash($key)
{
$lapse = self::singleton();
if (!is_string($key)) {
$key = $lapse->keyFromObject($key);
$key = $lapse->hashKey($key);
}
return $lapse->hashKey($key);
}
}