diff --git a/composer.json b/composer.json
index 6081fe0..2378f70 100644
--- a/composer.json
+++ b/composer.json
@@ -11,7 +11,8 @@
"ext-yaml": "*",
"ext-gmp": "*",
"ext-memcached": "*",
- "samdark/sitemap": "^2.1"
+ "samdark/sitemap": "^2.1",
+ "twig/twig": "^3.0"
},
"repositories": [
{
diff --git a/composer.lock b/composer.lock
index 5fedc9b..7e7eb39 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "5fba09fb495209abe5954d5dc485a308",
+ "content-hash": "d75fee72f82dcd543f7ac9fd20971de8",
"packages": [
{
"name": "erusev/parsedown",
@@ -267,19 +267,20 @@
},
{
"name": "samdark/sitemap",
- "version": "2.1.0",
+ "version": "2.4.1",
"source": {
"type": "git",
"url": "https://github.com/samdark/sitemap.git",
- "reference": "6b7eed71534b31d0c6e6dfd18d3cca5a677e0b5b"
+ "reference": "cf514750781275ad90fc9a828b4330c9c5ccba98"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/samdark/sitemap/zipball/6b7eed71534b31d0c6e6dfd18d3cca5a677e0b5b",
- "reference": "6b7eed71534b31d0c6e6dfd18d3cca5a677e0b5b",
+ "url": "https://api.github.com/repos/samdark/sitemap/zipball/cf514750781275ad90fc9a828b4330c9c5ccba98",
+ "reference": "cf514750781275ad90fc9a828b4330c9c5ccba98",
"shasum": ""
},
"require": {
+ "ext-xmlwriter": "*",
"php": ">=5.3.0"
},
"require-dev": {
@@ -311,7 +312,17 @@
"issues": "https://github.com/samdark/sitemap/issues",
"source": "https://github.com/samdark/sitemap"
},
- "time": "2017-11-24T07:24:48+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/samdark",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/samdark",
+ "type": "patreon"
+ }
+ ],
+ "time": "2023-11-01T08:41:34+00:00"
},
{
"name": "scrivo/highlight.php",
@@ -390,6 +401,311 @@
}
],
"time": "2022-12-17T21:53:22+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.5.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
+ "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.5-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-25T14:20:29+00:00"
+ },
+ {
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.31.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-ctype": "*"
+ },
+ "suggest": {
+ "ext-ctype": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Ctype\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for ctype functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "ctype",
+ "polyfill",
+ "portable"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.31.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341",
+ "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-mbstring": "*"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for the Mbstring extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "mbstring",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "twig/twig",
+ "version": "v3.20.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/twigphp/Twig.git",
+ "reference": "3468920399451a384bef53cf7996965f7cd40183"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/twigphp/Twig/zipball/3468920399451a384bef53cf7996965f7cd40183",
+ "reference": "3468920399451a384bef53cf7996965f7cd40183",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1.0",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-ctype": "^1.8",
+ "symfony/polyfill-mbstring": "^1.3"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^2.0",
+ "psr/container": "^1.0|^2.0",
+ "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/Resources/core.php",
+ "src/Resources/debug.php",
+ "src/Resources/escaper.php",
+ "src/Resources/string_loader.php"
+ ],
+ "psr-4": {
+ "Twig\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com",
+ "homepage": "http://fabien.potencier.org",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "Twig Team",
+ "role": "Contributors"
+ },
+ {
+ "name": "Armin Ronacher",
+ "email": "armin.ronacher@active-4.com",
+ "role": "Project Founder"
+ }
+ ],
+ "description": "Twig, the flexible, fast, and secure template language for PHP",
+ "homepage": "https://twig.symfony.com",
+ "keywords": [
+ "templating"
+ ],
+ "support": {
+ "issues": "https://github.com/twigphp/Twig/issues",
+ "source": "https://github.com/twigphp/Twig/tree/v3.20.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/twig/twig",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-02-13T08:34:43+00:00"
}
],
"packages-dev": [],
@@ -409,6 +725,6 @@
"ext-gmp": "*",
"ext-memcached": "*"
},
- "platform-dev": [],
- "plugin-api-version": "2.3.0"
+ "platform-dev": {},
+ "plugin-api-version": "2.6.0"
}
diff --git a/deploy/build_css.sh b/deploy/build_css.sh
index c53157d..91fedab 100755
--- a/deploy/build_css.sh
+++ b/deploy/build_css.sh
@@ -1,10 +1,8 @@
#!/bin/sh
-PROGNAME="$0"
DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
ROOT="$(realpath "$DIR/../")"
CLEANCSS="$ROOT"/node_modules/clean-css-cli/bin/cleancss
-
. $DIR/build_common.sh
build_scss() {
diff --git a/deploy/build_js.sh b/deploy/build_js.sh
index 07a4285..22ff999 100755
--- a/deploy/build_js.sh
+++ b/deploy/build_js.sh
@@ -1,8 +1,6 @@
#!/bin/sh
-PROGNAME="$0"
DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
-
. $DIR/build_common.sh
# suckless version of webpack
diff --git a/deploy/gen_static_config.php b/deploy/gen_static_config.php
index dca6f87..f475279 100755
--- a/deploy/gen_static_config.php
+++ b/deploy/gen_static_config.php
@@ -61,4 +61,29 @@ EOF;
function get_hash(string $path): string {
return substr(sha1(file_get_contents($path)), 0, 8);
+}
+
+function glob_escape(string $pattern): string {
+ if (str_contains($pattern, '[') || str_contains($pattern, ']')) {
+ $placeholder = uniqid();
+ $replaces = array( $placeholder.'[', $placeholder.']', );
+ $pattern = str_replace( array('[', ']', ), $replaces, $pattern);
+ $pattern = str_replace( $replaces, array('[[]', '[]]', ), $pattern);
+ }
+ return $pattern;
+}
+
+/**
+ * Does not support flag GLOB_BRACE
+ *
+ * @param string $pattern
+ * @param int $flags
+ * @return array
+ */
+function glob_recursive(string $pattern, int $flags = 0): array {
+ $files = glob(glob_escape($pattern), $flags);
+ foreach (glob(glob_escape(dirname($pattern)).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
+ $files = array_merge($files, glob_recursive($dir.'/'.basename($pattern), $flags));
+ }
+ return $files;
}
\ No newline at end of file
diff --git a/engine/logging.php b/engine/logging.php
index 5f2b612..375922b 100644
--- a/engine/logging.php
+++ b/engine/logging.php
@@ -1,6 +1,35 @@
value + ($fg_bright ? 90 : 30);
+ if (!is_null($bg))
+ $codes[] = $bg->value + ($bg_bright ? 100 : 40);
+ if ($bold)
+ $codes[] = 1;
+
+ if (empty($codes))
+ return $text;
+
+ return "\033[".implode(';', $codes)."m".$text."\033[0m";
+}
enum LogLevel: int {
case ERROR = 10;
@@ -32,15 +61,15 @@ abstract class Logger {
/** @var ?callable $filter */
protected $filter = null;
- function setErrorFilter(callable $filter): void {
+ public function setErrorFilter(callable $filter): void {
$this->filter = $filter;
}
- function disable(): void {
+ public function disable(): void {
$this->enabled = false;
}
- function enable(): void {
+ public function enable(): void {
static $error_handler_set = false;
$this->enabled = true;
@@ -87,14 +116,14 @@ abstract class Logger {
$error_handler_set = true;
}
- function log(LogLevel $level, ?string $stacktrace = null, ...$args): void {
- if (!is_dev() && $level == LogLevel::DEBUG)
+ public function log(LogLevel $level, ?string $stacktrace = null, ...$args): void {
+ if (!isDev() && $level == LogLevel::DEBUG)
return;
$this->write($level, strVars($args),
stacktrace: $stacktrace);
}
- function canReport(): bool {
+ public function canReport(): bool {
return $this->recursionLevel < 3;
}
@@ -123,7 +152,7 @@ abstract class Logger {
class FileLogger extends Logger {
- function __construct(protected string $logFile) {}
+ public function __construct(protected string $logFile) {}
protected function writer(LogLevel $level,
int $num,
@@ -145,7 +174,7 @@ class FileLogger extends Logger {
if (strlen($exec_time) < 6)
$exec_time .= str_repeat('0', 6 - strlen($exec_time));
- $title = is_cli() ? 'cli' : $_SERVER['REQUEST_URI'];
+ $title = isCli() ? 'cli' : $_SERVER['REQUEST_URI'];
$date = date('d/m/y H:i:s', $time);
$buf = '';
@@ -178,7 +207,7 @@ class FileLogger extends Logger {
$buf .= $message."\n";
if (in_array($level, [LogLevel::ERROR, LogLevel::WARNING]))
- $buf .= ($stacktrace ?: backtrace_as_string(2))."\n";
+ $buf .= ($stacktrace ?: backtraceAsString(2))."\n";
$set_perm = false;
if (!file_exists($this->logFile)) {
@@ -216,8 +245,6 @@ class DatabaseLogger extends Logger {
?string $errline = null,
?string $stacktrace = null): void
{
- global $AdminSession;
-
$db = DB();
$data = [
@@ -229,12 +256,12 @@ class DatabaseLogger extends Logger {
'line' => $errline ?: 0,
'text' => $message,
'level' => $level->value,
- 'stacktrace' => $stacktrace ?: backtrace_as_string(2),
- 'is_cli' => intval(is_cli()),
- 'admin_id' => is_admin() ? $AdminSession->id : 0,
+ 'stacktrace' => $stacktrace ?: backtraceAsString(2),
+ 'is_cli' => intval(isCli()),
+ 'admin_id' => isAdmin() ? admin::getId() : 0,
];
- if (is_cli()) {
+ if (isCli()) {
$data += [
'ip' => '',
'ua' => '',
@@ -274,7 +301,7 @@ function strVars(array $args): string {
return implode(' ', $args);
}
-function backtrace_as_string(int $shift = 0): string {
+function backtraceAsString(int $shift = 0): string {
$bt = debug_backtrace();
$lines = [];
foreach ($bt as $i => $t) {
diff --git a/engine/model.php b/engine/model.php
index e967dc2..ffcdc5a 100644
--- a/engine/model.php
+++ b/engine/model.php
@@ -124,7 +124,7 @@ abstract class model {
}
public function get_id() {
- return $this->{to_camel_case(static::DB_KEY)};
+ return $this->{toCamelCase(static::DB_KEY)};
}
public function as_array(array $properties = [], array $custom_getters = []): array {
@@ -136,7 +136,7 @@ abstract class model {
if (isset($custom_getters[$field]) && is_callable($custom_getters[$field])) {
$array[$field] = $custom_getters[$field]();
} else {
- $array[$field] = $this->{to_camel_case($field)};
+ $array[$field] = $this->{toCamelCase($field)};
}
}
@@ -220,7 +220,7 @@ abstract class model {
realType: $real_type,
nullable: $type->allowsNull(),
modelName: $name,
- dbName: from_camel_case($name)
+ dbName: fromCamelCase($name)
);
$list[] = $model_descr;
$db_name_map[$model_descr->getDbName()] = $index++;
diff --git a/engine/mysql.php b/engine/mysql.php
index 66c1400..510bab5 100644
--- a/engine/mysql.php
+++ b/engine/mysql.php
@@ -2,12 +2,12 @@
class mysql {
- const DATE_FORMAT = 'Y-m-d';
- const DATETIME_FORMAT = 'Y-m-d H:i:s';
+ const string DATE_FORMAT = 'Y-m-d';
+ const string DATETIME_FORMAT = 'Y-m-d H:i:s';
protected ?mysqli $link = null;
- function __construct(
+ public function __construct(
protected string $host,
protected string $user,
protected string $password,
@@ -40,11 +40,11 @@ class mysql {
return $sql;
}
- function insert(string $table, array $fields) {
+ public function insert(string $table, array $fields) {
return $this->performInsert('INSERT', $table, $fields);
}
- function replace(string $table, array $fields) {
+ public function replace(string $table, array $fields) {
return $this->performInsert('REPLACE', $table, $fields);
}
@@ -66,7 +66,7 @@ class mysql {
return $this->query(...$values);
}
- function update(string $table, array $rows, ...$cond) {
+ public function update(string $table, array $rows, ...$cond) {
$fields = [];
$args = [];
foreach ($rows as $row_name => $row_value) {
@@ -82,13 +82,13 @@ class mysql {
return $this->query($sql, ...$args);
}
- function multipleInsert(string $table, array $rows) {
+ public function multipleInsert(string $table, array $rows) {
list($names, $values) = $this->getMultipleInsertValues($rows);
$sql = "INSERT INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values;
return $this->query($sql);
}
- function multipleReplace(string $table, array $rows) {
+ public function multipleReplace(string $table, array $rows) {
list($names, $values) = $this->getMultipleInsertValues($rows);
$sql = "REPLACE INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values;
return $this->query($sql);
@@ -110,12 +110,12 @@ class mysql {
return [$names, implode(', ', $sql_rows)];
}
- function __destruct() {
+ public function __destruct() {
if ($this->link)
$this->link->close();
}
- function connect(): bool {
+ public function connect(): bool {
$this->link = new mysqli();
$result = $this->link->real_connect($this->host, $this->user, $this->password, $this->database);
if ($result)
@@ -123,24 +123,24 @@ class mysql {
return !!$result;
}
- function query(string $sql, ...$args): mysqli_result|bool {
+ public function query(string $sql, ...$args): mysqli_result|bool {
$sql = $this->prepareQuery($sql, ...$args);
$q = false;
try {
$q = $this->link->query($sql);
if (!$q)
- logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtrace_as_string(1));
+ logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtraceAsString(1));
} catch (mysqli_sql_exception $e) {
- logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".backtrace_as_string(1));
+ logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".backtraceAsString(1));
}
return $q;
}
- function error() {
+ public function error() {
return $this->link?->error;
}
- function fetch($q): ?array {
+ public function fetch($q): ?array {
$row = $q->fetch_assoc();
if (!$row) {
$q->free();
@@ -149,7 +149,7 @@ class mysql {
return $row;
}
- function fetchAll($q): ?array {
+ public function fetchAll($q): ?array {
if (!$q)
return null;
$list = [];
@@ -160,31 +160,31 @@ class mysql {
return $list;
}
- function fetchRow($q): ?array {
+ public function fetchRow($q): ?array {
return $q?->fetch_row();
}
- function result($q, $field = 0) {
+ public function result($q, $field = 0) {
return $q?->fetch_row()[$field];
}
- function insertId(): int {
+ public function insertId(): int {
return $this->link->insert_id;
}
- function numRows($q): ?int {
+ public function numRows($q): ?int {
return $q?->num_rows;
}
- function affectedRows(): ?int {
+ public function affectedRows(): ?int {
return $this->link?->affected_rows;
}
- function foundRows(): int {
+ public function foundRows(): int {
return (int)$this->fetch($this->query("SELECT FOUND_ROWS() AS `count`"))['count'];
}
- function escape(string $s): string {
+ public function escape(string $s): string {
return $this->link->real_escape_string($s);
}
@@ -251,7 +251,7 @@ function DB(): mysql|null {
$config['mysql']['password'],
$config['mysql']['database']);
if (!$link->connect()) {
- if (!is_cli()) {
+ if (!isCli()) {
header('HTTP/1.1 503 Service Temporarily Unavailable');
header('Status: 503 Service Temporarily Unavailable');
header('Retry-After: 300');
diff --git a/engine/request.php b/engine/request.php
index 789b2b5..1646293 100644
--- a/engine/request.php
+++ b/engine/request.php
@@ -1,43 +1,5 @@
2) {
- for ($i = 2; $i < count($route); $i++) {
- $var = $route[$i];
- list($k, $v) = explode('=', $var);
- $RouterInput[trim($k)] = trim($v);
- }
- }
-
- /** @var request_handler $handler */
- $handler = new $handler_class();
- $handler->call_act($_SERVER['REQUEST_METHOD'], $action);
-}
-
-function request_path(): string {
- $uri = $_SERVER['REQUEST_URI'];
- if (($pos = strpos($uri, '?')) !== false)
- $uri = substr($uri, 0, $pos);
- return $uri;
-}
-
-
enum HTTPCode: int {
case MovedPermanently = 301;
case Found = 302;
@@ -51,92 +13,6 @@ enum HTTPCode: int {
case NotImplemented = 501;
}
-function http_error(HTTPCode $http_code, string $message = ''): void {
- if (is_xhr_request()) {
- $data = [];
- if ($message != '')
- $data['message'] = $message;
- ajax_error((object)$data, $http_code->value);
- } else {
- $ctx = skin('error');
- $http_message = preg_replace('/(?name);
- $html = $ctx->http_error($http_code->value, $http_message, $message);
- http_response_code($http_code->value);
- echo $html;
- exit;
- }
-}
-
-function redirect(string $url, HTTPCode $code = HTTPCode::MovedPermanently): void {
- if (!in_array($code, [HTTPCode::MovedPermanently, HTTPCode::Found]))
- internal_server_error('invalid http code');
- if (is_xhr_request()) {
- ajax_ok(['redirect' => $url]);
- }
- http_response_code($code->value);
- header('Location: '.$url);
- exit;
-}
-
-function invalid_request(string $message = '') { http_error(HTTPCode::InvalidRequest, $message); }
-function internal_server_error(string $message = '') { http_error(HTTPCode::InternalServerError, $message); }
-function not_found(string $message = '') { http_error(HTTPCode::NotFound, $message); }
-function forbidden(string $message = '') { http_error(HTTPCode::Forbidden, $message); }
-function is_xhr_request(): bool { return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'; }
-function ajax_ok(mixed $data): void { ajax_response(['response' => $data]); }
-function ajax_error(mixed $error, int $code = 200): void { ajax_response(['error' => $error], $code); }
-
-function ajax_response(mixed $data, int $code = 200): void {
- header('Cache-Control: no-cache, must-revalidate');
- header('Pragma: no-cache');
- header('Content-Type: application/json; charset=utf-8');
- http_response_code($code);
- echo jsonEncode($data);
- exit;
-}
-
-function ensure_admin() {
- if (!is_admin())
- forbidden();
- set_skin_opts(['inside_admin_interface' => true]);
-}
-
-function ensure_xhr() {
- if (!is_xhr_request())
- invalid_request();
-}
-
-abstract class request_handler {
- function __construct() {
- add_static(
- 'css/common.css',
- 'js/common.js'
- );
- }
-
- function before_dispatch(string $http_method, string $action) {}
-
- function call_act(string $http_method, string $action, array $input = []) {
- global $RouterInput;
-
- $handler_method = $_SERVER['REQUEST_METHOD'].'_'.$action;
- if (!method_exists($this, $handler_method))
- not_found(static::class.'::'.$handler_method.' is not defined');
-
- if (!((new ReflectionMethod($this, $handler_method))->isPublic()))
- not_found(static::class.'::'.$handler_method.' is not public');
-
- if (!empty($input)) {
- foreach ($input as $k => $v)
- $RouterInput[$k] = $v;
- }
-
- $args = $this->before_dispatch($http_method, $action);
- return call_user_func_array([$this, $handler_method], is_array($args) ? [$args] : []);
- }
-}
-
-
enum InputVarType: string {
case INTEGER = 'i';
case FLOAT = 'f';
@@ -145,94 +21,218 @@ enum InputVarType: string {
case ENUM = 'e';
}
-function input(string $input, array $options = []): array {
- global $RouterInput;
+//function ensureAdmin() {
+// if (!isAdmin())
+// forbidden();
+// $this->skin->setRenderOptions(['inside_admin_interface' => true]);
+//}
- $options = array_merge(['trim' => false], $options);
- $strval = fn(mixed $val): string => $options['trim'] ? trim((string)$val) : (string)$val;
+abstract class request_handler {
- $input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY);
- $ret = [];
- foreach ($input as $var) {
- $enum_values = null;
- $enum_default = null;
+ protected array $routerInput = [];
+ protected skin $skin;
- $pos = strpos($var, ':');
- if ($pos === 1) { // only one-character type specifiers are supported
- $type = substr($var, 0, $pos);
- $rest = substr($var, $pos + 1);
+ public static function resolveAndDispatch() {
+ if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET']))
+ self::httpError(HTTPCode::NotImplemented, 'Method '.$_SERVER['REQUEST_METHOD'].' not implemented');
- $vartype = InputVarType::tryFrom($type);
- if (is_null($vartype))
- internal_server_error('invalid input type '.$type);
+ $uri = $_SERVER['REQUEST_URI'];
+ if (($pos = strpos($uri, '?')) !== false)
+ $uri = substr($uri, 0, $pos);
- if ($vartype == InputVarType::ENUM) {
- $br_from = strpos($rest, '(');
- $br_to = strpos($rest, ')');
+ $router = router::getInstance();
+ $route = $router->find($uri);
+ if ($route === null)
+ self::httpError(HTTPCode::NotFound, 'Route not found');
- if ($br_from === false || $br_to === false)
- internal_server_error('failed to parse enum values: '.$rest);
+ $route = preg_split('/ +/', $route);
+ $handler_class = $route[0].'Handler';
+ if (!class_exists($handler_class))
+ self::httpError(HTTPCode::NotFound, isDev() ? 'Handler class "'.$handler_class.'" not found' : '');
- $enum_values = array_map('trim', explode('|', trim(substr($rest, $br_from + 1, $br_to - $br_from - 1))));
- $name = trim(substr($rest, 0, $br_from));
+ $action = $route[1];
+ $input = [];
+ if (count($route) > 2) {
+ for ($i = 2; $i < count($route); $i++) {
+ $var = $route[$i];
+ list($k, $v) = explode('=', $var);
+ $input[trim($k)] = trim($v);
+ }
+ }
- if (!empty($enum_values)) {
- foreach ($enum_values as $key => $val) {
- if (str_starts_with($val, '=')) {
- $enum_values[$key] = substr($val, 1);
- $enum_default = $enum_values[$key];
+ /** @var request_handler $handler */
+ $handler = new $handler_class();
+ $handler->callAct($_SERVER['REQUEST_METHOD'], $action, $input);
+ }
+
+ public function __construct() {
+ $this->skin = skin::getInstance();
+ $this->skin->addStatic(
+ 'css/common.css',
+ 'js/common.js'
+ );
+ $this->skin->setGlobal([
+ 'is_admin' => isAdmin(),
+ 'is_dev' => isDev()
+ ]);
+ }
+
+ public function beforeDispatch(string $http_method, string $action) {}
+
+ public function callAct(string $http_method, string $action, array $input = []) {
+ $handler_method = $_SERVER['REQUEST_METHOD'].'_'.$action;
+ if (!method_exists($this, $handler_method))
+ $this->notFound(static::class.'::'.$handler_method.' is not defined');
+
+ if (!((new ReflectionMethod($this, $handler_method))->isPublic()))
+ $this->notFound(static::class.'::'.$handler_method.' is not public');
+
+ if (!empty($input))
+ $this->routerInput += $input;
+
+ $args = $this->beforeDispatch($http_method, $action);
+ return call_user_func_array([$this, $handler_method], is_array($args) ? [$args] : []);
+ }
+
+ public function input(string $input, array $options = []): array {
+ $options = array_merge(['trim' => false], $options);
+ $strval = fn(mixed $val): string => $options['trim'] ? trim((string)$val) : (string)$val;
+
+ $input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY);
+ $ret = [];
+ foreach ($input as $var) {
+ $enum_values = null;
+ $enum_default = null;
+
+ $pos = strpos($var, ':');
+ if ($pos === 1) { // only one-character type specifiers are supported
+ $type = substr($var, 0, $pos);
+ $rest = substr($var, $pos + 1);
+
+ $vartype = InputVarType::tryFrom($type);
+ if (is_null($vartype))
+ self::internalServerError('invalid input type '.$type);
+
+ if ($vartype == InputVarType::ENUM) {
+ $br_from = strpos($rest, '(');
+ $br_to = strpos($rest, ')');
+
+ if ($br_from === false || $br_to === false)
+ self::internalServerError('failed to parse enum values: '.$rest);
+
+ $enum_values = array_map('trim', explode('|', trim(substr($rest, $br_from + 1, $br_to - $br_from - 1))));
+ $name = trim(substr($rest, 0, $br_from));
+
+ if (!empty($enum_values)) {
+ foreach ($enum_values as $key => $val) {
+ if (str_starts_with($val, '=')) {
+ $enum_values[$key] = substr($val, 1);
+ $enum_default = $enum_values[$key];
+ }
}
}
+ } else {
+ $name = trim($rest);
}
+
} else {
- $name = trim($rest);
+ $vartype = InputVarType::STRING;
+ $name = trim($var);
}
- } else {
- $vartype = InputVarType::STRING;
- $name = trim($var);
- }
-
- $val = null;
- if (isset($RouterInput[$name])) {
- $val = $RouterInput[$name];
- } else if (isset($_POST[$name])) {
- $val = $_POST[$name];
- } else if (isset($_GET[$name])) {
- $val = $_GET[$name];
- }
- if (is_array($val))
- $val = $strval(implode($val));
+ $val = null;
+ if (isset($this->routerInput[$name])) {
+ $val = $this->routerInput[$name];
+ } else if (isset($_POST[$name])) {
+ $val = $_POST[$name];
+ } else if (isset($_GET[$name])) {
+ $val = $_GET[$name];
+ }
+ if (is_array($val))
+ $val = $strval(implode($val));
- $ret[] = match($vartype) {
- InputVarType::INTEGER => (int)$val,
- InputVarType::FLOAT => (float)$val,
- InputVarType::BOOLEAN => (bool)$val,
- InputVarType::ENUM => !in_array($val, $enum_values) ? $enum_default ?? '' : $strval($val),
- default => $strval($val)
- };
+ $ret[] = match($vartype) {
+ InputVarType::INTEGER => (int)$val,
+ InputVarType::FLOAT => (float)$val,
+ InputVarType::BOOLEAN => (bool)$val,
+ InputVarType::ENUM => !in_array($val, $enum_values) ? $enum_default ?? '' : $strval($val),
+ default => $strval($val)
+ };
+ }
+ return $ret;
}
- return $ret;
-}
-function csrf_get(string $key): string {
- global $AdminSession, $config;
- $user_key = is_admin() ? $AdminSession->csrfSalt : $_SERVER['REMOTE_ADDR'];
- return substr(hash('sha256', $config['csrf_token'].$user_key.$key), 0, 20);
-}
+ public function getPage(int $per_page, ?int $count = null): array {
+ list($page) = $this->input('i:page');
+ $pages = $count !== null ? ceil($count / $per_page) : null;
+ if ($pages !== null && $page > $pages)
+ $page = $pages;
+ if ($page < 1)
+ $page = 1;
+ $offset = $per_page * ($page-1);
+ return [$page, $pages, $offset];
+ }
-function csrf_check(string $key) {
- if (csrf_get($key) != ($_REQUEST['token'] ?? ''))
- forbidden('invalid token');
-}
+ protected static function ensureXhr(): void {
+ if (!self::isXhrRequest())
+ self::invalidRequest();
+ }
-function get_page(int $per_page, ?int $count = null): array {
- list($page) = input('i:page');
- $pages = $count !== null ? ceil($count / $per_page) : null;
- if ($pages !== null && $page > $pages)
- $page = $pages;
- if ($page < 1)
- $page = 1;
- $offset = $per_page * ($page-1);
- return [$page, $pages, $offset];
-}
\ No newline at end of file
+ public static function getCSRF(string $key): string {
+ global $config;
+ $user_key = isAdmin() ? admin::getCSRFSalt() : $_SERVER['REMOTE_ADDR'];
+ return substr(hash('sha256', $config['csrf_token'].$user_key.$key), 0, 20);
+ }
+
+ protected static function checkCSRF(string $key): void {
+ if (self::getCSRF($key) != ($_REQUEST['token'] ?? ''))
+ self::forbidden('invalid token');
+ }
+
+ public static function httpError(HTTPCode $http_code, string $message = ''): void {
+ if (self::isXhrRequest()) {
+ $data = [];
+ if ($message != '')
+ $data['message'] = $message;
+ self::ajaxError((object)$data, $http_code->value);
+ } else {
+ $http_message = preg_replace('/(?name);
+ $html = skin::getInstance()->render('error.twig', [
+ 'code' => $http_code->value,
+ 'title' => $http_message,
+ 'message' => $message
+ ]);
+ http_response_code($http_code->value);
+ echo $html;
+ exit;
+ }
+ }
+
+ protected static function redirect(string $url, HTTPCode $code = HTTPCode::MovedPermanently): never {
+ if (!in_array($code, [HTTPCode::MovedPermanently, HTTPCode::Found]))
+ self::internalServerError('invalid http code');
+ if (self::isXhrRequest())
+ self::ajaxOk(['redirect' => $url]);
+ http_response_code($code->value);
+ header('Location: '.$url);
+ exit;
+ }
+
+ protected static function invalidRequest(string $message = '') { self::httpError(HTTPCode::InvalidRequest, $message); }
+ protected static function internalServerError(string $message = '') { self::httpError(HTTPCode::InternalServerError, $message); }
+ protected static function notFound(string $message = '') { self::httpError(HTTPCode::NotFound, $message); }
+ protected static function forbidden(string $message = '') { self::httpError(HTTPCode::Forbidden, $message); }
+ protected static function isXhrRequest(): bool { return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'; }
+ protected static function ajaxOk(mixed $data): void { self::ajaxResponse(['response' => $data]); }
+ protected static function ajaxError(mixed $error, int $code = 200): void { self::ajaxResponse(['error' => $error], $code); }
+
+ protected static function ajaxResponse(mixed $data, int $code = 200): never {
+ header('Cache-Control: no-cache, must-revalidate');
+ header('Pragma: no-cache');
+ header('Content-Type: application/json; charset=utf-8');
+ http_response_code($code);
+ echo jsonEncode($data);
+ exit;
+ }
+
+}
diff --git a/engine/router.php b/engine/router.php
index 5845356..f29dffd 100644
--- a/engine/router.php
+++ b/engine/router.php
@@ -1,185 +1,201 @@
[],
- 're_children' => []
-];
-
-function router_init(): void {
- global $Routes;
- $mc = MC();
-
- $from_cache = !is_dev();
- $write_cache = !is_dev();
-
- if ($from_cache) {
- $cache = $mc->get(ROUTER_MC_KEY);
-
- if ($cache === false || !isset($cache['version']) || $cache['version'] < ROUTER_VERSION) {
- $from_cache = false;
- } else {
- $Routes = $cache['routes'];
- }
- }
-
- if (!$from_cache) {
- $routes_table = require_once APP_ROOT.'/routes.php';
-
- foreach ($routes_table as $controller => $routes) {
- foreach ($routes as $route => $resolve)
- router_add($route, $controller.' '.$resolve);
- }
-
- if ($write_cache)
- $mc->set(ROUTER_MC_KEY, ['version' => ROUTER_VERSION, 'routes' => $Routes]);
- }
-}
-
-function router_add(string $template, string $value): void {
- global $Routes;
- if ($template == '')
- return;
-
- // expand {enum,erat,ions}
- $templates = [[$template, $value]];
- if (preg_match_all('/\{([\w\d_\-,]+)\}/', $template, $matches)) {
- foreach ($matches[1] as $match_index => $variants) {
- $variants = explode(',', $variants);
- $variants = array_map('trim', $variants);
- $variants = array_filter($variants, function($s) { return $s != ''; });
-
- for ($i = 0; $i < count($templates); ) {
- list($template, $value) = $templates[$i];
- $new_templates = [];
- foreach ($variants as $variant_index => $variant) {
- $new_templates[] = [
- str_replace_once($matches[0][$match_index], $variant, $template),
- str_replace('${'.($match_index+1).'}', $variant, $value)
- ];
- }
- array_splice($templates, $i, 1, $new_templates);
- $i += count($new_templates);
- }
- }
- }
-
- // process all generated routes
- foreach ($templates as $template) {
- list($template, $value) = $template;
-
- $start_pos = 0;
- $parent = &$Routes;
- $template_len = strlen($template);
-
- while ($start_pos < $template_len) {
- $slash_pos = strpos($template, '/', $start_pos);
- if ($slash_pos !== false) {
- $part = substr($template, $start_pos, $slash_pos-$start_pos+1);
- $start_pos = $slash_pos+1;
- } else {
- $part = substr($template, $start_pos);
- $start_pos = $template_len;
- }
-
- $parent = &_router_add($parent, $part,
- $start_pos < $template_len ? null : $value);
- }
- }
-}
-
-function &_router_add(&$parent, $part, $value = null) {
- $par_pos = strpos($part, '(');
- $is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\');
-
- $children_key = !$is_regex ? 'children' : 're_children';
-
- if (isset($parent[$children_key][$part])) {
- if (is_null($value)) {
- $parent = &$parent[$children_key][$part];
- } else {
- if (!isset($parent[$children_key][$part]['value'])) {
- $parent[$children_key][$part]['value'] = $value;
- } else {
- trigger_error(__METHOD__.': route is already defined');
- }
- }
- return $parent;
- }
-
- $child = [
+ protected array $routes = [
'children' => [],
're_children' => []
];
- if (!is_null($value))
- $child['value'] = $value;
+ protected static ?router $instance = null;
- $parent[$children_key][$part] = $child;
- return $parent[$children_key][$part];
-}
+ public static function getInstance(): router {
+ if (self::$instance === null)
+ self::$instance = new router();
+ return self::$instance;
+ }
-function router_find($uri) {
- global $Routes;
- if ($uri != '/' && $uri[0] == '/')
- $uri = substr($uri, 1);
+ private function __construct() {
+ $mc = MC();
- $start_pos = 0;
- $parent = &$Routes;
- $uri_len = strlen($uri);
- $matches = [];
+ $from_cache = !isDev();
+ $write_cache = !isDev();
- while ($start_pos < $uri_len) {
- $slash_pos = strpos($uri, '/', $start_pos);
- if ($slash_pos !== false) {
- $part = substr($uri, $start_pos, $slash_pos-$start_pos+1);
- $start_pos = $slash_pos+1;
- } else {
- $part = substr($uri, $start_pos);
- $start_pos = $uri_len;
+ if ($from_cache) {
+ $cache = $mc->get(ROUTER_MC_KEY);
+
+ if ($cache === false || !isset($cache['version']) || $cache['version'] < ROUTER_VERSION) {
+ $from_cache = false;
+ } else {
+ $this->routes = $cache['routes'];
+ }
}
- $found = false;
- if (isset($parent['children'][$part])) {
- $parent = &$parent['children'][$part];
- $found = true;
- } else if (!empty($parent['re_children'])) {
- foreach ($parent['re_children'] as $re => &$child) {
- $exp = '#^'.$re.'$#';
- $re_result = preg_match($exp, $part, $match);
- if ($re_result === false) {
- logError(__METHOD__.": regex $exp failed");
- continue;
- }
+ if (!$from_cache) {
+ $routes_table = require_once APP_ROOT.'/routes.php';
- if ($re_result) {
- if (count($match) > 1)
- $matches = array_merge($matches, array_slice($match, 1));
- $parent = &$child;
- $found = true;
- break;
+ foreach ($routes_table as $controller => $routes) {
+ foreach ($routes as $route => $resolve)
+ $this->add($route, $controller.' '.$resolve);
+ }
+
+ if ($write_cache)
+ $mc->set(ROUTER_MC_KEY, ['version' => ROUTER_VERSION, 'routes' => $this->routes]);
+ }
+ }
+
+ public function add(string $template, string $value): void {
+ if ($template == '')
+ return;
+
+ // expand {enum,erat,ions}
+ $templates = [[$template, $value]];
+ if (preg_match_all('/\{([\w\d_\-,]+)}/', $template, $matches)) {
+ foreach ($matches[1] as $match_index => $variants) {
+ $variants = explode(',', $variants);
+ $variants = array_map('trim', $variants);
+ $variants = array_filter($variants, function($s) { return $s != ''; });
+
+ for ($i = 0; $i < count($templates); ) {
+ list($template, $value) = $templates[$i];
+ $new_templates = [];
+ foreach ($variants as $variant_index => $variant) {
+ $new_templates[] = [
+ strReplaceOnce($matches[0][$match_index], $variant, $template),
+ str_replace('${'.($match_index+1).'}', $variant, $value)
+ ];
+ }
+ array_splice($templates, $i, 1, $new_templates);
+ $i += count($new_templates);
}
}
}
- if (!$found)
- return null;
- }
+ // process all generated routes
+ foreach ($templates as $template) {
+ list($template, $value) = $template;
- if (!isset($parent['value']))
- return null;
+ $start_pos = 0;
+ $parent = &$this->routes;
+ $template_len = strlen($template);
- $value = $parent['value'];
- if (!empty($matches)) {
- foreach ($matches as $i => $match) {
- $needle = '$('.($i+1).')';
- $pos = strpos($value, $needle);
- if ($pos !== false)
- $value = substr_replace($value, $match, $pos, strlen($needle));
+ while ($start_pos < $template_len) {
+ $slash_pos = strpos($template, '/', $start_pos);
+ if ($slash_pos !== false) {
+ $part = substr($template, $start_pos, $slash_pos-$start_pos+1);
+ $start_pos = $slash_pos+1;
+ } else {
+ $part = substr($template, $start_pos);
+ $start_pos = $template_len;
+ }
+
+ $parent = &$this->_addRoute($parent, $part,
+ $start_pos < $template_len ? null : $value);
+ }
}
}
- return $value;
+ protected function &_addRoute(&$parent, $part, $value = null) {
+ $par_pos = strpos($part, '(');
+ $is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\');
+
+ $children_key = !$is_regex ? 'children' : 're_children';
+
+ if (isset($parent[$children_key][$part])) {
+ if (is_null($value)) {
+ $parent = &$parent[$children_key][$part];
+ } else {
+ if (!isset($parent[$children_key][$part]['value'])) {
+ $parent[$children_key][$part]['value'] = $value;
+ } else {
+ trigger_error(__METHOD__.': route is already defined');
+ }
+ }
+ return $parent;
+ }
+
+ $child = [
+ 'children' => [],
+ 're_children' => []
+ ];
+ if (!is_null($value)) {
+ $child['value'] = $value;
+ }
+
+ $parent[$children_key][$part] = $child;
+ return $parent[$children_key][$part];
+ }
+
+ public function find($uri) {
+ if ($uri != '/' && $uri[0] == '/') {
+ $uri = substr($uri, 1);
+ }
+ $start_pos = 0;
+ $parent = &$this->routes;
+ $uri_len = strlen($uri);
+ $matches = [];
+
+ while ($start_pos < $uri_len) {
+ $slash_pos = strpos($uri, '/', $start_pos);
+ if ($slash_pos !== false) {
+ $part = substr($uri, $start_pos, $slash_pos-$start_pos+1);
+ $start_pos = $slash_pos+1;
+ } else {
+ $part = substr($uri, $start_pos);
+ $start_pos = $uri_len;
+ }
+
+ $found = false;
+ if (isset($parent['children'][$part])) {
+ $parent = &$parent['children'][$part];
+ $found = true;
+ } else if (!empty($parent['re_children'])) {
+ foreach ($parent['re_children'] as $re => &$child) {
+ $exp = '#^'.$re.'$#';
+ $re_result = preg_match($exp, $part, $match);
+ if ($re_result === false) {
+ logError(__METHOD__.": regex $exp failed");
+ continue;
+ }
+
+ if ($re_result) {
+ if (count($match) > 1) {
+ $matches = array_merge($matches, array_slice($match, 1));
+ }
+ $parent = &$child;
+ $found = true;
+ break;
+ }
+ }
+ }
+
+ if (!$found) {
+ return false;
+ }
+ }
+
+ if (!isset($parent['value'])) {
+ return false;
+ }
+
+ $value = $parent['value'];
+ if (!empty($matches)) {
+ foreach ($matches as $i => $match) {
+ $needle = '$('.($i+1).')';
+ $pos = strpos($value, $needle);
+ if ($pos !== false) {
+ $value = substr_replace($value, $match, $pos, strlen($needle));
+ }
+ }
+ }
+
+ return $value;
+ }
+
+ public function load($routes): void {
+ $this->routes = $routes;
+ }
+
+ public function dump(): array {
+ return $this->routes;
+ }
}
diff --git a/engine/skin.php b/engine/skin.php
index cf38312..e75ce4e 100644
--- a/engine/skin.php
+++ b/engine/skin.php
@@ -1,13 +1,20 @@
false,
'wide' => false,
@@ -19,241 +26,213 @@ $SkinState = new class {
'inside_admin_interface' => false,
];
public array $static = [];
- public array $svg_defs = [];
-};
+ protected array $styleNames = [];
+ protected array $svgDefs = [];
-function render($f, ...$vars): void {
- global $SkinState, $config;
+ public \Twig\Environment $twig;
- add_skin_strings(['4in1']);
+ protected static ?skin $instance = null;
- $ctx = skin(substr($f, 0, ($pos = strrpos(str_replace('/', '\\', $f), '\\'))));
- $body = call_user_func_array([$ctx, substr($f, $pos + 1)], $vars);
- if (is_array($body))
- list($body, $js) = $body;
- else
- $js = null;
+ public static function getInstance(): skin {
+ if (self::$instance === null)
+ self::$instance = new skin();
+ return self::$instance;
+ }
- $theme = getUserTheme();
- if ($theme != 'auto' && !themeExists($theme))
- $theme = 'auto';
+ /**
+ * @throws LoaderError
+ */
+ protected function __construct() {
+ global $config;
+ $cache_dir = $config['skin_cache_'.(isDev() ? 'dev' : 'prod').'_dir'];
+ if (!file_exists($cache_dir)) {
+ if (mkdir($cache_dir, $config['dirs_mode'], true))
+ setperm($cache_dir);
+ }
- $is_system_theme_dark = $theme == 'auto' && isUserSystemThemeDark();
+ // must specify a second argument ($rootPath) here
+ // otherwise it will be getcwd() and it's www-prod/htdocs/ for apache and www-prod/ for cli code
+ // this is bad for templates rebuilding
+ $twig_loader = new \Twig\Loader\FilesystemLoader(APP_ROOT.'/skin', APP_ROOT);
+ // $twig_loader->addPath(APP_ROOT.'/htdocs/svg', 'svg');
- $layout_ctx = skin('base');
-
- $lang = [];
- foreach ($SkinState->lang as $key)
- $lang[$key] = lang($key);
- $lang = !empty($lang) ? jsonEncode($lang) : '';
-
- $title = $SkinState->title;
- if (!$SkinState->options['is_index'])
- $title = lang('4in1').' - '.$title;
-
- $html = $layout_ctx->layout(
- static: $SkinState->static,
- theme: $theme,
- is_system_theme_dark: $is_system_theme_dark,
- title: $title,
- opts: $SkinState->options,
- js: $js,
- meta: $SkinState->meta,
- unsafe_lang: $lang,
- unsafe_body: $body,
- exec_time: exectime(),
- admin_email: $config['admin_email'],
- svg_defs: $SkinState->svg_defs
- );
- echo $html;
- exit;
-}
-
-function set_title(string $title): void {
- global $SkinState;
- if (str_starts_with($title, '$'))
- $title = lang(substr($title, 1));
- else if (str_starts_with($title, '\\$'))
- $title = substr($title, 1);
- $SkinState->title = $title;
-}
-
-function set_skin_opts(array $options) {
- global $SkinState;
- $SkinState->options = array_merge($SkinState->options, $options);
-}
-
-function add_skin_strings(array $keys): void {
- global $SkinState;
- $SkinState->lang = array_merge($SkinState->lang, $keys);
-}
-
-function add_skin_strings_re(string $re): void {
- global $__lang;
- add_skin_strings($__lang->search($re));
-}
-
-function add_static(string ...$files): void {
- global $SkinState;
- foreach ($files as $file)
- $SkinState->static[] = $file;
-}
-
-function add_meta(array $data) {
- global $SkinState;
- static $twitter_limits = [
- 'title' => 70,
- 'description' => 200
- ];
- $real_meta = [];
- $add_og_twitter = function($key, $value) use (&$real_meta, $twitter_limits) {
- foreach (['og', 'twitter'] as $social) {
- if ($social == 'twitter' && isset($twitter_limits[$key])) {
- if (mb_strlen($value) > $twitter_limits[$key])
- $value = mb_substr($value, 0, $twitter_limits[$key]-3).'...';
- }
- $real_meta[] = [
- $social == 'twitter' ? 'name' : 'property' => $social.':'.$key,
- 'content' => $value
+ $env_options = [];
+ if (!is_null($cache_dir)) {
+ $env_options += [
+ 'cache' => $cache_dir,
+ 'auto_reload' => isDev()
];
}
- };
- foreach ($data as $key => $value) {
- if (str_starts_with($value, '$'))
- $value = lang(substr($value, 1));
- switch ($key) {
- case '$url':
- case '$title':
- case '$image':
- $add_og_twitter(substr($key, 1), $value);
- break;
+ $twig = new \Twig\Environment($twig_loader, $env_options);
+ $twig->addExtension(new \TwigAddons\MyExtension());
- case '$description':
- case '$keywords':
- $real_name = substr($key, 1);
- $add_og_twitter($real_name, $value);
- $real_meta[] = ['name' => $real_name, 'content' => $value];
- break;
+ $this->twig = $twig;
+ }
- default:
- if (str_starts_with($key, 'og:')) {
- $real_meta[] = ['property' => $key, 'content' => $value];
- } else {
- logWarning("unsupported meta: $key => $value");
+ public function addMeta(array $data) {
+ static $twitter_limits = [
+ 'title' => 70,
+ 'description' => 200
+ ];
+ $real_meta = [];
+ $add_og_twitter = function($key, $value) use (&$real_meta, $twitter_limits) {
+ foreach (['og', 'twitter'] as $social) {
+ if ($social == 'twitter' && isset($twitter_limits[$key])) {
+ if (mb_strlen($value) > $twitter_limits[$key])
+ $value = mb_substr($value, 0, $twitter_limits[$key]-3).'...';
}
- break;
- }
- }
- $SkinState->meta = array_merge($SkinState->meta, $real_meta);
-}
-
-
-class SkinContext {
-
- protected string $ns;
- protected array $data = [];
-
- function __construct(string $namespace) {
- $this->ns = $namespace;
- require_once APP_ROOT.str_replace('\\', DIRECTORY_SEPARATOR, $namespace).'.phps';
- }
-
- function __call($name, array $arguments) {
- $plain_args = array_is_list($arguments);
-
- $fn = $this->ns.'\\'.$name;
- $refl = new ReflectionFunction($fn);
- $fparams = $refl->getParameters();
- $fparams_required_count = 0;
- foreach ($fparams as $param) {
- if (!$param->isDefaultValueAvailable())
- $fparams_required_count++;
- }
- $given_count = count($arguments)+1;
- assert($given_count >= $fparams_required_count && $given_count <= count($fparams),
- "$fn: invalid number of arguments (function has ".$fparams_required_count." required arguments".(count($fparams) != $fparams_required_count ? ' and '.count($fparams).' total arguments' : '').", received ".(count($arguments) + 1).")");
-
- foreach ($fparams as $n => $param) {
- if ($n == 0)
- continue; // skip $ctx
-
- $key = $plain_args ? $n - 1 : $param->name;
- if (!$plain_args && !array_key_exists($param->name, $arguments)) {
- if (!$param->isDefaultValueAvailable())
- throw new InvalidArgumentException('argument '.$param->name.' not found');
- else
- continue;
+ $real_meta[] = [
+ $social == 'twitter' ? 'name' : 'property' => $social.':'.$key,
+ 'content' => $value
+ ];
}
+ };
+ foreach ($data as $key => $value) {
+ if (str_starts_with($value, '@'))
+ $value = lang(substr($value, 1));
+ switch ($key) {
+ case '@url':
+ case '@title':
+ case '@image':
+ $add_og_twitter(substr($key, 1), $value);
+ break;
- if ($plain_args && !isset($arguments[$key]))
- break;
+ case '@description':
+ case '@keywords':
+ $real_name = substr($key, 1);
+ $add_og_twitter($real_name, $value);
+ $real_meta[] = ['name' => $real_name, 'content' => $value];
+ break;
- if (is_string($arguments[$key]) || $arguments[$key] instanceof SkinString) {
- if (is_string($arguments[$key]))
- $arguments[$key] = new SkinString($arguments[$key]);
-
- if (($pos = strpos($param->name, '_')) !== false) {
- $mod_type = match (substr($param->name, 0, $pos)) {
- 'unsafe' => SkinStringModificationType::RAW,
- 'urlencoded' => SkinStringModificationType::URL,
- 'jsonencoded' => SkinStringModificationType::JSON,
- 'addslashes' => SkinStringModificationType::ADDSLASHES,
- 'nl2br' => SkinStringModificationType::NL2BR,
- default => SkinStringModificationType::HTML
- };
- } else {
- $mod_type = SkinStringModificationType::HTML;
- }
- $arguments[$key]->setModType($mod_type);
+ default:
+ if (str_starts_with($key, 'og:')) {
+ $real_meta[] = ['property' => $key, 'content' => $value];
+ } else {
+ logWarning("unsupported meta: $key => $value");
+ }
+ break;
}
}
-
- array_unshift($arguments, $this);
- return call_user_func_array($fn, $arguments);
+ $this->meta = array_merge($this->meta, $real_meta);
}
- function &__get(string $name) {
- $fn = $this->ns.'\\'.$name;
- if (function_exists($fn)) {
- $f = [$this, $name];
- return $f;
+ public function exportStrings(array|string $keys): void {
+ global $__lang;
+ $this->lang = array_merge($this->lang, is_string($keys) ? $__lang->search($keys) : $keys);
+ }
+
+ public function setTitle(string $title): void {
+ if (str_starts_with($title, '$'))
+ $title = lang(substr($title, 1));
+ else if (str_starts_with($title, '\\$'))
+ $title = substr($title, 1);
+ $this->title = $title;
+ }
+
+ public function addPageTitleModifier(callable $callable): void {
+ if (!is_callable($callable)) {
+ trigger_error(__METHOD__.': argument is not callable');
+ } else {
+ $this->titleModifiers[] = $callable;
}
-
- if (array_key_exists($name, $this->data))
- return $this->data[$name];
}
- function __set(string $name, $value) {
- $this->data[$name] = $value;
+ protected function getTitle(): string {
+ $title = $this->title != '' ? $this->title : lang('site_title');
+ if (!empty($this->titleModifiers)) {
+ foreach ($this->titleModifiers as $modifier)
+ $title = $modifier($title);
+ }
+ return $title;
}
- function if_not($cond, $callback, ...$args) {
- return $this->_if_condition(!$cond, $callback, ...$args);
+ public function set($arg1, $arg2 = null) {
+ if (is_array($arg1)) {
+ foreach ($arg1 as $key => $value)
+ $this->vars[$key] = $value;
+ } elseif ($arg2 !== null) {
+ $this->vars[$arg1] = $arg2;
+ }
}
- function if_true($cond, $callback, ...$args) {
- return $this->_if_condition($cond, $callback, ...$args);
+ public function isSet($key): bool {
+ return isset($this->vars[$key]);
}
- function if_admin($callback, ...$args) {
- return $this->_if_condition(is_admin(), $callback, ...$args);
+ public function setGlobal($arg1, $arg2 = null): void {
+ if ($this->globalsApplied)
+ logError(__METHOD__.': WARNING: globals were already applied, your change will not be visible');
+
+ if (is_array($arg1)) {
+ foreach ($arg1 as $key => $value)
+ $this->globalVars[$key] = $value;
+ } elseif ($arg2 !== null) {
+ $this->globalVars[$arg1] = $arg2;
+ }
}
- function if_dev($callback, ...$args) {
- return $this->_if_condition(is_dev(), $callback, ...$args);
+ public function isGlobalSet($key): bool {
+ return isset($this->globalVars[$key]);
}
- function if_then_else($cond, $cb1, $cb2) {
- return $cond ? $this->_return_callback($cb1) : $this->_return_callback($cb2);
+ public function getGlobal($key) {
+ return $this->isGlobalSet($key) ? $this->globalVars[$key] : null;
}
- function csrf($key): string {
- return csrf_get($key);
+ public function applyGlobals(): void {
+ if (!empty($this->globalVars) && !$this->globalsApplied) {
+ foreach ($this->globalVars as $key => $value)
+ $this->twig->addGlobal($key, $value);
+ $this->globalsApplied = true;
+ }
}
- function bc(array $items, ?string $style = null, bool $mt = false): string {
+ public function addStatic(string ...$files): void {
+ foreach ($files as $file)
+ $this->static[] = $file;
+ }
+
+ public function addJS(string $js): void {
+ if ($js != '')
+ $this->js[] = $js;
+ }
+
+ protected function getJS(): string {
+ if (empty($this->js))
+ return '';
+ return implode("\n", $this->js);
+ }
+
+ public function preloadSVG(string $name): void {
+ if (isset($this->svgDefs[$name]))
+ return;
+
+ if (!preg_match_all('/\d+/', $name, $matches))
+ throw new InvalidArgumentException('icon name '.$name.' is invalid, it should follow following pattern: $name_$size[_$size]');
+
+ $size = array_slice($matches[0], -2);
+ $this->svgDefs[$name] = [
+ 'width' => $size[0],
+ 'height' => $size[1] ?? $size[0]
+ ];
+ }
+
+ public function getSVG(string $name, bool $in_place = false): ?string {
+ $this->preloadSVG($name);
+ $w = $this->svgDefs[$name]['width'];
+ $h = $this->svgDefs[$name]['height'];
+ if ($in_place) {
+ $svg = '';
+ $svg .= file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg');
+ $svg .= ' ';
+ return $svg;
+ } else {
+ return ' ';
+ }
+ }
+
+ public function renderBreadCrumbs(array $items, ?string $style = null, bool $mt = false): string {
static $chevron = ' ';
-
$buf = implode(array_map(function(array $i) use ($chevron): string {
$buf = '';
$has_url = array_key_exists('url', $i);
@@ -277,13 +256,11 @@ class SkinContext {
return '
'.$buf.'
';
}
- function pagenav(int $page, int $pages, string $link_template, ?array $opts = null) {
+ public function renderPageNav(int $page, int $pages, string $link_template, ?array $opts = null): string {
if ($opts === null) {
$count = 0;
} else {
- $opts = array_merge([
- 'count' => 0,
- ], $opts);
+ $opts = array_merge(['count' => 0], $opts);
$count = $opts['count'];
}
@@ -294,25 +271,23 @@ class SkinContext {
$base_class = 'pn-button no-hover no-select no-drag is-page';
for ($p = $min_page; $p <= $max_page; $p++) {
$class = $base_class;
- if ($p == $page) {
+ if ($p == $page)
$class .= ' is-page-cur';
- }
-
- $pages_html .= ''.$p.' ';
+ $pages_html .= ''.$p.' ';
}
if ($min_page > 2) {
$pages_html = '
'.$pages_html;
}
if ($min_page > 1) {
- $pages_html = '1 '.$pages_html;
+ $pages_html = '1 '.$pages_html;
}
if ($max_page < $pages-1) {
$pages_html .= '
';
}
if ($max_page < $pages) {
- $pages_html .= ''.$pages.' ';
+ $pages_html .= ''.$pages.' ';
}
$pn_class = 'pn';
@@ -334,146 +309,258 @@ HTML;
return $html;
}
- protected static function _page_nav_get_link($page, $link_template) {
- return is_callable($link_template)
- ? $link_template($page)
- : str_replace('{page}', $page, $link_template);
+ protected static function pageNavGetLink($page, $link_template) {
+ return is_callable($link_template) ? $link_template($page) : str_replace('{page}', $page, $link_template);
}
- protected function _if_condition($condition, $callback, ...$args) {
- if (is_string($condition) || $condition instanceof Stringable)
- $condition = (string)$condition !== '';
- if ($condition)
- return $this->_return_callback($callback, $args);
- return '';
+ protected function getSVGTags(): string {
+ $buf = '';
+ foreach ($this->svgDefs as $name => $icon) {
+ $content = file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg');
+ $buf .= "$content ";
+ }
+ $buf .= ' ';
+ return $buf;
}
- protected function _return_callback($callback, $args = []) {
- if (is_callable($callback))
- return call_user_func_array($callback, $args);
- else if (is_string($callback))
- return $callback;
+ public function setRenderOptions(array $options): void {
+ $this->options = array_merge($this->options, $options);
}
- function for_each(array $iterable, callable $callback) {
- $html = '';
- foreach ($iterable as $k => $v)
- $html .= call_user_func($callback, $v, $k);
+ public function render($template, array $vars = []): string {
+ $this->applyGlobals();
+ return $this->doRender($template, $this->vars + $vars);
+ }
+
+ public function renderPage(string $template, array $vars = []): never {
+ $this->exportStrings(['4in1']);
+ $this->applyGlobals();
+
+ // render body first
+ $b = $this->renderBody($template, $vars);
+
+ // then everything else
+ $h = $this->renderHeader();
+ $f = $this->renderFooter();
+
+ echo $h;
+ echo $b;
+ echo $f;
+
+ exit;
+ }
+
+ protected function renderHeader(): string {
+ global $config;
+
+ $body_class = [];
+ if ($this->options['full_width'])
+ $body_class[] = 'full-width';
+ else if ($this->options['wide'])
+ $body_class[] = 'wide';
+
+ $title = $this->getTitle();
+ if (!$this->options['is_index'])
+ $title = lang('4in1').' - '.$title;
+
+ $vars = [
+ 'title' => $title,
+ 'meta_html' => $this->getMetaTags(),
+ 'static_html' => $this->getHeaderStaticTags(),
+ 'svg_html' => $this->getSVGTags(),
+ 'render_options' => $this->options,
+ 'app_config' => [
+ 'domain' => $config['domain'],
+ 'devMode' => $config['is_dev'],
+ 'cookieHost' => $config['cookie_host'],
+ ],
+ 'body_class' => $body_class,
+ 'theme' => themes::getUserTheme(),
+ ];
+
+ return $this->doRender('header.twig', $vars);
+ }
+
+ protected function renderBody(string $template, array $vars): string {
+ return $this->doRender($template, $this->vars + $vars);
+ }
+
+ protected function renderFooter(): string {
+ global $config;
+
+ $exec_time = microtime(true) - START_TIME;
+ $exec_time = round($exec_time, 4);
+
+ $footer_vars = [
+ 'exec_time' => $exec_time,
+ 'render_options' => $this->options,
+ 'admin_email' => $config['admin_email'],
+ // 'lang_json' => json_encode($this->getLangKeys(), JSON_UNESCAPED_UNICODE),
+ // 'static_config' => $this->getStaticConfig(),
+ 'script_html' => $this->getFooterScriptTags(),
+ 'this_page_url' => $_SERVER['REQUEST_URI'],
+ 'theme' => themes::getUserTheme(),
+ ];
+ return $this->doRender('footer.twig', $footer_vars);
+ }
+
+ protected function doRender(string $template, array $vars = []): string {
+ $s = '';
+ try {
+ $s = $this->twig->render($template, $vars);
+ } catch (\Twig\Error\Error $e) {
+ $error = get_class($e).": failed to render";
+ $source_ctx = $e->getSourceContext();
+ if ($source_ctx) {
+ $path = $source_ctx->getPath();
+ if (str_starts_with($path, APP_ROOT))
+ $path = substr($path, strlen(APP_ROOT)+1);
+ $error .= " ".$source_ctx->getName()." (".$path.") at line ".$e->getTemplateLine();
+ }
+ $error .= ": ";
+ $error .= $e->getMessage();
+ logError($error);
+ if (isDev())
+ $s = $error."\n";
+ }
+ return $s;
+ }
+
+ protected function getMetaTags(): string {
+ if (empty($this->meta))
+ return '';
+ return implode('', array_map(function(array $item): string {
+ $s = ' $v)
+ $s .= ' '.htmlescape($k).'="'.htmlescape($v).'"';
+ $s .= '/>';
+ $s .= "\n";
+ return $s;
+ }, $this->meta));
+ }
+
+ protected function getHeaderStaticTags(): string {
+ $html = [];
+ $theme = themes::getUserTheme();
+ $dark = $theme == 'dark' || ($theme == 'auto' && themes::isUserSystemThemeDark());
+ $this->styleNames = [];
+ foreach ($this->static as $name) {
+ // javascript
+ if (str_starts_with($name, 'js/'))
+ $html[] = $this->jsLink($name);
+
+ // css
+ else if (str_starts_with($name, 'css/')) {
+ $html[] = $this->cssLink($name, 'light', $style_name_ptr);
+ $this->styleNames[] = $style_name_ptr;
+
+ if ($dark)
+ $html[] = $this->cssLink($name, 'dark', $style_name_ptr);
+ else if (!isDev())
+ $html[] = $this->cssPrefetchLink($style_name_ptr.'_dark');
+ }
+ else
+ logError(__FUNCTION__.': unexpected static entry: '.$name);
+ }
+ return implode("\n", $html);
+ }
+
+ protected function getFooterScriptTags(): string {
+ global $config;
+
+ $html = '';
return $html;
}
- function lang(...$args): string {
- return htmlescape($this->langRaw(...$args));
- }
-
- function lang_num(...$args): string {
- return htmlescape(lang_num(...$args));
- }
-
- function langRaw(string $key, ...$args) {
- $val = lang($key);
- return empty($args) ? $val : sprintf($val, ...$args);
- }
-
-}
-
-class SVGSkinContext extends SkinContext {
-
- function __construct() {
- parent::__construct('\\skin\\icons');
- }
-
- function __call($name, array $arguments) {
- global $SkinState;
-
- $already_defined = isset($SkinState->svg_defs[$name]);
- if (!array_is_list($arguments)) {
- $in_place = isset($arguments['in_place']) && $arguments['in_place'] === true;
- $preload_symbol = isset($arguments['preload_symbol']) && $arguments['preload_symbol'] === true;
+ protected function jsLink(string $name): string {
+ list (, $bname) = $this->getStaticNameParts($name);
+ if (isDev()) {
+ $href = '/js.php?name='.urlencode($bname).'&v='.time();
} else {
- $in_place = false;
- $preload_symbol = false;
+ $href = '/dist-js/'.$bname.'.js?v='.$this->getStaticVersion($name);
}
+ return '';
+ }
- if ($already_defined && $preload_symbol)
- return null;
+ protected function cssLink(string $name, string $theme, &$bname = null): string {
+ list(, $bname) = $this->getStaticNameParts($name);
- if ($in_place || !$already_defined) {
- if (!preg_match_all('/\d+/', $name, $matches))
- throw new InvalidArgumentException('icon name '.$name.' is invalid, it should follow following pattern: $name_$size[_$size]');
- $size = array_slice($matches[0], -2);
- $width = $size[0];
- $height = $size[1] ?? $size[0];
- }
+ $config_name = 'css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css';
- if (!$in_place && (!$already_defined || $preload_symbol)) {
- $SkinState->svg_defs[$name] = [
- 'svg' => parent::__call($name, !$preload_symbol ? $arguments : []),
- 'width' => $width,
- 'height' => $height
- ];
- }
-
- if ($preload_symbol)
- return null;
-
- if ($already_defined && !isset($width)) {
- $width = $SkinState->svg_defs[$name]['width'];
- $height = $SkinState->svg_defs[$name]['height'];
- }
-
- if ($in_place) {
- $content = parent::__call($name, []);
- return <<{$content}
-SVG;
+ if (isDev()) {
+ $href = '/sass.php?name='.urlencode($bname).'&theme='.$theme.'&v='.time();
} else {
- return <<
-SVG;
+ $version = $this->getStaticVersion($config_name);
+ $href = '/dist-css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css?v='.$version;
}
+
+ $id = 'style_'.$bname;
+ if ($theme == 'dark')
+ $id .= '_dark';
+
+ return ' getStaticIntegrityAttribute($config_name).'>';
+ }
+
+ protected function cssPrefetchLink(string $name): string {
+ $url = '/dist-css/'.$name.'.css?v='.$this->getStaticVersion('css/'.$name.'.css');
+ $integrity = $this->getStaticIntegrityAttribute('css/'.$name.'.css');
+ return ' ';
+ }
+
+ protected function getStaticNameParts(string $name): array {
+ $dname = dirname($name);
+ $bname = basename($name);
+ if (($pos = strrpos($bname, '.'))) {
+ $ext = substr($bname, $pos+1);
+ $bname = substr($bname, 0, $pos);
+ } else {
+ $ext = '';
+ }
+ return [$dname, $bname, $ext];
+ }
+
+ protected function getStaticVersion(string $name): string {
+ global $config;
+ if (isDev())
+ return time();
+ if (str_starts_with($name, '/')) {
+ logWarning(__FUNCTION__.': '.$name.' starts with /');
+ $name = substr($name, 1);
+ }
+ return $config['static'][$name]['version'] ?? 'notfound';
+ }
+
+ protected function getStaticIntegrityAttribute(string $name): string {
+ if (isDev())
+ return '';
+ global $config;
+ return ' integrity="'.implode(' ', array_map(fn($hash_type) => $hash_type.'-'.$config['static'][$name]['integrity'][$hash_type], RESOURCE_INTEGRITY_HASHES)).'"';
}
}
-
-function skin($name): SkinContext {
- static $cache = [];
- if (!isset($cache[$name])) {
- $cache[$name] = new SkinContext('\\skin\\'.$name);
- }
- return $cache[$name];
-}
-
-function svg(): SVGSkinContext {
- static $svg = null;
- if ($svg === null)
- $svg = new SVGSkinContext();
- return $svg;
-}
-
-enum SkinStringModificationType {
- case RAW;
- case URL;
- case HTML;
- case JSON;
- case ADDSLASHES;
- case NL2BR;
-}
-
-class SkinString implements Stringable {
- protected SkinStringModificationType $modType;
-
- function __construct(protected string $string) {}
- function setModType(SkinStringModificationType $modType) { $this->modType = $modType; }
-
- function __toString(): string {
- return match ($this->modType) {
- SkinStringModificationType::HTML => htmlescape($this->string),
- SkinStringModificationType::URL => urlencode($this->string),
- SkinStringModificationType::JSON => jsonEncode($this->string),
- SkinStringModificationType::ADDSLASHES => addslashes($this->string),
- SkinStringModificationType::NL2BR => nl2br(htmlescape($this->string)),
- default => $this->string,
- };
- }
-}
diff --git a/engine/sphinx.php b/engine/sphinx.php
deleted file mode 100644
index 34bed9b..0000000
--- a/engine/sphinx.php
+++ /dev/null
@@ -1,104 +0,0 @@
- 1) {
- $mark_count = substr_count($sql, '?');
- $positions = array();
- $last_pos = -1;
- for ($i = 0; $i < $mark_count; $i++) {
- $last_pos = strpos($sql, '?', $last_pos + 1);
- $positions[] = $last_pos;
- }
- for ($i = $mark_count - 1; $i >= 0; $i--) {
- $arg = func_get_arg($i + 1);
- if (is_string($arg))
- $arg = _sphinx_normalize($arg);
- $v = '\''.$link->real_escape_string($arg).'\'';
- $sql = substr_replace($sql, $v, $positions[$i], 1);
- }
- }
-
- $q = $link->query($sql);
-
- $error = sphinx_error();
- if ($error)
- logError(__FUNCTION__, $error);
-
- return $q;
-}
-
-function sphinx_error() {
- $link = _sphinxql_link(auto_create: false);
- return $link?->error;
-}
-
-function sphinx_mkquery($q, array $opts = []) {
- $defaults = [
- 'any_word' => false,
- 'star' => false,
- 'and' => false,
- 'exact_first' => false
- ];
- $opts = array_merge($defaults, $opts);
- $q = preg_replace('/\s+/', ' ', $q);
- $q = _sphinx_normalize($q);
- $q = trim($q);
- $q = sphinx_client()->escapeString($q);
- if ($opts['star']) {
- $words = explode(' ', $q);
- $words = array_map(fn($word) => $word.'*', $words);
- $q = implode(' ', $words);
- }
- if ($opts['any_word']) {
- $q = str_replace(' ', ' | ', $q);
- } else if ($opts['and']) {
- $q = str_replace(' ', ' AND ', $q);
- }
- if ($opts['exact_first']) {
- $q = '"^'.$q.'$" | "'.$q.'" | ('.$q.')';
- }
- return $q;
-}
-
-function sphinx_client(): Sphinx\SphinxClient {
- static $cl = null;
- if (!is_null($cl))
- return $cl;
- return $cl = new Sphinx\SphinxClient;
-}
-
-function _sphinx_normalize(string $origstr): string {
- $buf = preg_replace('/[Ёё]/iu', 'е', $origstr);
- if (!pcre_no_error($buf, no_error: true)) {
- $origstr = mb_convert_encoding($origstr, 'UTF-8', 'UTF-8');
- $buf = preg_replace('/[Ёё]/iu', 'е', $origstr);
- pcre_no_error($buf);
- }
- if ($buf === null) {
- logError(__METHOD__.': preg_replace() failed with error: '.preg_last_error().': '.preg_last_error_msg());
- $buf = $origstr;
- }
- return preg_replace('/[!\?]/', '', $buf);
-}
-
-function _sphinxql_link($auto_create = true) {
- global $config;
-
- /** @var ?mysqli $link */
- static $link = null;
- if (!is_null($link) || !$auto_create)
- return $link;
-
- $link = new mysqli();
- $link->real_connect(
- $config['sphinx']['host'],
- ini_get('mysql.default_user'),
- ini_get('mysql.default_password'),
- null,
- 9306);
- $link->set_charset('utf8');
-
- return $link;
-}
diff --git a/engine/strings.php b/engine/strings.php
index 2d9b1d3..b2b289f 100644
--- a/engine/strings.php
+++ b/engine/strings.php
@@ -21,19 +21,19 @@ enum NameType: int {
class StringsBase implements ArrayAccess {
protected array $data = [];
- function offsetSet(mixed $offset, mixed $value): void {
+ public function offsetSet(mixed $offset, mixed $value): void {
throw new RuntimeException('Not implemented');
}
- function offsetExists(mixed $offset): bool {
+ public function offsetExists(mixed $offset): bool {
return isset($this->data[$offset]);
}
- function offsetUnset(mixed $offset): void {
+ public function offsetUnset(mixed $offset): void {
throw new RuntimeException('Not implemented');
}
- function offsetGet(mixed $offset): mixed {
+ public function offsetGet(mixed $offset): mixed {
if (!isset($this->data[$offset])) {
logError(__METHOD__.': '.$offset.' not found');
return '{'.$offset.'}';
@@ -41,7 +41,7 @@ class StringsBase implements ArrayAccess {
return $this->data[$offset];
}
- function get(string $key, mixed ...$sprintf_args): string|array {
+ public function get(string $key, mixed ...$sprintf_args): string|array {
$val = $this[$key];
if (!empty($sprintf_args)) {
array_unshift($sprintf_args, $val);
@@ -51,7 +51,7 @@ class StringsBase implements ArrayAccess {
}
}
- function num(string $key, int $num, array$opts = []) {
+ public function num(string $key, int $num, array$opts = []) {
$s = $this[$key];
$default_opts = [
@@ -115,7 +115,7 @@ class Strings extends StringsBase {
return self::$instance;
}
- function load(string ...$pkgs): array {
+ public function load(string ...$pkgs): array {
$keys = [];
foreach ($pkgs as $name) {
$raw = yaml_parse_file(APP_ROOT.'/strings/'.$name.'.yaml');
@@ -126,13 +126,13 @@ class Strings extends StringsBase {
return $keys;
}
- function flex(string $s, DeclensionCase $case, NameSex $sex, NameType $type): string {
+ public function flex(string $s, DeclensionCase $case, NameSex $sex, NameType $type): string {
$s = iconv('utf-8', 'cp1251', $s);
$s = vkflex($s, $case->value, $sex->value, 0, $type->value);
return iconv('cp1251', 'utf-8', $s);
}
- function search(string $regexp): array {
+ public function search(string $regexp): array {
return preg_grep($regexp, array_keys($this->data));
}
}
\ No newline at end of file
diff --git a/functions.php b/functions.php
index 0b0161c..1142135 100644
--- a/functions.php
+++ b/functions.php
@@ -1,6 +1,6 @@
'gif',
@@ -167,7 +167,7 @@ function ulong2ip(int $ip): string {
return long2ip(-$long);
}
-function from_camel_case(string $s): string {
+function fromCamelCase(string $s): string {
$buf = '';
$len = strlen($s);
for ($i = 0; $i < $len; $i++) {
@@ -180,11 +180,11 @@ function from_camel_case(string $s): string {
return $buf;
}
-function to_camel_case(string $input, string $separator = '_'): string {
+function toCamelCase(string $input, string $separator = '_'): string {
return lcfirst(str_replace($separator, '', ucwords($input, $separator)));
}
-function str_replace_once(string $needle, string $replace, string $haystack) {
+function strReplaceOnce(string $needle, string $replace, string $haystack) {
$pos = strpos($haystack, $needle);
if ($pos !== false)
$haystack = substr_replace($haystack, $replace, $pos, strlen($needle));
@@ -205,7 +205,7 @@ function strgen(int $len): string {
return $buf;
}
-function sanitize_filename(string $name): string {
+function sanitizeFilename(string $name): string {
$name = mb_strtolower($name);
$name = transliterate($name);
$name = preg_replace('/[^\w\d\-_\s.]/', '', $name);
@@ -213,31 +213,6 @@ function sanitize_filename(string $name): string {
return $name;
}
-function glob_escape(string $pattern): string {
- if (strpos($pattern, '[') !== false || strpos($pattern, ']') !== false) {
- $placeholder = uniqid();
- $replaces = array( $placeholder.'[', $placeholder.']', );
- $pattern = str_replace( array('[', ']', ), $replaces, $pattern);
- $pattern = str_replace( $replaces, array('[[]', '[]]', ), $pattern);
- }
- return $pattern;
-}
-
-/**
- * Does not support flag GLOB_BRACE
- *
- * @param string $pattern
- * @param int $flags
- * @return array
- */
-function glob_recursive(string $pattern, int $flags = 0): array {
- $files = glob(glob_escape($pattern), $flags);
- foreach (glob(glob_escape(dirname($pattern)).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
- $files = array_merge($files, glob_recursive($dir.'/'.basename($pattern), $flags));
- }
- return $files;
-}
-
function setperm(string $file): void {
global $config;
@@ -259,7 +234,7 @@ function setperm(string $file): void {
}
}
-function salt_password(string $pwd): string {
+function saltPassword(string $pwd): string {
global $config;
return hash('sha256', "{$pwd}|{$config['password_salt']}");
}
@@ -285,19 +260,25 @@ function lang() {
global $__lang;
return call_user_func_array([$__lang, 'get'], func_get_args());
}
-function lang_num() {
+function langNum() {
global $__lang;
return call_user_func_array([$__lang, 'num'], func_get_args());
}
-function is_dev(): bool { global $config; return $config['is_dev']; }
-function is_cli(): bool { return PHP_SAPI == 'cli'; };
-function is_retina(): bool { return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina']; }
+function isDev(): bool { global $config; return $config['is_dev']; }
+function isCli(): bool { return PHP_SAPI == 'cli'; };
+function isRetina(): bool { return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina']; }
+
+function isAdmin(): bool {
+ if (admin::getId() === null)
+ admin::check();
+ return admin::getId() != 0;
+}
function jsonEncode($obj): ?string { return json_encode($obj, JSON_UNESCAPED_UNICODE) ?: null; }
function jsonDecode($json) { return json_decode($json, true); }
-function pcre_no_error(mixed &$result, bool $no_error = false): bool {
+function pcreNoError(mixed &$result, bool $no_error = false): bool {
if ($result === null) {
if (preg_last_error() !== PREG_NO_ERROR) {
if (!$no_error)
@@ -308,7 +289,7 @@ function pcre_no_error(mixed &$result, bool $no_error = false): bool {
return true;
}
-function hl_matched(string $s, string|Stringable|SkinString|array|null $keywords = []): string {
+function highlightSubstring(string $s, string|array|null $keywords = []): string {
if (is_null($keywords))
return htmlescape($s);
@@ -369,7 +350,7 @@ function hl_matched(string $s, string|Stringable|SkinString|array|null $keywords
return $buf;
}
-function format_time($ts, array $opts = array()) {
+function formatTime($ts, array $opts = array()) {
$default_opts = [
'date_only' => false,
'day_of_week' => false,
@@ -411,30 +392,3 @@ function format_time($ts, array $opts = array()) {
return $date;
}
-function arabic_to_roman($number) {
- $map = [
- 1000 => 'M',
- 900 => 'CM',
- 500 => 'D',
- 400 => 'CD',
- 100 => 'C',
- 90 => 'XC',
- 50 => 'L',
- 40 => 'XL',
- 10 => 'X',
- 9 => 'IX',
- 5 => 'V',
- 4 => 'IV',
- 1 => 'I',
- ];
- $result = '';
-
- foreach ($map as $arabic => $roman) {
- while ($number >= $arabic) {
- $result .= $roman;
- $number -= $arabic;
- }
- }
-
- return $result;
-}
diff --git a/handler/FilesHandler.php b/handler/FilesHandler.php
deleted file mode 100644
index 40e10cc..0000000
--- a/handler/FilesHandler.php
+++ /dev/null
@@ -1,166 +0,0 @@
- '$meta_files_title',
- '$description' => '$meta_files_description'
- ]);
- set_title('$files');
- set_skin_opts(['head_section' => 'files']);
- $collections = array_map(fn(FilesCollection $c) => new CollectionItem($c), FilesCollection::cases());
- $books = books_get();
- $misc = books_get(category: BookCategory::MISC);
- render('files/index',
- collections: $collections,
- books: $books,
- misc: $misc);
- }
-
- function GET_folder() {
- list($folder_id) = input('i:folder_id');
- $parents = books_get_folder($folder_id, true);
- if (!$parents)
- not_found();
- if (count($parents) > 1)
- $parents = array_reverse($parents);
- $folder = $parents[count($parents)-1];
- $files = books_get($folder_id, category: $folder->category);
- add_meta([
- '$title' => lang('meta_files_book_folder_title', $folder->getTitle()),
- '$description' => lang('meta_files_book_folder_description', $folder->getTitle())
- ]);
- set_title(lang('files').' - '.$folder->title);
- render('files/folder',
- folder: $folder,
- parents: $parents,
- files: $files);
- }
-
- function GET_collection() {
- list($collection, $folder_id, $query, $offset) = input('collection, i:folder_id, q, i:offset');
- $collection = FilesCollection::from($collection);
- $parents = null;
-
- $query = trim($query);
- if (!$query)
- $query = null;
-
- add_skin_strings_re('/^files_(.*?)_collection$/');
- add_skin_strings([
- 'files_search_results_count'
- ]);
-
- $vars = [];
- $text_excerpts = null;
- $func_prefix = $collection->value;
-
- if ($query !== null) {
- $files = call_user_func("{$func_prefix}_search", $query, $offset, self::SEARCH_RESULTS_PER_PAGE);
- $vars += [
- 'search_count' => $files['count'],
- 'search_query' => $query
- ];
-
- /** @var WFFCollectionItem[]|MDFCollectionItem[]|BaconianaCollectionItem[] $files */
- $files = $files['items'];
-
- $query_words = array_map('mb_strtolower', preg_split('/\s+/', $query));
- $found = [];
- $result_ids = [];
- foreach ($files as $file) {
- if ($file->isFolder())
- continue;
- $result_ids[] = $file->id;
-
- switch ($collection) {
- case FilesCollection::MercureDeFrance:
- $candidates = [
- $file->date,
- (string)$file->issue
- ];
- break;
- case FilesCollection::WilliamFriedman:
- $candidates = [
- mb_strtolower($file->getTitle()),
- strtolower($file->documentId)
- ];
- break;
- case FilesCollection::Baconiana:
- $candidates = [
- // TODO
- ];
- break;
- }
-
- foreach ($candidates as $haystack) {
- foreach ($query_words as $qw) {
- if (mb_strpos($haystack, $qw) !== false) {
- $found[$file->id] = true;
- continue 2;
- }
- }
- }
- }
-
- $found = array_map('intval', array_keys($found));
- $not_found = array_diff($result_ids, $found);
- if (!empty($not_found))
- $text_excerpts = call_user_func("{$func_prefix}_get_text_excerpts", $not_found, $query_words);
-
- if (is_xhr_request()) {
- ajax_ok([
- ...$vars,
- 'new_offset' => $offset + count($files),
- 'html' => skin('files')->collection_files($files, $query, $text_excerpts)
- ]);
- }
- } else {
- if (in_array($collection, [FilesCollection::WilliamFriedman, FilesCollection::Baconiana]) && $folder_id) {
- $parents = call_user_func("{$func_prefix}_get_folder", $folder_id, true);
- if (!$parents)
- not_found();
- if (count($parents) > 1)
- $parents = array_reverse($parents);
- }
- $files = call_user_func("{$func_prefix}_get", $folder_id);
- }
-
- $title = lang('files_'.$collection->value.'_collection');
- if ($folder_id && $parents)
- $title .= ' - '.htmlescape($parents[count($parents)-1]->getTitle());
- if ($query)
- $title .= ' - '.htmlescape($query);
- set_title($title);
-
- if (!$folder_id && !$query)
- add_meta([
- '$title' => lang('4in1').' - '.lang('meta_files_collection_title', lang('files_'.$collection->value.'_collection')),
- '$description' => lang('meta_files_'.$collection->value.'_description')
- ]);
- else if ($query || $parents) {
- add_meta([
- '$title' => lang('4in1').' - '.$title,
- '$description' => lang('meta_files_'.($query ? 'search' : 'folder').'_description',
- $query ?: $parents[count($parents)-1]->getTitle(),
- lang('files_'.$collection->value.'_collection'))
- ]);
- }
-
- render('files/collection',
- ...$vars,
- collection: $collection,
- files: $files,
- parents: $parents,
- search_results_per_page: self::SEARCH_RESULTS_PER_PAGE,
- search_min_query_length: self::SEARCH_MIN_QUERY_LENGTH,
- text_excerpts: $text_excerpts);
- }
-
-}
\ No newline at end of file
diff --git a/handler/MainHandler.php b/handler/MainHandler.php
deleted file mode 100644
index db40b08..0000000
--- a/handler/MainHandler.php
+++ /dev/null
@@ -1,206 +0,0 @@
- 'website',
- '$url' => 'https://'.$config['domain'].'/',
- '$title' => lang('meta_index_title'),
- '$description' => lang('meta_index_description'),
- '$image' => 'https://'.$config['domain'].'/img/4in1-preview.jpg'
- ]);
-
- set_title('$site_title');
- set_skin_opts(['is_index' => true]);
-
- render('main/index',
- posts: $posts,
- posts_lang: $posts_lang,
- versions: $config['book_versions']);
- }
-
- function GET_about() { redirect('/info/'); }
- function GET_contacts() { redirect('/info/'); }
-
- function GET_page() {
- global $config;
-
- list($name) = input('name');
-
- $page = pages::getByName($name);
- if (!$page) {
- if (is_admin()) {
- set_title($name);
- render('admin/pageNew',
- short_name: $name);
- }
- not_found();
- }
-
- if (!is_admin() && !$page->visible)
- not_found();
-
- if ($page->shortName == 'info')
- set_skin_opts(['head_section' => 'about']);
- else if ($page->shortName == $config['wiki_root'])
- set_skin_opts(['head_section' => $page->shortName]);
-
- $bc = null;
- if ($page) {
- add_meta([
- '$url' => 'https://'.$config['domain'].$page->getUrl(),
- '$title' => $page->title,
- ]);
-
- if ($page->parentId) {
- $bc = [];
- $parent = $page;
- while ($parent?->parentId) {
- $parent = pages::getById($parent->parentId);
- if ($parent)
- $bc[] = ['url' => $parent->getUrl(), 'text' => $parent->title];
- }
- if (empty($bc))
- $bc = null;
- }
- }
-
- set_title($page ? $page->title : '???');
- render('main/page',
- unsafe_html: $page->getHtml(is_retina(), getUserTheme()),
- page_url: $page->getUrl(),
- bc: $bc,
- short_name: $page->shortName);
-
- not_found();
- }
-
- function GET_post() {
- global $config;
- list($name, $input_lang) = input('name, lang');
-
- $lang = null;
- try {
- if ($input_lang)
- $lang = PostLanguage::from($input_lang);
- } catch (ValueError $e) {
- not_found($e->getMessage());
- }
-
- if (!$lang)
- $lang = PostLanguage::getDefault();
-
- $post = posts::getByName($name);
- if (!$post || (!$post->visible && !is_admin()))
- not_found();
-
- if ($lang == PostLanguage::getDefault() && $input_lang == $lang->value)
- redirect($post->getUrl());
- if (!$post->hasLang($lang))
- not_found('no text for language '.$lang->name);
- if (!$post->visible && !is_admin())
- not_found();
-
- $pt = $post->getText($lang);
-
- $other_langs = [];
- foreach (PostLanguage::cases() as $pl) {
- if ($pl == $lang)
- continue;
- if ($post->hasLang($pl))
- $other_langs[] = $pl->value;
- }
-
- $meta = [
- '$title' => $pt->title,
- '$url' => $config['domain'].$post->getUrl(),
- '$description' => $pt->getDescriptionPreview(155)
- ];
- if ($pt->keywords)
- $meta['$keywords'] = $pt->keywords;
- add_meta($meta);
-
- if (($img = $pt->getFirstImage()) !== null)
- add_meta(['$image' => $img->getDirectUrl()]);
-
- set_skin_opts(['articles_lang' => $lang->value]);
-
- set_title($pt->title);
-
- if ($pt->hasTableOfContents())
- set_skin_opts(['wide' => true]);
-
- render('main/post',
- title: $pt->title,
- id: $post->id,
- source_url: $post->sourceUrl,
- unsafe_html: $pt->getHtml(is_retina(), getUserTheme()),
- unsafe_toc_html: $pt->getTableOfContentsHtml(),
- date: $post->getFullDate(),
- visible: $post->visible,
- url: $post->getUrl(),
- lang: $lang->value,
- other_langs: $other_langs);
- }
-
- function GET_rss() {
- global $config;
-
- $lang = PostLanguage::getDefault();
- $items = array_map(function(Post $post) use ($lang) {
- $pt = $post->getText($lang);
- return [
- 'title' => $pt->title,
- 'link' => $post->getUrl(),
- 'pub_date' => date(DATE_RSS, $post->getTimestamp()),
- 'description' => $pt->getDescriptionPreview(500)
- ];
- }, posts::getList(0, 20, filter_by_lang: $lang));
-
- $ctx = skin('rss');
- $body = $ctx->atom(
- title: lang('site_title'),
- link: 'https://'.$config['domain'],
- rss_link: 'https://'.$config['domain'].'/feed.rss',
- items: $items);
-
- header('Content-Type: application/rss+xml; charset=utf-8');
- echo $body;
- exit;
- }
-
- function GET_articles() {
- list($lang) = input('lang');
- if ($lang) {
- $lang = PostLanguage::tryFrom($lang);
- if (!$lang || $lang == PostLanguage::getDefault())
- redirect('/articles/');
- } else {
- $lang = PostLanguage::getDefault();
- }
-
- add_meta([
- '$description' => lang('blog_expl_'.$lang->value)
- ]);
-
- $posts = posts::getList(
- include_hidden: is_admin(),
- filter_by_lang: $lang);
-
- set_title('$articles');
- set_skin_opts(['head_section' => 'articles']);
-
- render('main/articles',
- posts: $posts,
- selected_lang: $lang);
- }
-
-}
\ No newline at end of file
diff --git a/handler/AdminHandler.php b/handlers/AdminHandler.php
similarity index 52%
rename from handler/AdminHandler.php
rename to handlers/AdminHandler.php
index 684581c..3a1ef23 100644
--- a/handler/AdminHandler.php
+++ b/handlers/AdminHandler.php
@@ -2,50 +2,53 @@
class AdminHandler extends request_handler {
- function __construct() {
+ public function __construct() {
parent::__construct();
- add_static('css/admin.css', 'js/admin.js');
- add_skin_strings(['error']);
- set_skin_opts(['inside_admin_interface' => true]);
+ $this->skin->addStatic('css/admin.css', 'js/admin.js');
+ $this->skin->exportStrings(['error']);
+ $this->skin->setRenderOptions(['inside_admin_interface' => true]);
}
- function before_dispatch(string $http_method, string $action) {
- if ($action != 'login' && !is_admin())
- forbidden();
+ public function beforeDispatch(string $http_method, string $action) {
+ if ($action != 'login' && !isAdmin())
+ self::forbidden();
}
- function GET_index() {
- global $AdminSession;
+ public function GET_index() {
//$admin_info = admin_current_info();
- set_title('$admin_title');
- render('admin/index',
- admin_login: $AdminSession->login);
+ $this->skin->setTitle('$admin_title');
+ $this->skin->renderPage('admin_index.twig', [
+ 'admin_login' => admin::getLogin(),
+ 'logout_token' => self::getCSRF('logout'),
+ ]);
}
- function GET_login() {
- if (is_admin())
- redirect('/admin/');
- set_title('$admin_title');
- render('admin/login');
+ public function GET_login() {
+ if (isAdmin())
+ self::redirect('/admin/');
+ $this->skin->setTitle('$admin_title');
+ $this->skin->renderPage('admin_login.twig', [
+ 'form_token' => self::getCSRF('adminlogin'),
+ ]);
}
- function POST_login() {
- csrf_check('adminlogin');
- list($login, $password) = input('login, password');
- admin_auth($login, $password)
- ? redirect('/admin/')
- : forbidden();
+ public function POST_login() {
+ self::checkCSRF('adminlogin');
+ list($login, $password) = $this->input('login, password');
+ admin::auth($login, $password)
+ ? self::redirect('/admin/')
+ : self::forbidden();
}
- function GET_logout() {
- csrf_check('logout');
- admin_logout();
- redirect('/admin/login/', HTTPCode::Found);
+ public function GET_logout() {
+ self::checkCSRF('logout');
+ admin::logout();
+ self::redirect('/admin/login/', HTTPCode::Found);
}
- function GET_errors() {
+ public function GET_errors() {
list($ip, $query, $url_query, $file_query, $line_query, $per_page)
- = input('i:ip, query, url_query, file_query, i:line_query, i:per_page');
+ = $this->input('i:ip, query, url_query, file_query, i:line_query, i:per_page');
if (!$per_page)
$per_page = 100;
@@ -78,14 +81,14 @@ class AdminHandler extends request_handler {
}
$count = (int)$db->result($db->query("SELECT COUNT(*) FROM backend_errors".$sql_where));
- list($page, $pages, $offset) = get_page($per_page, $count);
+ list($page, $pages, $offset) = $this->getPage($per_page, $count);
$q = $db->query("SELECT *, INET_NTOA(ip) ip_s FROM backend_errors $sql_where ORDER BY id DESC LIMIT $offset, $per_page");
$list = [];
while ($row = $db->fetch($q)) {
- $row['date'] = format_time($row['ts'], [
+ $row['date'] = formatTime($row['ts'], [
'seconds' => true,
'short_months' => true,
]);
@@ -138,22 +141,20 @@ class AdminHandler extends request_handler {
$query_var_names = ['query', 'url_query', 'file_query', 'line_query'];
foreach ($query_var_names as $query_var_name) {
- if ($$query_var_name) {
+ if ($$query_var_name)
$vars += [$query_var_name => $$query_var_name];
- }
}
- set_skin_opts(['wide' => true]);
- set_title('$admin_errors');
- render('admin/errors',
- ...$vars);
+ $this->skin->setRenderOptions(['wide' => true]);
+ $this->skin->setTitle('$admin_errors');
+ $this->skin->renderPage('admin_errors.twig', $vars);
}
- function GET_auth_log() {
+ public function GET_auth_log() {
$db = DB();
$count = (int)$db->result($db->query("SELECT COUNT(*) FROM admin_log"));
$per_page = 100;
- list($page, $pages, $offset) = get_page($per_page, $count);
+ list($page, $pages, $offset) = $this->getPage($per_page, $count);
$q = $db->query("SELECT *,
INET_NTOA(ip) AS ip,
@@ -167,24 +168,23 @@ class AdminHandler extends request_handler {
if (!empty($list)) {
$list = array_map(function($item) {
- $item['date'] = format_time($item['ts']);
- $item['activity_ts_s'] = format_time($item['activity_ts']);
+ $item['date'] = formatTime($item['ts']);
+ $item['activity_ts_s'] = formatTime($item['activity_ts']);
return $item;
}, $list);
}
- $vars = [
+ $this->skin->setRenderOptions(['wide' => true]);
+ $this->skin->setTitle('$admin_auth_log');
+ $this->skin->set([
'list' => $list,
'pn_page' => $page,
'pn_pages' => $pages
- ];
- set_skin_opts(['wide' => true]);
- set_title('$admin_auth_log');
- render('admin/auth_log',
- ...$vars);
+ ]);
+ $this->skin->renderPage('admin_auth_log.twig');
}
- function GET_actions_log() {
+ public function GET_actions_log() {
$field_types = \AdminActions\Util\Logger::getFieldTypes();
foreach ($field_types as $type_prefix => $type_data) {
for ($i = 1; $i <= $type_data['count']; $i++) {
@@ -197,7 +197,7 @@ class AdminHandler extends request_handler {
$per_page = 100;
$count = \AdminActions\Util\Logger::getRecordsCount();
- list($page, $pages, $offset) = get_page($per_page, $count);
+ list($page, $pages, $offset) = $this->getPage($per_page, $count);
$admin_ids = [];
$admin_logins = [];
@@ -210,7 +210,7 @@ class AdminHandler extends request_handler {
}
if (!empty($admin_ids))
- $admin_logins = admin_get_logins_by_id(array_keys($admin_ids));
+ $admin_logins = admin::getLoginsById(array_keys($admin_ids));
$url = '/admin/actions-log/?';
@@ -222,39 +222,37 @@ class AdminHandler extends request_handler {
}
}
- $vars = [
+ $this->skin->setRenderOptions(['wide' => true]);
+ $this->skin->setTitle('$admin_actions_log');
+ $this->skin->renderPage('admin_actions_log.twig', [
'list' => $records,
'pn_page' => $page,
'pn_pages' => $pages,
'admin_logins' => $admin_logins,
'url' => $url,
'action_types' => \AdminActions\Util\Logger::getActions(true),
- ];
-
- set_skin_opts(['wide' => true]);
- set_title('$admin_actions_log');
- render('admin/actions_log',
- ...$vars);
+ ]);
}
- function GET_uploads() {
- list($error) = input('error');
+ public function GET_uploads() {
+ list($error) = $this->input('error');
$uploads = uploads::getAllUploads();
- set_title('$blog_upload');
- render('admin/uploads',
- error: $error,
- uploads: $uploads,
- langs: PostLanguage::cases());
+ $this->skin->setTitle('$blog_upload');
+ $this->skin->renderPage('admin_uploads.twig', [
+ 'error' => $error,
+ 'uploads' => $uploads,
+ 'langs' => PostLanguage::casesAsStrings(),
+ 'form_token' => self::getCSRF('add_upload'),
+ ]);
}
- function POST_uploads() {
- csrf_check('addupl');
-
- list($custom_name, $note_en, $note_ru) = input('name, note_en, note_ru');
+ public function POST_uploads() {
+ self::checkCSRF('add_upload');
+ list($custom_name, $note_en, $note_ru) = $this->input('name, note_en, note_ru');
if (!isset($_FILES['files']))
- redirect('/admin/uploads/?error='.urlencode('no file'));
+ self::redirect('/admin/uploads/?error='.urlencode('no file'));
$files = [];
for ($i = 0; $i < count($_FILES['files']['name']); $i++) {
@@ -275,14 +273,14 @@ class AdminHandler extends request_handler {
foreach ($files as $f) {
if ($f['error'])
- redirect('/admin/uploads/?error='.urlencode('error code '.$f['error']));
+ self::redirect('/admin/uploads/?error='.urlencode('error code '.$f['error']));
if (!$f['size'])
- redirect('/admin/uploads/?error='.urlencode('received empty file'));
+ self::redirect('/admin/uploads/?error='.urlencode('received empty file'));
$ext = extension($f['name']);
if (!uploads::isExtensionAllowed($ext))
- redirect('/admin/uploads/?error='.urlencode('extension not allowed'));
+ self::redirect('/admin/uploads/?error='.urlencode('extension not allowed'));
$name = $custom_name ?: $f['name'];
$upload_id = uploads::add(
@@ -292,36 +290,36 @@ class AdminHandler extends request_handler {
$note_ru);
if (!$upload_id)
- redirect('/admin/uploads/?error='.urlencode('failed to create upload'));
+ self::redirect('/admin/uploads/?error='.urlencode('failed to create upload'));
- admin_log(new \AdminActions\UploadsAdd($upload_id, $name, $note_en, $note_ru));
+ admin::log(new \AdminActions\UploadsAdd($upload_id, $name, $note_en, $note_ru));
}
- redirect('/admin/uploads/');
+ self::redirect('/admin/uploads/');
}
- function GET_upload_delete() {
- list($id) = input('i:id');
+ public function GET_upload_delete() {
+ list($id) = $this->input('i:id');
$upload = uploads::get($id);
if (!$upload)
- redirect('/admin/uploads/?error='.urlencode('upload not found'));
- csrf_check('delupl'.$id);
+ self::redirect('/admin/uploads/?error='.urlencode('upload not found'));
+ self::checkCSRF('delupl'.$id);
uploads::delete($id);
- admin_log(new \AdminActions\UploadsDelete($id));
- redirect('/admin/uploads/');
+ admin::log(new \AdminActions\UploadsDelete($id));
+ self::redirect('/admin/uploads/');
}
- function POST_upload_edit_note() {
- list($id, $note, $lang) = input('i:id, note, lang');
+ public function POST_upload_edit_note() {
+ list($id, $note, $lang) = $this->input('i:id, note, lang');
$lang = PostLanguage::tryFrom($lang);
if (!$lang)
- not_found();
+ self::notFound();
$upload = uploads::get($id);
if (!$upload)
- redirect('/admin/uploads/?error='.urlencode('upload not found'));
+ self::redirect('/admin/uploads/?error='.urlencode('upload not found'));
- csrf_check('editupl'.$id);
+ self::checkCSRF('editupl'.$id);
$upload->setNote($lang, $note);
$texts = posts::getTextsWithUpload($upload);
@@ -332,13 +330,13 @@ class AdminHandler extends request_handler {
}
}
- admin_log(new \AdminActions\UploadsEditNote($id, $note, $lang->value));
- redirect('/admin/uploads/');
+ admin::log(new \AdminActions\UploadsEditNote($id, $note, $lang->value));
+ self::redirect('/admin/uploads/');
}
- function POST_ajax_md_preview() {
- ensure_xhr();
- list($md, $title, $use_image_previews, $lang, $is_page) = input('md, title, b:use_image_previews, lang, b:is_page');
+ public function POST_ajax_md_preview() {
+ self::ensureXhr();
+ list($md, $title, $use_image_previews, $lang, $is_page) = $this->input('md, title, b:use_image_previews, lang, b:is_page');
$lang = PostLanguage::tryFrom($lang);
if (!$lang)
$lang = PostLanguage::getDefault();
@@ -347,37 +345,48 @@ class AdminHandler extends request_handler {
$title = '';
}
$html = markup::markdownToHtml($md, $use_image_previews, $lang);
- $ctx = skin('admin');
- $html = $ctx->markdownPreview(
- unsafe_html: $html,
- title: $title
- );
- ajax_ok(['html' => $html]);
+ $html = $this->skin->render('markdown_preview.twig', [
+ 'unsafe_html' => $html,
+ 'title' => $title
+ ]);
+ self::ajaxOk(['html' => $html]);
}
- function GET_page_add() {
- list($name) = input('short_name');
+ public function GET_page_add() {
+ list($name) = $this->input('short_name');
$page = pages::getByName($name);
if ($page)
- redirect($page->getUrl(), code: HTTPCode::Found);
- add_skin_strings_re('/^(err_)?pages_/');
- add_skin_strings_re('/^(err_)?blog_/');
- set_title(lang('pages_create_title', $name));
- static::make_wide();
- render('admin/pageForm',
- short_name: $name,
- title: '',
- text: '',
- langs: PostLanguage::cases());
+ self::redirect($page->getUrl(), code: HTTPCode::Found);
+ $this->skin->exportStrings('/^(err_)?pages_/');
+ $this->skin->exportStrings('/^(err_)?blog_/');
+ $this->skin->setTitle(lang('pages_create_title', $name));
+ $this->setWidePageOptions();
+
+ $js_params = [
+ 'pages' => true,
+ 'edit' => false,
+ 'token' => self::getCSRF('addpage'),
+ 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing
+ ];
+
+ $this->skin->renderPage('admin_page_form.twig', [
+ 'is_edit' => false,
+ 'short_name' => $name,
+ 'title' => '',
+ 'text' => '',
+ 'langs' => PostLanguage::cases(),
+ 'js_params' => $js_params,
+ 'form_url' => '/'.$name.'/create/'
+ ]);
}
- function POST_page_add() {
- csrf_check('addpage');
+ public function POST_page_add() {
+ self::checkCSRF('addpage');
- list($name, $text, $title) = input('short_name, text, title');
+ list($name, $text, $title) = $this->input('short_name, text, title');
$page = pages::getByName($name);
if ($page)
- not_found();
+ self::notFound();
$error_code = null;
@@ -388,48 +397,48 @@ class AdminHandler extends request_handler {
}
if ($error_code)
- ajax_error(['code' => $error_code]);
+ self::ajaxError(['code' => $error_code]);
if (!pages::add([
'short_name' => $name,
'title' => $title,
'md' => $text
])) {
- ajax_error(['code' => 'db_err']);
+ self::ajaxError(['code' => 'db_err']);
}
- admin_log(new \AdminActions\PageCreate($name));
+ admin::log(new \AdminActions\PageCreate($name));
$page = pages::getByName($name);
- ajax_ok(['url' => $page->getUrl()]);
+ self::ajaxOk(['url' => $page->getUrl()]);
}
- function GET_page_delete() {
- list($name) = input('short_name');
+ public function GET_page_delete() {
+ list($name) = $this->input('short_name');
$page = pages::getByName($name);
if (!$page)
- not_found();
+ self::notFound();
$url = $page->getUrl();
- csrf_check('delpage'.$page->shortName);
+ self::checkCSRF('delpage'.$page->shortName);
pages::delete($page);
- admin_log(new \AdminActions\PageDelete($name));
- redirect($url, code: HTTPCode::Found);
+ admin::log(new \AdminActions\PageDelete($name));
+ self::redirect($url, code: HTTPCode::Found);
}
- function GET_page_edit() {
- list($short_name, $saved) = input('short_name, b:saved');
+ public function GET_page_edit() {
+ list($short_name, $saved) = $this->input('short_name, b:saved');
$page = pages::getByName($short_name);
if (!$page)
- not_found();
+ self::notFound();
- add_skin_strings_re('/^(err_)?pages_/');
- add_skin_strings_re('/^(err_)?blog_/');
- set_title(lang('pages_page_edit_title', $page->shortName));
- static::make_wide();
+ $this->skin->exportStrings('/^(err_)?pages_/');
+ $this->skin->exportStrings('/^(err_)?blog_/');
+ $this->skin->setTitle(lang('pages_page_edit_title', $page->shortName));
+ $this->setWidePageOptions();
$js_text = [
'text' => $page->md,
'title' => $page->title,
@@ -442,32 +451,43 @@ class AdminHandler extends request_handler {
$parent = $parent_page->shortName;
}
- render('admin/pageForm',
- is_edit: true,
- short_name: $page->shortName,
- title: $page->title,
- text: $page->md,
- visible: $page->visible,
- render_title: $page->renderTitle,
- parent: $parent,
- saved: $saved,
- langs: PostLanguage::cases(),
- js_text: $js_text);
+ $js_params = [
+ 'pages' => true,
+ 'edit' => true,
+ 'token' => self::getCSRF('editpage'.$short_name),
+ 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing
+ 'text' => [
+ 'text' => $page->md,
+ 'title' => $page->title,
+ ]
+ ];
+
+ $this->skin->renderPage('admin_page_form.twig', [
+ 'is_edit' => true,
+ 'short_name' => $page->shortName,
+ 'title' => $page->title,
+ 'text' => $page->md,
+ 'visible' => $page->visible,
+ 'render_title' => $page->renderTitle,
+ 'parent' => $parent,
+ 'saved' => $saved,
+ 'langs' => PostLanguage::cases(),
+ 'js_params' => $js_params,
+ ]);
}
- function POST_page_edit() {
- ensure_xhr();
-
- list($short_name) = input('short_name');
+ public function POST_page_edit() {
+ self::ensureXhr();
+ list($short_name) = $this->input('short_name');
$page = pages::getByName($short_name);
if (!$page)
- not_found();
+ self::notFound();
- csrf_check('editpage'.$page->shortName);
+ self::checkCSRF('editpage'.$page->shortName);
list($text, $title, $visible, $short_name, $parent, $render_title)
- = input('text, title, b:visible, new_short_name, parent, b:render_title');
+ = $this->input('text, title, b:visible, new_short_name, parent, b:render_title');
$text = trim($text);
$title = trim($title);
@@ -482,7 +502,7 @@ class AdminHandler extends request_handler {
}
if ($error_code)
- ajax_error(['code' => $error_code]);
+ self::ajaxError(['code' => $error_code]);
$new_short_name = $page->shortName != $short_name ? $short_name : null;
$parent_page = pages::getByName($parent);
@@ -498,14 +518,14 @@ class AdminHandler extends request_handler {
'parent_id' => $parent_id
]);
- admin_log(new \AdminActions\PageEdit($short_name, $new_short_name));
- ajax_ok(['url' => $page->getUrl().'edit/?saved=1']);
+ admin::log(new \AdminActions\PageEdit($short_name, $new_short_name));
+ self::ajaxOk(['url' => $page->getUrl().'edit/?saved=1']);
}
- function GET_post_add() {
- add_skin_strings_re('/^(err_)?blog_/');
- set_title('$blog_write');
- static::make_wide();
+ public function GET_post_add() {
+ $this->skin->exportStrings('/^(err_)?blog_/');
+ $this->skin->setTitle('$blog_write');
+ $this->setWidePageOptions();
$js_texts = [];
foreach (PostLanguage::cases() as $pl) {
@@ -517,30 +537,47 @@ class AdminHandler extends request_handler {
];
}
- render('admin/postForm',
- title: '',
- text: '',
- langs: PostLanguage::cases(),
- short_name: '',
- source_url: '',
- keywords: '',
- js_texts: $js_texts,
- lang: PostLanguage::getDefault()->value);
+ $js_params = [
+ 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
+ 'token' => self::getCSRF('post_add')
+ ];
+ $form_url = '/articles/write/';
+
+ $bc = [
+ ['url' => '/articles/?lang='.PostLanguage::getDefault()->value, 'text' => lang('articles')],
+ ['text' => lang('blog_new_post')]
+ ];
+
+ $this->skin->renderPage('admin_post_form.twig', [
+ // form data
+ 'title' => '',
+ 'text' => '',
+ 'short_name' => '',
+ 'source_url' => '',
+ 'keywords' => '',
+ 'date' => '',
+
+ 'bc' => $bc,
+ 'js_params' => $js_params,
+ 'form_url' => $form_url,
+ 'langs' => PostLanguage::casesAsStrings(),
+ 'lang' => PostLanguage::getDefault()->value
+ ]);
}
- function POST_post_add() {
- ensure_xhr();
- csrf_check('post_add');
+ public function POST_post_add() {
+ self::ensureXhr();
+ self::checkCSRF('post_add');
list($visibility_enabled, $short_name, $langs, $date)
- = input('b:visible, short_name, langs, date');
+ = $this->input('b:visible, short_name, langs, date');
self::_postEditValidateCommonData($date);
$lang_data = [];
$at_least_one_lang_is_written = false;
foreach (PostLanguage::cases() as $lang) {
- list($title, $text, $keywords, $toc_enabled) = input("title:{$lang->value}, text:{$lang->value}, keywords:{$lang->value}, b:toc:{$lang->value}", ['trim' => true]);
+ list($title, $text, $keywords, $toc_enabled) = $this->input("title:{$lang->value}, text:{$lang->value}, keywords:{$lang->value}, b:toc:{$lang->value}", ['trim' => true]);
if ($title !== '' && $text !== '') {
$lang_data[$lang->value] = [$title, $text, $keywords, $toc_enabled];
$at_least_one_lang_is_written = true;
@@ -554,7 +591,7 @@ class AdminHandler extends request_handler {
$error_code = 'no_short_name';
}
if ($error_code)
- ajax_error(['code' => $error_code]);
+ self::ajaxError(['code' => $error_code]);
$post = posts::add([
'visible' => $visibility_enabled,
@@ -564,7 +601,7 @@ class AdminHandler extends request_handler {
]);
if (!$post)
- ajax_error(['code' => 'db_err', 'message' => 'failed to add post']);
+ self::ajaxError(['code' => 'db_err', 'message' => 'failed to add post']);
// add texts
$added_texts = []; // for admin actions logging, at the end
@@ -578,47 +615,47 @@ class AdminHandler extends request_handler {
toc: $toc_enabled))
) {
posts::delete($post);
- ajax_error(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]);
+ self::ajaxError(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]);
} else {
$added_texts[] = [$new_post_text->id, $lang];
}
}
- admin_log(new \AdminActions\PostCreate($post->id));
+ admin::log(new \AdminActions\PostCreate($post->id));
foreach ($added_texts as $added_text) {
list($id, $lang) = $added_text;
- admin_log(new \AdminActions\PostTextCreate($id, $post->id, $lang));
+ admin::log(new \AdminActions\PostTextCreate($id, $post->id, $lang));
}
// done
- ajax_ok(['url' => $post->getUrl()]);
+ self::ajaxOk(['url' => $post->getUrl()]);
}
- function GET_post_delete() {
- list($name) = input('short_name');
+ public function GET_post_delete() {
+ list($name) = $this->input('short_name');
$post = posts::getByName($name);
if (!$post)
- not_found();
+ self::notFound();
$id = $post->id;
- csrf_check('delpost'.$id);
+ self::checkCSRF('delpost'.$id);
posts::delete($post);
- admin_log(new \AdminActions\PostDelete($id));
- redirect('/articles/', code: HTTPCode::Found);
+ admin::log(new \AdminActions\PostDelete($id));
+ self::redirect('/articles/', code: HTTPCode::Found);
}
- function GET_post_edit() {
- list($short_name, $saved, $lang) = input('short_name, b:saved, lang');
+ public function GET_post_edit() {
+ list($short_name, $saved, $lang) = $this->input('short_name, b:saved, lang');
$lang = PostLanguage::from($lang);
$post = posts::getByName($short_name);
if (!$post)
- not_found();
+ self::notFound();
$texts = $post->getTexts();
if (!isset($texts[$lang->value]))
- not_found();
+ self::notFound();
$js_texts = [];
foreach (PostLanguage::cases() as $pl) {
@@ -642,48 +679,64 @@ class AdminHandler extends request_handler {
$text = $texts[$lang->value];
- add_skin_strings_re('/^(err_)?blog_/');
- add_skin_strings(['blog_post_edit_title']);
- set_title(lang('blog_post_edit_title', $text->title));
- static::make_wide();
- render('admin/postForm',
- is_edit: true,
- post_id: $post->id,
- post_url: $post->getUrl(),
- title: $text->title,
- text: $text->md,
- date: $post->getDateForInputField(),
- visible: $post->visible,
- toc: $text->toc,
- saved: $saved,
- short_name: $short_name,
- source_url: $post->sourceUrl,
- keywords: $text->keywords,
- langs: PostLanguage::cases(),
- lang: $text->lang->value,
- js_texts: $js_texts
- );
+ $this->skin->exportStrings('/^(err_)?blog_/');
+ $this->skin->exportStrings(['blog_post_edit_title']);
+ $this->skin->setTitle(lang('blog_post_edit_title', $text->title));
+ $this->setWidePageOptions();
+
+ $bc = [
+ ['url' => '/articles/?lang='.$text->lang->value, 'text' => lang('articles')],
+ ['url' => $post->getUrl().'?lang='.$text->lang->value, 'text' => lang('blog_view_post')]
+ ];
+
+ $js_params = [
+ 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
+ 'token' => self::getCSRF('editpost'.$post->id),
+ 'edit' => true,
+ 'id' => $post->id,
+ 'texts' => $js_texts
+ ];
+ $form_url = $post->getUrl().'edit/';
+
+ $this->skin->renderPage('admin_post_form.twig', [
+ 'is_edit' => true,
+ 'post' => $post,
+ 'title' => $text->title,
+ 'text' => $text->md,
+ 'date' => $post->getDateForInputField(),
+ 'visible' => $post->visible,
+ 'toc' => $text->toc,
+ 'short_name' => $short_name,
+ 'source_url' => $post->sourceUrl,
+ 'keywords' => $text->keywords,
+ 'saved' => $saved,
+ 'langs' => PostLanguage::casesAsStrings(),
+ 'lang' => $text->lang->value,
+ 'js_params' => $js_params,
+ 'form_url' => $form_url,
+ 'bc' => $bc
+ ]);
}
- function POST_post_edit() {
- ensure_xhr();
+ public function POST_post_edit() {
+ self::ensureXhr();
- list($old_short_name, $short_name, $langs, $date, $source_url) = input('short_name, new_short_name, langs, date, source_url');
+ list($old_short_name, $short_name, $langs, $date, $source_url) = $this->input('short_name, new_short_name, langs, date, source_url');
$post = posts::getByName($old_short_name);
if (!$post)
- not_found();
+ self::notFound();
- csrf_check('editpost'.$post->id);
+ self::checkCSRF('editpost'.$post->id);
self::_postEditValidateCommonData($date);
if (empty($short_name))
- ajax_error(['code' => 'no_short_name']);
+ self::ajaxError(['code' => 'no_short_name']);
foreach (explode(',', $langs) as $lang) {
$lang = PostLanguage::from($lang);
- list($text, $title, $visible, $toc, $keywords) = input("text:{$lang->value}, title:{$lang->value}, b:visible, b:toc:{$lang->value}, keywords:{$lang->value}");
+ list($text, $title, $visible, $toc, $keywords) = $this->input("text:{$lang->value}, title:{$lang->value}, b:visible, b:toc:{$lang->value}, keywords:{$lang->value}");
$error_code = null;
if (!$title)
@@ -691,7 +744,7 @@ class AdminHandler extends request_handler {
else if (!$text)
$error_code = 'no_text';
if ($error_code)
- ajax_error(['code' => $error_code]);
+ self::ajaxError(['code' => $error_code]);
$pt = $post->getText($lang);
if (!$pt) {
@@ -703,7 +756,7 @@ class AdminHandler extends request_handler {
toc: $toc
);
if (!$pt)
- ajax_error(['code' => 'db_err']);
+ self::ajaxError(['code' => 'db_err']);
} else {
previous_texts::add(PreviousText::TYPE_POST_TEXT, $pt->id, $pt->md, $post->getUpdateTimestamp() ?: $post->getTimestamp());
$pt->edit([
@@ -724,24 +777,24 @@ class AdminHandler extends request_handler {
$post_data['short_name'] = $short_name;
$post->edit($post_data);
- admin_log(new \AdminActions\PostEdit($post->id));
- ajax_ok(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]);
+ admin::log(new \AdminActions\PostEdit($post->id));
+ self::ajaxOk(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]);
}
- function GET_books() {
- set_title('$admin_books');
- render('admin/books');
+ public function GET_books() {
+ $this->skin->setTitle('$admin_books');
+ $this->skin->renderPage('admin_books.twig');
}
protected static function _postEditValidateCommonData($date) {
$dt = DateTime::createFromFormat("Y-m-d", $date);
$date_is_valid = $dt && $dt->format("Y-m-d") === $date;
if (!$date_is_valid)
- ajax_error(['code' => 'no_date']);
+ self::ajaxError(['code' => 'no_date']);
}
- protected static function make_wide() {
- set_skin_opts([
+ protected function setWidePageOptions(): void {
+ $this->skin->setRenderOptions([
'full_width' => true,
'no_footer' => true
]);
diff --git a/handlers/FilesHandler.php b/handlers/FilesHandler.php
new file mode 100644
index 0000000..f8bf679
--- /dev/null
+++ b/handlers/FilesHandler.php
@@ -0,0 +1,220 @@
+ new CollectionItem($c), FilesCollection::cases());
+ $books = files::books_get();
+ $misc = files::books_get(category: BookCategory::MISC);
+ $this->skin->addMeta([
+ '@title' => '$meta_files_title',
+ '@description' => '$meta_files_description'
+ ]);
+ $this->skin->setTitle('$files');
+ $this->skin->setRenderOptions(['head_section' => 'files']);
+ $this->skin->renderPage('files_index.twig', [
+ 'collections' => $collections,
+ 'books' => $books,
+ 'misc' => $misc
+ ]);
+ }
+
+ public function GET_folder() {
+ list($folder_id) = $this->input('i:folder_id');
+
+ $parents = files::books_get_folder($folder_id, true);
+ if (!$parents)
+ self::notFound();
+
+ if (count($parents) > 1)
+ $parents = array_reverse($parents);
+
+ $folder = $parents[count($parents)-1];
+ $files = files::books_get($folder_id, category: $folder->category);
+
+ $bc = [
+ ['text' => lang('files'), 'url' => '/files/'],
+ ];
+ if ($parents) {
+ for ($i = 0; $i < count($parents)-1; $i++) {
+ $parent = $parents[$i];
+ $bc_item = ['text' => $parent->getTitle()];
+ if ($i < count($parents)-1)
+ $bc_item['url'] = $parent->getUrl();
+ $bc[] = $bc_item;
+ }
+ }
+ $bc[] = ['text' => $folder->title];
+
+ $this->skin->addMeta([
+ '@title' => lang('meta_files_book_folder_title', $folder->getTitle()),
+ '@description' => lang('meta_files_book_folder_description', $folder->getTitle())
+ ]);
+ $this->skin->setTitle(lang('files').' - '.$folder->title);
+ $this->skin->renderPage('files_folder.twig', [
+ 'folder' => $folder,
+ 'bc' => $bc,
+ 'files' => $files
+ ]);
+ }
+
+ public function GET_collection() {
+ list($collection, $folder_id, $query, $offset) = $this->input('collection, i:folder_id, q, i:offset');
+ $collection = FilesCollection::from($collection);
+ $parents = null;
+
+ $query = trim($query);
+ if (!$query)
+ $query = null;
+
+ $this->skin->exportStrings('/^files_(.*?)_collection$/');
+ $this->skin->exportStrings([
+ 'files_search_results_count'
+ ]);
+
+ $vars = [];
+ $text_excerpts = null;
+ $func_prefix = $collection->value;
+
+ if ($query !== null) {
+ $files = call_user_func("files::{$func_prefix}_search", $query, $offset, self::SEARCH_RESULTS_PER_PAGE);
+ $vars += [
+ 'search_count' => $files['count'],
+ 'search_query' => $query
+ ];
+
+ /** @var WFFCollectionItem[]|MDFCollectionItem[]|BaconianaCollectionItem[] $files */
+ $files = $files['items'];
+
+ $query_words = array_map('mb_strtolower', preg_split('/\s+/', $query));
+ $found = [];
+ $result_ids = [];
+ foreach ($files as $file) {
+ if ($file->isFolder())
+ continue;
+ $result_ids[] = $file->id;
+
+ switch ($collection) {
+ case FilesCollection::MercureDeFrance:
+ $candidates = [
+ $file->date,
+ (string)$file->issue
+ ];
+ break;
+ case FilesCollection::WilliamFriedman:
+ $candidates = [
+ mb_strtolower($file->getTitle()),
+ strtolower($file->documentId)
+ ];
+ break;
+ case FilesCollection::Baconiana:
+ $candidates = [
+ // TODO
+ ];
+ break;
+ }
+
+ foreach ($candidates as $haystack) {
+ foreach ($query_words as $qw) {
+ if (mb_strpos($haystack, $qw) !== false) {
+ $found[$file->id] = true;
+ continue 2;
+ }
+ }
+ }
+ }
+
+ $found = array_map('intval', array_keys($found));
+ $not_found = array_diff($result_ids, $found);
+ if (!empty($not_found))
+ $text_excerpts = call_user_func("files::{$func_prefix}_get_text_excerpts", $not_found, $query_words);
+
+ if (self::isXhrRequest()) {
+ self::ajaxOk([
+ ...$vars,
+ 'new_offset' => $offset + count($files),
+ 'html' => skin::getInstance()->render('files_list.twig', [
+ 'files' => $files,
+ 'query' => $query,
+ 'text_excerpts' => $text_excerpts
+ ])
+ ]);
+ }
+ } else {
+ if (in_array($collection, [FilesCollection::WilliamFriedman, FilesCollection::Baconiana]) && $folder_id) {
+ $parents = call_user_func("files::{$func_prefix}_get_folder", $folder_id, true);
+ if (!$parents)
+ self::notFound();
+ if (count($parents) > 1)
+ $parents = array_reverse($parents);
+ }
+ $files = call_user_func("files::{$func_prefix}_get", $folder_id);
+ }
+
+ $title = lang('files_'.$collection->value.'_collection');
+ if ($folder_id && $parents)
+ $title .= ' - '.htmlescape($parents[count($parents)-1]->getTitle());
+ if ($query)
+ $title .= ' - '.htmlescape($query);
+ $this->skin->setTitle($title);
+
+ if (!$folder_id && !$query) {
+ $this->skin->addMeta([
+ '@title' => lang('4in1').' - '.lang('meta_files_collection_title', lang('files_'.$collection->value.'_collection')),
+ '@description' => lang('meta_files_'.$collection->value.'_description')
+ ]);
+ } else if ($query || $parents) {
+ $this->skin->addMeta([
+ '@title' => lang('4in1').' - '.$title,
+ '@description' => lang('meta_files_'.($query ? 'search' : 'folder').'_description',
+ $query ?: $parents[count($parents)-1]->getTitle(),
+ lang('files_'.$collection->value.'_collection'))
+ ]);
+ }
+
+ $bc = [
+ ['text' => lang('files'), 'url' => '/files/'],
+ ];
+ if ($parents) {
+ $bc[] = ['text' => lang('files_'.$collection->value.'_collection_short'), 'url' => "/files/{$collection->value}/"];
+ for ($i = 0; $i < count($parents); $i++) {
+ $parent = $parents[$i];
+ $bc_item = ['text' => $parent->getTitle()];
+ if ($i < count($parents)-1)
+ $bc_item['url'] = $parent->getUrl();
+ $bc[] = $bc_item;
+ }
+ } else {
+ $bc[] = ['text' => lang('files_'.$collection->value.'_collection')];
+ }
+
+ $js_params = [
+ 'container' => 'files_list',
+ 'per_page' => self::SEARCH_RESULTS_PER_PAGE,
+ 'min_query_length' => self::SEARCH_MIN_QUERY_LENGTH,
+ 'base_url' => "/files/{$collection->value}/",
+ 'query' => $vars['search_query'],
+ 'count' => $vars['search_count'],
+ 'collection_name' => $collection->value,
+ 'inited_with_search' => !!$vars['search_query']
+ ];
+
+ $this->skin->set($vars);
+ $this->skin->set([
+ 'collection' => $collection->value,
+ 'files' => $files,
+ 'bc' => $bc,
+ 'do_show_search' => empty($parents),
+ 'do_show_more' => $vars['search_count'] > 0 && count($files) < $vars['search_count'],
+ // 'search_results_per_page' => self::SEARCH_RESULTS_PER_PAGE,
+ // 'search_min_query_length' => self::SEARCH_MIN_QUERY_LENGTH,
+ 'text_excerpts' => $text_excerpts,
+ 'js_params' => $js_params,
+ ]);
+ $this->skin->renderPage('files_collection.twig');
+ }
+
+}
\ No newline at end of file
diff --git a/handlers/MainHandler.php b/handlers/MainHandler.php
new file mode 100644
index 0000000..2b81a30
--- /dev/null
+++ b/handlers/MainHandler.php
@@ -0,0 +1,197 @@
+skin->addMeta([
+ 'og:type' => 'website',
+ '@url' => 'https://'.$config['domain'].'/',
+ '@title' => lang('meta_index_title'),
+ '@description' => lang('meta_index_description'),
+ '@image' => 'https://'.$config['domain'].'/img/4in1-preview.jpg'
+ ]);
+ $this->skin->setTitle('$site_title');
+ $this->skin->set([
+ 'posts' => $posts,
+ 'posts_lang' => $posts_lang,
+ 'versions' => $config['book_versions']
+ ]);
+ $this->skin->setRenderOptions(['is_index' => true]);
+ $this->skin->renderPage('index.twig');
+ }
+
+ public function GET_about() { self::redirect('/info/'); }
+ public function GET_contacts() { self::redirect('/info/'); }
+
+ public function GET_page() {
+ global $config;
+ list($name) = $this->input('name');
+
+ $page = pages::getByName($name);
+ if (!$page) {
+ if (isAdmin()) {
+ $this->skin->setTitle($name);
+ $this->skin->renderPage('admin_page_new.twig', [
+ 'short_name' => $name
+ ]);
+ }
+ self::notFound();
+ }
+
+ if (!isAdmin() && !$page->visible)
+ self::notFound();
+
+ $bc = null;
+ $render_opts = [];
+ if ($page) {
+ $this->skin->addMeta([
+ '@url' => 'https://'.$config['domain'].$page->getUrl(),
+ '@title' => $page->title,
+ ]);
+
+ if ($page->parentId) {
+ $bc = [];
+ $parent = $page;
+ while ($parent?->parentId) {
+ $parent = pages::getById($parent->parentId);
+ if ($parent)
+ $bc[] = ['url' => $parent->getUrl(), 'text' => $parent->title];
+ }
+ if (empty($bc))
+ $bc = null;
+ }
+
+ if ($page->shortName == 'info')
+ $render_opts = ['head_section' => 'about'];
+ else if ($page->shortName == $config['wiki_root'])
+ $render_opts = ['head_section' => $page->shortName];
+ }
+
+ $this->skin->setRenderOptions($render_opts);
+ $this->skin->setTitle($page ? $page->title : '???');
+ $this->skin->renderPage('page.twig', [
+ 'page' => $page,
+ 'html' => $page->getHtml(isRetina(), themes::getUserTheme()),
+ 'bc' => $bc,
+ 'delete_token' => self::getCSRF('delpage'.$page->shortName)
+ ]);
+ }
+
+ public function GET_post() {
+ global $config;
+ list($name, $input_lang) = $this->input('name, lang');
+
+ $lang = null;
+ try {
+ if ($input_lang)
+ $lang = PostLanguage::from($input_lang);
+ } catch (ValueError $e) {
+ self::notFound($e->getMessage());
+ }
+
+ if (!$lang)
+ $lang = PostLanguage::getDefault();
+
+ $post = posts::getByName($name);
+ if (!$post || (!$post->visible && !isAdmin()))
+ self::notFound();
+
+ if ($lang == PostLanguage::getDefault() && $input_lang == $lang->value)
+ self::redirect($post->getUrl());
+ if (!$post->hasLang($lang))
+ self::notFound('no text for language '.$lang->name);
+ if (!$post->visible && !isAdmin())
+ self::notFound();
+
+ $pt = $post->getText($lang);
+
+ $other_langs = [];
+ foreach (PostLanguage::cases() as $pl) {
+ if ($pl == $lang)
+ continue;
+ if ($post->hasLang($pl))
+ $other_langs[] = $pl->value;
+ }
+
+ $meta = [
+ '@title' => $pt->title,
+ '@url' => $config['domain'].$post->getUrl(),
+ '@description' => $pt->getDescriptionPreview(155)
+ ];
+ if ($pt->keywords)
+ $meta['@keywords'] = $pt->keywords;
+ $this->skin->addMeta($meta);
+ if (($img = $pt->getFirstImage()) !== null)
+ $this->skin->addMeta(['@image' => $img->getDirectUrl()]);
+
+ $this->skin->setTitle($pt->title);
+ $this->skin->setRenderOptions(['articles_lang' => $lang->value, 'wide' => $pt->hasTableOfContents()]);
+ $this->skin->renderPage('post.twig', [
+ 'post' => $post,
+ 'pt' => $pt,
+ 'html' => $pt->getHtml(isRetina(), themes::getUserTheme()),
+ 'selected_lang' => $lang->value,
+ 'other_langs' => $other_langs,
+ 'delete_token' => self::getCSRF('delpost'.$post->id)
+ ]);
+ }
+
+ public function GET_rss() {
+ global $config;
+
+ $lang = PostLanguage::getDefault();
+ $items = array_map(function(Post $post) use ($lang) {
+ $pt = $post->getText($lang);
+ return [
+ 'title' => $pt->title,
+ 'link' => $post->getUrl(),
+ 'pub_date' => date(DATE_RSS, $post->getTimestamp()),
+ 'description' => $pt->getDescriptionPreview(500)
+ ];
+ }, posts::getList(0, 20, filter_by_lang: $lang));
+
+ $body = $this->skin->render('rss.twig', [
+ 'title' => lang('site_title'),
+ 'link' => 'https =>//'.$config['domain'],
+ 'rss_link' => 'https =>//'.$config['domain'].'/feed.rss',
+ 'items' => $items
+ ]);
+
+ header('Content-Type: application/rss+xml; charset=utf-8');
+ echo $body;
+ exit;
+ }
+
+ public function GET_articles() {
+ list($lang) = $this->input('lang');
+ if ($lang) {
+ $lang = PostLanguage::tryFrom($lang);
+ if (!$lang || $lang == PostLanguage::getDefault())
+ self::redirect('/articles/');
+ } else {
+ $lang = PostLanguage::getDefault();
+ }
+
+ $posts = posts::getList(
+ include_hidden: isAdmin(),
+ filter_by_lang: $lang);
+
+ $this->skin->setTitle('$articles');
+ $this->skin->addMeta([
+ '@description' => lang('blog_expl_'.$lang->value)
+ ]);
+ $this->skin->setRenderOptions(['head_section' => 'articles']);
+ $this->skin->renderPage('articles.twig', [
+ 'posts' => $posts,
+ 'selected_lang' => $lang->value
+ ]);
+ }
+
+}
\ No newline at end of file
diff --git a/handler/ServicesHandler.php b/handlers/ServicesHandler.php
similarity index 56%
rename from handler/ServicesHandler.php
rename to handlers/ServicesHandler.php
index 1b8d26b..57b7c1f 100644
--- a/handler/ServicesHandler.php
+++ b/handlers/ServicesHandler.php
@@ -2,7 +2,7 @@
class ServicesHandler extends request_handler {
- function GET_robots_txt() {
+ public function GET_robots_txt() {
$txt = <<input('lang');
if (!isset($config['book_versions'][$lang]))
- not_found();
- redirect("https://files.4in1.ws/4in1-{$lang}.pdf?{$config['book_versions'][$lang]}",
+ self::notFound();
+ self::redirect("https://files.4in1.ws/4in1-{$lang}.pdf?{$config['book_versions'][$lang]}",
code: HTTPCode::Found);
}
diff --git a/htdocs/index.php b/htdocs/index.php
index deee5ad..f8697d7 100644
--- a/htdocs/index.php
+++ b/htdocs/index.php
@@ -2,5 +2,4 @@
require_once __DIR__.'/../init.php';
-router_init();
-dispatch_request();
\ No newline at end of file
+request_handler::resolveAndDispatch();
diff --git a/htdocs/js.php b/htdocs/js.php
index 9899291..cdd7d2b 100644
--- a/htdocs/js.php
+++ b/htdocs/js.php
@@ -5,7 +5,7 @@ global $config;
$name = $_REQUEST['name'] ?? '';
-if (!is_dev() || !$name || !is_dir($path = APP_ROOT.'/htdocs/js/'.$name)) {
+if (!isDev() || !$name || !is_dir($path = APP_ROOT.'/htdocs/js/'.$name)) {
http_response_code(403);
exit;
}
diff --git a/htdocs/js/admin/11-write-form.js b/htdocs/js/admin/11-write-form.js
index 1dd9158..6348a79 100644
--- a/htdocs/js/admin/11-write-form.js
+++ b/htdocs/js/admin/11-write-form.js
@@ -105,7 +105,7 @@ extend(AdminWriteEditForm.prototype, {
params.title = this.form.elements.title.value;
params.lang = this.getCurrentLang();
}
- if (this.isPage() && this.form.render_title.checked) {
+ if (this.isPage() && this.isEditing() && this.form.render_title.checked) {
params.title = this.form.elements.title.value;
params.is_page = 1
}
diff --git a/htdocs/js/common/35-theme-switcher.js b/htdocs/js/common/35-theme-switcher.js
index ac2e167..c4fd69f 100644
--- a/htdocs/js/common/35-theme-switcher.js
+++ b/htdocs/js/common/35-theme-switcher.js
@@ -135,12 +135,9 @@ var ThemeSwitcher = (function() {
/**
* @param {string} selectedMode
*/
- function setIcon(selectedMode) {
+ function setLabel(selectedMode) {
document.body.setAttribute('data-theme', selectedMode);
- for (var i = 0; i < modes.length; i++) {
- var mode = modes[i];
- document.getElementById('svgicon_moon_'+mode+'_18').style.display = mode === selectedMode ? 'block': 'none';
- }
+ ge('switch-theme').innerHTML = escape(selectedMode);
}
return {
@@ -182,7 +179,7 @@ var ThemeSwitcher = (function() {
onSystemChange(window.matchMedia('(prefers-color-scheme: dark)').matches === true);
}
- setIcon(modes[currentModeIndex]);
+ setLabel(modes[currentModeIndex]);
},
next: function(e) {
@@ -214,7 +211,7 @@ var ThemeSwitcher = (function() {
break;
}
- setIcon(modes[currentModeIndex]);
+ setLabel(modes[currentModeIndex]);
setCookie('theme', modes[currentModeIndex]);
return cancelEvent(e);
diff --git a/htdocs/sass.php b/htdocs/sass.php
index 05a334d..40aec7e 100644
--- a/htdocs/sass.php
+++ b/htdocs/sass.php
@@ -10,7 +10,7 @@ if ($theme != 'light' && $theme != 'dark') {
exit;
}
-if (!is_dev() || !$name || !file_exists($path = APP_ROOT.'/htdocs/scss/entries/'.$name.'/'.$theme.'.scss')) {
+if (!isDev() || !$name || !file_exists($path = APP_ROOT.'/htdocs/scss/entries/'.$name.'/'.$theme.'.scss')) {
// logError(__FILE__.': access denied');
http_response_code(403);
exit;
diff --git a/htdocs/scss/app/common.scss b/htdocs/scss/app/common.scss
index 0e8d667..0930074 100644
--- a/htdocs/scss/app/common.scss
+++ b/htdocs/scss/app/common.scss
@@ -240,7 +240,7 @@ table.contacts div.note {
padding: 40px 20px;
color: $grey;
@include radius(3px);
- background-color: $dark-bg;
+ background-color: $light-bg;
}
.md-file-attach {
diff --git a/htdocs/scss/app/foot.scss b/htdocs/scss/app/foot.scss
index 03a04db..4b25d41 100644
--- a/htdocs/scss/app/foot.scss
+++ b/htdocs/scss/app/foot.scss
@@ -3,10 +3,21 @@
//border-radius: 5px;
padding: 15px 0;
margin-top: 10px;
- text-align: right;
color: $dark_grey;
- > span { color: $fg; }
- > a {
- @include no-underline(true);
+
+ &-right {
+ float: right;
+ text-align: right;
+ }
+
+ &-right, &-left {
+ > span { color: $fg; }
+ > a {
+ @include no-underline(true);
+ }
+ }
+
+ &-separator {
+ opacity: 0.33;
}
}
\ No newline at end of file
diff --git a/htdocs/scss/app/head.scss b/htdocs/scss/app/head.scss
index 2e7289e..9e3f638 100644
--- a/htdocs/scss/app/head.scss
+++ b/htdocs/scss/app/head.scss
@@ -71,10 +71,6 @@
}
}
-//body:not(.theme-changing) .head-logo {
-// @include transition(background-color, 0.03s);
-//}
-
.head-items {
text-align: right;
display: table-cell;
@@ -101,6 +97,9 @@ a.head-item {
height: 18px;
}
}
+ &.is-ic {
+ color: $link-color;
+ }
&:hover, &.is-selected {
border-radius: 4px;
@@ -119,7 +118,6 @@ body a.head-item.is-settings svg path {
fill: $fg;
}
-
#svgicon_moon_light_18,
#svgicon_moon_dark_18,
#svgicon_moon_auto_18 {
diff --git a/init.php b/init.php
index 078c611..fb3ce41 100644
--- a/init.php
+++ b/init.php
@@ -14,42 +14,20 @@ define('START_TIME', microtime(true));
set_include_path(get_include_path().PATH_SEPARATOR.APP_ROOT);
spl_autoload_register(function($class) {
- static $libs = [
- 'lib/pages' => ['Page', 'pages'],
- 'lib/previous_texts' => ['previous_texts', 'PreviousText'],
- 'lib/posts' => ['Post', 'PostText', 'PostLanguage', 'posts'],
- 'lib/uploads' => ['Upload', 'uploads'],
- 'engine/model' => ['model'],
- 'engine/skin' => ['SkinContext'],
- ];
-
if (str_contains($class, '\\'))
$class = str_replace('\\', '/', $class);
- $path = null;
- foreach (['Handler', 'Helper'] as $sfx) {
- if (str_ends_with($class, $sfx)) {
- $path = APP_ROOT.'/'.strtolower($sfx).'/'.$class.'.php';
- break;
- }
- }
+ if ($class == 'model')
+ $path = 'engine/model';
+ else if (str_ends_with($class, 'Handler'))
+ $path = 'handlers/'.$class;
+ else
+ $path = 'lib/'.$class;
- if (is_null($path)) {
- foreach ($libs as $lib_file => $class_names) {
- if (in_array($class, $class_names)) {
- $path = APP_ROOT.'/'.$lib_file.'.php';
- break;
- }
- }
- }
-
- if (is_null($path))
- $path = APP_ROOT.'/lib/'.$class.'.php';
-
- if (!is_file($path))
+ if (!is_file(APP_ROOT.'/'.$path.'.php'))
return;
- require_once $path;
+ require_once APP_ROOT.'/'.$path.'.php';
});
if (!file_exists(APP_ROOT.'/config.yaml'))
@@ -69,12 +47,12 @@ require_once 'engine/request.php';
require_once 'engine/logging.php';
try {
- if (is_cli()) {
- verify_hostname($config['domain']);
+ if (isCli()) {
+ verifyHostname($config['domain']);
$_SERVER['HTTP_HOST'] = $config['domain'];
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
} else {
- verify_hostname();
+ verifyHostname();
if (array_key_exists('HTTP_X_REAL_IP', $_SERVER))
$_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP'];
@@ -86,12 +64,12 @@ try {
die('Fatal error: '.$e->getMessage());
}
-$__logger = is_dev()
+$__logger = isDev()
? new FileLogger(APP_ROOT.'/log/debug.log')
: new DatabaseLogger();
$__logger->enable();
-if (!is_dev()) {
+if (!isDev()) {
if (file_exists(APP_ROOT.'/config-static.php'))
$config['static'] = require_once 'config-static.php';
else
@@ -102,7 +80,7 @@ if (!is_dev()) {
ini_set('display_errors', 0);
}
-if (!is_cli()) {
+if (!isCli()) {
$__lang = Strings::getInstance();
$__lang->load('main');
}
diff --git a/lib/AdminActions/BaseAction.php b/lib/AdminActions/BaseAction.php
index 1bacbfb..b2f0072 100644
--- a/lib/AdminActions/BaseAction.php
+++ b/lib/AdminActions/BaseAction.php
@@ -55,7 +55,7 @@ abstract class BaseAction {
}
public function getDate(): string {
- return format_time($this->timeStamp, ['short_months' => true]);
+ return formatTime($this->timeStamp, ['short_months' => true]);
}
public function getTimeStamp(): int {
@@ -70,7 +70,7 @@ abstract class BaseAction {
return $this->recordId;
}
- function renderHtml(): string {
+ public function renderHtml(): string {
$rc = new \ReflectionClass($this);
$lines = [];
$fields = $rc->getProperties(\ReflectionProperty::IS_PUBLIC);
diff --git a/lib/AdminActions/Util/Logger.php b/lib/AdminActions/Util/Logger.php
index efffc43..555acac 100644
--- a/lib/AdminActions/Util/Logger.php
+++ b/lib/AdminActions/Util/Logger.php
@@ -1,6 +1,6 @@
time(),
];
- if (is_cli()) {
+ if (isCli()) {
$data += [
'cli' => 1,
];
} else {
$data += [
- 'admin_id' => $AdminSession->id,
+ 'admin_id' => \admin::getId(),
'ip' => !empty($_SERVER['REMOTE_ADDR']) ? ip2ulong($_SERVER['REMOTE_ADDR']) : 0,
];
}
diff --git a/lib/BaconianaCollectionItem.php b/lib/BaconianaCollectionItem.php
new file mode 100644
index 0000000..6fe9271
--- /dev/null
+++ b/lib/BaconianaCollectionItem.php
@@ -0,0 +1,62 @@
+title !== '')
+ return $this->title;
+
+ return ($this->jobc ? lang('baconiana_old_name') : lang('baconiana')).' №'.$this->issues;
+ }
+
+ public function isTargetBlank(): bool { return $this->isFile(); }
+ public function getId(): string { return $this->id; }
+
+ public function getUrl(): string {
+ if ($this->isFolder()) {
+ return '/files/'.FilesCollection::Baconiana->value.'/'.$this->id.'/';
+ }
+ global $config;
+ return 'https://'.$config['files_domain'].'/'.$this->path;
+ }
+
+ public function getMeta(?string $hl_matched = null): array {
+ $items = [];
+ if ($this->isFolder())
+ return $items;
+
+ if ($this->year >= 2007)
+ $items = array_merge($items, ['Online Edition']);
+
+ $items = array_merge($items, [
+ sizeString($this->size),
+ 'PDF'
+ ]);
+
+ return [
+ 'inline' => false,
+ 'items' => $items
+ ];
+ }
+
+ public function getSubtitle(): ?string {
+ return $this->year > 0 ? '('.$this->year.')' : null;
+ }
+}
diff --git a/lib/BookCategory.php b/lib/BookCategory.php
new file mode 100644
index 0000000..7fc1915
--- /dev/null
+++ b/lib/BookCategory.php
@@ -0,0 +1,6 @@
+id;
+ }
+
+ public function getUrl(): string {
+ if ($this->isFolder() && !$this->external)
+ return '/files/'.$this->id.'/';
+ global $config;
+ $buf = 'https://'.$config['files_domain'];
+ if (!str_starts_with($this->path, '/'))
+ $buf .= '/';
+ $buf .= $this->path;
+ return $buf;
+ }
+
+ public function getTitleHtml(): ?string {
+ if ($this->isFolder() || !$this->author)
+ return null;
+ $buf = ''.htmlescape($this->author).' ';
+ if (!str_ends_with($this->author, '.'))
+ $buf .= '.';
+ $buf .= ' '.htmlescape($this->title).' ';
+ return $buf;
+ }
+
+ public function getTitle(): string {
+ return $this->title;
+ }
+
+ public function getMeta(?string $hl_matched = null): array {
+ if ($this->isFolder())
+ return [];
+
+ $items = [
+ sizeString($this->size),
+ strtoupper($this->getExtension())
+ ];
+
+ return [
+ 'inline' => false,
+ 'items' => $items
+ ];
+ }
+
+ protected function getExtension(): string {
+ return extension(basename($this->path));
+ }
+
+ public function isAvailable(): bool {
+ return true;
+ }
+
+ public function isTargetBlank(): bool {
+ return $this->isFile() || $this->external;
+ }
+
+ public function getSubtitle(): ?string {
+ if (!$this->year && !$this->subtitle)
+ return null;
+ $buf = '(';
+ $buf .= $this->subtitle ?: $this->year;
+ $buf .= ')';
+ return $buf;
+ }
+}
diff --git a/lib/CollectionItem.php b/lib/CollectionItem.php
new file mode 100644
index 0000000..fa47bf2
--- /dev/null
+++ b/lib/CollectionItem.php
@@ -0,0 +1,21 @@
+collection->value; }
+ public function isFolder(): bool { return true; }
+ public function isFile(): bool { return false; }
+ public function isAvailable(): bool { return true; }
+ public function getUrl(): string {
+ return '/files/'.$this->collection->value.'/';
+ }
+ public function getSize(): ?int { return null; }
+ public function getTitle(): string { return lang("files_{$this->collection->value}_collection"); }
+ public function getMeta(?string $hl_matched = null): array { return []; }
+ public function isTargetBlank(): bool { return false; }
+ public function getSubtitle(): ?string { return null; }
+}
\ No newline at end of file
diff --git a/lib/FilesCollection.php b/lib/FilesCollection.php
new file mode 100644
index 0000000..2532902
--- /dev/null
+++ b/lib/FilesCollection.php
@@ -0,0 +1,7 @@
+isFile() ? $this->size : null; }
+}
\ No newline at end of file
diff --git a/lib/FilesItemType.php b/lib/FilesItemType.php
new file mode 100644
index 0000000..90357e6
--- /dev/null
+++ b/lib/FilesItemType.php
@@ -0,0 +1,6 @@
+type == FilesItemType::FOLDER;
+ }
+
+ public function isFile(): bool {
+ return $this->type == FilesItemType::FILE;
+ }
+
+ public function isBook(): bool {
+ return $this instanceof BookItem && $this->fileType == BookFileType::BOOK;
+ }
+
+}
\ No newline at end of file
diff --git a/lib/MDFCollectionItem.php b/lib/MDFCollectionItem.php
new file mode 100644
index 0000000..fff6f08
--- /dev/null
+++ b/lib/MDFCollectionItem.php
@@ -0,0 +1,83 @@
+issue}, {$this->getHumanFriendlyDate()}";
+ }
+
+ public function getHumanFriendlyDate(): string {
+ $dt = new DateTime($this->date);
+ return $dt->format('j M Y');
+ }
+
+ public function isTargetBlank(): bool { return true; }
+ public function getId(): string { return $this->id; }
+ public function getUrl(): string {
+ global $config;
+ return 'https://'.$config['files_domain'].'/Mercure-de-France-OCR/'.$this->path;
+ }
+
+ public function getMeta(?string $hl_matched = null): array {
+ return [
+ 'inline' => true,
+ 'items' => [
+ 'Vol. '.$this->getRomanVolume(),
+ 'pp. '.$this->pageFrom.'-'.$this->pageTo,
+ sizeString($this->size),
+ 'PDF'
+ ]
+ ];
+ }
+
+ public function getRomanVolume(): string {
+ $number = $this->volume;
+ $map = [
+ 1000 => 'M',
+ 900 => 'CM',
+ 500 => 'D',
+ 400 => 'CD',
+ 100 => 'C',
+ 90 => 'XC',
+ 50 => 'L',
+ 40 => 'XL',
+ 10 => 'X',
+ 9 => 'IX',
+ 5 => 'V',
+ 4 => 'IV',
+ 1 => 'I',
+ ];
+ $result = '';
+ foreach ($map as $arabic => $roman) {
+ while ($number >= $arabic) {
+ $result .= $roman;
+ $number -= $arabic;
+ }
+ }
+ return $result;
+ }
+
+ public function getSubtitle(): ?string {
+ return null;
+ //return 'Vol. '.$this->getRomanVolume().', pp. '.$this->pageFrom.'-'.$this->pageTo;
+ }
+}
diff --git a/lib/ext/MyParsedown.php b/lib/MyParsedown.php
similarity index 83%
rename from lib/ext/MyParsedown.php
rename to lib/MyParsedown.php
index 20eb664..4b5160c 100644
--- a/lib/ext/MyParsedown.php
+++ b/lib/MyParsedown.php
@@ -1,13 +1,12 @@
fileupload($upload->name, $upload->getDirectUrl(), $upload->noteRu, $upload->getSize());
+ $result['element']['rawHtml'] = skin::getInstance()->render('markdown_fileupload.twig', [
+ 'name' => $upload->name,
+ 'direct_url' => $upload->getDirectUrl(),
+ 'note' => $upload->noteRu,
+ 'size' => $upload->getSize()
+ ]);
return $result;
}
@@ -107,19 +110,19 @@ class MyParsedown extends ParsedownExtended {
unset($result['element']['text']);
- $ctx = self::getSkinContext();
- $result['element']['rawHtml'] = $ctx->image(
- w: $opts['w'],
- nolabel: $opts['nolabel'],
- align: $opts['align'],
- padding_top: round($h / $w * 100, 4),
- may_have_alpha: $image->imageMayHaveAlphaChannel(),
+ $result['element']['rawHtml'] = skin::getInstance()->render('markdown_image.twig', [
+ 'w' => $opts['w'],
+ 'nolabel' => $opts['nolabel'],
+ 'align' => $opts['align'],
+ 'padding_top' => round($h / $w * 100, 4),
+ 'may_have_alpha' => $image->imageMayHaveAlphaChannel(),
- url: $image_url,
- direct_url: $image->getDirectUrl(),
- unsafe_note: markup::markdownToHtml($this->lang !== null && $this->lang == PostLanguage::Russian ? $image->noteRu : $image->noteEn,
- no_paragraph: true)
- );
+ 'url' => $image_url,
+ 'direct_url' => $image->getDirectUrl(),
+ 'unsafe_note' => markup::markdownToHtml(
+ md: $this->lang !== null && $this->lang == PostLanguage::Russian ? $image->noteRu : $image->noteEn,
+ no_paragraph: true),
+ ]);
return $result;
}
@@ -166,12 +169,11 @@ class MyParsedown extends ParsedownExtended {
unset($result['element']['text']);
- $ctx = self::getSkinContext();
- $result['element']['rawHtml'] = $ctx->video(
- url: $video_url,
- w: $opts['w'],
- h: $opts['h']
- );
+ $result['element']['rawHtml'] = skin::getInstance()->render('markdown_video.twig', [
+ 'url' => $video_url,
+ 'w' => $opts['w'],
+ 'h' => $opts['h']
+ ]);
return $result;
}
@@ -218,8 +220,4 @@ class MyParsedown extends ParsedownExtended {
return parent::blockFencedCodeComplete($block);
}
- protected static function getSkinContext(): SkinContext {
- return skin('markdown');
- }
-
}
diff --git a/lib/Page.php b/lib/Page.php
new file mode 100644
index 0000000..6d5067e
--- /dev/null
+++ b/lib/Page.php
@@ -0,0 +1,47 @@
+md || $fields['render_title'] != $this->renderTitle || $fields['title'] != $this->title) {
+ $md = $fields['md'];
+ if ($fields['render_title'])
+ $md = '# '.$fields['title']."\n\n".$md;
+ $fields['html'] = markup::markdownToHtml($md);
+ }
+ parent::edit($fields);
+ }
+
+ public function isUpdated(): bool {
+ return $this->updateTs && $this->updateTs != $this->ts;
+ }
+
+ public function getHtml(bool $is_retina, string $user_theme): string {
+ return markup::htmlImagesFix($this->html, $is_retina, $user_theme);
+ }
+
+ public function getUrl(): string {
+ return "/{$this->shortName}/";
+ }
+
+ public function updateHtml(): void {
+ $html = markup::markdownToHtml($this->md);
+ $this->html = $html;
+ DB()->query("UPDATE pages SET html=? WHERE short_name=?", $html, $this->shortName);
+ }
+
+}
diff --git a/lib/Post.php b/lib/Post.php
new file mode 100644
index 0000000..5304ec8
--- /dev/null
+++ b/lib/Post.php
@@ -0,0 +1,129 @@
+ $title,
+ 'lang' => $lang->value,
+ 'post_id' => $this->id,
+ 'html' => $html,
+ 'text' => $text,
+ 'md' => $md,
+ 'toc' => $toc,
+ 'keywords' => $keywords,
+ ];
+
+ $db = DB();
+ if (!$db->insert('posts_texts', $data))
+ return null;
+
+ $id = $db->insertId();
+
+ $post_text = posts::getText($id);
+ $post_text->updateImagePreviews();
+
+ return $post_text;
+ }
+
+ public function registerText(PostText $postText): void {
+ if (array_key_exists($postText->lang->value, $this->texts))
+ throw new Exception("text for language {$postText->lang->value} has already been registered");
+ $this->texts[$postText->lang->value] = $postText;
+ }
+
+ public function loadTexts() {
+ if (!empty($this->texts))
+ return;
+ $db = DB();
+ $q = $db->query("SELECT * FROM posts_texts WHERE post_id=?", $this->id);
+ while ($row = $db->fetch($q)) {
+ $text = new PostText($row);
+ $this->registerText($text);
+ }
+ }
+
+ /**
+ * @return PostText[]
+ */
+ public function getTexts(): array {
+ $this->loadTexts();
+ return $this->texts;
+ }
+
+ public function getText(PostLanguage|string $lang): ?PostText {
+ if (is_string($lang))
+ $lang = PostLanguage::from($lang);
+ $this->loadTexts();
+ return $this->texts[$lang->value] ?? null;
+ }
+
+ public function hasLang(PostLanguage $lang) {
+ $this->loadTexts();
+ foreach ($this->texts as $text) {
+ if ($text->lang == $lang)
+ return true;
+ }
+ return false;
+ }
+
+ public function hasSourceUrl(): bool {
+ return $this->sourceUrl != '';
+ }
+
+ public function getUrl(PostLanguage|string|null $lang = null): string {
+ $buf = $this->shortName != '' ? "/articles/{$this->shortName}/" : "/articles/{$this->id}/";
+ if ($lang) {
+ if (is_string($lang))
+ $lang = PostLanguage::from($lang);
+ if ($lang != PostLanguage::English)
+ $buf .= '?lang=' . $lang->value;
+ }
+ return $buf;
+ }
+
+ public function getTimestamp(): int {
+ return (new DateTime($this->date))->getTimestamp();
+ }
+
+ public function getUpdateTimestamp(): ?int {
+ if (!$this->updateTime)
+ return null;
+ return (new DateTime($this->updateTime))->getTimestamp();
+ }
+
+ public function getDate(): string {
+ return date('j M', $this->getTimestamp());
+ }
+
+ public function getYear(): int {
+ return (int)date('Y', $this->getTimestamp());
+ }
+
+ public function getFullDate(): string {
+ return date('j F Y', $this->getTimestamp());
+ }
+
+ public function getDateForInputField(): string {
+ return date('Y-m-d', $this->getTimestamp());
+ }
+}
\ No newline at end of file
diff --git a/lib/PostLanguage.php b/lib/PostLanguage.php
new file mode 100644
index 0000000..ac5b5e7
--- /dev/null
+++ b/lib/PostLanguage.php
@@ -0,0 +1,20 @@
+value, self::cases(), true);
+ }
+
+ public static function casesAsStrings(): array {
+ return array_map(fn($v) => $v->value, self::cases());
+ }
+
+}
\ No newline at end of file
diff --git a/lib/PostText.php b/lib/PostText.php
new file mode 100644
index 0000000..1796188
--- /dev/null
+++ b/lib/PostText.php
@@ -0,0 +1,116 @@
+md) {
+ $fields['html'] = markup::markdownToHtml($fields['md'], lang: $this->lang);
+ $fields['text'] = markup::htmlToText($fields['html']);
+ }
+
+ if ((isset($fields['toc']) && $fields['toc']) || $this->toc) {
+ $fields['toc_html'] = markup::toc($fields['md']);
+ }
+
+ parent::edit($fields);
+ $this->updateImagePreviews();
+ }
+
+ public function updateHtml(): void {
+ $html = markup::markdownToHtml($this->md, lang: $this->lang);
+ $this->html = $html;
+ DB()->query("UPDATE posts_texts SET html=? WHERE id=?", $html, $this->id);
+ }
+
+ public function updateText(): void {
+ $html = markup::markdownToHtml($this->md, lang: $this->lang);
+ $text = markup::htmlToText($html);
+ $this->text = $text;
+ DB()->query("UPDATE posts_texts SET text=? WHERE id=?", $text, $this->id);
+ }
+
+ public function getDescriptionPreview(int $len): string {
+ if (mb_strlen($this->text) >= $len)
+ return mb_substr($this->text, 0, $len-3).'...';
+ return $this->text;
+ }
+
+ public function getFirstImage(): ?Upload {
+ if (!preg_match('/\{image:([\w]{8})/', $this->md, $match))
+ return null;
+ return uploads::getUploadByRandomId($match[1]);
+ }
+
+ public function getHtml(bool $is_retina, string $theme): string {
+ $html = $this->html;
+ return markup::htmlImagesFix($html, $is_retina, $theme);
+ }
+
+ public function getTableOfContentsHtml(): ?string {
+ return $this->tocHtml ?: null;
+ }
+
+ public function hasTableOfContents(): bool {
+ return $this->toc;
+ }
+
+ /**
+ * @param bool $update Whether to overwrite preview if already exists
+ * @return int
+ * @throws Exception
+ */
+ public function updateImagePreviews(bool $update = false): int {
+ $images = [];
+ if (!preg_match_all('/\{image:([\w]{8}),(.*?)}/', $this->md, $matches))
+ return 0;
+
+ for ($i = 0; $i < count($matches[0]); $i++) {
+ $id = $matches[1][$i];
+ $w = $h = null;
+ $opts = explode(',', $matches[2][$i]);
+ foreach ($opts as $opt) {
+ if (str_contains($opt, '=')) {
+ list($k, $v) = explode('=', $opt);
+ if ($k == 'w')
+ $w = (int)$v;
+ else if ($k == 'h')
+ $h = (int)$v;
+ }
+ }
+ $images[$id][] = [$w, $h];
+ }
+
+ if (empty($images))
+ return 0;
+
+ $images_affected = 0;
+ $uploads = uploads::getUploadsByRandomId(array_keys($images), true);
+ foreach ($uploads as $upload_key => $u) {
+ if ($u === null) {
+ logError(__METHOD__.': upload '.$upload_key.' is null');
+ continue;
+ }
+ foreach ($images[$u->randomId] as $s) {
+ list($w, $h) = $s;
+ list($w, $h) = $u->getImagePreviewSize($w, $h);
+ if ($u->createImagePreview($w, $h, $update, $u->imageMayHaveAlphaChannel()))
+ $images_affected++;
+ }
+ }
+
+ return $images_affected;
+ }
+
+}
diff --git a/lib/PreviousText.php b/lib/PreviousText.php
new file mode 100644
index 0000000..1bb47aa
--- /dev/null
+++ b/lib/PreviousText.php
@@ -0,0 +1,16 @@
+getNode('params'));
+
+ $compiler->addDebugInfo($this);
+ $compiler
+ ->raw(PHP_EOL);
+
+ for ($i = 0; ($i < $count); $i++) {
+ // argument is not an expression (such as, a \Twig\Node\Textbody)
+ // we should trick with output buffering to get a valid argument to pass
+ // to the functionToCall() function.
+ if (!($this->getNode('params')->getNode($i) instanceof \Twig\Node\Expression\AbstractExpression)) {
+ $compiler
+ ->write('ob_start();')
+ ->raw(PHP_EOL);
+
+ $compiler
+ ->subcompile($this->getNode('params')->getNode($i));
+
+ $compiler
+ ->write('$js = ob_get_clean();')
+ ->raw(PHP_EOL);
+ }
+ }
+
+ $compiler
+ ->write('skin::getInstance()->addJS($js);')
+ ->raw(PHP_EOL)
+ ->write('unset($js);')
+ ->raw(PHP_EOL);
+ }
+
+}
diff --git a/lib/TwigAddons/JsTagParamsNode.php b/lib/TwigAddons/JsTagParamsNode.php
new file mode 100644
index 0000000..e0a07a3
--- /dev/null
+++ b/lib/TwigAddons/JsTagParamsNode.php
@@ -0,0 +1,6 @@
+getLine();
+ $stream = $this->parser->getStream();
+
+ // recovers all inline parameters close to your tag name
+ $params = array_merge([], $this->getInlineParams($token));
+
+ $continue = true;
+ while ($continue) {
+ // create subtree until the decideJsTagFork() callback returns true
+ $body = $this->parser->subparse($this->decideJsTagFork(...));
+
+ // I like to put a switch here, in case you need to add middle tags, such
+ // as: {% js %}, {% nextjs %}, {% endjs %}.
+ $tag = $stream->next()->getValue();
+ switch ($tag) {
+ case 'endjs':
+ $continue = false;
+ break;
+ default:
+ throw new \Twig\Error\SyntaxError(sprintf(
+ 'Unexpected end of template. Twig was looking for the ' .
+ 'following tags "endjs" to close the "js" block started ' .
+ 'at line %d)',
+ $lineno,
+ ), -1);
+ }
+
+ // you want $body at the beginning of your arguments
+ array_unshift($params, $body);
+
+ // if your endjs can also contains params, you can uncomment this line:
+ // $params = array_merge($params, $this->getInlineParams($token));
+ // and comment this one:
+ $stream->expect(\Twig\Token::BLOCK_END_TYPE);
+ }
+
+ return new JsTagNode(['params' => new JsTagParamsNode($params)], [], $lineno);
+ }
+
+ /**
+ * Recovers all tag parameters until we find a BLOCK_END_TYPE ( %} )
+ *
+ * @param \Twig\Token $token
+ * @return \Twig\Node\Expression\AbstractExpression[]
+ */
+ protected function getInlineParams(\Twig\Token $token) {
+ $stream = $this->parser->getStream();
+ $params = array ();
+ while (!$stream->test(\Twig\Token::BLOCK_END_TYPE)) {
+ $params[] = $this->parser->getExpressionParser()->parseExpression();
+ }
+ $stream->expect(\Twig\Token::BLOCK_END_TYPE);
+ return $params;
+ }
+
+ /**
+ * Callback called at each tag name when subparsing, must return
+ * true when the expected end tag is reached.
+ *
+ * @param \Twig\Token $token
+ * @return bool
+ */
+ public function decideJsTagFork(\Twig\Token $token) {
+ return $token->test(['endjs']);
+ }
+
+ /**
+ * Your tag name: if the parsed tag match the one you put here, your parse()
+ * method will be called.
+ *
+ * @return string
+ */
+ public function getTag() {
+ return 'js';
+ }
+
+}
diff --git a/lib/TwigAddons/MyExtension.php b/lib/TwigAddons/MyExtension.php
new file mode 100644
index 0000000..408a86b
--- /dev/null
+++ b/lib/TwigAddons/MyExtension.php
@@ -0,0 +1,105 @@
+ \skin::getInstance()->getSVG($name),
+ ['is_safe' => ['html']]),
+
+ new TwigFunction('svgInPlace', fn($name) => \skin::getInstance()->getSVG($name, in_place: true),
+ ['is_safe' => ['html']]),
+
+ new TwigFunction('svgPreload', function(...$icons) {
+ $skin = \skin::getInstance();
+ foreach ($icons as $icon)
+ $skin->preloadSVG($icon);
+ return null;
+ }),
+
+ new TwigFunction('bc', fn(...$args) => \skin::getInstance()->renderBreadCrumbs(...$args),
+ ['is_safe' => ['html']]),
+
+ new TwigFunction('pageNav', fn(...$args) => \skin::getInstance()->renderPageNav(...$args),
+ ['is_safe' => ['html']]),
+
+ new TwigFunction('csrf', fn($value) => \request_handler::getCSRF($value))
+ ];
+ }
+
+ public function getFilters() {
+ return array(
+ new TwigFilter('lang', function($key, array $args = []) {
+ global $__lang;
+ array_walk($args, function(&$item, $key) {
+ $item = htmlescape($item);
+ });
+ array_unshift($args, $key);
+ return call_user_func_array([$__lang, 'get'], $args);
+ }, ['is_variadic' => true]),
+
+ new TwigFilter('hl', function($s, $keywords) {
+ return highlightSubstring($s, $keywords);
+ }),
+
+ new TwigFilter('plural', function($text, array $args = []) {
+ global $__lang;
+ array_unshift($args, $text);
+ return call_user_func_array([$__lang, 'num'], $args);
+ }, ['is_variadic' => true])
+
+ // new TwigFilter('format_number', function($number, array $args = []) {
+ // array_unshift($args, $number);
+ // return call_user_func_array('formatNumber', $args);
+ // }, ['is_variadic' => true]),
+
+ // new TwigFilter('short_number', function($number, array $args = []) {
+ // array_unshift($args, $number);
+ // return call_user_func_array('shortNumber', $args);
+ // }, ['is_variadic']),
+ //
+ // new TwigFilter('format_time', function($ts, array $args = []) {
+ // array_unshift($args, $ts);
+ // return call_user_func_array('formatTime', $args);
+ // }, ['is_variadic' => true]),
+ //
+ // new TwigFilter('format_duration', function($seconds, array $args = []) {
+ // array_unshift($args, $seconds);
+ // return call_user_func_array('formatDuration', $args);
+ // }, ['is_variadic' => true]),
+ //
+ // new TwigFilter('format_size', function ($number, array $args = []) {
+ // array_unshift($args, $number);
+ // return call_user_func_array('sizeString', $args);
+ // }, ['is_variadic' => true]),
+
+// new TwigFilter('hl', function($text, array $args = []) {
+// $keywords = explode(' ', $args[0]);
+// $keywords = array_filter($keywords, function($item) {
+// return trim($item) != '';
+// });
+//
+// return highlightKeyword($text, $keywords);
+// }, ['is_variadic' => true, 'is_safe' => ['html']]),
+
+// new TwigFilter('hex', function($number, array $args = []) {
+// return '0x'.dechex((int)$number);
+// })
+ );
+ }
+
+ public function getTokenParsers() {
+ return [new JsTagTokenParser()];
+ }
+
+ public function getName() {
+ return 'lang';
+ }
+
+}
diff --git a/lib/Upload.php b/lib/Upload.php
new file mode 100644
index 0000000..3a25ff5
--- /dev/null
+++ b/lib/Upload.php
@@ -0,0 +1,170 @@
+randomId;
+ }
+
+ public function getDirectUrl(): string {
+ global $config;
+ return $config['uploads_path'].'/'.$this->randomId.'/'.$this->name;
+ }
+
+ public function getDirectPreviewUrl(int $w, int $h, bool $retina = false): string {
+ global $config;
+ if ($w == $this->imageW && $this->imageH == $h)
+ return $this->getDirectUrl();
+
+ if ($retina) {
+ $w *= 2;
+ $h *= 2;
+ }
+
+ $prefix = $this->imageMayHaveAlphaChannel() ? 'a' : 'p';
+ return $config['uploads_path'].'/'.$this->randomId.'/'.$prefix.$w.'x'.$h.'.jpg';
+ }
+
+ // TODO remove?
+ public function incrementDownloads() {
+ $db = DB();
+ $db->query("UPDATE uploads SET downloads=downloads+1 WHERE id=?", $this->id);
+ $this->downloads++;
+ }
+
+ public function getSize(): string {
+ return sizeString($this->size);
+ }
+
+ public function getMarkdown(?string $options = null): string {
+ if ($this->isImage()) {
+ $md = '{image:'.$this->randomId.',w='.$this->imageW.',h='.$this->imageH.($options ? ','.$options : '').'}{/image}';
+ } else if ($this->isVideo()) {
+ $md = '{video:'.$this->randomId.($options ? ','.$options : '').'}{/video}';
+ } else {
+ $md = '{fileAttach:'.$this->randomId.($options ? ','.$options : '').'}{/fileAttach}';
+ }
+ $md .= ' ';
+ return $md;
+ }
+
+ public function setNote(PostLanguage $lang, string $note) {
+ $db = DB();
+ $db->query("UPDATE uploads SET note_{$lang->value}=? WHERE id=?", $note, $this->id);
+ }
+
+ public function isImage(): bool {
+ return in_array(extension($this->name), self::$ImageExtensions);
+ }
+
+ // assume all png images have alpha channel
+ // i know this is wrong, but anyway
+ public function imageMayHaveAlphaChannel(): bool {
+ return strtolower(extension($this->name)) == 'png';
+ }
+
+ public function isVideo(): bool {
+ return in_array(extension($this->name), self::$VideoExtensions);
+ }
+
+ public function getImageRatio(): float {
+ return $this->imageW / $this->imageH;
+ }
+
+ public function getImagePreviewSize(?int $w = null, ?int $h = null): array {
+ if (is_null($w) && is_null($h))
+ throw new Exception(__METHOD__.': both width and height can\'t be null');
+
+ if (is_null($h))
+ $h = round($w / $this->getImageRatio());
+
+ if (is_null($w))
+ $w = round($h * $this->getImageRatio());
+
+ return [$w, $h];
+ }
+
+ public function createImagePreview(?int $w = null,
+ ?int $h = null,
+ bool $force_update = false,
+ bool $may_have_alpha = false): bool {
+ global $config;
+
+ $orig = $config['uploads_dir'].'/'.$this->randomId.'/'.$this->name;
+ $updated = false;
+
+ foreach (themes::getThemes() as $theme) {
+ if (!$may_have_alpha && $theme == 'dark')
+ continue;
+
+ for ($mult = 1; $mult <= 2; $mult++) {
+ $dw = $w * $mult;
+ $dh = $h * $mult;
+
+ $prefix = $may_have_alpha ? 'a' : 'p';
+ $dst = $config['uploads_dir'].'/'.$this->randomId.'/'.$prefix.$dw.'x'.$dh.($theme == 'dark' ? '_dark' : '').'.jpg';
+
+ if (file_exists($dst)) {
+ if (!$force_update)
+ continue;
+ unlink($dst);
+ }
+
+ $img = imageopen($orig);
+ imageresize($img, $dw, $dh, themes::getThemeAlphaColorAsRGB($theme));
+ imagejpeg($img, $dst, $mult == 1 ? 93 : 67);
+ imagedestroy($img);
+
+ setperm($dst);
+ $updated = true;
+ }
+ }
+
+ return $updated;
+ }
+
+ /**
+ * @return int Number of deleted files
+ */
+ public function deleteAllImagePreviews(): int {
+ global $config;
+ $dir = $config['uploads_dir'].'/'.$this->randomId;
+ $files = scandir($dir);
+ $deleted = 0;
+ foreach ($files as $f) {
+ if (preg_match('/^[ap](\d+)x(\d+)(?:_dark)?\.jpg$/', $f)) {
+ if (is_file($dir.'/'.$f))
+ unlink($dir.'/'.$f);
+ else
+ logError(__METHOD__.': '.$dir.'/'.$f.' is not a file!');
+ $deleted++;
+ }
+ }
+ return $deleted;
+ }
+
+ public function getJSONEncodedHtmlSafeNote(string $lang): string {
+ $value = $lang == 'en' ? $this->noteEn : $this->noteRu;
+ return jsonEncode(preg_replace('/(\r)?\n/', '\n', addslashes($value)));
+ }
+
+}
\ No newline at end of file
diff --git a/lib/WFFCollectionItem.php b/lib/WFFCollectionItem.php
new file mode 100644
index 0000000..6a14f71
--- /dev/null
+++ b/lib/WFFCollectionItem.php
@@ -0,0 +1,53 @@
+id; }
+ public function isAvailable(): bool { return true; }
+ public function getTitle(): string { return $this->title; }
+ public function getDocumentId(): string { return $this->isFolder() ? str_replace('_', ' ', basename($this->path)) : $this->documentId; }
+ public function isTargetBlank(): bool { return $this->isFile(); }
+ public function getSubtitle(): ?string { return null; }
+
+ public function getUrl(): string {
+ global $config;
+ return $this->isFolder()
+ ? "/files/wff/{$this->id}/"
+ : "https://{$config['files_domain']}/NSA Friedman Documents/{$this->path}";
+ }
+
+ public function getMeta(?string $hl_matched = null): array {
+ if ($this->isFolder()) {
+ if (!$this->parentId)
+ return [];
+ return [
+ 'items' => [
+ highlightSubstring($this->getDocumentId(), $hl_matched),
+ langNum('files_count', $this->filesCount)
+ ]
+ ];
+ }
+ return [
+ 'inline' => false,
+ 'items' => [
+ highlightSubstring('Document '.$this->documentId),
+ sizeString($this->size),
+ 'PDF'
+ ]
+ ];
+ }
+
+}
diff --git a/lib/admin.php b/lib/admin.php
index debc3bb..e3e2c7b 100644
--- a/lib/admin.php
+++ b/lib/admin.php
@@ -1,156 +1,132 @@
id = null;
- $this->authId = null;
- $this->csrfSalt = null;
- $this->login = null;
+ // session data
+ protected static ?int $id = null;
+ protected static ?int $authId = null;
+ protected static ?string $csrfSalt = null;
+ protected static ?string $login = null;
+
+ public static function exists(string $login): bool {
+ $db = DB();
+ return (int)$db->result($db->query("SELECT COUNT(*) FROM admins WHERE login=? LIMIT 1", $login)) > 0;
}
- public function makeCSRFSalt(string $salted_password): void {
- $this->csrfSalt = salt_password(strrev($salted_password));
+ public static function add(string $login, string $password): int {
+ $db = DB();
+ $db->insert('admins', [
+ 'login' => $login,
+ 'password' => saltPassword($password),
+ 'activity_ts' => 0
+ ]);
+ return $db->insertId();
}
-};
-function is_admin(): bool {
- global $AdminSession;
- if ($AdminSession->id === null)
- _admin_check();
- return $AdminSession->id != 0;
-}
-
-function admin_exists(string $login): bool {
- $db = DB();
- return (int)$db->result($db->query("SELECT COUNT(*) FROM admins WHERE login=? LIMIT 1", $login)) > 0;
-}
-
-function admin_add(string $login, string $password): int {
- $db = DB();
- $db->insert('admins', [
- 'login' => $login,
- 'password' => salt_password($password),
- 'activity_ts' => 0
- ]);
- return $db->insertId();
-}
-
-function admin_delete(string $login): bool {
- $db = DB();
- $id = admin_get_id_by_login($login);
- if (!$db->query("DELETE FROM admins WHERE login=?", $login)) return false;
- if (!$db->query("DELETE FROM admin_auth WHERE admin_id=?", $id)) return false;
- return true;
-}
-
-/**
- * @param int[] $ids
- * @return string[]
- */
-function admin_get_logins_by_id(array $ids): array {
- $db = DB();
- $logins = [];
- $q = $db->query("SELECT id, login FROM admins WHERE id IN (".implode(',', $ids).")");
- while ($row = $db->fetch($q)) {
- $logins[(int)$row['id']] = $row['login'];
+ public static function delete(string $login): bool {
+ $db = DB();
+ $id = self::getIdByLogin($login);
+ if (!$db->query("DELETE FROM admins WHERE login=?", $login)) return false;
+ if (!$db->query("DELETE FROM admin_auth WHERE admin_id=?", $id)) return false;
+ return true;
}
- return $logins;
-}
-function admin_get_id_by_login(string $login): ?int {
- $db = DB();
- $q = $db->query("SELECT id FROM admins WHERE login=?", $login);
- return $db->numRows($q) > 0 ? (int)$db->result($q) : null;
-}
+ /**
+ * @param int[] $ids
+ * @return string[]
+ */
+ public static function getLoginsById(array $ids): array {
+ $db = DB();
+ $logins = [];
+ $q = $db->query("SELECT id, login FROM admins WHERE id IN (".implode(',', $ids).")");
+ while ($row = $db->fetch($q)) {
+ $logins[(int)$row['id']] = $row['login'];
+ }
+ return $logins;
+ }
-function admin_set_password(string $login, string $password): bool {
- $db = DB();
- $db->query("UPDATE admins SET password=? WHERE login=?", salt_password($password), $login);
- return $db->affectedRows() > 0;
-}
+ protected static function getIdByLogin(string $login): ?int {
+ $db = DB();
+ $q = $db->query("SELECT id FROM admins WHERE login=?", $login);
+ return $db->numRows($q) > 0 ? (int)$db->result($q) : null;
+ }
-function admin_auth(string $login, string $password): bool {
- global $AdminSession;
+ public static function setPassword(string $login, string $password): bool {
+ $db = DB();
+ $db->query("UPDATE admins SET password=? WHERE login=?", saltPassword($password), $login);
+ return $db->affectedRows() > 0;
+ }
- $db = DB();
- $salted_password = salt_password($password);
- $q = $db->query("SELECT id, active FROM admins WHERE login=? AND password=?", $login, $salted_password);
- if (!$db->numRows($q))
- return false;
+ public static function auth(string $login, string $password): bool {
+ $db = DB();
+ $salted_password = saltPassword($password);
+ $q = $db->query("SELECT id, active FROM admins WHERE login=? AND password=?", $login, $salted_password);
+ if (!$db->numRows($q)) {
+ logDebug(__METHOD__.': login or password is invalid');
+ return false;
+ }
- $row = $db->fetch($q);
- $id = (int)$row['id'];
- $active = (bool)$row['active'];
- if (!$active)
- return false;
+ $row = $db->fetch($q);
+ $id = (int)$row['id'];
+ $active = (bool)$row['active'];
+ if (!$active)
+ return false;
- $time = time();
+ $time = time();
- do {
- $token = strgen(32);
- } while ($db->numRows($db->query("SELECT id FROM admin_auth WHERE token=? LIMIT 1", $token)) > 0);
+ do {
+ $token = strgen(32);
+ } while ($db->numRows($db->query("SELECT id FROM admin_auth WHERE token=? LIMIT 1", $token)) > 0);
- $db->insert('admin_auth', [
- 'admin_id' => $id,
- 'token' => $token,
- 'ts' => $time
- ]);
- $auth_id = $db->insertId();
+ $db->insert('admin_auth', [
+ 'admin_id' => $id,
+ 'token' => $token,
+ 'ts' => $time
+ ]);
+ $auth_id = $db->insertId();
- $db->insert('admin_log', [
- 'admin_id' => $id,
- 'ts' => $time,
- 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
- 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
- ]);
+ $db->insert('admin_log', [
+ 'admin_id' => $id,
+ 'ts' => $time,
+ 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
+ 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
+ ]);
- $db->query("UPDATE admins SET activity_ts=? WHERE id=?", $time, $id);
+ $db->query("UPDATE admins SET activity_ts=? WHERE id=?", $time, $id);
- $AdminSession->id = $id;
- $AdminSession->login = $login;
- $AdminSession->makeCSRFSalt($salted_password);
- $AdminSession->authId = $auth_id;
+ self::setSessionData($id, $login, $auth_id, $salted_password);
+ self::setCookie($token);
- _admin_set_cookie($token);
- return true;
-}
+ return true;
+ }
-function admin_logout() {
- if (!is_admin())
- return;
+ public static function logout() {
+ if (!isAdmin())
+ return;
- global $AdminSession;
+ $db = DB();
+ $db->query("DELETE FROM admin_auth WHERE id=?", self::$authId);
- $db = DB();
- $db->query("DELETE FROM admin_auth WHERE id=?", $AdminSession->authId);
+ self::unsetSessionData();
+ self::unsetCookie();
+ }
- $AdminSession->mrProper();
- _admin_unset_cookie();
-}
+ public static function log(\AdminActions\BaseAction $action) {
+ \AdminActions\Util\Logger::record($action);
+ }
-function admin_log(\AdminActions\BaseAction $action) {
- \AdminActions\Util\Logger::record($action);
-}
+ public static function check(): void {
+ if (!isset($_COOKIE[self::ADMIN_COOKIE_NAME]))
+ return;
-function _admin_check(): void {
- if (!isset($_COOKIE[ADMIN_COOKIE_NAME]))
- return;
-
- $cookie = (string)$_COOKIE[ADMIN_COOKIE_NAME];
- $db = DB();
- $time = time();
- $q = $db->query("SELECT
+ $cookie = (string)$_COOKIE[self::ADMIN_COOKIE_NAME];
+ $db = DB();
+ $time = time();
+ $q = $db->query("SELECT
admin_auth.id AS auth_id,
admin_auth.admin_id AS id,
admins.activity_ts AS activity_ts,
@@ -161,27 +137,44 @@ function _admin_check(): void {
WHERE admin_auth.token=?
LIMIT 1", $cookie);
- if (!$db->numRows($q))
- return;
+ if (!$db->numRows($q)) {
+ unset($_COOKIE[self::ADMIN_COOKIE_NAME]);
+ return;
+ }
- $info = $db->fetch($q);
+ $info = $db->fetch($q);
+ self::setSessionData((int)$info['id'], $info['login'], (int)$info['auth_id'], $info['salted_password']);
- global $AdminSession;
- $AdminSession->id = (int)$info['id'];
- $AdminSession->login = $info['login'];
- $AdminSession->authId = (int)$info['auth_id'];
- $AdminSession->makeCSRFSalt($info['salted_password']);
+ if ($time - $info['activity_ts'] > 15)
+ $db->query("UPDATE admins SET activity_ts=? WHERE id=?", $time, self::$id);
+ }
+
+ protected static function setCookie(string $token): void {
+ global $config;
+ setcookie(self::ADMIN_COOKIE_NAME, $token, time() + self::ADMIN_SESSION_TIMEOUT, '/', $config['cookie_host']);
+ }
+
+ protected static function unsetCookie(): void {
+ global $config;
+ setcookie(self::ADMIN_COOKIE_NAME, '', 1, '/', $config['cookie_host']);
+ }
+
+ protected static function setSessionData(int $id, string $login, int $authId, string $saltedPassword) {
+ self::$id = $id;
+ self::$login = $login;
+ self::$authId = $authId;
+ self::$csrfSalt = saltPassword(strrev($saltedPassword));
+ }
+
+ protected static function unsetSessionData(): void {
+ self::$id = null;
+ self::$authId = null;
+ self::$csrfSalt = null;
+ self::$login = null;
+ }
+
+ public static function getId(): ?int { return self::$id; }
+ public static function getCSRFSalt(): ?string { return self::$csrfSalt; }
+ public static function getLogin(): ?string { return self::$login; }
- if ($time - $info['activity_ts'] > 15)
- $db->query("UPDATE admins SET activity_ts=? WHERE id=?", $time, $AdminSession->id);
-}
-
-function _admin_set_cookie(string $token): void {
- global $config;
- setcookie(ADMIN_COOKIE_NAME, $token, time() + ADMIN_SESSION_TIMEOUT, '/', $config['cookie_host']);
-}
-
-function _admin_unset_cookie(): void {
- global $config;
- setcookie(ADMIN_COOKIE_NAME, '', 1, '/', $config['cookie_host']);
}
diff --git a/lib/ansi.php b/lib/ansi.php
deleted file mode 100644
index 9e0a425..0000000
--- a/lib/ansi.php
+++ /dev/null
@@ -1,32 +0,0 @@
-value + ($fg_bright ? 90 : 30);
- if (!is_null($bg))
- $codes[] = $bg->value + ($bg_bright ? 100 : 40);
- if ($bold)
- $codes[] = 1;
-
- if (empty($codes))
- return $text;
-
- return "\033[".implode(';', $codes)."m".$text."\033[0m";
-}
\ No newline at end of file
diff --git a/lib/cli.php b/lib/cli.php
index 624a719..8df2203 100644
--- a/lib/cli.php
+++ b/lib/cli.php
@@ -17,15 +17,15 @@ class cli {
exit(is_null($error) ? 0 : 1);
}
- function on(string $command, callable $f) {
+ public function on(string $command, callable $f) {
$this->commands[$command] = $f;
return $this;
}
- function run(): void {
+ public function run(): void {
global $argv, $argc;
- if (!is_cli())
+ if (!isCli())
cli::die('SAPI != cli');
if ($argc < 2)
diff --git a/lib/files.php b/lib/files.php
index 185d13f..eb477c7 100644
--- a/lib/files.php
+++ b/lib/files.php
@@ -2,738 +2,389 @@
use Sphinx\SphinxClient;
-require_once 'engine/sphinx.php';
+class files {
+ const string WFF_ARCHIVE_SPHINX_RTINDEX = 'wff_collection';
+ const string MDF_ARCHIVE_SPHINX_RTINDEX = 'mdf_archive';
+ const string BACONIANA_ARCHIVE_SPHINX_RTINDEX = 'baconiana_archive';
-// ----------------------------------------------
-// ------------------- Common -------------------
-// ----------------------------------------------
+ /**
+ * @param string $table
+ * @param string $field_id
+ * @param int[] $ids
+ * @param string[] $keywords Must already be lower-cased
+ * @param int $before
+ * @param int $after
+ * @return array
+ */
+ public static function _get_text_excerpts(string $table, string $field_id, array $ids, array $keywords, int $before, int $after) {
+ $results = [];
+ foreach ($ids as $id)
+ $results[$id] = null;
-const WFF_ARCHIVE_SPHINX_RTINDEX = 'wff_collection';
-const MDF_ARCHIVE_SPHINX_RTINDEX = 'mdf_archive';
-const BACONIANA_ARCHIVE_SPHINX_RTINDEX = 'baconiana_archive';
+ $db = DB();
-enum FilesItemType: string {
- case FILE = 'file';
- case FOLDER = 'folder';
-}
+ $dynamic_sql_parts = [];
+ $combined_parts = [];
+ foreach ($keywords as $keyword) {
+ $part = "LOCATE('".$db->escape($keyword)."', text)";
+ $dynamic_sql_parts[] = $part;
+ }
+ if (count($dynamic_sql_parts) > 1) {
+ foreach ($dynamic_sql_parts as $part)
+ $combined_parts[] = "IF({$part} > 0, {$part}, CHAR_LENGTH(text) + 1)";
+ $combined_parts = implode(', ', $combined_parts);
+ $combined_parts = 'LEAST('.$combined_parts.')';
+ } else {
+ $combined_parts = "IF({$dynamic_sql_parts[0]} > 0, {$dynamic_sql_parts[0]}, CHAR_LENGTH(text) + 1)";
+ }
-interface FilesItemInterface {
- public function getId(): string;
- public function isFolder(): bool;
- public function isFile(): bool;
- public function getUrl(): string;
- public function getSize(): ?int;
- public function getTitle(): string;
- public function getTitleHtml(): ?string;
- public function getMeta(?string $hl_matched = null): array;
- public function isAvailable(): bool;
- public function isTargetBlank(): bool;
- public function getSubtitle(): ?string;
-}
-
-trait FilesItemTypeTrait {
- public FilesItemType $type;
- public function isFolder(): bool { return $this->type == FilesItemType::FOLDER; }
- public function isFile(): bool { return $this->type == FilesItemType::FILE; }
-}
-
-trait FilesItemSizeTrait {
- public int $size;
- public function getSize(): ?int { return $this->isFile() ? $this->size : null; }
-}
-
-/**
- * @param string $table
- * @param string $field_id
- * @param int[] $ids
- * @param string[] $keywords Must already be lower-cased
- * @param int $before
- * @param int $after
- * @return array
- */
-function _get_text_excerpts(string $table, string $field_id, array $ids, array $keywords, int $before, int $after) {
- $results = [];
- foreach ($ids as $id)
- $results[$id] = null;
-
- $db = DB();
-
- $dynamic_sql_parts = [];
- $combined_parts = [];
- foreach ($keywords as $keyword) {
- $part = "LOCATE('".$db->escape($keyword)."', text)";
- $dynamic_sql_parts[] = $part;
- }
- if (count($dynamic_sql_parts) > 1) {
- foreach ($dynamic_sql_parts as $part)
- $combined_parts[] = "IF({$part} > 0, {$part}, CHAR_LENGTH(text) + 1)";
- $combined_parts = implode(', ', $combined_parts);
- $combined_parts = 'LEAST('.$combined_parts.')';
- } else {
- $combined_parts = "IF({$dynamic_sql_parts[0]} > 0, {$dynamic_sql_parts[0]}, CHAR_LENGTH(text) + 1)";
- }
-
- $total = $before + $after;
- $sql = "SELECT
- {$field_id} AS id,
- GREATEST(
- 1,
- {$combined_parts} - {$before}
- ) AS excerpt_start_index,
- SUBSTRING(
- text,
+ $total = $before + $after;
+ $sql = "SELECT
+ {$field_id} AS id,
GREATEST(
1,
{$combined_parts} - {$before}
- ),
- LEAST(
- CHAR_LENGTH(text),
- {$total} + {$combined_parts} - GREATEST(1, {$combined_parts} - {$before})
- )
- ) AS excerpt
- FROM
- {$table}
- WHERE
- {$field_id} IN (".implode(',', $ids).")";
+ ) AS excerpt_start_index,
+ SUBSTRING(
+ text,
+ GREATEST(
+ 1,
+ {$combined_parts} - {$before}
+ ),
+ LEAST(
+ CHAR_LENGTH(text),
+ {$total} + {$combined_parts} - GREATEST(1, {$combined_parts} - {$before})
+ )
+ ) AS excerpt
+ FROM
+ {$table}
+ WHERE
+ {$field_id} IN (".implode(',', $ids).")";
- $q = $db->query($sql);
- while ($row = $db->fetch($q)) {
- $results[$row['id']] = [
- 'excerpt' => preg_replace('/\s+/', ' ', $row['excerpt']),
- 'index' => (int)$row['excerpt_start_index']
- ];
- }
-
- return $results;
-}
-
-function _search(string $index,
- string $q,
- int $offset,
- int $count,
- callable $items_getter,
- ?callable $sphinx_client_setup = null): array {
- $query_filtered = sphinx_mkquery($q);
-
- $cl = sphinx_client();
- $cl->setLimits($offset, $count);
-
- $cl->setMatchMode(Sphinx\SphinxClient::SPH_MATCH_EXTENDED);
-
- if (is_callable($sphinx_client_setup))
- $sphinx_client_setup($cl);
- else {
- $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25);
- $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE);
- }
-
- // run search
- $final_query = "$query_filtered";
- $result = $cl->query($final_query, $index);
- $error = $cl->getLastError();
- $warning = $cl->getLastWarning();
- if ($error)
- logError(__FUNCTION__, $error);
- if ($warning)
- logWarning(__FUNCTION__, $warning);
- if ($result === false)
- return ['count' => 0, 'items' => []];
-
- $total_found = (int)$result['total_found'];
-
- $items = [];
- if (!empty($result['matches']))
- $items = $items_getter($result['matches']);
-
- return ['count' => $total_found, 'items' => $items];
-}
-
-// ---------------------------------------------------
-// ------------------- Collections -------------------
-// ---------------------------------------------------
-
-enum FilesCollection: string {
- case WilliamFriedman = 'wff';
- case MercureDeFrance = 'mdf';
- case Baconiana = 'baconiana';
-}
-
-class CollectionItem implements FilesItemInterface {
- public function __construct(
- protected FilesCollection $collection
- ) {}
-
- public function getTitleHtml(): ?string { return null; }
- public function getId(): string { return $this->collection->value; }
- public function isFolder(): bool { return true; }
- public function isFile(): bool { return false; }
- public function isAvailable(): bool { return true; }
- public function getUrl(): string {
- return '/files/'.$this->collection->value.'/';
- }
- public function getSize(): ?int { return null; }
- public function getTitle(): string { return lang("files_{$this->collection->value}_collection"); }
- public function getMeta(?string $hl_matched = null): array { return []; }
- public function isTargetBlank(): bool { return false; }
- public function getSubtitle(): ?string { return null; }
-}
-
-
-// ----------------------------------------------------------------
-// ------------------- William Friedman Archive -------------------
-// ----------------------------------------------------------------
-
-class WFFCollectionItem extends model implements FilesItemInterface {
-
- const DB_TABLE = 'wff_collection';
-
- use FilesItemTypeTrait;
- use FilesItemSizeTrait;
-
- public int $id;
- public int $parentId;
- public string $title;
- public string $documentId;
- public string $path;
- public int $filesCount;
-
- public function getTitleHtml(): ?string { return null; }
- public function getId(): string { return (string)$this->id; }
- public function isAvailable(): bool { return true; }
- public function getTitle(): string { return $this->title; }
- public function getDocumentId(): string { return $this->isFolder() ? str_replace('_', ' ', basename($this->path)) : $this->documentId; }
- public function isTargetBlank(): bool { return $this->isFile(); }
- public function getSubtitle(): ?string { return null; }
-
- public function getUrl(): string {
- global $config;
- return $this->isFolder()
- ? "/files/wff/{$this->id}/"
- : "https://{$config['files_domain']}/NSA Friedman Documents/{$this->path}";
- }
-
- public function getMeta(?string $hl_matched = null): array {
- if ($this->isFolder()) {
- if (!$this->parentId)
- return [];
- return [
- 'items' => [
- hl_matched($this->getDocumentId(), $hl_matched),
- lang_num('files_count', $this->filesCount)
- ]
+ $q = $db->query($sql);
+ while ($row = $db->fetch($q)) {
+ $results[$row['id']] = [
+ 'excerpt' => preg_replace('/\s+/', ' ', $row['excerpt']),
+ 'index' => (int)$row['excerpt_start_index']
];
}
- return [
- 'inline' => false,
- 'items' => [
- hl_matched('Document '.$this->documentId),
- sizeString($this->size),
- 'PDF'
- ]
- ];
+
+ return $results;
}
-}
+ public static function _search(string $index,
+ string $q,
+ int $offset,
+ int $count,
+ callable $items_getter,
+ ?callable $sphinx_client_setup = null): array {
+ $query_filtered = sphinx::mkquery($q);
-/**
- * @param int $folder_id
- * @param bool $with_parents
- * @return WFFCollectionItem|WFFCollectionItem[]|null
- */
-function wff_get_folder(int $folder_id, bool $with_parents = false): WFFCollectionItem|array|null {
- $db = DB();
- $q = $db->query("SELECT * FROM wff_collection WHERE id=?", $folder_id);
- if (!$db->numRows($q))
- return null;
- $item = new WFFCollectionItem($db->fetch($q));
- if (!$item->isFolder())
- return null;
- if ($with_parents) {
- $items = [$item];
- if ($item->parentId) {
- $parents = wff_get_folder($item->parentId, true);
- if ($parents !== null)
- $items = array_merge($items, $parents);
- }
- return $items;
- }
- return $item;
-}
+ $cl = sphinx::getClient();
+ $cl->setLimits($offset, $count);
-/**
- * @param int|int[]|null $parent_id
- * @return array
- */
-function wff_get(int|array|null $parent_id = null) {
- $db = DB();
+ $cl->setMatchMode(Sphinx\SphinxClient::SPH_MATCH_EXTENDED);
- $where = [];
- $args = [];
-
- if (!is_null($parent_id)) {
- if (is_int($parent_id)) {
- $where[] = "parent_id=?";
- $args[] = $parent_id;
- } else {
- $where[] = "parent_id IN (".implode(", ", $parent_id).")";
- }
- }
- $sql = "SELECT * FROM wff_collection";
- if (!empty($where))
- $sql .= " WHERE ".implode(" AND ", $where);
- $sql .= " ORDER BY title";
- $q = $db->query($sql, ...$args);
-
- return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q));
-}
-
-/**
- * @param int[] $ids
- * @return WFFCollectionItem[]
- */
-function wff_get_by_id(array $ids): array {
- $db = DB();
- $q = $db->query("SELECT * FROM wff_collection WHERE id IN (".implode(',', $ids).")");
- return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q));
-}
-
-function wff_search(string $q, int $offset = 0, int $count = 0): array {
- return _search(WFF_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count,
- items_getter: function($matches) {
- return wff_get_by_id(array_keys($matches));
- },
- sphinx_client_setup: function(SphinxClient $cl) {
- $cl->setFieldWeights([
- 'title' => 50,
- 'document_id' => 60,
- ]);
- $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25);
- $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_EXTENDED, '@relevance DESC, is_folder DESC');
- }
- );
-}
-
-function wff_reindex(): void {
- sphinx_execute("TRUNCATE RTINDEX ".WFF_ARCHIVE_SPHINX_RTINDEX);
- $db = DB();
- $q = $db->query("SELECT * FROM wff_collection");
- while ($row = $db->fetch($q)) {
- $item = new WFFCollectionItem($row);
- $text = '';
- if ($item->isFile()) {
- $text_q = $db->query("SELECT text FROM wff_texts WHERE wff_id=?", $item->id);
- if ($db->numRows($text_q))
- $text = $db->result($text_q);
- }
- sphinx_execute("INSERT INTO ".WFF_ARCHIVE_SPHINX_RTINDEX." (id, document_id, title, text, is_folder, parent_id) VALUES (?, ?, ?, ?, ?, ?)",
- $item->id, $item->getDocumentId(), $item->title, $text, (int)$item->isFolder(), $item->parentId);
- }
-}
-
-function wff_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array {
- return _get_text_excerpts('wff_texts', 'wff_id', $ids, $keywords, $before, $after);
-}
-
-
-// ---------------------------------------------------------
-// ------------------- Mercure de France -------------------
-// ---------------------------------------------------------
-
-class MDFCollectionItem extends model implements FilesItemInterface {
-
- const DB_TABLE = 'mdf_collection';
-
- use FilesItemTypeTrait;
- use FilesItemSizeTrait;
-
- public int $id;
- public int $issue;
- public string $path;
- public string $date;
- public int $volume;
- public int $pageFrom;
- public int $pageTo;
- public int $pdfPages;
- public int $size;
-
- public function isAvailable(): bool { return true; }
-
- public function getTitleHtml(): ?string { return null; }
-
- public function getTitle(): string {
- return "№{$this->issue}, {$this->getHumanFriendlyDate()}";
- }
-
- public function getHumanFriendlyDate(): string {
- $dt = new DateTime($this->date);
- return $dt->format('j M Y');
- }
-
- public function isTargetBlank(): bool { return true; }
- public function getId(): string { return $this->id; }
- public function getUrl(): string {
- global $config;
- return 'https://'.$config['files_domain'].'/Mercure-de-France-OCR/'.$this->path;
- }
-
- public function getMeta(?string $hl_matched = null): array {
- return [
- 'inline' => true,
- 'items' => [
- 'Vol. '.$this->getRomanVolume(),
- 'pp. '.$this->pageFrom.'-'.$this->pageTo,
- sizeString($this->size),
- 'PDF'
- ]
- ];
- }
-
- public function getRomanVolume(): string {
- return arabic_to_roman($this->volume);
- }
-
- public function getSubtitle(): ?string {
- return null;
- //return 'Vol. '.$this->getRomanVolume().', pp. '.$this->pageFrom.'-'.$this->pageTo;
- }
-}
-
-/**
- * @return MDFCollectionItem[]
- */
-function mdf_get(): array {
- $db = DB();
- $q = $db->query("SELECT * FROM mdf_collection ORDER BY `date`");
- return array_map('MDFCollectionItem::create_instance', $db->fetchAll($q));
-}
-
-/**
- * @param int[] $ids
- * @return MDFCollectionItem[]
- */
-function mdf_get_by_id(array $ids): array {
- $db = DB();
- $q = $db->query("SELECT * FROM mdf_collection WHERE id IN (".implode(',', $ids).")");
- return array_map('MDFCollectionItem::create_instance', $db->fetchAll($q));
-}
-
-function mdf_search(string $q, int $offset = 0, int $count = 0): array {
- return _search(MDF_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count,
- items_getter: function($matches) {
- return mdf_get_by_id(array_keys($matches));
- },
- sphinx_client_setup: function(SphinxClient $cl) {
- $cl->setFieldWeights([
- 'date' => 10,
- 'issue' => 9,
- 'text' => 8
- ]);
+ if (is_callable($sphinx_client_setup))
+ $sphinx_client_setup($cl);
+ else {
$cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25);
$cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE);
}
- );
-}
-function mdf_reindex(): void {
- sphinx_execute("TRUNCATE RTINDEX ".MDF_ARCHIVE_SPHINX_RTINDEX);
- $db = DB();
- $mdf = mdf_get();
- foreach ($mdf as $item) {
- $text = $db->result($db->query("SELECT text FROM mdf_texts WHERE mdf_id=?", $item->id));
- sphinx_execute("INSERT INTO ".MDF_ARCHIVE_SPHINX_RTINDEX." (id, volume, issue, date, text) VALUES (?, ?, ?, ?, ?)",
- $item->id, $item->volume, (string)$item->issue, $item->getHumanFriendlyDate(), $text);
- }
-}
+ // run search
+ $final_query = "$query_filtered";
+ $result = $cl->query($final_query, $index);
+ $error = $cl->getLastError();
+ $warning = $cl->getLastWarning();
+ if ($error)
+ logError(__FUNCTION__, $error);
+ if ($warning)
+ logWarning(__FUNCTION__, $warning);
+ if ($result === false)
+ return ['count' => 0, 'items' => []];
-function mdf_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array {
- return _get_text_excerpts('mdf_texts', 'mdf_id', $ids, $keywords, $before, $after);
-}
+ $total_found = (int)$result['total_found'];
-
-// -------------------------------------------------
-// ------------------- Baconiana -------------------
-// -------------------------------------------------
-
-class BaconianaCollectionItem extends model implements FilesItemInterface {
-
- const DB_TABLE = 'baconiana_collection';
-
- use FilesItemTypeTrait;
- use FilesItemSizeTrait;
-
- public int $id;
- public int $parentId;
- public int $year;
- public string $issues;
- public string $path;
- public bool $jobc; // Journal of the Bacon Society
- public string $title; // Only for folders
-
- public function isAvailable(): bool { return true; }
-
- public function getTitleHtml(): ?string { return null; }
-
- public function getTitle(): string {
- if ($this->title !== '')
- return $this->title;
-
- return ($this->jobc ? lang('baconiana_old_name') : lang('baconiana')).' №'.$this->issues;
- }
-
- public function isTargetBlank(): bool { return $this->isFile(); }
- public function getId(): string { return $this->id; }
-
- public function getUrl(): string {
- if ($this->isFolder()) {
- return '/files/'.FilesCollection::Baconiana->value.'/'.$this->id.'/';
- }
- global $config;
- return 'https://'.$config['files_domain'].'/'.$this->path;
- }
-
- public function getMeta(?string $hl_matched = null): array {
$items = [];
- if ($this->isFolder())
+ if (!empty($result['matches']))
+ $items = $items_getter($result['matches']);
+
+ return ['count' => $total_found, 'items' => $items];
+ }
+
+
+ /**
+ * @param int $folder_id
+ * @param bool $with_parents
+ * @return WFFCollectionItem|WFFCollectionItem[]|null
+ */
+ public static function wff_get_folder(int $folder_id, bool $with_parents = false): WFFCollectionItem|array|null {
+ $db = DB();
+ $q = $db->query("SELECT * FROM wff_collection WHERE id=?", $folder_id);
+ if (!$db->numRows($q))
+ return null;
+ $item = new WFFCollectionItem($db->fetch($q));
+ if (!$item->isFolder())
+ return null;
+ if ($with_parents) {
+ $items = [$item];
+ if ($item->parentId) {
+ $parents = self::wff_get_folder($item->parentId, true);
+ if ($parents !== null)
+ $items = array_merge($items, $parents);
+ }
return $items;
-
- if ($this->year >= 2007)
- $items = array_merge($items, ['Online Edition']);
-
- $items = array_merge($items, [
- sizeString($this->size),
- 'PDF'
- ]);
-
- return [
- 'inline' => false,
- 'items' => $items
- ];
- }
-
- public function getSubtitle(): ?string {
- return $this->year > 0 ? '('.$this->year.')' : null;
- }
-}
-
-/**
- * @return BaconianaCollectionItem[]
- */
-function baconiana_get(?int $parent_id = 0): array {
- $db = DB();
- $sql = "SELECT * FROM baconiana_collection";
- if ($parent_id !== null)
- $sql .= " WHERE parent_id='".$db->escape($parent_id)."'";
- $sql .= " ORDER BY type, year, id";
- $q = $db->query($sql);
- return array_map('BaconianaCollectionItem::create_instance', $db->fetchAll($q));
-}
-
-/**
- * @param int[] $ids
- * @return BaconianaCollectionItem[]
- */
-function baconiana_get_by_id(array $ids): array {
- $db = DB();
- $q = $db->query("SELECT * FROM baconiana_collection WHERE id IN (".implode(',', $ids).")");
- return array_map('BaconianaCollectionItem::create_instance', $db->fetchAll($q));
-}
-
-/**
- * @param int $folder_id
- * @param bool $with_parents
- * @return BaconianaCollectionItem|BaconianaCollectionItem[]|null
- */
-function baconiana_get_folder(int $folder_id, bool $with_parents = false): WFFCollectionItem|array|null {
- $db = DB();
- $q = $db->query("SELECT * FROM baconiana_collection WHERE id=?", $folder_id);
- if (!$db->numRows($q))
- return null;
- $item = new BaconianaCollectionItem($db->fetch($q));
- if (!$item->isFolder())
- return null;
- if ($with_parents) {
- $items = [$item];
- if ($item->parentId) {
- $parents = baconiana_get_folder($item->parentId, true);
- if ($parents !== null)
- $items = array_merge($items, $parents);
}
- return $items;
+ return $item;
}
- return $item;
-}
-function baconiana_search(string $q, int $offset = 0, int $count = 0): array {
- return _search(BACONIANA_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count,
- items_getter: function($matches) {
- return baconiana_get_by_id(array_keys($matches));
- },
- sphinx_client_setup: function(SphinxClient $cl) {
- $cl->setFieldWeights([
- 'year' => 10,
- 'issues' => 9,
- 'text' => 8
- ]);
- $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25);
- $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE);
+ /**
+ * @param int|int[]|null $parent_id
+ * @return array
+ */
+ public static function wff_get(int|array|null $parent_id = null) {
+ $db = DB();
+
+ $where = [];
+ $args = [];
+
+ if (!is_null($parent_id)) {
+ if (is_int($parent_id)) {
+ $where[] = "parent_id=?";
+ $args[] = $parent_id;
+ } else {
+ $where[] = "parent_id IN (".implode(", ", $parent_id).")";
+ }
}
- );
-}
+ $sql = "SELECT * FROM wff_collection";
+ if (!empty($where))
+ $sql .= " WHERE ".implode(" AND ", $where);
+ $sql .= " ORDER BY title";
+ $q = $db->query($sql, ...$args);
-function baconiana_reindex(): void {
- sphinx_execute("TRUNCATE RTINDEX ".BACONIANA_ARCHIVE_SPHINX_RTINDEX);
- $db = DB();
- $baconiana = baconiana_get(null);
- foreach ($baconiana as $item) {
- $text_q = $db->query("SELECT text FROM baconiana_texts WHERE bcn_id=?", $item->id);
- if (!$db->numRows($text_q))
- continue;
- $text = $db->result($text_q);
- sphinx_execute("INSERT INTO ".BACONIANA_ARCHIVE_SPHINX_RTINDEX." (id, title, year, text) VALUES (?, ?, ?, ?)",
- $item->id, "$item->year ($item->issues)", $item->year, $text);
- }
-}
-
-function baconiana_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array {
- return _get_text_excerpts('baconiana_texts', 'bcn_id', $ids, $keywords, $before, $after);
-}
-
-
-// ----------------------------------------------------------
-// ------------------- Books and Articles -------------------
-// ----------------------------------------------------------
-
-enum BookFileType: string {
- case NONE = 'none';
- case BOOK = 'book';
- case ARTICLE = 'article';
-}
-
-enum BookCategory: string {
- case BOOKS = 'books';
- case MISC = 'misc';
-}
-
-class BookItem extends model implements FilesItemInterface {
-
- const DB_TABLE = 'books';
-
- public int $id;
- public int $parentId;
- public string $author;
- public string $title;
- public string $subtitle;
- public int $year;
- public int $size;
- public FilesItemType $type;
- public BookFileType $fileType;
- public string $path;
- public bool $external;
- public BookCategory $category;
-
- use FilesItemSizeTrait;
- use FilesItemTypeTrait;
-
- public function getId(): string {
- return $this->id;
+ return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q));
}
- public function getUrl(): string {
- if ($this->isFolder() && !$this->external)
- return '/files/'.$this->id.'/';
- global $config;
- $buf = 'https://'.$config['files_domain'];
- if (!str_starts_with($this->path, '/'))
- $buf .= '/';
- $buf .= $this->path;
- return $buf;
+ /**
+ * @param int[] $ids
+ * @return WFFCollectionItem[]
+ */
+ public static function wff_get_by_id(array $ids): array {
+ $db = DB();
+ $q = $db->query("SELECT * FROM wff_collection WHERE id IN (".implode(',', $ids).")");
+ return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q));
}
- public function getTitleHtml(): ?string {
- if ($this->isFolder() || !$this->author)
+ public static function wff_search(string $q, int $offset = 0, int $count = 0): array {
+ return self::_search(self::WFF_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count,
+ items_getter: function($matches) {
+ return self::wff_get_by_id(array_keys($matches));
+ },
+ sphinx_client_setup: function(SphinxClient $cl) {
+ $cl->setFieldWeights([
+ 'title' => 50,
+ 'document_id' => 60,
+ ]);
+ $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25);
+ $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_EXTENDED, '@relevance DESC, is_folder DESC');
+ }
+ );
+ }
+
+ public static function wff_reindex(): void {
+ sphinx::execute("TRUNCATE RTINDEX ".self::WFF_ARCHIVE_SPHINX_RTINDEX);
+ $db = DB();
+ $q = $db->query("SELECT * FROM wff_collection");
+ while ($row = $db->fetch($q)) {
+ $item = new WFFCollectionItem($row);
+ $text = '';
+ if ($item->isFile()) {
+ $text_q = $db->query("SELECT text FROM wff_texts WHERE wff_id=?", $item->id);
+ if ($db->numRows($text_q))
+ $text = $db->result($text_q);
+ }
+ sphinx::execute("INSERT INTO ".self::WFF_ARCHIVE_SPHINX_RTINDEX." (id, document_id, title, text, is_folder, parent_id) VALUES (?, ?, ?, ?, ?, ?)",
+ $item->id, $item->getDocumentId(), $item->title, $text, (int)$item->isFolder(), $item->parentId);
+ }
+ }
+
+ public static function wff_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array {
+ return self::_get_text_excerpts('wff_texts', 'wff_id', $ids, $keywords, $before, $after);
+ }
+
+ /**
+ * @return MDFCollectionItem[]
+ */
+ public static function mdf_get(): array {
+ $db = DB();
+ $q = $db->query("SELECT * FROM mdf_collection ORDER BY `date`");
+ return array_map('MDFCollectionItem::create_instance', $db->fetchAll($q));
+ }
+
+ /**
+ * @param int[] $ids
+ * @return MDFCollectionItem[]
+ */
+ public static function mdf_get_by_id(array $ids): array {
+ $db = DB();
+ $q = $db->query("SELECT * FROM mdf_collection WHERE id IN (".implode(',', $ids).")");
+ return array_map('MDFCollectionItem::create_instance', $db->fetchAll($q));
+ }
+
+ public static function mdf_search(string $q, int $offset = 0, int $count = 0): array {
+ return self::_search(self::MDF_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count,
+ items_getter: function($matches) {
+ return self::mdf_get_by_id(array_keys($matches));
+ },
+ sphinx_client_setup: function(SphinxClient $cl) {
+ $cl->setFieldWeights([
+ 'date' => 10,
+ 'issue' => 9,
+ 'text' => 8
+ ]);
+ $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25);
+ $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE);
+ }
+ );
+ }
+
+ public static function mdf_reindex(): void {
+ sphinx::execute("TRUNCATE RTINDEX ".self::MDF_ARCHIVE_SPHINX_RTINDEX);
+ $db = DB();
+ $mdf = self::mdf_get();
+ foreach ($mdf as $item) {
+ $text = $db->result($db->query("SELECT text FROM mdf_texts WHERE mdf_id=?", $item->id));
+ sphinx::execute("INSERT INTO ".self::MDF_ARCHIVE_SPHINX_RTINDEX." (id, volume, issue, date, text) VALUES (?, ?, ?, ?, ?)",
+ $item->id, $item->volume, (string)$item->issue, $item->getHumanFriendlyDate(), $text);
+ }
+ }
+
+ public static function mdf_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array {
+ return self::_get_text_excerpts('mdf_texts', 'mdf_id', $ids, $keywords, $before, $after);
+ }
+
+
+ /**
+ * @return BaconianaCollectionItem[]
+ */
+ public static function baconiana_get(?int $parent_id = 0): array {
+ $db = DB();
+ $sql = "SELECT * FROM baconiana_collection";
+ if ($parent_id !== null)
+ $sql .= " WHERE parent_id='".$db->escape($parent_id)."'";
+ $sql .= " ORDER BY type, year, id";
+ $q = $db->query($sql);
+ return array_map('BaconianaCollectionItem::create_instance', $db->fetchAll($q));
+ }
+
+ /**
+ * @param int[] $ids
+ * @return BaconianaCollectionItem[]
+ */
+ public static function baconiana_get_by_id(array $ids): array {
+ $db = DB();
+ $q = $db->query("SELECT * FROM baconiana_collection WHERE id IN (".implode(',', $ids).")");
+ return array_map('BaconianaCollectionItem::create_instance', $db->fetchAll($q));
+ }
+
+ /**
+ * @param int $folder_id
+ * @param bool $with_parents
+ * @return BaconianaCollectionItem|BaconianaCollectionItem[]|null
+ */
+ public static function baconiana_get_folder(int $folder_id, bool $with_parents = false): WFFCollectionItem|array|null {
+ $db = DB();
+ $q = $db->query("SELECT * FROM baconiana_collection WHERE id=?", $folder_id);
+ if (!$db->numRows($q))
return null;
- $buf = ''.htmlescape($this->author).' ';
- if (!str_ends_with($this->author, '.'))
- $buf .= '.';
- $buf .= ' '.htmlescape($this->title).' ';
- return $buf;
- }
-
- public function getTitle(): string {
- return $this->title;
- }
-
- public function getMeta(?string $hl_matched = null): array {
- if ($this->isFolder())
- return [];
-
- $items = [
- sizeString($this->size),
- strtoupper($this->getExtension())
- ];
-
- return [
- 'inline' => false,
- 'items' => $items
- ];
- }
-
- protected function getExtension(): string {
- return extension(basename($this->path));
- }
-
- public function isAvailable(): bool {
- return true;
- }
-
- public function isTargetBlank(): bool {
- return $this->isFile() || $this->external;
- }
-
- public function getSubtitle(): ?string {
- if (!$this->year && !$this->subtitle)
+ $item = new BaconianaCollectionItem($db->fetch($q));
+ if (!$item->isFolder())
return null;
- $buf = '(';
- $buf .= $this->subtitle ?: $this->year;
- $buf .= ')';
- return $buf;
- }
-}
-
-/**
- * @return BookItem[]
- */
-function books_get(int $parent_id = 0, BookCategory $category = BookCategory::BOOKS): array {
- $db = DB();
-
- if ($category == BookCategory::BOOKS) {
- $order_by = "type, ".($parent_id != 0 ? 'year, ': '')."author, title";
- }
- else
- $order_by = "type, title";
-
- $q = $db->query("SELECT * FROM books WHERE category=? AND parent_id=? ORDER BY $order_by",
- $category->value, $parent_id);
- return array_map('BookItem::create_instance', $db->fetchAll($q));
-}
-
-function books_get_folder(int $id, bool $with_parents = false): BookItem|array|null {
- $db = DB();
- $q = $db->query("SELECT * FROM books WHERE id=?", $id);
- if (!$db->numRows($q))
- return null;
- $item = new BookItem($db->fetch($q));
- if (!$item->isFolder())
- return null;
- if ($with_parents) {
- $items = [$item];
- if ($item->parentId) {
- $parents = books_get_folder($item->parentId, true);
- if ($parents !== null)
- $items = array_merge($items, $parents);
+ if ($with_parents) {
+ $items = [$item];
+ if ($item->parentId) {
+ $parents = self::baconiana_get_folder($item->parentId, true);
+ if ($parents !== null)
+ $items = array_merge($items, $parents);
+ }
+ return $items;
}
- return $items;
+ return $item;
}
- return $item;
+
+ public static function baconiana_search(string $q, int $offset = 0, int $count = 0): array {
+ return self::_search(self::BACONIANA_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count,
+ items_getter: function($matches) {
+ return self::baconiana_get_by_id(array_keys($matches));
+ },
+ sphinx_client_setup: function(SphinxClient $cl) {
+ $cl->setFieldWeights([
+ 'year' => 10,
+ 'issues' => 9,
+ 'text' => 8
+ ]);
+ $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25);
+ $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE);
+ }
+ );
+ }
+
+ public static function baconiana_reindex(): void {
+ sphinx::execute("TRUNCATE RTINDEX ".self::BACONIANA_ARCHIVE_SPHINX_RTINDEX);
+ $db = DB();
+ $baconiana = self::baconiana_get(null);
+ foreach ($baconiana as $item) {
+ $text_q = $db->query("SELECT text FROM baconiana_texts WHERE bcn_id=?", $item->id);
+ if (!$db->numRows($text_q))
+ continue;
+ $text = $db->result($text_q);
+ sphinx::execute("INSERT INTO ".self::BACONIANA_ARCHIVE_SPHINX_RTINDEX." (id, title, year, text) VALUES (?, ?, ?, ?)",
+ $item->id, "$item->year ($item->issues)", $item->year, $text);
+ }
+ }
+
+ public static function baconiana_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array {
+ return self::_get_text_excerpts('baconiana_texts', 'bcn_id', $ids, $keywords, $before, $after);
+ }
+
+
+ /**
+ * @return BookItem[]
+ */
+ public static function books_get(int $parent_id = 0, BookCategory $category = BookCategory::BOOKS): array {
+ $db = DB();
+
+ if ($category == BookCategory::BOOKS) {
+ $order_by = "type, ".($parent_id != 0 ? 'year, ': '')."author, title";
+ }
+ else
+ $order_by = "type, title";
+
+ $q = $db->query("SELECT * FROM books WHERE category=? AND parent_id=? ORDER BY $order_by",
+ $category->value, $parent_id);
+ return array_map('BookItem::create_instance', $db->fetchAll($q));
+ }
+
+ public static function books_get_folder(int $id, bool $with_parents = false): BookItem|array|null {
+ $db = DB();
+ $q = $db->query("SELECT * FROM books WHERE id=?", $id);
+ if (!$db->numRows($q))
+ return null;
+ $item = new BookItem($db->fetch($q));
+ if (!$item->isFolder())
+ return null;
+ if ($with_parents) {
+ $items = [$item];
+ if ($item->parentId) {
+ $parents = self::books_get_folder($item->parentId, true);
+ if ($parents !== null)
+ $items = array_merge($items, $parents);
+ }
+ return $items;
+ }
+ return $item;
+ }
+
}
diff --git a/lib/markup.php b/lib/markup.php
index 8bb63ab..29b73dd 100644
--- a/lib/markup.php
+++ b/lib/markup.php
@@ -1,8 +1,5 @@
(\[([io]?\d{1,2})]) (.*?)<\/p>/ms';
$result = preg_match_all($re, $html, $matches);
- if (pcre_no_error($result)) {
+ if (pcreNoError($result)) {
$reftitles_map = [];
foreach ($matches[2] as $i => $refname) {
$reftitles_map[$refname] = trim(htmlspecialchars_decode(strip_tags($matches[3][$i])));
diff --git a/lib/pages.php b/lib/pages.php
index ad24cb4..1ea6be1 100644
--- a/lib/pages.php
+++ b/lib/pages.php
@@ -1,56 +1,8 @@
md || $fields['render_title'] != $this->renderTitle || $fields['title'] != $this->title) {
- $md = $fields['md'];
- if ($fields['render_title'])
- $md = '# '.$fields['title']."\n\n".$md;
- $fields['html'] = markup::markdownToHtml($md);
- }
- parent::edit($fields);
- }
-
- function isUpdated(): bool {
- return $this->updateTs && $this->updateTs != $this->ts;
- }
-
- function getHtml(bool $is_retina, string $user_theme): string {
- $html = $this->html;
- $html = markup::htmlImagesFix($html, $is_retina, $user_theme);
- return $html;
- }
-
- function getUrl(): string {
- return "/{$this->shortName}/";
- }
-
- function updateHtml(): void {
- $html = markup::markdownToHtml($this->md);
- $this->html = $html;
- DB()->query("UPDATE pages SET html=? WHERE short_name=?", $html, $this->shortName);
- }
-
-}
-
class pages {
- static function add(array $data): bool {
+ public static function add(array $data): bool {
$db = DB();
$data['ts'] = time();
$data['html'] = markup::markdownToHtml($data['md']);
@@ -59,18 +11,18 @@ class pages {
return true;
}
- static function delete(Page $page): void {
+ public static function delete(Page $page): void {
DB()->query("DELETE FROM pages WHERE short_name=?", $page->shortName);
previous_texts::delete(PreviousText::TYPE_PAGE, $page->get_id());
}
- static function getById(int $id): ?Page {
+ public static function getById(int $id): ?Page {
$db = DB();
$q = $db->query("SELECT * FROM pages WHERE id=?", $id);
return $db->numRows($q) ? new Page($db->fetch($q)) : null;
}
- static function getByName(string $short_name): ?Page {
+ public static function getByName(string $short_name): ?Page {
$db = DB();
$q = $db->query("SELECT * FROM pages WHERE short_name=?", $short_name);
return $db->numRows($q) ? new Page($db->fetch($q)) : null;
@@ -79,7 +31,7 @@ class pages {
/**
* @return Page[]
*/
- static function getAll(): array {
+ public static function getAll(): array {
$db = DB();
return array_map('Page::create_instance', $db->fetchAll($db->query("SELECT * FROM pages")));
}
diff --git a/lib/posts.php b/lib/posts.php
index 563dc5c..f95f865 100644
--- a/lib/posts.php
+++ b/lib/posts.php
@@ -1,254 +1,8 @@
value, self::cases(), true);
- }
-}
-
-class Post extends model {
-
- const DB_TABLE = 'posts';
-
- public int $id;
- public string $date;
- public ?string $updateTime;
- public bool $visible;
- public string $shortName;
- public string $sourceUrl;
-
- protected array $texts = [];
-
- public function edit(array $fields) {
- $fields['update_time'] = date(mysql::DATETIME_FORMAT, time());
- parent::edit($fields);
- }
-
- public function addText(PostLanguage $lang, string $title, string $md, string $keywords, bool $toc): ?PostText {
- $html = markup::markdownToHtml($md, lang: $lang);
- $text = markup::htmlToText($html);
-
- $data = [
- 'title' => $title,
- 'lang' => $lang->value,
- 'post_id' => $this->id,
- 'html' => $html,
- 'text' => $text,
- 'md' => $md,
- 'toc' => $toc,
- 'keywords' => $keywords,
- ];
-
- $db = DB();
- if (!$db->insert('posts_texts', $data))
- return null;
-
- $id = $db->insertId();
-
- $post_text = posts::getText($id);
- $post_text->updateImagePreviews();
-
- return $post_text;
- }
-
- public function registerText(PostText $postText): void {
- if (array_key_exists($postText->lang->value, $this->texts))
- throw new Exception("text for language {$postText->lang->value} has already been registered");
- $this->texts[$postText->lang->value] = $postText;
- }
-
- public function loadTexts() {
- if (!empty($this->texts))
- return;
- $db = DB();
- $q = $db->query("SELECT * FROM posts_texts WHERE post_id=?", $this->id);
- while ($row = $db->fetch($q)) {
- $text = new PostText($row);
- $this->registerText($text);
- }
- }
-
- /**
- * @return PostText[]
- */
- public function getTexts(): array {
- $this->loadTexts();
- return $this->texts;
- }
-
- public function getText(PostLanguage $lang): ?PostText {
- $this->loadTexts();
- return $this->texts[$lang->value] ?? null;
- }
-
- public function hasLang(PostLanguage $lang) {
- $this->loadTexts();
- foreach ($this->texts as $text) {
- if ($text->lang == $lang)
- return true;
- }
- return false;
- }
-
- public function getUrl(?PostLanguage $lang = null): string {
- $buf = $this->shortName != '' ? "/articles/{$this->shortName}/" : "/articles/{$this->id}/";
- if ($lang && $lang != PostLanguage::English)
- $buf .= '?lang='.$lang->value;
- return $buf;
- }
-
- public function getTimestamp(): int {
- return (new DateTime($this->date))->getTimestamp();
- }
-
- public function getUpdateTimestamp(): ?int {
- if (!$this->updateTime)
- return null;
- return (new DateTime($this->updateTime))->getTimestamp();
- }
-
- public function getDate(): string {
- return date('j M', $this->getTimestamp());
- }
-
- public function getYear(): int {
- return (int)date('Y', $this->getTimestamp());
- }
-
- public function getFullDate(): string {
- return date('j F Y', $this->getTimestamp());
- }
-
- public function getDateForInputField(): string {
- return date('Y-m-d', $this->getTimestamp());
- }
-}
-
-class PostText extends model {
- const DB_TABLE = 'posts_texts';
-
- public int $id;
- public int $postId;
- public PostLanguage $lang;
- public string $title;
- public string $md;
- public string $html;
- public string $text;
- public bool $toc;
- public string $tocHtml;
- public string $keywords;
-
- public function edit(array $fields) {
- if ($fields['md'] != $this->md) {
- $fields['html'] = markup::markdownToHtml($fields['md'], lang: $this->lang);
- $fields['text'] = markup::htmlToText($fields['html']);
- }
-
- if ((isset($fields['toc']) && $fields['toc']) || $this->toc) {
- $fields['toc_html'] = markup::toc($fields['md']);
- }
-
- parent::edit($fields);
- $this->updateImagePreviews();
- }
-
- public function updateHtml(): void {
- $html = markup::markdownToHtml($this->md, lang: $this->lang);
- $this->html = $html;
- DB()->query("UPDATE posts_texts SET html=? WHERE id=?", $html, $this->id);
- }
-
- public function updateText(): void {
- $html = markup::markdownToHtml($this->md, lang: $this->lang);
- $text = markup::htmlToText($html);
- $this->text = $text;
- DB()->query("UPDATE posts_texts SET text=? WHERE id=?", $text, $this->id);
- }
-
- public function getDescriptionPreview(int $len): string {
- if (mb_strlen($this->text) >= $len)
- return mb_substr($this->text, 0, $len-3).'...';
- return $this->text;
- }
-
- public function getFirstImage(): ?Upload {
- if (!preg_match('/\{image:([\w]{8})/', $this->md, $match))
- return null;
- return uploads::getUploadByRandomId($match[1]);
- }
-
- public function getHtml(bool $is_retina, string $theme): string {
- $html = $this->html;
- return markup::htmlImagesFix($html, $is_retina, $theme);
- }
-
- public function getTableOfContentsHtml(): ?string {
- return $this->toc ? $this->tocHtml : null;
- }
-
- public function hasTableOfContents(): bool {
- return $this->toc;
- }
-
- /**
- * @param bool $update Whether to overwrite preview if already exists
- * @return int
- * @throws Exception
- */
- public function updateImagePreviews(bool $update = false): int {
- $images = [];
- if (!preg_match_all('/\{image:([\w]{8}),(.*?)}/', $this->md, $matches))
- return 0;
-
- for ($i = 0; $i < count($matches[0]); $i++) {
- $id = $matches[1][$i];
- $w = $h = null;
- $opts = explode(',', $matches[2][$i]);
- foreach ($opts as $opt) {
- if (str_contains($opt, '=')) {
- list($k, $v) = explode('=', $opt);
- if ($k == 'w')
- $w = (int)$v;
- else if ($k == 'h')
- $h = (int)$v;
- }
- }
- $images[$id][] = [$w, $h];
- }
-
- if (empty($images))
- return 0;
-
- $images_affected = 0;
- $uploads = uploads::getUploadsByRandomId(array_keys($images), true);
- foreach ($uploads as $upload_key => $u) {
- if ($u === null) {
- logError(__METHOD__.': upload '.$upload_key.' is null');
- continue;
- }
- foreach ($images[$u->randomId] as $s) {
- list($w, $h) = $s;
- list($w, $h) = $u->getImagePreviewSize($w, $h);
- if ($u->createImagePreview($w, $h, $update, $u->imageMayHaveAlphaChannel()))
- $images_affected++;
- }
- }
-
- return $images_affected;
- }
-
-}
-
class posts {
- static function getCount(bool $include_hidden = false): int {
+ public static function getCount(bool $include_hidden = false): int {
$db = DB();
$sql = "SELECT COUNT(*) FROM posts";
if (!$include_hidden) {
@@ -260,7 +14,7 @@ class posts {
/**
* @return Post[]
*/
- static function getList(int $offset = 0,
+ public static function getList(int $offset = 0,
int $count = -1,
bool $include_hidden = false,
?PostLanguage $filter_by_lang = null
@@ -293,14 +47,14 @@ class posts {
return array_values($posts);
}
- static function add(array $data = []): ?Post {
+ public static function add(array $data = []): ?Post {
$db = DB();
if (!$db->insert('posts', $data))
return null;
return self::get($db->insertId());
}
- static function delete(Post $post): void {
+ public static function delete(Post $post): void {
$db = DB();
$db->query("DELETE FROM posts WHERE id=?", $post->id);
@@ -313,25 +67,25 @@ class posts {
$db->query("DELETE FROM posts_texts WHERE post_id=?", $post->id);
}
- static function get(int $id): ?Post {
+ public static function get(int $id): ?Post {
$db = DB();
$q = $db->query("SELECT * FROM posts WHERE id=?", $id);
return $db->numRows($q) ? new Post($db->fetch($q)) : null;
}
- static function getText(int $text_id): ?PostText {
+ public static function getText(int $text_id): ?PostText {
$db = DB();
$q = $db->query("SELECT * FROM posts_texts WHERE id=?", $text_id);
return $db->numRows($q) ? new PostText($db->fetch($q)) : null;
}
- static function getByName(string $short_name): ?Post {
+ public static function getByName(string $short_name): ?Post {
$db = DB();
$q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name);
return $db->numRows($q) ? new Post($db->fetch($q)) : null;
}
- static function getPostsById(array $ids, bool $flat = false): array {
+ public static function getPostsById(array $ids, bool $flat = false): array {
if (empty($ids)) {
return [];
}
@@ -357,7 +111,7 @@ class posts {
return $posts;
}
- static function getPostTextsById(array $ids, bool $flat = false): array {
+ public static function getPostTextsById(array $ids, bool $flat = false): array {
if (empty($ids)) {
return [];
}
@@ -387,7 +141,7 @@ class posts {
* @param Upload $upload
* @return PostText[] Array of PostTexts that includes specified upload
*/
- static function getTextsWithUpload(Upload $upload): array {
+ public static function getTextsWithUpload(Upload $upload): array {
$db = DB();
$q = $db->query("SELECT id FROM posts_texts WHERE md LIKE '%{image:{$upload->randomId}%'");
$ids = [];
diff --git a/lib/previous_texts.php b/lib/previous_texts.php
index 3eb4014..c4f22c8 100644
--- a/lib/previous_texts.php
+++ b/lib/previous_texts.php
@@ -1,23 +1,8 @@
insert(PreviousText::DB_TABLE, [
'object_type' => $object_type,
@@ -27,7 +12,7 @@ class previous_texts {
]);
}
- static function delete(int $object_type, int|array $object_id): void {
+ public static function delete(int $object_type, int|array $object_id): void {
$sql = "DELETE FROM ".PreviousText::DB_TABLE." WHERE object_type=? AND object_id";
$args = [$object_type];
if (is_array($object_id))
diff --git a/lib/sphinx.php b/lib/sphinx.php
new file mode 100644
index 0000000..5be2d48
--- /dev/null
+++ b/lib/sphinx.php
@@ -0,0 +1,109 @@
+ 1) {
+ $mark_count = substr_count($sql, '?');
+ $positions = array();
+ $last_pos = -1;
+ for ($i = 0; $i < $mark_count; $i++) {
+ $last_pos = strpos($sql, '?', $last_pos + 1);
+ $positions[] = $last_pos;
+ }
+ for ($i = $mark_count - 1; $i >= 0; $i--) {
+ $arg = func_get_arg($i + 1);
+ if (is_string($arg))
+ $arg = self::normalize($arg);
+ $v = '\''.$link->real_escape_string($arg).'\'';
+ $sql = substr_replace($sql, $v, $positions[$i], 1);
+ }
+ }
+
+ $q = $link->query($sql);
+
+ $error = self::getError();
+ if ($error)
+ logError(__FUNCTION__, $error);
+
+ return $q;
+ }
+
+ public static function getError() {
+ $link = self::getLink(auto_create: false);
+ return $link?->error;
+ }
+
+ public static function mkquery($q, array $opts = []) {
+ $defaults = [
+ 'any_word' => false,
+ 'star' => false,
+ 'and' => false,
+ 'exact_first' => false
+ ];
+ $opts = array_merge($defaults, $opts);
+ $q = preg_replace('/\s+/', ' ', $q);
+ $q = self::normalize($q);
+ $q = trim($q);
+ $q = self::getClient()->escapeString($q);
+ if ($opts['star']) {
+ $words = explode(' ', $q);
+ $words = array_map(fn($word) => $word.'*', $words);
+ $q = implode(' ', $words);
+ }
+ if ($opts['any_word']) {
+ $q = str_replace(' ', ' | ', $q);
+ } else if ($opts['and']) {
+ $q = str_replace(' ', ' AND ', $q);
+ }
+ if ($opts['exact_first']) {
+ $q = '"^'.$q.'$" | "'.$q.'" | ('.$q.')';
+ }
+ return $q;
+ }
+
+ public static function getClient(): Sphinx\SphinxClient {
+ static $cl = null;
+ if (!is_null($cl))
+ return $cl;
+ return $cl = new Sphinx\SphinxClient;
+ }
+
+ protected static function normalize(string $origstr): string {
+ $buf = preg_replace('/[Ёё]/iu', 'е', $origstr);
+ if (!pcreNoError($buf, no_error: true)) {
+ $origstr = mb_convert_encoding($origstr, 'UTF-8', 'UTF-8');
+ $buf = preg_replace('/[Ёё]/iu', 'е', $origstr);
+ pcreNoError($buf);
+ }
+ if ($buf === null) {
+ logError(__METHOD__.': preg_replace() failed with error: '.preg_last_error().': '.preg_last_error_msg());
+ $buf = $origstr;
+ }
+ return preg_replace('/[!\?]/', '', $buf);
+ }
+
+ protected static function getLink($auto_create = true) {
+ global $config;
+
+ /** @var ?mysqli $link */
+ static $link = null;
+ if (!is_null($link) || !$auto_create)
+ return $link;
+
+ $link = new mysqli();
+ $link->real_connect(
+ $config['sphinx']['host'],
+ ini_get('mysql.default_user'),
+ ini_get('mysql.default_password'),
+ null,
+ 9306);
+ $link->set_charset('utf8');
+
+ return $link;
+ }
+
+
+}
\ No newline at end of file
diff --git a/lib/themes.php b/lib/themes.php
index dabe7aa..07a025a 100644
--- a/lib/themes.php
+++ b/lib/themes.php
@@ -1,45 +1,50 @@
[
- 'bg' => 0x222222,
- // 'alpha' => 0x303132,
- 'alpha' => 0x222222,
- ],
- 'light' => [
- 'bg' => 0xffffff,
- // 'alpha' => 0xf2f2f2,
- 'alpha' => 0xffffff,
- ]
-];
+class themes {
+ const array COLORS = [
+ 'dark' => [
+ 'bg' => 0x222222,
+ // 'alpha' => 0x303132,
+ 'alpha' => 0x222222,
+ ],
+ 'light' => [
+ 'bg' => 0xffffff,
+ // 'alpha' => 0xf2f2f2,
+ 'alpha' => 0xffffff,
+ ]
+ ];
-function getThemes(): array {
- return array_keys(THEMES);
-}
+ public static function getThemes(): array {
+ return array_keys(self::COLORS);
+ }
-function themeExists(string $name): bool {
- return array_key_exists($name, THEMES);
-}
+ public static function themeExists(string $name): bool {
+ return array_key_exists($name, self::COLORS);
+ }
-function getThemeAlphaColorAsRGB(string $name): array {
- $color = THEMES[$name]['alpha'];
- $r = ($color >> 16) & 0xff;
- $g = ($color >> 8) & 0xff;
- $b = $color & 0xff;
- return [$r, $g, $b];
-}
+ public static function getThemeAlphaColorAsRGB(string $name): array {
+ $color = self::COLORS[$name]['alpha'];
+ $r = ($color >> 16) & 0xff;
+ $g = ($color >> 8) & 0xff;
+ $b = $color & 0xff;
+ return [$r, $g, $b];
+ }
-function getUserTheme(): string {
- if (isset($_COOKIE['theme'])) {
- $val = $_COOKIE['theme'];
- if (is_array($val))
- $val = implode($val);
- } else
- $val = 'auto';
- return $val;
-}
+ public static function getUserTheme(): string {
+ if (isset($_COOKIE['theme'])) {
+ $val = $_COOKIE['theme'];
+ if (is_array($val))
+ $val = implode($val);
+ if ($val != 'auto' && !self::themeExists($val))
+ $val = 'auto';
+ } else
+ $val = 'auto';
+ return $val;
+ }
+
+ public static function isUserSystemThemeDark(): bool {
+ return ($_COOKIE['theme-system-value'] ?? '') === 'dark';
+ }
-function isUserSystemThemeDark(): bool {
- return ($_COOKIE['theme-system-value'] ?? '') === 'dark';
}
\ No newline at end of file
diff --git a/lib/uploads.php b/lib/uploads.php
index d698b4b..01fc0a0 100644
--- a/lib/uploads.php
+++ b/lib/uploads.php
@@ -1,38 +1,36 @@
result($db->query("SELECT COUNT(*) FROM uploads"));
}
- static function isExtensionAllowed(string $ext): bool {
- return in_array($ext, UPLOADS_ALLOWED_EXTENSIONS);
+ public static function isExtensionAllowed(string $ext): bool {
+ return in_array($ext, self::ALLOWED_EXTENSIONS);
}
- static function add(string $tmp_name,
+ public static function add(string $tmp_name,
string $name,
string $note_en = '',
string $note_ru = '',
string $source_url = ''): ?int {
global $config;
- $name = sanitize_filename($name);
+ $name = sanitizeFilename($name);
if (!$name)
$name = 'file';
$random_id = self::_getNewUploadRandomId();
$size = filesize($tmp_name);
- $is_image = detect_image_type($tmp_name) !== false;
+ $is_image = detectImageType($tmp_name) !== false;
$image_w = 0;
$image_h = 0;
if ($is_image) {
@@ -70,7 +68,7 @@ class uploads {
return $id;
}
- static function delete(int $id): bool {
+ public static function delete(int $id): bool {
$upload = self::get($id);
if (!$upload)
return false;
@@ -85,13 +83,13 @@ class uploads {
/**
* @return Upload[]
*/
- static function getAllUploads(): array {
+ public static function getAllUploads(): array {
$db = DB();
$q = $db->query("SELECT * FROM uploads ORDER BY id DESC");
return array_map('Upload::create_instance', $db->fetchAll($q));
}
- static function get(int $id): ?Upload {
+ public static function get(int $id): ?Upload {
$db = DB();
$q = $db->query("SELECT * FROM uploads WHERE id=?", $id);
if ($db->numRows($q)) {
@@ -106,7 +104,7 @@ class uploads {
* @param bool $flat
* @return Upload[]
*/
- static function getUploadsByRandomId(array $ids, bool $flat = false): array {
+ public static function getUploadsByRandomId(array $ids, bool $flat = false): array {
if (empty($ids)) {
return [];
}
@@ -132,7 +130,7 @@ class uploads {
return $uploads;
}
- static function getUploadByRandomId(string $random_id): ?Upload {
+ public static function getUploadByRandomId(string $random_id): ?Upload {
$db = DB();
$q = $db->query("SELECT * FROM uploads WHERE random_id=? LIMIT 1", $random_id);
if ($db->numRows($q)) {
@@ -142,7 +140,7 @@ class uploads {
}
}
- static function getUploadBySourceUrl(string $source_url): ?Upload {
+ public static function getUploadBySourceUrl(string $source_url): ?Upload {
$db = DB();
$q = $db->query("SELECT * FROM uploads WHERE source_url=? LIMIT 1", $source_url);
if ($db->numRows($q)) {
@@ -152,7 +150,7 @@ class uploads {
}
}
- static function _getNewUploadRandomId(): string {
+ public static function _getNewUploadRandomId(): string {
$db = DB();
do {
$random_id = strgen(8);
@@ -161,168 +159,3 @@ class uploads {
}
}
-
-
-class Upload extends model {
-
- const DB_TABLE = 'uploads';
-
- public static array $ImageExtensions = ['jpg', 'jpeg', 'png', 'gif'];
- public static array $VideoExtensions = ['mp4', 'ogg'];
-
- public int $id;
- public string $randomId;
- public int $ts;
- public string $name;
- public int $size;
- public int $downloads;
- public int $image; // TODO: remove
- public int $imageW;
- public int $imageH;
- public string $noteRu;
- public string $noteEn;
- public string $sourceUrl;
-
- function getDirectory(): string {
- global $config;
- return $config['uploads_dir'].'/'.$this->randomId;
- }
-
- function getDirectUrl(): string {
- global $config;
- return $config['uploads_path'].'/'.$this->randomId.'/'.$this->name;
- }
-
- function getDirectPreviewUrl(int $w, int $h, bool $retina = false): string {
- global $config;
- if ($w == $this->imageW && $this->imageH == $h)
- return $this->getDirectUrl();
-
- if ($retina) {
- $w *= 2;
- $h *= 2;
- }
-
- $prefix = $this->imageMayHaveAlphaChannel() ? 'a' : 'p';
- return $config['uploads_path'].'/'.$this->randomId.'/'.$prefix.$w.'x'.$h.'.jpg';
- }
-
- // TODO remove?
- function incrementDownloads() {
- $db = DB();
- $db->query("UPDATE uploads SET downloads=downloads+1 WHERE id=?", $this->id);
- $this->downloads++;
- }
-
- function getSize(): string {
- return sizeString($this->size);
- }
-
- function getMarkdown(?string $options = null): string {
- if ($this->isImage()) {
- $md = '{image:'.$this->randomId.',w='.$this->imageW.',h='.$this->imageH.($options ? ','.$options : '').'}{/image}';
- } else if ($this->isVideo()) {
- $md = '{video:'.$this->randomId.($options ? ','.$options : '').'}{/video}';
- } else {
- $md = '{fileAttach:'.$this->randomId.($options ? ','.$options : '').'}{/fileAttach}';
- }
- $md .= ' ';
- return $md;
- }
-
- function setNote(PostLanguage $lang, string $note) {
- $db = DB();
- $db->query("UPDATE uploads SET note_{$lang->value}=? WHERE id=?", $note, $this->id);
- }
-
- function isImage(): bool {
- return in_array(extension($this->name), self::$ImageExtensions);
- }
-
- // assume all png images have alpha channel
- // i know this is wrong, but anyway
- function imageMayHaveAlphaChannel(): bool {
- return strtolower(extension($this->name)) == 'png';
- }
-
- function isVideo(): bool {
- return in_array(extension($this->name), self::$VideoExtensions);
- }
-
- function getImageRatio(): float {
- return $this->imageW / $this->imageH;
- }
-
- function getImagePreviewSize(?int $w = null, ?int $h = null): array {
- if (is_null($w) && is_null($h))
- throw new Exception(__METHOD__.': both width and height can\'t be null');
-
- if (is_null($h))
- $h = round($w / $this->getImageRatio());
-
- if (is_null($w))
- $w = round($h * $this->getImageRatio());
-
- return [$w, $h];
- }
-
- function createImagePreview(?int $w = null,
- ?int $h = null,
- bool $force_update = false,
- bool $may_have_alpha = false): bool {
- global $config;
-
- $orig = $config['uploads_dir'].'/'.$this->randomId.'/'.$this->name;
- $updated = false;
-
- foreach (getThemes() as $theme) {
- if (!$may_have_alpha && $theme == 'dark')
- continue;
-
- for ($mult = 1; $mult <= 2; $mult++) {
- $dw = $w * $mult;
- $dh = $h * $mult;
-
- $prefix = $may_have_alpha ? 'a' : 'p';
- $dst = $config['uploads_dir'].'/'.$this->randomId.'/'.$prefix.$dw.'x'.$dh.($theme == 'dark' ? '_dark' : '').'.jpg';
-
- if (file_exists($dst)) {
- if (!$force_update)
- continue;
- unlink($dst);
- }
-
- $img = imageopen($orig);
- imageresize($img, $dw, $dh, getThemeAlphaColorAsRGB($theme));
- imagejpeg($img, $dst, $mult == 1 ? 93 : 67);
- imagedestroy($img);
-
- setperm($dst);
- $updated = true;
- }
- }
-
- return $updated;
- }
-
- /**
- * @return int Number of deleted files
- */
- function deleteAllImagePreviews(): int {
- global $config;
- $dir = $config['uploads_dir'].'/'.$this->randomId;
- $files = scandir($dir);
- $deleted = 0;
- foreach ($files as $f) {
- if (preg_match('/^[ap](\d+)x(\d+)(?:_dark)?\.jpg$/', $f)) {
- if (is_file($dir.'/'.$f))
- unlink($dir.'/'.$f);
- else
- logError(__METHOD__.': '.$dir.'/'.$f.' is not a file!');
- $deleted++;
- }
- }
- return $deleted;
- }
-
-}
\ No newline at end of file
diff --git a/routes.php b/routes.php
index c4ae38c..656b5d6 100644
--- a/routes.php
+++ b/routes.php
@@ -1,5 +1,8 @@
-
-
-
-
-
-
-
-
-HTML;
-
-$js = <<
- Authorized as {$admin_login} | Sign out
-
- Uploads
- {$ctx->lang('admin_errors')}
- {$ctx->lang('admin_auth_log')}
- {$ctx->lang('admin_actions_log')}
-
-HTML;
-}
-
-
-// uploads page
-// ------------
-
-function uploads($ctx, $uploads, $error, array $langs) {
-return <<if_true($error, $ctx->formError, $error)}
-
-{$ctx->bc([
- ['text' => $ctx->lang('admin_title'), 'url' => '/admin/'],
- ['text' => $ctx->lang('blog_uploads')],
-])}
-
-
-
-
- {$ctx->for_each($uploads, fn($u) => $ctx->uploads_item(
- id: $u->id,
- name: $u->name,
- direct_url: $u->getDirectUrl(),
- note_ru: $u->noteRu,
- note_en: $u->noteEn,
- markdown: $u->getMarkdown(),
- size: $u->getSize(),
- ))}
-
-HTML;
-}
-
-function uploads_form_note_field($ctx, PostLanguage $lang) {
-$label = $ctx->lang('blog_upload_form_note');
-$label .= ' ('.$lang->name.')';
-
-return <<
- {$label}:
-
-
-
-
-HTML;
-}
-
-function uploads_item($ctx, $id, $direct_url, $note_en, $note_ru, $markdown, $name, $size) {
-$fix_nl_re = '/(\r)?\n/';
-$as_note_ru = jsonEncode(preg_replace($fix_nl_re, '\n', addslashes($note_ru)));
-$as_note_en = jsonEncode(preg_replace($fix_nl_re, '\n', addslashes($note_en)));
-return <<
-
-
- {$size}
- {$ctx->if_true($note_en,
- fn() => 'En '.$note_en.'
')}
- {$ctx->if_true($note_ru,
- fn() => 'Ru '.$note_ru.'
')}
-
-
-
-
-HTML;
-}
-
-function postForm(\SkinContext $ctx,
- string|Stringable $title,
- string|Stringable $text,
- string|Stringable $short_name,
- string|Stringable $source_url,
- string|Stringable $keywords,
- array $langs,
- array $js_texts,
- string|Stringable|null $date = null,
- bool $is_edit = false,
- ?bool $saved = null,
- ?bool $visible = null,
- ?bool $toc = null,
- string|Stringable|null $post_url = null,
- ?int $post_id = null,
- ?string $lang = null): array {
-
-$form_url = !$is_edit ? '/articles/write/' : $post_url.'edit/';
-
-// breadcrumbs
-$bc_tree = [
- ['url' => '/articles/?lang='.$lang, 'text' => $ctx->lang('articles')]
-];
-if ($is_edit) {
- $bc_tree[] = ['url' => $post_url.'?lang='.$lang, 'text' => $ctx->lang('blog_view_post')];
-} else {
- $bc_tree[] = ['text' => $ctx->lang('blog_new_post')];
-}
-
-$html = <<
-{$ctx->if_true($saved, fn() => ''.$ctx->lang('info_saved').'
')}
-{$ctx->bc($bc_tree, 'padding-bottom: 12px')}
-
-HTML;
-
-$js_params = [
- 'langs' => array_map(fn($lang) => $lang->value, $langs),
- 'token' => $is_edit ? csrf_get('editpost'.$post_id) : csrf_get('post_add')
-];
-if ($is_edit)
- $js_params += [
- 'edit' => true,
- 'id' => $post_id,
- 'texts' => $js_texts
- ];
-$js_params = jsonEncode($js_params);
-
-$js = <<bc([
- ['url' => '/'.$short_name.'/', 'text' => $ctx->lang('view_page')]
- ], 'padding-bottom: 12px');
-} else {
- $bc_html = '';
-}
-
-$html = <<
-{$ctx->if_true($saved, fn() => ''.$ctx->lang('info_saved').'
')}
-{$bc_html}
-
-
-
-
-
-
-
-
- {$ctx->if_then_else($is_edit,
- fn() => $ctx->pageFormEditOptions($short_name, $parent, $visible, $render_title),
- fn() => $ctx->pageFormAddOptions($short_name))}
-
-
-
-
-
-
-
-
-
-HTML;
-
-$js_params = [
- 'pages' => true,
- 'edit' => $is_edit,
- 'token' => $is_edit ? $ctx->csrf('editpage'.$short_name) : $ctx->csrf('addpage'),
- 'langs' => array_map(fn($lang) => $lang->value, $langs), // still needed for draft erasing
-];
-if ($js_text !== null)
- $js_params['text'] = $js_text;
-$js_params = jsonEncode($js_params);
-
-$js = <<
-
-
-
-
-
{$ctx->lang('pages_write_form_short_name')}
-
-
-
-
-
-
-
-
{$ctx->lang('pages_write_form_parent')}
-
-
-
-
-
-
-
-
-
-
-
- {$ctx->lang('pages_write_form_submit_btn')}
-
-
-
-
-HTML;
-}
-
-// TODO: add visible and reader_title checkbox here
-function pageFormAddOptions($ctx, $short_name) {
-return <<
-
-
- {$ctx->lang('pages_write_form_submit_btn')}
-
-
-
-HTML;
-}
-
-function pageNew($ctx, $short_name) {
-return <<
-
-
-HTML;
-
-}
-
-// misc
-function formError($ctx, $error) {
-return <<{$ctx->lang('error')}: {$error}
-HTML;
-}
-
-function markdownPreview($ctx, $unsafe_html, $title) {
-return <<
- {$ctx->if_true($title, '
'.$title.' ')}
- {$unsafe_html}
-
-HTML;
-}
-
-
-
-
-// ----------------------------------------------------
-// ---------------------- ERRORS ----------------------
-// ----------------------------------------------------
-
-
-function errors($ctx,
- array $list,
- int $count,
- int $pn_page,
- int $pn_pages,
- string $url,
- ?string $ip_filter = null,
- ?int $ip = null,
- ?string $query = null,
- ?string $url_query = null,
- ?string $file_query = null,
- ?string $line_query = null) {
-return <<bc([
- ['text' => $ctx->lang('admin_title'), 'url' => '/admin/'],
- ['text' => $ctx->lang('admin_errors')],
-])}
-
-
- {$ctx->if_true($ip, fn() => ' ')}
-
-
-
-
-
-
-
-{$ctx->if_then_else(!empty($list),
- fn() => $ctx->errors_table($list),
- fn() => 'Error log is empty.
')}
-
-{$ctx->pagenav($pn_page, $pn_pages, $url.'page={page}')}
-HTML;
-}
-
-function errors_table($ctx,
- array $list) {
-return <<
-
-
- Time
- Source
- Error
-
-
-
- {$ctx->for_each($list, fn($item) => $ctx->errors_table_item(
- date: $item['date'],
- is_cli: (bool)$item['is_cli'],
- is_custom: (bool)$item['custom'],
- user_agent: $item['ua'],
- ip: (int)$item['ip'],
- ip_s: $item['ip_s'],
- full_url: $item['full_url'],
- url: $item['url'],
- file: $item['file'],
- line: (int)$item['line'],
- admin_id: (int)$item['admin_id'],
- nl2br_text: $item['text'],
- num: (int)$item['num'],
- errtype: $item['errtype'] ?? null,
- time: $item['time'],
- stacktrace: $item['stacktrace'],
- item_id: (int)$item['id']
- ))}
-
-
-HTML;
-}
-
-function errors_table_item($ctx,
- int $item_id,
- string $date,
- bool $is_cli,
- bool $is_custom,
- string $user_agent,
- int $ip,
- string $ip_s,
- string $full_url,
- string $url,
- string $file,
- int $line,
- int $admin_id,
- string $nl2br_text,
- int $num,
- ?string $errtype,
- string $time,
- string $stacktrace) {
-return <<
-
- {$date}
-
-
- {$ctx->if_then_else(!$is_cli,
- fn() => ''.$ip_s.' '.$url.' '.$user_agent,
- fn() => 'cmd ')}
-
-
-
- {$ctx->if_true($admin_id,
- fn() => 'admin='.$admin_id.' ')}
- {$ctx->if_then_else($is_custom,
- fn() => ''.$num.', '.$time.' '.$file.' :'.$line.' '
- .''.$errtype.' '.$nl2br_text,
- fn() => ''.$num.', '.$time.' '.$nl2br_text)}
-
- {$ctx->if_true($stacktrace,
- fn() => $ctx->errors_table_item_stacktrace($item_id, $stacktrace))}
-
-
-HTML;
-}
-
-function errors_table_item_stacktrace($ctx, $item_id, $nl2br_stacktrace) {
-return <<
- Show/hide stacktrace
- {$nl2br_stacktrace}
-
-HTML;
-}
-
-
-// ------------------------------------------------------
-// ---------------------- AUTH LOG ----------------------
-// ------------------------------------------------------
-
-function auth_log($ctx, array $list, int $pn_page, int $pn_pages) {
-return <<bc([
- ['text' => $ctx->lang('admin_title'), 'url' => '/admin/'],
- ['text' => $ctx->lang('admin_auth_log')],
-])}
-
-{$ctx->if_then_else(!empty($list),
- fn() => $ctx->auth_log_table($list),
- fn() => 'Auth log is empty.
')}
-
-{$ctx->pagenav($pn_page, $pn_pages, '/admin/auth-log/?page={page}')}
-HTML;
-}
-
-function auth_log_table($ctx, array $list) {
-return <<
-
-
- Admin
- Time
- IP
- User-Agent
-
-
-
- {$ctx->for_each($list,
- fn($item) => $ctx->auth_log_table_item(
- date: $item['date'],
- ip: $item['ip'],
- user_agent: $item['ua'],
- admin_login: $item['login'],
- admin_id: (int)$item['admin_id'],
- activity_ts: $item['activity_ts_s']))}
-
-
-HTML;
-}
-
-function auth_log_table_item($ctx, $date, $ip, $user_agent, string $admin_login, int $admin_id, string $activity_ts) {
-return <<
-
- {$admin_login} (id={$admin_id})
-
- {$date}
- {$ip}
- {$user_agent}
-
-HTML;
-}
-
-
-// ---------------------------------------------------------
-// ---------------------- ACTIONS LOG ----------------------
-// ---------------------------------------------------------
-
-function actions_log($ctx,
- array $list,
- array $admin_logins,
- string $url,
- array $action_types,
- int $pn_page,
- int $pn_pages) {
- return <<bc([
- ['text' => $ctx->lang('admin_title'), 'url' => '/admin/'],
- ['text' => $ctx->lang('admin_actions_log')],
- ])}
-
-{$ctx->if_then_else(!empty($list),
- fn() => $ctx->actions_log_table($list, $admin_logins, $action_types),
- fn() => 'Actions log is empty.
')}
-
-{$ctx->pagenav($pn_page, $pn_pages, $url.'page={page}')}
-HTML;
-}
-
-function actions_log_table($ctx, array $list, array $admin_logins, array $action_types) {
- return <<
-
-
- Time
- Who
- Action
- Data
-
-
-
- {$ctx->for_each($list,
- fn(\AdminActions\BaseAction $item) => $ctx->actions_log_table_item(
- date: $item->getDate(),
- ip: $item->getIPv4(),
- is_cli: $item->isCommandLineAction(),
- admin_login: $admin_logins[$item->getAdminId()],
- action_name: $item->getActionName(),
- unsafe_data: $item->renderHtml()))}
-
-
-HTML;
-}
-
-function actions_log_table_item($ctx,
- string $date,
- string $ip,
- bool $is_cli,
- string $admin_login,
- string $action_name,
- string $unsafe_data) {
-return <<
- {$date}
-
- {$ctx->if_then_else(!$is_cli,
- fn() => $admin_login.', '.$ip,
- fn() => 'console')}
-
- {$action_name}
- {$unsafe_data}
-
-HTML;
-}
diff --git a/skin/admin_actions_log.twig b/skin/admin_actions_log.twig
new file mode 100644
index 0000000..ad22c2b
--- /dev/null
+++ b/skin/admin_actions_log.twig
@@ -0,0 +1,37 @@
+{{ bc([
+ {text: "admin_title"|lang, url: '/admin/'},
+ {text: "admin_actions_log"|lang}
+]) }}
+
+{% if list %}
+
+
+
+ Time
+ Who
+ Action
+ Data
+
+
+
+ {% for item in list %}
+
+ {{ item.getDate() }}
+
+ {% if item.isCommandLineActions() %}
+ {{ admin_logs[item.getAdminId()] }}, {{ item.getIPv4() }}
+ {% else %}
+ console
+ {% endif %}
+
+ {{ item.getActionName() }}
+ {{ item.renderHtml()|raw }}
+
+ {% endfor %}
+
+
+
+ {{ pageNav(pn_page, pn_pages, "#{url}page={page}") }}
+{% else %}
+ Actions log is empty.
+{% endif %}
\ No newline at end of file
diff --git a/skin/admin_auth_log.twig b/skin/admin_auth_log.twig
new file mode 100644
index 0000000..c056985
--- /dev/null
+++ b/skin/admin_auth_log.twig
@@ -0,0 +1,33 @@
+{{ bc([
+ {text: "admin_title"|lang, url: '/admin/'},
+ {text: "admin_auth_log"|lang}
+]) }}
+
+{% if list %}
+
+
+
+ Admin
+ Time
+ IP
+ User-Agent
+
+
+
+ {% for item in list %}
+
+
+ {{ item.login }} (id={{ item.admin_id }})
+
+ {{ item.date }}
+ {{ item.ip }}
+ {{ item.ua }}
+
+ {% endfor %}
+
+
+
+ {{ pageNav(pn_page, pn_pages, "/admin/auth-log/?page={page}") }}
+{% else %}
+ Auth log is empty.
+{% endif %}
\ No newline at end of file
diff --git a/skin/admin_errors.twig b/skin/admin_errors.twig
new file mode 100644
index 0000000..8e2ee91
--- /dev/null
+++ b/skin/admin_errors.twig
@@ -0,0 +1,69 @@
+{{ bc([
+ {text: "admin_title"|lang, url: '/admin/'},
+ {text: "admin_errors"|lang}
+]) }}
+
+{% if list %}
+
+ {% if ip %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+ Time
+ Source
+ Error
+
+
+
+ {% for item in list %}
+
+ {{ item.date }}
+
+ {% if item.is_cli %}
+ cmd
+ {% else %}
+
+ {{ item.ip_s }}
+ {{ item.url }}
+ {{ item.ua }}
+ {% endif %}
+
+
+ {% if item.admin_id %}
+ admin={{ item.admin_id }}
+ {% endif %}
+
+ {% if item.custom %}
+ {{ item.num }}, {{ item.time }} {{ item.file }} :{{ item.line }}
+ {{ item.errtype }} {{ item.text|nl2br }}
+ {% else %}
+ {{ item.num }}, {{ item.time }} {{ item.text|nl2br }}
+ {% endif %}
+
+ {% if item.stacktrace %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+ {% if pn_pages > 1 %}
+ {{ pageNav(pn_page, pn_pages, url~'page={page}') }}
+ {% endif %}
+{% else %}
+ Error log is empty.
+{% endif %}
diff --git a/skin/admin_index.twig b/skin/admin_index.twig
new file mode 100644
index 0000000..ac16fb8
--- /dev/null
+++ b/skin/admin_index.twig
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/skin/admin_login.twig b/skin/admin_login.twig
new file mode 100644
index 0000000..37399b7
--- /dev/null
+++ b/skin/admin_login.twig
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+{% js %}
+document.forms.admin_login.login.focus();
+{% endjs %}
\ No newline at end of file
diff --git a/skin/admin_page_form.twig b/skin/admin_page_form.twig
new file mode 100644
index 0000000..8b200bf
--- /dev/null
+++ b/skin/admin_page_form.twig
@@ -0,0 +1,104 @@
+
+
+{% if saved %}
+ {{ "info_saved"|lang }}
+{% endif %}
+
+{% if is_edit %}
+ {{ bc([
+ {url: "/#{short_name}/", text: "view_page"|lang},
+ ], 'padding-bottom: 12px') }}
+{% endif %}
+
+
+
+
+
+
+
+
+
+ {% if is_edit %}
+
+ {% else %}
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+{% js %}
+cur.form = new AdminWriteEditForm({{ js_params|json_encode|raw }});
+{% endjs %}
\ No newline at end of file
diff --git a/skin/admin_page_new.twig b/skin/admin_page_new.twig
new file mode 100644
index 0000000..430996d
--- /dev/null
+++ b/skin/admin_page_new.twig
@@ -0,0 +1,5 @@
+
diff --git a/skin/admin_post_form.twig b/skin/admin_post_form.twig
new file mode 100644
index 0000000..a86a107
--- /dev/null
+++ b/skin/admin_post_form.twig
@@ -0,0 +1,112 @@
+
+
+{% if saved %}
+ {{ "info_saved"|lang }}
+{% endif %}
+
+{{ bc(bc) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% js %}
+cur.form = new AdminWriteEditForm({{ js_params|json_encode|raw }});
+{% endjs %}
\ No newline at end of file
diff --git a/skin/admin_uploads.twig b/skin/admin_uploads.twig
new file mode 100644
index 0000000..8445114
--- /dev/null
+++ b/skin/admin_uploads.twig
@@ -0,0 +1,72 @@
+{% if error %}
+ {{ "error"|lang }}: {{ error }}
+{% endif %}
+
+{{ bc([
+ {text: "admin_title"|lang, url: '/admin/'},
+ {text: "blog_uploads"|lang}
+]) }}
+
+
+
+
+
+
+
+
+
+ {% for l in langs %}
+
+ {% endfor %}
+
+
+
+
+
+
+ {% for item in uploads %}
+
+
+
+
+
{{ item.getSize() }}
+
+ {% if item.noteEn %}
+
En {{ item.noteEn }}
+ {% endif %}
+
+ {% if item.noteRu %}
+
Ru {{ item.noteRu }}
+ {% endif %}
+
+
+
+
+
+ {% endfor %}
+
\ No newline at end of file
diff --git a/skin/articles.twig b/skin/articles.twig
new file mode 100644
index 0000000..b0ee01b
--- /dev/null
+++ b/skin/articles.twig
@@ -0,0 +1,30 @@
+{% if not posts %}
+
+ {{ "blog_no"|lang }}
+ {% include 'articles_right_links.twig' %}
+
+{% else %}
+
+ {{ ("blog_expl_"~selected_lang)|lang|nl2br }}
+
+
+
+ {% set year = 3000 %}
+ {% for post in posts %}
+ {% if year > post.getYear() %}
+
+ {{ post.getYear() }}
+ {% if loop.index == 1 %}
+ {% include 'articles_right_links.twig' %}
+ {% endif %}
+
+ {% set year = post.getYear() %}
+ {% endif %}
+
+ {{ post.getDate() }}
+ {{ post.getText(selected_lang).title }}
+
+ {% endfor %}
+
+
+{% endif %}
\ No newline at end of file
diff --git a/skin/articles_right_links.twig b/skin/articles_right_links.twig
new file mode 100644
index 0000000..4a3cb4f
--- /dev/null
+++ b/skin/articles_right_links.twig
@@ -0,0 +1,24 @@
+
+{% set links = [
+ {url: selected_lang != 'en' ? '/articles/' : null, label: "lang_en"|lang},
+ {url: selected_lang != 'ru' ? '/articles/?lang=ru' : null, label: "lang_ru"|lang},
+] %}
+{% if is_admin %}
+ {% set links = links|merge([{
+ url: '/articles/write/?lang='~selected_lang,
+ label: "write"|lang
+ }]) %}
+{% endif %}
+
+
+ {% for link in links %}
+ {% if loop.index > 1 %}
+
|
+ {% endif %}
+ {% if link.url %}
+
{{ link.label }}
+ {% else %}
+ {{ link.label }}
+ {% endif %}
+ {% endfor %}
+
diff --git a/skin/base.phps b/skin/base.phps
deleted file mode 100644
index 7d9d985..0000000
--- a/skin/base.phps
+++ /dev/null
@@ -1,305 +0,0 @@
- $config['domain'],
- 'devMode' => $config['is_dev'],
- 'cookieHost' => $config['cookie_host'],
-]);
-
-$body_class = [];
-if ($opts['full_width'])
- $body_class[] = 'full-width';
-else if ($opts['wide'])
- $body_class[] = 'wide';
-
-return <<
-
-
-
-
-
-
-
- {$title}
-
- {$ctx->meta($meta)}
- {$ctx->renderStatic($static, $theme, $is_system_theme_dark)}
-
-if_true($body_class, ' class="'.implode(' ', $body_class).'"')}>
- {$ctx->if_true($svg_defs, fn() => $ctx->renderSVGIcons($svg_defs))}
-
- {$ctx->renderHeader($theme, $opts['head_section'], $opts['articles_lang'], $opts['is_index'])}
-
{$unsafe_body}
- {$ctx->if_not($opts['full_width'], fn() => $ctx->renderFooter($admin_email))}
-
- {$ctx->renderScript($js, $unsafe_lang)}
- {$ctx->if_not($opts['inside_admin_interface'], fn() => $ctx->render_external_counters())}
-
-
-
-{$ctx->if_admin(fn() => "")}
-HTML;
-}
-
-function render_external_counters($ctx) {
-return <<
-
-
-
-HTML;
-
-}
-
-function renderSVGIcons($ctx, $svg_defs) {
- $buf = '';
- foreach ($svg_defs as $name => $icon) {
- $buf .= <<
- {$icon['svg']}
-
-SVG;
- }
- $buf .= ' ';
- return $buf;
-}
-
-function renderScript($ctx, $unsafe_js, $unsafe_lang) {
-global $config;
-
-$styles = jsonEncode($ctx->styleNames);
-if ($config['is_dev'])
- $versions = '{}';
-else {
- $versions = [];
- foreach ($config['static'] as $name => $v) {
- list($type, $bname) = getStaticNameParts($name);
- $versions[$type][$bname] = $v;
- }
- $versions = jsonEncode($versions);
-}
-
-return <<
-StaticManager.init({$styles}, {$versions});
-{$ctx->if_true($unsafe_js, '(function(){try{'.$unsafe_js.'}catch(e){window.console&&console.error("caught exception:",e)}})();')}
-{$ctx->if_true($unsafe_lang, 'extend(__lang, '.$unsafe_lang.');')}
-ThemeSwitcher.init();
-
-HTML;
-}
-
-function meta($ctx, $meta) {
- if (empty($meta))
- return '';
- return implode('', array_map(function(array $item): string {
- $s = ' $v)
- $s .= ' '.htmlescape($k).'="'.htmlescape($v).'"';
- $s .= '/>';
- $s .= "\n";
- return $s;
- }, $meta));
-}
-
-function renderStatic($ctx, $static, $theme, $is_system_theme_dark) {
- global $config;
- $html = [];
- $dark = $theme == 'dark' || $is_system_theme_dark;
- $ctx->styleNames = [];
- foreach ($static as $name) {
- // javascript
- if (str_starts_with($name, 'js/'))
- $html[] = jsLink($name);
-
- // css
- else if (str_starts_with($name, 'css/')) {
- $html[] = cssLink($name, 'light', $style_name);
- $ctx->styleNames[] = $style_name;
-
- if ($dark)
- $html[] = cssLink($name, 'dark', $style_name);
- else if (!$config['is_dev'])
- $html[] = cssPrefetchLink($style_name.'_dark');
- }
- else
- logError(__FUNCTION__.': unexpected static entry: '.$name);
- }
- return implode("\n", $html);
-}
-
-function jsLink(string $name): string {
- global $config;
- list (, $bname) = getStaticNameParts($name);
- if ($config['is_dev']) {
- $href = '/js.php?name='.urlencode($bname).'&v='.time();
- } else {
- $href = '/dist-js/'.$bname.'.js?v='.getStaticVersion($name);
- }
- return '';
-}
-
-function cssLink(string $name, string $theme, &$bname = null): string {
- list(, $bname) = getStaticNameParts($name);
-
- $config_name = 'css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css';
-
- if (is_dev()) {
- $href = '/sass.php?name='.urlencode($bname).'&theme='.$theme.'&v='.time();
- } else {
- $version = getStaticVersion($config_name);
- $href = '/dist-css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css?v='.$version;
- }
-
- $id = 'style_'.$bname;
- if ($theme == 'dark')
- $id .= '_dark';
-
- return ' ';
-}
-
-function cssPrefetchLink(string $name): string {
-$url = '/dist-css/'.$name.'.css?v='.getStaticVersion('css/'.$name.'.css');
-$integrity = getStaticIntegrityAttribute('css/'.$name.'.css');
-return <<
-HTML;
-}
-
-function getStaticNameParts(string $name): array {
- $dname = dirname($name);
- $bname = basename($name);
- if (($pos = strrpos($bname, '.'))) {
- $ext = substr($bname, $pos+1);
- $bname = substr($bname, 0, $pos);
- } else {
- $ext = '';
- }
- return [$dname, $bname, $ext];
-}
-
-function getStaticVersion(string $name): string {
- global $config;
- if ($config['is_dev'])
- return time();
- if (str_starts_with($name, '/')) {
- logWarning(__FUNCTION__.': '.$name.' starts with /');
- $name = substr($name, 1);
- }
- return $config['static'][$name]['version'] ?? 'notfound';
-}
-
-function getStaticIntegrityAttribute(string $name): string {
- if (is_dev())
- return '';
- global $config;
- return ' integrity="'.implode(' ', array_map(fn($hash_type) => $hash_type.'-'.$config['static'][$name]['integrity'][$hash_type], RESOURCE_INTEGRITY_HASHES)).'"';
-}
-
-
-function renderHeader(SkinContext $ctx,
- string $theme,
- ?string $section,
- ?string $articles_lang,
- bool $show_subtitle): string {
-global $config;
-$icons = svg();
-$items = [];
-$items[] = ['url' => '/articles/'.($articles_lang && $articles_lang != 'en' ? '?lang='.$articles_lang : ''), 'label' => 'articles', 'selected' => $section === 'articles'];
-$items[] = ['url' => '/files/', 'label' => 'files', 'selected' => $section === 'files'];
-$items[] = ['url' => '/'.$config['wiki_root'].'/', 'label' => 'wiki', 'selected' => $section === $config['wiki_root']];
-$items[] = ['url' => '/info/', 'label' => 'about', 'selected' => $section === 'about'];
-if (is_admin())
- $items[] = ['url' => '/admin/', 'label' => $icons->settings_28(in_place: true), 'type' => 'settings', 'selected' => $section === 'admin'];
-$items[] = [
- 'url' => 'javascript:void(0)',
- 'label' => $icons->moon_auto_18(in_place: true)
- .$icons->moon_light_18(in_place: true)
- .$icons->moon_dark_18(in_place: true),
- 'type' => 'theme-switcher',
- 'type_opts' => $theme
-];
-
-// here, items are rendered using for_each, so that there are no gaps (whitespaces) between tags
-
-$class = 'head';
-if (!$show_subtitle)
- $class .= ' no-subtitle';
-
-return <<
-
-
-
- {$ctx->for_each($items, fn($item) => $ctx->renderHeaderItem(
- $item['url'],
- $item['label'],
- $item['type'] ?? false,
- $item['type_opts'] ?? null,
- $item['selected'] ?? false
- ))}
-
-
-
-HTML;
-}
-
-function renderHeaderItem(SkinContext $ctx,
- string $url,
- ?Stringable $unsafe_label,
- ?string $type,
- ?string $type_opts,
- bool $selected): string {
-$args = '';
-$class = '';
-switch ($type) {
- case 'theme-switcher':
- $args = ' onclick="return ThemeSwitcher.next(event)"';
- $class = ' is-theme-switcher '.$type_opts;
- break;
- case 'settings':
- $class = ' is-settings';
- break;
-}
-if ($selected)
- $class .= ' is-selected';
-
-return <<{$unsafe_label}
-HTML;
-}
-
-function renderFooter($ctx, $admin_email): string {
-return <<
- Email: {$admin_email}
-
-HTML;
-
-}
diff --git a/skin/error.phps b/skin/error.phps
deleted file mode 100644
index cfcb1ab..0000000
--- a/skin/error.phps
+++ /dev/null
@@ -1,23 +0,0 @@
-
- $code $title
-
- $code $title
- {$ctx->if_true($message,
- ''.$message.'
'
- )}
-
-
-HTML;
-
-}
\ No newline at end of file
diff --git a/skin/error.twig b/skin/error.twig
new file mode 100644
index 0000000..934f165
--- /dev/null
+++ b/skin/error.twig
@@ -0,0 +1,9 @@
+
+{{ code }} {{ title }}
+
+{{ code }} {{ title }}
+{% if message %}
+ {{ message }}
+{% endif %}
+
+
\ No newline at end of file
diff --git a/skin/files.phps b/skin/files.phps
deleted file mode 100644
index ecaa952..0000000
--- a/skin/files.phps
+++ /dev/null
@@ -1,261 +0,0 @@
-bc([
- ['text' => $ctx->lang('files_archives')]
-])}
-
-
- {$ctx->for_each($collections,
- fn(FilesItemInterface $file) => $ctx->file(
- file: $file,
- disabled: !$file->isAvailable()))}
-
-
-{$ctx->bc([
- ['text' => $ctx->lang('files_books')]
-], mt: true)}
-
-
- {$ctx->for_each($books, fn(FilesItemInterface $file) => $ctx->file(file: $file))}
-
-
-{$ctx->bc([
- ['text' => $ctx->lang('files_misc')]
-], mt: true)}
-
-
- {$ctx->for_each($misc, fn(FilesItemInterface $file) => $ctx->file(file: $file))}
-
-HTML;
-}
-
-function folder($ctx, BookItem $folder, array $files, ?array $parents) {
-$svg = svg();
-$svg->folder_20(preload_symbol: true);
-$svg->file_20(preload_symbol: true);
-
-$bc = [
- ['text' => $ctx->lang('files'), 'url' => '/files/'],
-];
-
-if ($parents) {
- for ($i = 0; $i < count($parents)-1; $i++) {
- $parent = $parents[$i];
- $bc_item = ['text' => $parent->getTitle()];
- if ($i < count($parents)-1)
- $bc_item['url'] = $parent->getUrl();
- $bc[] = $bc_item;
- }
-}
-$bc[] = ['text' => $folder->title];
-
-return <<bc($bc)}
-
-
- {$ctx->collection_files($files)}
-
-
-HTML;
-
-}
-
-function collection(SkinContext $ctx,
- FilesCollection $collection,
- array $files,
- ?array $parents,
- int $search_results_per_page,
- int $search_min_query_length,
- ?string $search_query = null,
- ?int $search_count = null,
- ?array $text_excerpts = null) {
- $widgets = skin('widgets');
-
- $svg = svg();
- $svg->folder_20(preload_symbol: true);
- $svg->file_20(preload_symbol: true);
-
- $bc = [
- ['text' => $ctx->lang('files'), 'url' => '/files/'],
- ];
- if ($parents) {
- $bc[] = ['text' => $ctx->lang('files_'.$collection->value.'_collection_short'), 'url' => "/files/{$collection->value}/"];
- for ($i = 0; $i < count($parents); $i++) {
- $parent = $parents[$i];
- $bc_item = ['text' => $parent->getTitle()];
- if ($i < count($parents)-1)
- $bc_item['url'] = $parent->getUrl();
- $bc[] = $bc_item;
- }
- } else {
- $bc[] = ['text' => $ctx->lang('files_'.$collection->value.'_collection')];
- }
-
- $do_show_search = empty($parents);
- $do_show_more = $search_count > 0 && count($files) < $search_count;
-
- $html = <<bc($bc)}
-{$ctx->if_true($do_show_search, fn() => $ctx->collection_search($search_count, $search_query, $ctx->lang('files_'.$collection->value.'_search_ph')))}
-
-
-
- {$ctx->collection_files($files, $search_query, $text_excerpts)}
-
-
if_not($do_show_more, ' style="display: none"')}>
- {$ctx->lang('files_show_more')}
- {$widgets->spinner('files_show_more_spinner')}
-
-
-
-HTML;
-
- if ($do_show_search) {
- $opts = [
- 'container' => 'files_list',
- 'per_page' => $search_results_per_page,
- 'min_query_length' => $search_min_query_length,
- 'base_url' => "/files/{$collection->value}/",
- 'query' => $search_query,
- 'count' => $search_count,
- 'collection_name' => $collection->value,
- 'inited_with_search' => !!$search_query
- ];
- $opts = jsonEncode($opts);
-
- $js = <<for_each($files, fn(FilesItemInterface $f) => $ctx->file(
- file: $f,
- unsafe_query: $search_query,
- text_excerpts: $text_excerpts));
-}
-
-function collection_search(SkinContext $ctx, $count, $query, ?string $placeholder = null) {
- $icons = svg();
- $widgets = skin('widgets');
- $clear_dsp = $query ? 'block' : 'none';
-
- return <<
-
-
{$icons->search_20()}
-
-
{$icons->clear_16()}
-
-
-
-
-
{$widgets->spinner()}
-
{$ctx->if_then_else($query, fn() => $ctx->lang_num('files_search_results_count', $count), ' ')}
-
-
-
-HTML;
-}
-
-
-function file(SkinContext $ctx,
- FilesItemInterface $file,
- ?SkinString $unsafe_query = null,
- bool $disabled = false,
- ?array $text_excerpts = null) {
-$icons = svg();
-if ($file instanceof BookItem && $file->fileType == BookFileType::BOOK)
- $icon = $icons->book_20();
-else
- $icon = $file->isFile() ? $icons->file_20() : $icons->folder_20();
-
-$class = 'files-list-item clearfix';
-if ($disabled)
- $class .= ' is-disabled';
-
-$mapper = function($s) use ($unsafe_query) {
- if ($unsafe_query !== null) {
- return hl_matched($s, $unsafe_query);
- } else {
- return htmlescape($s);
- }
-};
-
-$title = $file->getTitleHtml();
-if ($title === null) {
- // we don't apply $mapper to already htmlescaped string
- $title = $mapper($file->getTitle());
-}
-
-$meta = $file->getMeta($unsafe_query);
-$meta_is_inline = $meta['inline'] ?? false;
-$meta_items = $meta['items'] ?? [];
-$url = htmlescape($file->getUrl());
-
-$subtitle = $file->getSubtitle();
-
-return <<if_true($file->isTargetBlank(), ' target="_blank"')}>
- {$icon}
-
-
-
{$title}
- {$ctx->if_true($file->isFolder() && $file->isTargetBlank(), fn() => '
'.$icons->arrow_up_right_out_square_outline_12().' ')}
- {$ctx->if_true($subtitle, fn() => '
'.htmlescape($subtitle).' ')}
- {$ctx->if_true($meta_is_inline, $ctx->for_each($meta_items, fn($s) => '
'.$s.'
'))}
-
- {$ctx->if_true($meta_items && !$meta_is_inline, $ctx->meta($meta_items))}
- {$ctx->if_true(is_array($text_excerpts) && isset($text_excerpts[$file->getId()]),
- fn() => $ctx->text_excerpt($text_excerpts[$file->getId()]['excerpt'], $text_excerpts[$file->getId()]['index'], $unsafe_query))}
-
-
-HTML;
-}
-
-/**
- * @param SkinContext $ctx
- * @param string[] $meta strings are already html-safe
- * @return string
- */
-function meta($ctx, array $meta) {
-return <<
- {$ctx->for_each($meta, fn($s) => ''.$s.'
')}
-
-HTML;
-}
-
-
-function text_excerpt($ctx, $unsafe_excerpt, $index, $unsafe_query) {
- if ($index > 0)
- $unsafe_excerpt = '...'.$unsafe_excerpt;
- $unsafe_excerpt .= '...';
- $text = hl_matched($unsafe_excerpt, $unsafe_query);
- return <<{$text}
-HTML;
-}
\ No newline at end of file
diff --git a/skin/files_collection.twig b/skin/files_collection.twig
new file mode 100644
index 0000000..f63c58a
--- /dev/null
+++ b/skin/files_collection.twig
@@ -0,0 +1,44 @@
+{{ svgPreload('folder_20', 'file_20') }}
+{{ bc(bc) }}
+
+{% set do_show_more = search_count > 0 and files|length < search_count %}
+{% if do_show_search %}
+ {% set placeholder = "files_#{collection}_search_ph"|lang %}
+
+
+
+
{{ svg('search_20') }}
+
+
{{ svg('clear_16') }}
+
+
+
+
+
{% include 'spinner.twig' %}
+
{% if search_query %}{{ "files_search_results_count"|plural(search_count) }}{% else %} {% endif %}
+
+
+
+{% endif %}
+
+
+
+ {% include 'files_list.twig' %}
+
+
+ {{ "files_show_more"|lang }}
+ {% include 'spinner.twig' with {spinner_id: 'files_show_more_spinner'} %}
+
+
+
+
+
+{% js %}
+{% if do_show_search %}
+cur.search = new FileSearch({{ js_params|json_encode|raw }});
+{% endif %}
+{% endjs %}
\ No newline at end of file
diff --git a/skin/files_file.twig b/skin/files_file.twig
new file mode 100644
index 0000000..a10a5d5
--- /dev/null
+++ b/skin/files_file.twig
@@ -0,0 +1,67 @@
+{% macro excerptWithHighlight(index, unsafe_excerpt, q) %}
+ {% set modified_excerpt = unsafe_excerpt %}
+ {% if index > 0 %}
+ {% set modified_excerpt = '...' ~ modified_excerpt %}
+ {% endif %}
+ {% set modified_excerpt = modified_excerpt ~ '...' %}
+ {% set text = modified_excerpt|hl(q) %}
+ {{ text|raw }}
+{% endmacro %}
+
+{% import _self as macros %}
+
+{% set subtitle = file.getSubtitle() %}
+{% set meta = file.getMeta(query) %}
+{% set title = file.getTitleHtml() %}
+{% if not title %}
+ {% set title = file.getTitle()|hl(query) %}
+{% endif %}
+
+
+
+ {% if file.isBook() %}
+ {{ svg('book_20') }}
+ {% else %}
+ {% if file.isFile() %}
+ {{ svg('file_20') }}
+ {% else %}
+ {{ svg('folder_20') }}
+ {% endif %}
+ {% endif %}
+
+
+
+
+
{{ title|raw }}
+ {% if file.isFolder() and file.isTargetBlank() %}
+
{{ svg('arrow_up_right_out_square_outline_12') }}
+ {% endif %}
+
+ {% if subtitle %}
+
{{ subtitle }}
+ {% endif %}
+
+ {% if meta.inline %}
+ {% for item in meta.items %}
+
{{ item }}
+ {% endfor %}
+ {% endif %}
+
+
+ {% if meta.items and not meta.inline %}
+
+ {% endif %}
+
+ {% if text_excerpts[file.getId()] %}
+ {{ macros.excerptWithHighlight(text_excerpts[file.getId()]['index'], text_excerpts[file.getId()]['excerpt'], query) }}
+ {% endif %}
+
+
\ No newline at end of file
diff --git a/skin/files_folder.twig b/skin/files_folder.twig
new file mode 100644
index 0000000..bf66cad
--- /dev/null
+++ b/skin/files_folder.twig
@@ -0,0 +1,7 @@
+{{ svgPreload('folder_20', 'file_20') }}
+{{ bc(bc) }}
+
+
+ {% include 'files_list.twig' %}
+
+
\ No newline at end of file
diff --git a/skin/files_index.twig b/skin/files_index.twig
new file mode 100644
index 0000000..6cde643
--- /dev/null
+++ b/skin/files_index.twig
@@ -0,0 +1,14 @@
+{{ bc([{text: "files_archives"|lang}]) }}
+
+ {% include 'files_list.twig' with {files: collections} %}
+
+
+{{ bc([{text: "files_books"|lang}], null, true) }}
+
+ {% include 'files_list.twig' with {files: books} %}
+
+
+{{ bc([{text: "files_misc"|lang}], null, true) }}
+
+ {% include 'files_list.twig' with {files: misc} %}
+
diff --git a/skin/files_list.twig b/skin/files_list.twig
new file mode 100644
index 0000000..edd31f3
--- /dev/null
+++ b/skin/files_list.twig
@@ -0,0 +1,3 @@
+{% for file in files %}
+ {% include 'files_file.twig' with {file: file} %}
+{% endfor %}
\ No newline at end of file
diff --git a/skin/footer.twig b/skin/footer.twig
new file mode 100644
index 0000000..5ba5ddc
--- /dev/null
+++ b/skin/footer.twig
@@ -0,0 +1,44 @@
+
+
+{% if not render_options.full_width %}
+
+{% endif %}
+
+
+
+{{ script_html|raw }}
+
+{% if not render_options.inside_admin_interface %}
+
+
+
+
+{% endif %}
+
+
+
+
+{% if is_admin %}
+
+{% endif %}
\ No newline at end of file
diff --git a/skin/header.twig b/skin/header.twig
new file mode 100644
index 0000000..a448967
--- /dev/null
+++ b/skin/header.twig
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+ {{ title }}
+
+ {{ meta_html|raw }}
+ {{ static_html|raw }}
+
+
+{{ svg_html|raw }}
+
+
+
+
+
\ No newline at end of file
diff --git a/skin/icons.phps b/skin/icons.phps
deleted file mode 100644
index abc2e48..0000000
--- a/skin/icons.phps
+++ /dev/null
@@ -1,71 +0,0 @@
-
-SVG;
-}
-
-function settings_28($ctx) {
-return <<
-SVG;
-}
-
-function moon_auto_18($ctx) {
-return <<
-
-SVG;
-}
-
-function moon_light_18($ctx) {
-return <<
-SVG;
-}
-
-function moon_dark_18($ctx) {
-return <<
-SVG;
-}
-
-function search_20($ctx) {
-return <<
-SVG;
-}
-
-function file_20($ctx) {
-return <<
-SVG;
-}
-
-function book_20($ctx) {
-return <<
-SVG;
-}
-
-function clear_20($ctx) {
-return <<
-SVG;
-}
-
-function clear_16($ctx) {
-return <<
-SVG;
-}
-
-function arrow_up_right_out_square_outline_12($ctx) {
-return <<
-SVG;
-
-}
\ No newline at end of file
diff --git a/skin/index.twig b/skin/index.twig
new file mode 100644
index 0000000..b61a0cc
--- /dev/null
+++ b/skin/index.twig
@@ -0,0 +1,43 @@
+
+
+
+
{{ "recent_articles"|lang }}
+
+ {% for i, post in posts %}
+
+
{{ post.getDate() }}
+
+
+ {% endfor %}
+
+
+ {{ "view_all_articles"|lang }}
+
+
+
+
+
\ No newline at end of file
diff --git a/skin/main.phps b/skin/main.phps
deleted file mode 100644
index ee49389..0000000
--- a/skin/main.phps
+++ /dev/null
@@ -1,269 +0,0 @@
-
-
-
-
{$ctx->lang('recent_articles')}
-
- {$ctx->for_each($posts,
- fn($post, $i) => $ctx->articles_post_row($i, $post, $posts_lang, true, false))}
-
- {$ctx->lang('view_all_articles')}
-
-
-
-
-
-HTML;
-
-}
-
-function articles($ctx, array $posts, PostLanguage $selected_lang): string {
-if (empty($posts))
- return $ctx->articles_empty($selected_lang);
-
-$expl = $ctx->lang('blog_expl_'.$selected_lang->value);
-$expl = nl2br($expl);
-
-return <<{$expl}
-
- {$ctx->articles_posts_table($posts, $selected_lang)}
-
-HTML;
-}
-
-function articles_empty($ctx, PostLanguage $selected_lang) {
-return <<
- {$ctx->lang('blog_no')}
- {$ctx->articles_right_links($selected_lang->value)}
-
-HTML;
-}
-
-function articles_posts_table($ctx, array $posts, PostLanguage $selected_lang): string {
-$ctx->year = 3000;
-return <<
- {$ctx->for_each($posts,
- fn($post, $i) => $ctx->articles_post_row($i, $post, $selected_lang))}
-
-HTML;
-}
-
-function articles_post_row($ctx,
- int $index,
- Post $post,
- PostLanguage $selected_lang,
- bool $no_block = false,
- bool $show_year = true): string {
-$year = $post->getYear();
-$date = $post->getDate();
-$url = $post->getUrl($selected_lang);
-
-$pt = $post->getText($selected_lang);
-$title = $pt->title;
-
-$class = ['blog-list-item', 'clearfix'];
-if (!$post->visible)
- $class[] = 'is-hidden';
-if ($no_block)
- $class[] = 'no-block';
-$class = 'class="'.implode(' ', $class).'"';
-
-$buf = $no_block ? '' : '';
-
-return <<if_true($show_year && $ctx->year > $year,
- fn() => $ctx->articles_index_year_line($year, $index === 0, $selected_lang->value))}
-{$buf}
-HTML;
-}
-
-function articles_index_year_line($ctx, $year, $show_right_links, string $selected_lang): string {
-$ctx->year = $year;
-return <<
- {$year}
-
- {$ctx->if_true($show_right_links,
- fn() => $ctx->articles_right_links($selected_lang))}
-
-HTML;
-}
-
-function articles_right_links($ctx, string $selected_lang) {
-$links = [
- ['url' => $selected_lang != 'en' ? '/articles/' : null, 'label' => lang('lang_en')],
- ['url' => $selected_lang != 'ru' ? '/articles/?lang=ru' : null, 'label' => lang('lang_ru')],
-];
-if (is_admin()) {
- $links[] = ['url' => '/articles/write/?lang='.$selected_lang, 'label' => 'write'];
-}
-
-return <<
- {$ctx->for_each($links, fn($link, $index) => $ctx->articles_right_link($link['url'], $link['label'], $index))}
-
-HTML;
-}
-
-function articles_right_link($ctx, $url, string $label, int $index) {
- $buf = '';
- if ($index > 0)
- $buf .= ' | ';
- $buf .= !$url ? $label : ''.$label.' ';
- return $buf;
-}
-
-
-// any page
-// --------
-
-function page($ctx, $page_url, $short_name, $unsafe_html, $bc) {
-$html = <<
- {$ctx->if_true($bc, fn() => $ctx->bc($bc))}
- {$ctx->if_admin($ctx->page_admin_links, $page_url, $short_name)}
- {$unsafe_html}
-
-HTML;
-
-return [$html, js_markdownThemeChangeListener()];
-}
-
-function page_admin_links($ctx, $url, $short_name) {
-return <<
- {$ctx->lang('edit')}
- {$ctx->lang('delete')}
-
-HTML;
-
-}
-
-
-// post page
-// ---------
-
-function post($ctx, $id, $title, $unsafe_html, $unsafe_toc_html, $date, $visible, $url, string $lang, $other_langs, string $source_url) {
-$html = <<
-
-
- {$ctx->bc([
- ['url' => '/articles/?lang='.$lang, 'text' => $ctx->lang('articles')]
- ])}
-
-
{$title}
-
- {$ctx->if_not($visible, $ctx->lang('blog_post_hidden').' |')}
- {$date}
- {$ctx->if_true($other_langs, fn() => $ctx->post_other_langs($url, $other_langs))}
- {$ctx->if_true($source_url, fn() => ' |
Source at kiwi arXiv ')}
- {$ctx->if_admin($ctx->post_admin_links, $url, $id, $lang)}
-
-
-
{$unsafe_html}
-
- {$ctx->if_true($unsafe_toc_html, $ctx->postToc, $unsafe_toc_html)}
-
-
-HTML;
-
-return [$html, js_markdownThemeChangeListener()];
-}
-
-function post_other_langs($ctx, $url, $other_langs) {
- $buf = '';
- foreach ($other_langs as $lang) {
- $buf .= ' | '.$ctx->lang('blog_read_in_'.$lang).' ';
- }
- return $buf;
-}
-
-function postToc($ctx, $unsafe_toc_html) {
-return <<
-
-
-
{$ctx->lang('toc')}
- {$unsafe_toc_html}
-
-
-
-HTML;
-
-}
-
-function post_admin_links($ctx, $url, $id, string $lang) {
-return <<{$ctx->lang('edit')}
-| {$ctx->lang('delete')}
-HTML;
-}
-
-function js_markdownThemeChangeListener() {
-return << div');
- if (!div) {
- console.warn('could not found a>div on this node:', node);
- continue;
- }
- var style = div.getAttribute('style');
- if (isDark) {
- style = style.replace(/(a[\d]+x[\d]+)\.jpg/, '$1_dark.jpg');
- } else {
- style = style.replace(/(a[\d]+x[\d]+)_dark\.jpg/, '$1.jpg');
- }
- div.setAttribute('style', style);
- }
-});
-JS;
-}
diff --git a/skin/markdown.phps b/skin/markdown.phps
deleted file mode 100644
index 31aa1dc..0000000
--- a/skin/markdown.phps
+++ /dev/null
@@ -1,41 +0,0 @@
-
- {$name}
- {$ctx->if_true($note, ''.$note.' ')}
- {$size}
-
-HTML;
-}
-
-function image($ctx,
- // options
- $align, $nolabel, $w, $padding_top, $may_have_alpha,
- // image data
- $direct_url, $url, $unsafe_note) {
-return <<
-
- {$ctx->if_true($unsafe_note != '' && !$nolabel,
- fn() => ''.$unsafe_note.'
')}
-
-HTML;
-}
-
-function video($ctx, $url, $w, $h) {
-return <<
-
- if_true($w, ' width="'.$w.'"')}{$ctx->if_true($h, ' height="'.$h.'"')}>
-
-
-HTML;
-}
\ No newline at end of file
diff --git a/skin/markdown_fileupload.twig b/skin/markdown_fileupload.twig
new file mode 100644
index 0000000..5ae7c72
--- /dev/null
+++ b/skin/markdown_fileupload.twig
@@ -0,0 +1,7 @@
+
+
{{ name }}
+ {% if note %}
+
{{ note }}
+ {% endif %}
+
{{ size }}
+
\ No newline at end of file
diff --git a/skin/markdown_image.twig b/skin/markdown_image.twig
new file mode 100644
index 0000000..377cc6d
--- /dev/null
+++ b/skin/markdown_image.twig
@@ -0,0 +1,10 @@
+
+
+ {% if unsafe_note and not nolabel %}
+
{{ unsafe_note|raw }}
+ {% endif %}
+
\ No newline at end of file
diff --git a/skin/markdown_preview.twig b/skin/markdown_preview.twig
new file mode 100644
index 0000000..830a187
--- /dev/null
+++ b/skin/markdown_preview.twig
@@ -0,0 +1,6 @@
+
+ {% if title %}
+
{{ title }}
+ {% endif %}
+
{{ unsafe_html|raw }}
+
\ No newline at end of file
diff --git a/skin/markdown_video.twig b/skin/markdown_video.twig
new file mode 100644
index 0000000..17dcf1e
--- /dev/null
+++ b/skin/markdown_video.twig
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/skin/page.twig b/skin/page.twig
new file mode 100644
index 0000000..ee87445
--- /dev/null
+++ b/skin/page.twig
@@ -0,0 +1,14 @@
+
+ {% if bc %}
+ {{ bc(bc) }}
+ {% endif %}
+
+ {% if is_admin %}
+
+ {% endif %}
+
+
{{ html|raw }}
+
\ No newline at end of file
diff --git a/skin/post.twig b/skin/post.twig
new file mode 100644
index 0000000..66eb6fd
--- /dev/null
+++ b/skin/post.twig
@@ -0,0 +1,63 @@
+
+
+
+ {{ bc([{url: '/articles/?lang='~selected_lang, text: "articles"|lang}]) }}
+
+
{{ pt.title }}
+
+ {% if not post.visible %}
+ {{ ("blog_post_hidden"|lang)~" |" }}
+ {% endif %}
+ {{ post.getFullDate() }}
+ {% if other_langs %}
+ {% for l in other_langs %}
+ |
{{ ("blog_read_in_"~l)|lang }}
+ {% endfor %}
+ {% endif %}
+ {% if post.hasSourceUrl() %}
+ |
Source at kiwi arXiv
+ {% endif %}
+ {% if is_admin %}
+ |
{{ "edit"|lang }}
+ |
{{ "delete"|lang }}
+ {% endif %}
+
+
+
{{ html|raw }}
+
+ {% if pt.hasTableOfContents() %}
+
+
+
+
{{ "toc"|lang }}
+ {{ pt.getTableOfContentsHtml()|raw }}
+
+
+
+ {% endif %}
+
+
+
+{% js %}
+ThemeSwitcher.addOnChangeListener(function(isDark) {
+ var nodes = document.querySelectorAll('.md-image-wrap');
+ for (var i = 0; i < nodes.length; i++) {
+ var node = nodes[i];
+ var alpha = parseInt(node.getAttribute('data-alpha'), 10);
+ if (!alpha)
+ continue;
+ var div = node.querySelector('a > div');
+ if (!div) {
+ console.warn('could not found a>div on this node:', node);
+ continue;
+ }
+ var style = div.getAttribute('style');
+ if (isDark) {
+ style = style.replace(/(a[\d]+x[\d]+)\.jpg/, '$1_dark.jpg');
+ } else {
+ style = style.replace(/(a[\d]+x[\d]+)_dark\.jpg/, '$1.jpg');
+ }
+ div.setAttribute('style', style);
+ }
+});
+{% endjs %}
\ No newline at end of file
diff --git a/skin/rss.phps b/skin/rss.phps
deleted file mode 100644
index 8bc19e9..0000000
--- a/skin/rss.phps
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
- {$title}
- {$link}
-
-
- {$ctx->for_each($items, fn($item) => $ctx->item(...$item))}
-
-
-HTML;
-}
-
-
-function item(\SkinContext $ctx,
- string $title,
- string $link,
- string $pub_date,
- string $description): string {
-return <<
- {$title}
- {$link}
- {$pub_date}
- {$description}
-
-HTML;
-}
\ No newline at end of file
diff --git a/skin/rss.twig b/skin/rss.twig
new file mode 100644
index 0000000..4b4e46a
--- /dev/null
+++ b/skin/rss.twig
@@ -0,0 +1,17 @@
+
+
+
+ {{ title }}
+ {{ link }}
+
+
+ {% for item in items %}
+ -
+
{{ title }}
+ {{ link }}
+ {{ pub_date }}
+ {{ description }}
+
+ {% endfor %}
+
+
\ No newline at end of file
diff --git a/skin/spinner.twig b/skin/spinner.twig
new file mode 100644
index 0000000..8e5ce5d
--- /dev/null
+++ b/skin/spinner.twig
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/skin/svg/arrow_up_right_out_square_outline_12.svg b/skin/svg/arrow_up_right_out_square_outline_12.svg
new file mode 100644
index 0000000..691c20f
--- /dev/null
+++ b/skin/svg/arrow_up_right_out_square_outline_12.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skin/svg/book_20.svg b/skin/svg/book_20.svg
new file mode 100644
index 0000000..29f0396
--- /dev/null
+++ b/skin/svg/book_20.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skin/svg/clear_16.svg b/skin/svg/clear_16.svg
new file mode 100644
index 0000000..a7dabcb
--- /dev/null
+++ b/skin/svg/clear_16.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skin/svg/clear_20.svg b/skin/svg/clear_20.svg
new file mode 100644
index 0000000..529172f
--- /dev/null
+++ b/skin/svg/clear_20.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skin/svg/file_20.svg b/skin/svg/file_20.svg
new file mode 100644
index 0000000..aa4ac5a
--- /dev/null
+++ b/skin/svg/file_20.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skin/svg/folder_20.svg b/skin/svg/folder_20.svg
new file mode 100644
index 0000000..6a65211
--- /dev/null
+++ b/skin/svg/folder_20.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skin/svg/moon_auto_18.svg b/skin/svg/moon_auto_18.svg
new file mode 100644
index 0000000..156638d
--- /dev/null
+++ b/skin/svg/moon_auto_18.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/skin/svg/moon_dark_18.svg b/skin/svg/moon_dark_18.svg
new file mode 100644
index 0000000..b5a5801
--- /dev/null
+++ b/skin/svg/moon_dark_18.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skin/svg/moon_light_18.svg b/skin/svg/moon_light_18.svg
new file mode 100644
index 0000000..a59293d
--- /dev/null
+++ b/skin/svg/moon_light_18.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skin/svg/search_20.svg b/skin/svg/search_20.svg
new file mode 100644
index 0000000..de8af12
--- /dev/null
+++ b/skin/svg/search_20.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skin/svg/settings_28.svg b/skin/svg/settings_28.svg
new file mode 100644
index 0000000..df4330f
--- /dev/null
+++ b/skin/svg/settings_28.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skin/widgets.phps b/skin/widgets.phps
deleted file mode 100644
index 3acb6ae..0000000
--- a/skin/widgets.phps
+++ /dev/null
@@ -1,14 +0,0 @@
-if_true($id, ' id="'.$id.'"')}>
-
-
-
-
-HTML;
-}
\ No newline at end of file
diff --git a/tools/cli_util.php b/tools/cli_util.php
index b3405bd..384bca9 100755
--- a/tools/cli_util.php
+++ b/tools/cli_util.php
@@ -9,25 +9,25 @@ require_once 'lib/admin.php';
->on('admin-add', function() {
list($login, $password) = _get_admin_login_password_input();
- if (admin_exists($login))
+ if (admin::exists($login))
cli::die("Admin ".$login." already exists");
- $id = admin_add($login, $password);
+ $id = admin::add($login, $password);
echo "ok: id = $id\n";
})
->on('admin-delete', function() {
$login = cli::input('Login: ');
- if (!admin_exists($login))
+ if (!admin::exists($login))
cli::die("No such admin");
- if (!admin_delete($login))
+ if (!admin::delete($login))
cli::die("Database error");
echo "ok\n";
})
->on('admin-set-password', function() {
list($login, $password) = _get_admin_login_password_input();
- echo admin_set_password($login, $password) ? 'ok' : 'fail';
+ echo admin::setPassword($login, $password) ? 'ok' : 'fail';
echo "\n";
})
@@ -103,8 +103,8 @@ function _get_admin_login_password_input(): array {
if (trim($pwd1) == '')
cli::die("Password can not be empty");
- if (strlen($login) > ADMIN_LOGIN_MAX_LENGTH)
- cli::die("Login is longer than max length (".ADMIN_LOGIN_MAX_LENGTH.")");
+ if (strlen($login) > admin::ADMIN_LOGIN_MAX_LENGTH)
+ cli::die("Login is longer than max length (".admin::ADMIN_LOGIN_MAX_LENGTH.")");
return [$login, $pwd1];
}