1
0

adding kirby3-janitor

This commit is contained in:
Philip Wagner
2024-10-14 14:22:24 +02:00
parent b0db09492d
commit 94fbb996f0
204 changed files with 27855 additions and 4 deletions

View File

@@ -10,6 +10,13 @@ columns:
fields:
type: fields
fields:
janitor:
type: janitor
label: Copy from de to here
progress: Working...
job: copyFromDe
data: 'layout'
confirm: "This will replace everything under here"
layout:
label: Layout
type: layout

View File

@@ -32,6 +32,13 @@ tabs:
work:
label: Projekt
fields:
janitor:
type: janitor
label: Copy from de to here
progress: Working...
job: copyFromDe
data: 'layout'
confirm: "This will replace everything under here"
layout:
label: Layout
type: layout

View File

@@ -3,6 +3,18 @@
return [
'languages' => true,
'debug' => true,
'bnomei.janitor.jobs' => [
'copyFromDe' => function (Kirby\Cms\Page $page = null, string $data) {
$content = $page->content('de')->toArray();
$page->update([$data => $content[$data]], kirby()->languageCode());
return [
'status' => 200,
'label' => 'Done',
'reload' => true
];
},
],
'thumbs' => [
'srcsets' => [
'full' => [

View File

@@ -0,0 +1,25 @@
[*.{css,scss,less,js,json,ts,sass,html,hbs,mustache,phtml,html.twig,md,yml}]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
insert_final_newline = true
[*.md, *.txt]
indent_size = 4
trim_trailing_whitespace = false
[site/templates/**.php]
indent_size = 2
[site/snippets/**.php]
indent_size = 2
[package.json,.{babelrc,editorconfig,eslintrc,lintstagedrc,stylelintrc}]
indent_style = space
indent_size = 2
[composer.json]
indent_size = 4

View File

@@ -0,0 +1,18 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:vue/recommended",
"prettier"
],
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"rules": {
"vue/require-default-prop": "off"
}
}

View File

@@ -0,0 +1,19 @@
<?php
$finder = PhpCsFixer\Finder::create()
->exclude('content')
->exclude('kirby')
->exclude('node_modules')
//->exclude('site/plugins')
->exclude('src')
->exclude('vendor')
->in(__DIR__)
;
return (new PhpCsFixer\Config())
->setRules([
'@PSR12' => true,
])
->setFinder($finder)
;

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Bruno Meilick
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,67 @@
# Kirby 3 Janitor
![Release](https://flat.badgen.net/packagist/v/bnomei/kirby3-janitor?color=ae81ff)
![Downloads](https://flat.badgen.net/packagist/dt/bnomei/kirby3-janitor?color=272822)
[![Build Status](https://flat.badgen.net/travis/bnomei/kirby3-janitor)](https://travis-ci.com/bnomei/kirby3-janitor)
[![Coverage Status](https://flat.badgen.net/coveralls/c/github/bnomei/kirby3-janitor)](https://coveralls.io/github/bnomei/kirby3-janitor)
[![Maintainability](https://flat.badgen.net/codeclimate/maintainability/bnomei/kirby3-janitor)](https://codeclimate.com/github/bnomei/kirby3-janitor)
[![Twitter](https://flat.badgen.net/badge/twitter/bnomei?color=66d9ef)](https://twitter.com/bnomei)
Kirby 3 Plugin for running jobs.
- It is a Panel Button!
- It has jobs build-in for cleaning the cache, sessions, create zip-backup, pre-generate thumbs, open URLs, refresh the current Panel page and more.
- You can define your own jobs (call API hooks, play a game, hack a server, ...)
- It can be triggered in your frontend code and with CRON.
- It can also be used as a CLI with fancy output.
- It can also create logs of what it did.
## Install
Using composer:
```bash
composer require bnomei/kirby3-janitor
```
Using git submodules:
```bash
git submodule add https://github.com/bnomei/kirby3-janitor.git site/plugins/kirby3-janitor
```
Using download & copy: download [the latest release](https://github.com/bnomei/kirby3-janitor/releases) and copy to `site/plugins`
## Commerical Usage
> <br>
><b>Support open source!</b><br><br>
> This plugin is free but if you use it in a commercial project please consider to sponsor me or make a donation.<br>
> If my work helped you to make some cash it seems fair to me that I might get a little reward as well, right?<br><br>
> Be kind. Share a little. Thanks.<br><br>
> &dash; Bruno<br>
> &nbsp;
| M | O | N | E | Y |
|---|----|---|---|---|
| [Github sponsor](https://github.com/sponsors/bnomei) | [Patreon](https://patreon.com/bnomei) | [Buy Me a Coffee](https://buymeacoff.ee/bnomei) | [Paypal dontation](https://www.paypal.me/bnomei/15) | [Hire me](mailto:b@bnomei.com?subject=Kirby) |
## Wiki
Continue to the [Janitor Wiki](https://github.com/bnomei/kirby3-janitor/wiki) to read more on how to install, setup and use this plugin.
## Dependencies
- [Symfony Finder](https://symfony.com/doc/current/components/finder.html)
- [CLIMate](https://github.com/thephpleague/climate)
## Disclaimer
This plugin is provided "as is" with no guarantee. Use it at your own risk and always test it yourself before using it in a production environment. If you find any issues, please [create a new issue](https://github.com/bnomei/kirby3-janitor/issues/new).
## License
[MIT](https://opensource.org/licenses/MIT)
It is discouraged to use this plugin in any project that promotes racism, sexism, homophobia, animal abuse, violence or any other form of hate speech.

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Bnomei;
use Kirby\Cms\Page;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Dir;
use Kirby\Toolkit\F;
use Symfony\Component\Finder\Finder;
use ZipArchive;
final class BackupZipJob extends JanitorJob
{
/** @var array */
private $options;
public function __construct(?Page $page = null, ?string $data = null)
{
parent::__construct($page, $data);
$this->options = [
'ulimit' => option('bnomei.janitor.backupzip.ulimit', 512), // 1024 seems to be unix default
'date' => option('bnomei.janitor.backupzip.date', null), // null to disable, 'since 1 day ago'
'roots' => option('bnomei.janitor.backupzip.roots', function () {
return [
kirby()->roots()->accounts(),
kirby()->roots()->content(),
];
}),
'target' => option('bnomei.janitor.backupzip.target', function () {
$dir = realpath(kirby()->roots()->accounts() . '/../') . '/backups';
Dir::make($dir);
$prefix = option('bnomei.janitor.backupzip.prefix', '');
return $dir . '/' . $prefix . time() . '.zip'; // date('Y-m-d')
}),
];
foreach ($this->options as $key => $value) {
if (!is_string($value) && is_callable($value)) {
$this->options[$key] = $value();
}
}
}
/**
* @param string|null $key
* @return array
*/
public function option(?string $key = null)
{
if ($key) {
return A::get($this->options, $key);
}
return $this->options;
}
public static function directory(): string
{
return \dirname((string) (new self())->option('target'));
}
/**
* @return array
*/
public function job(): array
{
$time = time();
$climate = \Bnomei\Janitor::climate();
$zipPath = (string) $this->option('target');
$zip = new ZipArchive();
if ($zip->open($zipPath, ZIPARCHIVE::CREATE | ZIPARCHIVE::OVERWRITE) !== true) {
if ($climate) {
$climate->red('Failed to create: ' . $zipPath);
}
return [
'status' => 500,
];
}
$roots = $this->option('roots');
$finder = new Finder();
$finder->files()->in($roots);
if ($date = $this->option('date')) {
$finder->date($date);
}
$count = iterator_count($finder); // closing of zip
$ulimit = $this->option('ulimit');
$zipped = 0;
if ($climate) {
$climate->out('Files: ' . $count);
}
$progress = null;
if ($count && $climate) {
$progress = $climate->progress()->total($count);
}
foreach ($finder as $file) {
$filePath = $file->getPath() . DIRECTORY_SEPARATOR . $file->getFilename();
$localFilePath = $filePath;
foreach ($roots as $root) {
$localFilePath = str_replace(dirname($root), '', $localFilePath);
}
if ($zip->addFile($filePath, $localFilePath)) {
$mime = F::mime($filePath);
if (in_array($mime, [
'application/json', 'text/json',
'application/yaml', 'text/yaml',
'text/html',
'text/plain',
'text/xml',
'application/x-javascript',
'text/css',
'text/csv', 'text/x-comma-separated-values',
'text/comma-separated-values', 'application/octet-stream',
])) {
$zip->setCompressionName($filePath, ZipArchive::CM_DEFLATE);
} else {
$zip->setCompressionName($filePath, ZipArchive::CM_STORE);
}
$zipped++;
if ($progress && $climate) {
$progress->current($zipped);
}
if ($zipped % $ulimit === 0) {
$zip->close();
if ($zip->open($zipPath) === false) {
@unlink($zipPath);
if ($climate) {
$climate->red('Hit ulimit but failed to reopen zip: ' . $zipPath);
}
return [
'status' => 500,
'error' => 'Hit ulimit but failed to reopen zip: ' . $zipPath,
];
}
}
}
}
if ($climate) {
$climate->out('Closing zip...');
}
$zip->close();
if ($climate) {
$climate->out($zipPath);
}
return [
'status' => $zipped > 0 ? 200 : 204,
'duration' => time() - $time,
'filename' => \basename($zipPath, '.zip'),
'files' => $zipped,
'nicesize' => \Kirby\Toolkit\F::niceSize($zipPath),
'modified' => date('d/m/Y, H:i:s', \Kirby\Toolkit\F::modified($zipPath)),
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Bnomei;
use Kirby\Toolkit\F;
use Symfony\Component\Finder\Finder;
final class CleanCacheFilesJob extends JanitorJob
{
/**
* @return array
*/
public function job(): array
{
$dir = kirby()->roots()->cache();
$removed = 0;
$finder = new Finder();
$finder->files()->name('*.cache')->in($dir);
$count = iterator_count($finder);
$climate = \Bnomei\Janitor::climate();
$progress = null;
if ($count && $climate) {
$progress = $climate->progress()->total($count);
}
foreach ($finder as $cacheFile) {
if (F::remove($cacheFile->getRealPath())) {
$removed++;
if ($progress && $climate) {
$progress->current($removed);
}
}
}
return [
'status' => $removed > 0 ? 200 : 204,
];
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Bnomei;
use Kirby\Toolkit\F;
final class CleanContentJob extends JanitorJob
{
private $progress;
private $climate;
/**
* based on cookbook by @texnixe
* https://getkirby.com/docs/cookbook/extensions/content-file-cleanup
*
* @return array
*/
public function job(): array
{
$kirby = kirby();
// Authenticate as almighty
$kirby->impersonate('kirby');
// Define your collection
// Don't use `$site->index()` for thousands of pages
$collection = $kirby->site()->index();
$time = microtime(true);
$count = $collection->count();
$updated = 0;
$this->climate = \Bnomei\Janitor::climate();
// set the fields to be ignored
$ignore = option('bnomei.jabitor.cleancontentjob.ignore', ['title', 'slug', 'template', 'sort']);
// call the script for all languages if multilang
if ($kirby->multilang() === true) {
$languages = $kirby->languages();
foreach ($languages as $language) {
$updated += $this->cleanUp($collection, $ignore, $language->code());
}
} else {
$updated += $this->cleanUp($collection, $ignore);
}
if ($this->climate) {
$this->climate->blue('duration: ' . round((microtime(true) - $time) * 1000) . 'ms');
$this->climate->blue('count: ' . $count);
$this->climate->blue('updated: ' . $updated);
}
return [
'status' => $updated > 0 ? 200 : 204,
];
}
/**
* based on cookbook by @texnixe
* https://getkirby.com/docs/cookbook/extensions/content-file-cleanup
*/
protected function cleanUp($collection, $ignore = null, string $lang = null): int
{
$updated = 0;
foreach ($collection as $item) {
// get all fields in the content file
$contentFields = $item->content($lang)->fields();
// unset all fields in the `$ignore` array
foreach ($ignore as $field) {
if (array_key_exists($field, $contentFields) === true) {
unset($contentFields[$field]);
}
}
// get the keys
$contentFields = array_keys($contentFields);
// get all field keys from blueprint
$blueprintFields = array_keys($item->blueprint()->fields());
// get all field keys that are in $contentFields but not in $blueprintFields
$fieldsToBeDeleted = array_diff($contentFields, $blueprintFields);
// update page only if there are any fields to be deleted
if (count($fieldsToBeDeleted) > 0) {
// flip keys and values and set new values to null
$data = array_map(function ($value) {
return null;
}, array_flip($fieldsToBeDeleted));
// try to update the page with the data
try {
$item->update($data, $lang);
$updated++;
if ($this->climate) {
$this->climate->green('+++ ' . $item->id());
}
} catch (\Exception $e) {
if ($this->climate) {
$this->climate->red('ERR ' . $item->id() . ': ' .$e->getMessage());
}
}
} else {
if ($this->climate) {
$this->climate->white('=== ' . $item->id());
}
}
}
return $updated;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Bnomei;
final class CleanSessionsJob extends JanitorJob
{
/**
* @return array
*/
public function job(): array
{
$success = kirby()->app()->session()->store()->collectGarbage();
return [
'status' => $success ? 200 : 204,
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Bnomei;
final class ContextJob extends JanitorJob
{
/**
* @return array
*/
public function job(): array
{
return [
'status' => 200,
'label' => $this->page()->title()->value() . ' ' . $this->data(),
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Bnomei;
final class FlushLapseJob extends JanitorJob
{
/**
* @return array
*/
public function job(): array
{
$success = false;
if (class_exists('\Bnomei\Lapse')) {
$success = \Bnomei\Lapse::singleton()->flush();
}
return [
'status' => $success ? 200 : 204,
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Bnomei;
final class FlushPagesCacheJob extends JanitorJob
{
/**
* @return array
*/
public function job(): array
{
return [
'status' => kirby()->cache('pages')->flush() ? 200 : 404,
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Bnomei;
final class FlushRedisDBJob extends JanitorJob
{
/**
* @return array
*/
public function job(): array
{
$success = false;
if (class_exists('\Bnomei\Redis')) {
$redis = new \Bnomei\Redis();
if ($redis->redisClient()->dbsize() > 1) {
// DANGER: $this->connection->flushdb()
$redis->flush();
$success = $redis->redisClient()->dbsize() === 0;
}
}
return [
'status' => $success ? 200 : 204,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Bnomei;
use Kirby\Toolkit\F;
use Symfony\Component\Finder\Finder;
final class FlushSessionFilesJob extends JanitorJob
{
/**
* @return array
*/
public function job(): array
{
$dir = kirby()->root('sessions');
$removed = 0;
$finder = new Finder();
$finder->files()->name('*.sess')->in($dir);
$count = iterator_count($finder);
$climate = \Bnomei\Janitor::climate();
$progress = null;
if ($count && $climate) {
$progress = $climate->progress()->total($count);
}
foreach ($finder as $cacheFile) {
if (F::remove($cacheFile->getRealPath())) {
$removed++;
if ($progress && $climate) {
$progress->current($removed);
}
}
}
return [
'status' => $removed > 0 ? 200 : 204,
];
}
}

View File

@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace Bnomei;
use Kirby\Cms\File;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use League\CLImate\CLImate;
final class Janitor
{
/**
* @var array
*/
private $options;
/**
* Janitor constructor.
* @param array $options
*/
public function __construct(array $options = [])
{
$defaults = [
'debug' => option('debug'),
'log' => option('bnomei.janitor.log.fn'),
'jobs' => option('bnomei.janitor.jobs'),
'jobs-defaults' => ['bnomei.janitor.jobs-defaults'],
'jobs-extends' => option('bnomei.janitor.jobs-extends'),
'secret' => option('bnomei.janitor.secret'),
];
$this->options = array_merge($defaults, $options);
$extends = array_merge($this->options['jobs-defaults'], $this->options['jobs-extends']);
foreach ($extends as $extend) {
// NOTE: it is intended that jobs override merged not other way around
$this->options['jobs'] = array_change_key_case(
array_merge(option($extend, []), $this->options['jobs'])
);
}
foreach ($this->options as $key => $call) {
if (is_callable($call) && in_array($key, ['secret'])) {
$this->options[$key] = $call();
}
}
}
/**
* @param string|null $key
* @return array
*/
public function option(?string $key = null)
{
if ($key) {
return A::get($this->options, $key);
}
return $this->options;
}
/**
* @param string $secret
* @param string $name
* @param array $data
* @return array
*/
public function jobWithSecret(string $secret, string $name, array $data = []): array
{
if ($secret === $this->option('secret')) {
return $this->job($name, $data);
}
return [
'status' => 401,
];
}
/**
* @param string $name
* @param array $data
* @return array
*/
public function job(string $name, array $data = []): array
{
$job = $this->findJob($name);
if (!is_string($job) && is_callable($job)) {
return $this->jobFromCallable($job, $data);
} elseif (class_exists($job)) {
return $this->jobFromClass($job, $data);
}
return [
'status' => 404,
];
}
/**
* @return mixed
*/
public function listJobs()
{
// find in jobs config
return array_keys($this->option('jobs'));
}
/**
* @param string $name
* @return mixed|string
*/
public function findJob(string $name)
{
// find in jobs config
$jobInConfig = A::get($this->option('jobs'), strtolower($name));
if ($jobInConfig) {
return $jobInConfig;
}
// could be a class
return $name;
}
/**
* @param $job
* @param array $data
* @return array
*/
public function jobFromCallable($job, array $data): array
{
$return = false;
try {
set_time_limit(0);
} catch (\Exception $ex) {
// ignore
}
try {
$return = $job(
page(str_replace('+', '/', urldecode(A::get($data, 'contextPage', '')))),
str_replace('+S_L_A_S_H+', '/', urldecode(A::get($data, 'contextData', '')))
);
} catch (\BadMethodCallException $ex) {
$return = $job();
}
if (is_array($return)) {
return $return;
}
return [
'status' => $return ? 200 : 404,
];
}
/**
* @param string $job
* @param array $data
* @return array
*/
public function jobFromClass(string $job, array $data): array
{
$object = new $job(
page(str_replace('+', '/', urldecode(A::get($data, 'contextPage', '')))),
str_replace('+S_L_A_S_H+', '/', urldecode(A::get($data, 'contextData', '')))
);
if (method_exists($object, 'job')) {
try {
set_time_limit(0);
} catch (\Exception $ex) {
// ignore
}
return $object->job();
}
return [
'status' => 400,
];
}
/**
* @param string $msg
* @param string $level
* @param array $context
* @return bool
*/
public function log(string $msg = '', string $level = 'info', array $context = []): bool
{
$log = $this->option('log');
if ($log && is_callable($log)) {
if (!$this->option('debug') && $level == 'debug') {
// skip but...
return true;
} else {
return $log($msg, $level, $context);
}
}
return false;
}
/*
* @var Janitor
*/
private static $singleton;
/**
* @param array $options
* @return Janitor
*/
public static function singleton(array $options = []): Janitor
{
if (self::$singleton) {
return self::$singleton;
}
self::$singleton = new Janitor($options);
return self::$singleton;
}
/**
* @param string|null $template
* @param mixed|null $model
* @return string
*/
public static function query(string $template = null, $model = null): string
{
$page = null;
$file = null;
$user = kirby()->user();
if ($model && $model instanceof Page) {
$page = $model;
} elseif ($model && $model instanceof File) {
$file = $model;
} elseif ($model && $model instanceof User) {
$user = $model;
}
return Str::template($template, [
'kirby' => kirby(),
'site' => kirby()->site(),
'page' => $page,
'file' => $file,
'user' => $user,
'model' => $model ? get_class($model) : null,
]);
}
/**
* @param $val
* @param bool $return_null
* @return bool
*/
public static function isTrue($val, $return_null = false): bool
{
$boolval = (is_string($val) ? filter_var($val, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) : (bool) $val);
$boolval = ($boolval === null && !$return_null ? false : $boolval);
return $boolval;
}
/**
* @var \League\CLImate\CLImate
*/
private static $climate;
/**
* @param CLImate|null $climate
* @return CLImate|null
*/
public static function climate(?CLImate $climate = null): ?CLImate
{
if ($climate && !self::$climate) {
self::$climate = $climate;
}
return self::$climate;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Bnomei;
use Kirby\Cms\Page;
abstract class JanitorJob implements Job
{
/*
* @var Page
*/
private $page;
/*
* @var string
*/
private $data;
public function __construct(?Page $page = null, ?string $data = null)
{
$this->page = $page;
$this->data = $data;
}
/**
* @return string|null
*/
public function data(): ?string
{
return $this->data;
}
/**
* @return Page|null
*/
public function page(): ?Page
{
return $this->page;
}
/**
* @return array
*/
abstract public function job(): array;
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Bnomei;
use Kirby\Cms\Page;
interface Job
{
public function __construct(?Page $page = null, ?string $data = null);
public function data(): ?string;
public function page(): ?Page;
public function job(): array;
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Bnomei;
use League\CLImate\Util\Writer\WriterInterface;
class QuietWriter implements WriterInterface
{
public function write($content)
{
// be quiet here
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Bnomei;
final class ReindexAutoIDJob extends JanitorJob
{
/**
* @return array
*/
public function job(): array
{
$success = false;
if (class_exists('\Bnomei\AutoID')) {
$success = \Bnomei\AutoID::index(true) > 0;
}
return [
'status' => $success ? 200 : 204,
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Bnomei;
final class ReindexSearchForKirbyJob extends JanitorJob
{
/**
* @return array
*/
public function job(): array
{
if (class_exists('\Kirby\Search\Index')) {
try {
(new \Kirby\Search\Index())->build();
} catch (\Exception $e) {
return [
'status' => 500,
'error' => $e->getMessage(),
];
}
return [
'status' => 200,
];
}
return [
'status' => 204,
];
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace Bnomei;
use Exception;
use Kirby\Cms\Media;
use Kirby\Cms\Page;
use Kirby\Cms\Pages;
use Kirby\Filesystem\Dir;
use Kirby\Http\Remote;
use Kirby\Toolkit\Query;
use Kirby\Toolkit\Str;
use Symfony\Component\Finder\Finder;
final class RenderJob extends JanitorJob
{
/**
* @var int|void
*/
private $countPages;
/**
* @var bool
*/
private $verbose;
/**
* @var array
*/
private $failed;
/**
* @var array
*/
private $found;
private $countLanguages;
private $renderSiteUrl;
/**
* @var mixed
*/
private $renderTemplate;
/**
* @return array
*/
public function job(): array
{
$kirby = kirby();
$climate = Janitor::climate();
$progress = null;
$this->verbose = $climate ? $climate->arguments->defined('verbose') : false;
$time = time();
// make sure the thumbs are triggered
$kirby->cache('pages')->flush();
if (class_exists('\Bnomei\Lapse')) {
Lapse::singleton()->flush();
}
// visit all pages to generate media/*.job files
$allPages = $this->getAllPagesIDs();
$this->countPages = count($allPages);
$this->countLanguages = $kirby->languages()->count() > 0 ? $kirby->languages()->count() : 1;
$visited = 0;
if ($climate) {
$climate->out('Languages: ' . $this->countLanguages);
$climate->out('Pages: ' . $this->countPages);
$climate->blue('Rendering Pages...');
}
if ($this->countPages && $climate) {
$progress = $climate->progress()->total($this->countPages);
}
$this->failed = [];
$this->found = [];
$this->renderSiteUrl = rtrim((string)\option('bnomei.janitor.renderSiteUrl')(), '/');
foreach ($allPages as $pageId) {
try {
$content = '';
if (strlen($this->renderSiteUrl) > 0) {
$content = $this->remoteGetPage($pageId);
} else {
$content = $this->renderPage($pageId);
}
if ($this->verbose && strlen($content) > 0) {
$this->verboseCheckContent($content);
}
} catch (Exception $ex) {
$this->failed[] = $pageId . ': ' . $ex->getMessage();
}
$visited++;
if ($progress && $climate) {
$progress->current($visited);
}
}
$this->found = array_unique($this->found);
if ($climate && count($this->found)) {
$climate->out('Found images with media/pages/* : ' . count($this->found));
}
if (count($this->failed)) {
$climate->out('Rendering failed for Pages: ' . count($this->failed));
foreach ($this->failed as $fail) {
$climate->red($fail);
}
}
$duration = time() - $time;
return [
'status' => $visited > 0 ? 200 : 204,
'duration' => $duration,
];
}
private function getAllPagesIDs(): array
{
$ids = [];
$allPages = null;
if ($this->data()) {
$allPages = (new Query(
$this->data(),
[
'kirby' => kirby(),
'site' => site(),
'page' => $this->page(),
]
))->result();
if (is_a($allPages, Page::class)) {
$allPages = new Pages([$allPages]);
}
foreach ($allPages as $page) {
$ids[] = $page->id(); // this should not fully load the page yet
}
}
if (!$allPages) {
$finder = new Finder();
$finder->directories()
->in(kirby()->roots()->content());
foreach ($finder as $folder) {
$id = $folder->getRelativePathname();
if (strpos($id, '_drafts') === false) {
$ids[] = ltrim(preg_replace('/\/*\d+_/', '/', $id), '/');
}
}
}
return $ids;
}
private function remoteGetPage(string $pageId): string
{
$content = Remote::get($this->renderSiteUrl . '/' . $pageId)->content();
foreach (kirby()->languages() as $lang) {
$content .= Remote::get($this->renderSiteUrl . '/' . $lang->code() . '/' . $pageId)->content();
}
return $content;
}
private function verboseCheckContent(string $content)
{
preg_match_all('~/media/pages/([a-zA-Z0-9-_./]+.(?:png|jpg|jpeg|webp|avif|gif))~', $content, $matches);
if ($matches && count($matches) > 1) {
$this->found = array_merge($this->found, $matches[1]);
}
}
private function renderPage(string $pageId)
{
$page = page($pageId);
$content = '';
if ($this->countLanguages > 1) {
$content = $page->render();
foreach (kirby()->languages() as $lang) {
site()->visit($page, $lang->code());
$content .= $page->render();
}
} else {
site()->visit($page);
$content = $page->render();
}
return $content;
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Bnomei;
use Kirby\Cms\Media;
use Kirby\Cms\Page;
use Kirby\Cms\Pages;
use Kirby\Data\Data;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Http\Remote;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Query;
use Kirby\Toolkit\Str;
use Symfony\Component\Finder\Finder;
final class ThumbsJob extends JanitorJob
{
/**
* @return array
*/
public function job(): array
{
$climate = Janitor::climate();
$progress = null;
$verbose = $climate ? $climate->arguments->defined('verbose') : false;
$time = time();
$root = realpath(kirby()->roots()->index() . '/media/') . '/pages';
if ($this->page() && $this->data()) {
$root = $this->page()->mediaRoot();
}
Dir::make($root);
if ($verbose) {
$finder = new Finder();
$finder->files()
->in($root)
->name('/\.(?:avif|png|jpg|jpeg|webp|gif)$/');
if ($climate) {
$climate->out('Thumbs found: ' . iterator_count($finder));
}
}
$finder = new Finder();
$finder->files()
->in($root)
->ignoreDotFiles(false)
->name('/\.json$/');
$countJobs = iterator_count($finder);
$jobs = 0;
$created = 0;
$jobsSkipped = [];
if ($climate) {
$climate->out('Jobs found: ' . $countJobs);
}
if ($countJobs && $climate) {
$climate->blue('Generating Thumbs...');
$progress = $climate->progress()->total($countJobs);
}
foreach ($finder as $file) {
$jobs++;
$parentID = null;
$page = null;
$page = null;
if (preg_match('/.*\/media\/pages\/(.*)\/.*-[\d]*\/\.jobs/', $file->getPath(), $matches)) {
$page = page($matches[1]);
}
if (!$page) {
$jobsSkipped[] = 'Page not found: ' . $parentID;
continue;
}
$path = $file->getPath() . '/' . $file->getFilename();
$options = Data::read($path);
$jobFilename = $file->getFilenameWithoutExtension();
$filename = A::get($options, 'filename');
$pageFile = $page->file($filename);
if (!$pageFile) {
$jobsSkipped[] = 'File not found: ' . $parentID . '/' . $filename;
continue;
}
$hash = basename(str_replace('/.jobs', '', $file->getPath()));
if (Media::link($page, $hash, $jobFilename) !== false) {
$created++;
}
if ($progress && $climate) {
$progress->current($jobs);
}
}
$duration = time() - $time;
if ($climate) {
$climate->out('Thumbs created: ' . $created);
if ($jobsSkipped) {
$climate->out('Jobs executed: ' . $jobs);
$climate->out('Jobs skipped: ' . count($jobsSkipped));
foreach ($jobsSkipped as $skip) {
$climate->red($skip);
}
}
$climate->out('Duration in seconds: ' . strval($duration));
}
return [
'status' => $created > 0 ? 200 : 204,
'duration' => $duration,
'thumbs' => [
'jobs' => $countJobs,
'created' => $created,
],
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Bnomei;
final class WhistleJob extends JanitorJob
{
/**
* @return array
*/
public function job(): array
{
return [
'status' => 200,
'label' => '♫',
];
}
}

View File

@@ -0,0 +1,75 @@
{
"name": "bnomei/kirby3-janitor",
"type": "kirby-plugin",
"version": "2.16.0",
"license": "MIT",
"description": "Kirby 3 Plugin for running jobs like cleaning the cache from within the Panel, PHP code or a cronjob",
"authors": [
{
"name": "Bruno Meilick",
"email": "b@bnomei.com"
}
],
"keywords": [
"kirby3",
"kirby3-cms",
"kirby3-plugin",
"cache",
"clean",
"janitor",
"job-runner",
"cronjob",
"ajax",
"button"
],
"autoload": {
"psr-4": {
"Bnomei\\": "classes/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true,
"allow-plugins": {
"getkirby/composer-installer": true
}
},
"require": {
"php": ">=7.4.0",
"getkirby/composer-installer": "^1.2",
"league/climate": "^3.7",
"symfony/deprecation-contracts": "2.5",
"symfony/finder": "^5.4"
},
"require-dev": {
"getkirby/cms": "^3.5",
"php-coveralls/php-coveralls": "^2.4",
"phpunit/phpunit": "^9.5"
},
"scripts": {
"build": [
"yarn",
"yarn run build"
],
"analyze": "phpstan analyse classes",
"fix": "php-cs-fixer fix",
"test": [
"mkdir -p tests/logs",
"@putenv XDEBUG_MODE=coverage",
"phpunit --configuration ./phpunit.xml"
],
"dist": [
"composer install --no-dev --optimize-autoloader",
"git rm -rf --cached .; git add .;"
],
"kirby": [
"composer install",
"composer update",
"composer install --working-dir=tests/kirby --no-dev --optimize-autoloader",
"composer update --working-dir=tests/kirby"
]
},
"extra": {
"kirby-cms-path": "tests/kirby"
}
}

4619
site/plugins/kirby3-janitor/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
version: '3.8'
services:
webserver:
image: webdevops/php-apache:7.4
ports:
- 8000:80
environment:
WEB_DOCUMENT_ROOT: /app/tests/
WEB_ALIAS_DOMAIN: janitor.test
volumes:
- .:/app:rw

View File

@@ -0,0 +1 @@
.janitor{background-color:var(--color-text);color:#fff;border-radius:3px;padding:.5rem 1rem;line-height:1.25rem;text-align:left}.janitor:hover{background-color:#222}.janitor .k-button-text{opacity:1}.janitor.is-running{background-color:var(--color-border)}.janitor.is-running .k-button-text{color:var(--color-text)}.janitor.has-response{background-color:var(--color-text)}.janitor.is-success{background-color:var(--color-positive)}.janitor.has-error{background-color:var(--color-negative-light)}.visually-hidden{position:absolute;width:1px;height:1px;border:0;padding:0;margin:0;clip-path:inset(50%);overflow:hidden;white-space:nowrap}

View File

@@ -0,0 +1 @@
(()=>{(function(){"use strict";var p=function(){var t=this,e=t.$createElement,i=t._self._c||e;return i("div",{staticClass:"janitor-wrapper"},[i("k-button",{class:["janitor",t.button.state],attrs:{id:t.id,icon:t.currentIcon,job:t.job,disabled:!t.isUnsaved&&t.hasChanges},on:{click:t.runJanitor}},[t._v(" "+t._s(t.button.label||t.label)+" ")]),i("a",{directives:[{name:"show",rawName:"v-show",value:t.downloadRequest,expression:"downloadRequest"}],ref:"downloadAnchor",staticClass:"visually-hidden",attrs:{href:t.downloadRequest,download:""}}),i("a",{directives:[{name:"show",rawName:"v-show",value:t.urlRequest,expression:"urlRequest"}],ref:"tabAnchor",staticClass:"visually-hidden",attrs:{href:t.urlRequest,target:"_blank"}})],1)},v=[],q="";function g(t,e,i,c,o,r,l,h){var s=typeof t=="function"?t.options:t;e&&(s.render=e,s.staticRenderFns=i,s._compiled=!0),c&&(s.functional=!0),r&&(s._scopeId="data-v-"+r);var a;if(l?(a=function(n){n=n||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,!n&&typeof __VUE_SSR_CONTEXT__!="undefined"&&(n=__VUE_SSR_CONTEXT__),o&&o.call(this,n),n&&n._registeredComponents&&n._registeredComponents.add(l)},s._ssrRegister=a):o&&(a=h?function(){o.call(this,(s.functional?this.parent:this).$root.$options.shadowRoot)}:o),a)if(s.functional){s._injectStyles=a;var R=s.render;s.render=function(k,b){return a.call(b),R(k,b)}}else{var f=s.beforeCreate;s.beforeCreate=f?[].concat(f,a):[a]}return{exports:t,options:s}}const u="janitor.runAfterAutosave",m={props:{label:String,progress:String,job:String,cooldown:Number,status:String,data:String,pageURI:String,clipboard:Boolean,unsaved:Boolean,autosave:Boolean,intab:Boolean,confirm:String,icon:{type:[Boolean,String],default:!1}},data(){return{button:{label:null,state:null},downloadRequest:null,clipboardRequest:null,urlRequest:null,isUnsaved:!1,icons:{"is-running":"janitorLoader","is-success":"check","has-error":"alert"}}},computed:{id(){var t;return"janitor-"+this.hashCode(this.job+((t=this.button.label)!=null?t:"")+this.pageURI)},hasChanges(){return this.$store.getters["content/hasChanges"]()},currentIcon(){var t;return(t=this.icons[this.status])!=null?t:this.icon}},created(){this.$events.$on("model.update",()=>sessionStorage.getItem(u)&&location.reload()),sessionStorage.getItem(u)===this.id&&(sessionStorage.removeItem(u),this.runJanitor())},methods:{hashCode(t){let e=0;if(t.length===0)return e;for(const i of t)e=(e<<5)-e+t.charCodeAt(i),e=e&e;return e},async runJanitor(){if(this.confirm&&!window.confirm(this.confirm))return;if(this.autosave&&this.hasChanges){const e=document.querySelector(".k-panel .k-form-buttons .k-view").lastChild;if(e){this.isUnsaved=!1,sessionStorage.setItem(u,this.id),this.simulateClick(e);return}}if(this.clipboard){this.clipboardRequest=this.data,this.button.label=this.progress,this.button.state="is-success",setTimeout(this.resetButton,this.cooldown),this.$nextTick(()=>{this.copyToClipboard(this.data)});return}if(this.clipboardRequest){await this.copyToClipboard(this.clipboardRequest),this.resetButton(),this.clipboardRequest=null;return}if(this.status)return;let t=this.job+"/"+encodeURIComponent(this.pageURI);this.data&&(t=t+"/"+encodeURIComponent(this.data)),this.getRequest(t)},async getRequest(t){var h;this.button.label=(h=this.progress)!=null?h:`${this.label} \u2026`,this.button.state="is-running";const{label:e,status:i,reload:c,href:o,download:r,clipboard:l}=await this.$api.get(t);e&&(this.button.label=e),i?this.button.state=i===200?"is-success":"has-error":this.button.state="has-response",c&&location.reload(),o&&(this.intab?(this.urlRequest=o,this.$nextTick(()=>{this.simulateClick(this.$refs.tabAnchor)})):location.href=o),r&&(this.downloadRequest=r,this.$nextTick(()=>{this.simulateClick(this.$refs.downloadAnchor)})),l?this.clipboardRequest=l:setTimeout(this.resetButton,this.cooldown)},resetButton(){this.button.label=null,this.button.state=null},simulateClick(t){const e=new MouseEvent("click",{bubbles:!0,cancelable:!0,view:window});t.dispatchEvent(e)},async copyToClipboard(t){try{await navigator.clipboard.writeText(t)}catch{console.error("navigator.clipboard is not available")}}}},d={};var _=g(m,p,v,!1,w,null,null,null);function w(t){for(let e in d)this[e]=d[e]}var C=function(){return _.exports}();window.panel.plugin("bnomei/janitor",{fields:{janitor:C},icons:{janitorLoader:'<g fill="none" fill-rule="evenodd"><g transform="translate(1 1)" stroke-width="1.75"><circle cx="7" cy="7" r="7.2" stroke="#000" stroke-opacity=".2"/><path d="M14.2,7c0-4-3.2-7.2-7.2-7.2" stroke="#000"><animateTransform attributeName="transform" type="rotate" from="0 7 7" to="360 7 7" dur="1s" repeatCount="indefinite"/></path></g></g>'}})})();})();

View File

@@ -0,0 +1,210 @@
<?php
@include_once __DIR__ . '/vendor/autoload.php';
/*
janitor [noun]
one who keeps the premises of a building (such as an apartment or
office) clean, tends the heating system, and makes minor repairs
*/
Kirby::plugin('bnomei/janitor', [
'options' => [
'jobs' => [],
'jobs-defaults' => [
'clean' => 'Bnomei\\CleanCacheFilesJob', // legacy
'cleanCache' => 'Bnomei\\CleanCacheFilesJob',
'flush' => 'Bnomei\\FlushPagesCacheJob', // legacy
'flushPages' => 'Bnomei\\FlushPagesCacheJob',
'cleanSessions' => 'Bnomei\\CleanSessionsJob',
'cleanContent' => 'Bnomei\\CleanContentJob',
'flushSessions' => 'Bnomei\\FlushSessionFilesJob',
'flushLapse' => 'Bnomei\\FlushLapseJob',
'flushRedisDB' => 'Bnomei\\FlushRedisDBJob',
'reindexAutoID' => 'Bnomei\\ReindexAutoIDJob',
'reindexSearch' => 'Bnomei\\ReindexSearchForKirbyJob',
'backupZip' => 'Bnomei\\BackupZipJob',
'render' => 'Bnomei\\RenderJob',
'thumbs' => 'Bnomei\\ThumbsJob',
],
'jobs-extends' => [
'bnomei.lapse.jobs', // https://github.com/bnomei/kirby3-lapse/blob/master/index.php#L10
],
'label.cooldown' => 2000, // ms
'secret' => null,
'thumbsOnUpload' => false,
'renderSiteUrl' => function () {
$url = site()->url();
// $url = 'https://www.example.com/';
return php_sapi_name() === 'cli' ? $url : '';
},
'log.enabled' => false,
'log.fn' => function (string $msg, string $level = 'info', array $context = []): bool {
if (option('bnomei.janitor.log.enabled')) {
if (function_exists('monolog')) {
monolog()->{$level}($msg, $context);
} elseif (function_exists('kirbyLog')) {
kirbyLog('bnomei.janitor.log')->log($msg, $level, $context);
}
return true;
}
return false;
},
'icon' => false,
],
'snippets' => [
'maintenance' => __DIR__ . '/snippets/maintenance.php',
],
'fields' => [
'janitor' => [
'props' => [
'label' => function ($label = null) {
return \Kirby\Toolkit\I18n::translate($label, $label);
},
'progress' => function ($progress = null) {
return \Kirby\Toolkit\I18n::translate($progress, $progress);
},
'job' => function (?string $job = null) {
return 'plugin-janitor/' . $job;
},
'cooldown' => function (int $cooldownMilliseconds = 2000) {
return intval(option('bnomei.janitor.label.cooldown', $cooldownMilliseconds));
},
'data' => function (?string $data = null) {
$data = \Bnomei\Janitor::query($data, $this->model());
return str_replace(
'/',
'+S_L_A_S_H+',
\Kirby\Toolkit\I18n::translate($data, $data)
);
},
'clipboard' => function ($clipboard = null) {
return \Bnomei\Janitor::isTrue($clipboard);
},
'unsaved' => function ($allowUnsaved = true) {
return \Bnomei\Janitor::isTrue($allowUnsaved);
},
'autosave' => function ($doAutosave = false) {
return \Bnomei\Janitor::isTrue($doAutosave);
},
'intab' => function ($intab = false) {
return \Bnomei\Janitor::isTrue($intab);
},
'confirm' => function ($confirm = '') {
return $confirm;
},
'pageURI' => function () {
$uri = kirby()->site()->homePageId();
if (is_a($this->model(), \Kirby\Cms\Page::class)) {
$uri = $this->model()->uri();
}
if (is_a($this->model(), \Kirby\Cms\File::class)) {
$uri = $this->model()->parent()->uri();
}
if (is_a($this->model(), \Kirby\Cms\User::class)) {
$uri = $this->model()->panelPath();
}
if (is_a($this->model(), \Kirby\Cms\Site::class)) {
$uri = '$'; // any not empty string so route /$/DATA is used
}
return str_replace('/', '+', $uri);
},
'icon' => function ($icon = false) {
return $icon ?? option('bnomei.janitor.icon');
},
],
],
],
'routes' => [
[
'pattern' => 'plugin-janitor/(:any)/(:any)',
'action' => function (string $job, string $secret) {
$janitor = new \Bnomei\Janitor();
$janitor->log('janitor-api-secret', 'debug');
$response = $janitor->jobWithSecret($secret, $job);
return Kirby\Http\Response::json($response, A::get($response, 'status', 400));
},
],
],
'hooks' => [
'file.create:after' => function ($file) {
if (option('bnomei.janitor.thumbsOnUpload') && $file->isResizable()) {
janitor('render', $file->page(), 'page');
janitor('thumbs', $file->page(), 'page');
}
},
'route:before' => function () {
$isPanel = strpos(
kirby()->request()->url()->toString(),
kirby()->urls()->panel()
) !== false;
$isApi = strpos(
kirby()->request()->url()->toString(),
kirby()->urls()->api()
) !== false;
if (!$isPanel && !$isApi) {
if (F::exists(kirby()->roots()->index() . '/down')) {
snippet('maintenance');
die;
}
}
},
],
'api' => [
'routes' => [
[
'pattern' => 'plugin-janitor/(:any)/(:any)/(:any)',
'action' => function (string $job, string $page, string $data) {
$janitor = \Bnomei\Janitor::singleton();
$janitor->log('janitor-api-auth', 'debug');
return $janitor->job($job, [
'contextPage' => $page,
'contextData' => $data,
]);
},
],
[
'pattern' => 'plugin-janitor/(:any)/(:any)',
'action' => function (string $job, string $page) {
$janitor = \Bnomei\Janitor::singleton();
$janitor->log('janitor-api-auth', 'debug');
return $janitor->job($job, [
'contextPage' => $page,
]);
},
],
[
'pattern' => 'plugin-janitor/(:any)',
'action' => function (string $job) {
$janitor = \Bnomei\Janitor::singleton();
$janitor->log('janitor-api-auth', 'debug');
return $janitor->job($job);
}
],
],
],
]);
if (!class_exists('Bnomei\Janitor')) {
require_once __DIR__ . '/classes/Janitor.php';
}
if (!function_exists('janitor')) {
function janitor(string $job, ?\Kirby\Cms\Page $contextPage = null, ?string $contextData = null, bool $dump = false)
{
$janitor = \Bnomei\Janitor::singleton();
$janitor->log('janitor()', 'debug');
$response = $janitor->job($job, [
'contextPage' => $contextPage ? urlencode(str_replace('/', '+', $contextPage->uri())) : '',
'contextData' => $contextData ? urlencode($contextData) : '',
]);
if ($dump) {
return $response;
}
return intval(A::get($response, 'status')) === 200;
}
}

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
foreach([// janitor from zip with vendor
__DIR__ . '/vendor/autoload.php',
// janitor from composer in site/plugins/kirby3-janitor
realpath(__DIR__ . '/../../../') . '/vendor/autoload.php',
] as $file) {
if (file_exists($file)) {
require $file;
break;
}
}
ini_set('display_errors', '1');
$climate = new \League\CLImate\CLImate();
$climate->arguments->add([
'help' => [
'prefix' => 'h',
'longPrefix' => 'help',
'description' => 'Display the help',
'noValue' => true,
],
'list' => [
'prefix' => 'l',
'longPrefix' => 'list',
'description' => 'List all registered jobs',
'noValue' => true,
],
'format' => [
'prefix' => 'f',
'longPrefix' => 'format',
'description' => 'Format the output as "label", "table" or "json"',
'defaultValue' => 'label',
],
'kirby' => [
'prefix' => 'k',
'longPrefix' => 'kirby',
'description' => 'Relative path to Kirbys public folder',
'defaultValue' => '/',
],
'verbose' => [
'prefix' => 'v',
'longPrefix' => 'verbose',
'description' => 'Show debug info',
'noValue' => true,
],
'job' => [
'description' => 'Run a job',
],
'quiet' => [
'prefix' => 'q',
'longPrefix' => 'quiet',
'description' => 'Run jobs silently',
'noValue' => true,
],
'tinker' => [
'prefix' => 't',
'longPrefix' => 'tinker',
'description' => 'Run a REPL session',
'noValue' => true,
],
'down' => [
'prefix' => 'd',
'longPrefix' => 'down',
'description' => 'Start maintenance mode',
'noValue' => true,
],
'up' => [
'prefix' => 'u',
'longPrefix' => 'up',
'description' => 'Stop maintenance mode',
'noValue' => true,
],
]);
$climate->arguments->parse();
$verbose = $climate->arguments->defined('verbose');
// COMMAND: kirby
$kirbyPublicFolder = null;
foreach([ // site/plugins/kirby3-janitor => /index.php
realpath(__DIR__ . '/../../../'),
// site/plugins/kirby3-janitor => /public/index.php
realpath(__DIR__ . '/../../../public'),
] as $dir) {
if ($dir !== false && file_exists($dir . '/index.php')) {
$kirbyPublicFolder = $dir;
break;
}
}
if ($climate->arguments->defined('kirby')) {
$kirbyPublicFolder = realpath(__DIR__ . DIRECTORY_SEPARATOR . ltrim($climate->arguments->get('kirby'), DIRECTORY_SEPARATOR));
}
if ($kirbyPublicFolder) {
$kirbyLoader = $kirbyPublicFolder . DIRECTORY_SEPARATOR . 'janitor-' . sha1(__DIR__) . '.php';
if (! file_exists($kirbyLoader)) {
file_put_contents(
$kirbyLoader,
str_replace('echo ', '// echo ',
file_get_contents($kirbyPublicFolder . DIRECTORY_SEPARATOR . 'index.php')
)
);
if ($verbose) {
$climate->backgroundYellow()->out('ATTENTION! Janitor created this file to load the Kirby instance: ' . $kirbyLoader);
}
}
include $kirbyLoader;
if ($verbose) {
$climate->backgroundCyan()->out('Using Kirby instance from: ' . $kirbyLoader);
}
}
\Bnomei\Janitor::climate($climate);
$janitor = new \Bnomei\Janitor();
// COMMAND: help
$command = $climate->arguments->defined('help');
if ($command) {
$climate->usage();
}
// COMMAND: list
$command = $climate->arguments->defined('list');
if ($command) {
foreach ($janitor->listJobs() as $key) {
$climate->out($key);
}
}
// COMMAND: quiet
$command = $climate->arguments->get('quiet');
if ($command) {
$climate->output->add('quiet', new \Bnomei\QuietWriter());
$climate->output->defaultTo('quiet');
}
// COMMAND: tinker
$command = $climate->arguments->get('tinker');
if ($command) {
while (true) eval($climate->input('>>> ')->prompt());
}
// COMMAND: down
$command = $climate->arguments->get('down');
if ($command) {
file_put_contents(kirby()->roots()->index() . '/down', date('c'));
}
// COMMAND: up
$command = $climate->arguments->get('up');
if ($command) {
$down = kirby()->roots()->index() . '/down';
if (file_exists($down)) {
unlink($down);
}
}
// COMMAND: job
$job = $climate->arguments->get('job');
if ($job) {
$format = $climate->arguments->get('format');
if ($format === 'class') {
$janitor->job($job, [], $climate);
} elseif ($format === 'json') {
$climate->json($janitor->job($job));
} elseif ($format === 'table') {
$table = [];
foreach ($janitor->job($job) as $key => $value) {
$table[] = [$key, $value];
}
$climate->table($table);
} else {
$data = $janitor->job($job);
$status = $data['status'];
$label = array_key_exists('label', $data) ? $data['label'] : $status;
if ($status === 200) {
$climate->lightGreen($label);
} elseif ($status === 204) {
$climate->lightRed($label);
} elseif ($status === 404) {
$climate->backgroundRed($label);
} else {
$climate->out($label);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
{
"private": true,
"scripts": {
"dev": "kirbyup src/index.js --watch",
"build": "kirbyup src/index.js",
"lint": "eslint \"src/**/*.{js,vue}\"",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --write \"src/**/*.{js,vue}\""
},
"devDependencies": {
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-vue": "^7.19.1",
"kirbyup": "^0.18.0"
}
}

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maintenance</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
padding: 10%;
text-align: center;
line-height: 1.5em;
}
a {
color: inherit;
}
a:hover,
a:focus {
color: #000;
}
p {
max-width: 30em;
margin: 0 auto;
}
.notice {
font-weight: bold;
}
.admin-advice {
font-size: .8em;
font-style: italic;
color: #999;
padding-top: 3rem;
}
</style>
</head>
<body>
<p class="notice">
This page is currently in maintenance.
</p>
</body>
</html>

View File

@@ -0,0 +1,296 @@
<template>
<div class="janitor-wrapper">
<k-button
:id="id"
:class="['janitor', button.state]"
:icon="currentIcon"
:job="job"
:disabled="!isUnsaved && hasChanges"
@click="runJanitor"
>
{{ button.label || label }}
</k-button>
<a
v-show="downloadRequest"
ref="downloadAnchor"
class="visually-hidden"
:href="downloadRequest"
download
/>
<a
v-show="urlRequest"
ref="tabAnchor"
class="visually-hidden"
:href="urlRequest"
target="_blank"
/>
</div>
</template>
<script>
const STORAGE_ID = "janitor.runAfterAutosave";
export default {
props: {
label: String,
progress: String,
job: String,
cooldown: Number,
status: String,
data: String,
pageURI: String,
clipboard: Boolean,
unsaved: Boolean,
autosave: Boolean,
intab: Boolean,
confirm: String,
icon: {
type: [Boolean, String],
default: false,
},
},
data() {
return {
button: {
label: null,
state: null,
},
downloadRequest: null,
clipboardRequest: null,
urlRequest: null,
isUnsaved: false,
icons: {
"is-running": "janitorLoader",
"is-success": "check",
"has-error": "alert",
},
};
},
computed: {
id() {
return (
"janitor-" +
this.hashCode(this.job + (this.button.label ?? "") + this.pageURI)
);
},
hasChanges() {
return this.$store.getters["content/hasChanges"]();
},
currentIcon() {
return this.icons[this.status] ?? this.icon;
},
},
created() {
this.$events.$on(
"model.update",
() => sessionStorage.getItem(STORAGE_ID) && location.reload()
);
if (sessionStorage.getItem(STORAGE_ID) === this.id) {
sessionStorage.removeItem(STORAGE_ID);
this.runJanitor();
}
},
methods: {
/**
* Source: https://stackoverflow.com/a/8831937
*/
hashCode(str) {
let hash = 0;
if (str.length === 0) {
return hash;
}
for (const i of str) {
hash = (hash << 5) - hash + str.charCodeAt(i);
// convert to 32bit integer
hash = hash & hash;
}
return hash;
},
async runJanitor() {
if (this.confirm && !window.confirm(this.confirm)) {
return;
}
if (this.autosave && this.hasChanges) {
// lock janitor button, press save and listen to `model.update` event
const saveButton = document.querySelector(
".k-panel .k-form-buttons .k-view"
).lastChild;
// revert & save
if (saveButton) {
this.isUnsaved = false;
sessionStorage.setItem(STORAGE_ID, this.id);
this.simulateClick(saveButton);
return;
}
}
if (this.clipboard) {
this.clipboardRequest = this.data;
this.button.label = this.progress;
this.button.state = "is-success";
setTimeout(this.resetButton, this.cooldown);
this.$nextTick(() => {
this.copyToClipboard(this.data);
});
return;
}
if (this.clipboardRequest) {
await this.copyToClipboard(this.clipboardRequest);
this.resetButton();
this.clipboardRequest = null;
return;
}
if (this.status) {
return;
}
let url = this.job + "/" + encodeURIComponent(this.pageURI);
if (this.data) {
url = url + "/" + encodeURIComponent(this.data);
}
this.getRequest(url);
},
async getRequest(url) {
this.button.label = this.progress ?? `${this.label}`;
this.button.state = "is-running";
const { label, status, reload, href, download, clipboard } =
await this.$api.get(url);
if (label) {
this.button.label = label;
}
if (status) {
this.button.state = status === 200 ? "is-success" : "has-error";
} else {
this.button.state = "has-response";
}
if (reload) {
location.reload();
}
if (href) {
if (this.intab) {
this.urlRequest = href;
this.$nextTick(() => {
this.simulateClick(this.$refs.tabAnchor);
});
} else {
location.href = href;
}
}
if (download) {
this.downloadRequest = download;
this.$nextTick(() => {
this.simulateClick(this.$refs.downloadAnchor);
});
}
if (clipboard) {
this.clipboardRequest = clipboard;
} else {
setTimeout(this.resetButton, this.cooldown);
}
},
resetButton() {
this.button.label = null;
this.button.state = null;
},
simulateClick(element) {
const evt = new MouseEvent("click", {
bubbles: true,
cancelable: true,
view: window,
});
element.dispatchEvent(evt);
},
async copyToClipboard(content) {
try {
await navigator.clipboard.writeText(content);
} catch (err) {
console.error("navigator.clipboard is not available");
}
},
},
};
</script>
<style>
.janitor {
background-color: var(--color-text);
color: white;
border-radius: 3px;
padding: 0.5rem 1rem;
line-height: 1.25rem;
text-align: left;
}
.janitor:hover {
background-color: #222;
}
.janitor .k-button-text {
opacity: 1;
}
.janitor.is-running {
background-color: var(--color-border);
}
.janitor.is-running .k-button-text {
color: var(--color-text);
}
.janitor.has-response {
background-color: var(--color-text);
}
.janitor.is-success {
background-color: var(--color-positive);
}
.janitor.has-error {
background-color: var(--color-negative-light);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
border: 0;
padding: 0;
margin: 0;
clip-path: inset(50%);
overflow: hidden;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,11 @@
import Janitor from "./components/fields/Janitor.vue";
window.panel.plugin("bnomei/janitor", {
fields: {
janitor: Janitor,
},
icons: {
janitorLoader:
'<g fill="none" fill-rule="evenodd"><g transform="translate(1 1)" stroke-width="1.75"><circle cx="7" cy="7" r="7.2" stroke="#000" stroke-opacity=".2"/><path d="M14.2,7c0-4-3.2-7.2-7.2-7.2" stroke="#000"><animateTransform attributeName="transform" type="rotate" from="0 7 7" to="360 7 7" dur="1s" repeatCount="indefinite"/></path></g></g>',
},
});