diff --git a/composer.json b/composer.json index c07e5dd..7649af7 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,7 @@ "gch1p/parsedown-highlight": "master", "gch1p/parsedown-highlight-extended": "dev-main", "erusev/parsedown": "1.8.0-beta-7", + "gigablah/sphinxphp": "2.0.*", "ext-mbstring": "*", "ext-gd": "*", "ext-mysqli": "*", diff --git a/composer.lock b/composer.lock index 9454cb5..be528c8 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": "ca8ca355a9f6ce85170f473238a57d6b", + "content-hash": "dba6710f07144861b58159926e3c0cc7", "packages": [ { "name": "erusev/parsedown", @@ -210,6 +210,61 @@ ], "time": "2023-03-01T22:30:01+00:00" }, + { + "name": "gigablah/sphinxphp", + "version": "2.0.8", + "source": { + "type": "git", + "url": "https://github.com/gigablah/sphinxphp.git", + "reference": "6d5e97fdd33c1129ca372203d1330827c1cbc46c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/gigablah/sphinxphp/zipball/6d5e97fdd33c1129ca372203d1330827c1cbc46c", + "reference": "6d5e97fdd33c1129ca372203d1330827c1cbc46c", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "3.7.*", + "satooshi/php-coveralls": "dev-master" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-0": { + "Sphinx": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0" + ], + "authors": [ + { + "name": "Andrew Aksyonoff", + "homepage": "http://sphinxsearch.com/" + } + ], + "description": "Sphinx Search PHP API", + "homepage": "http://sphinxsearch.com/", + "keywords": [ + "api", + "search", + "sphinx" + ], + "support": { + "issues": "https://github.com/gigablah/sphinxphp/issues", + "source": "https://github.com/gigablah/sphinxphp/tree/2.0.x" + }, + "time": "2013-08-22T08:05:44+00:00" + }, { "name": "scrivo/highlight.php", "version": "v9.18.1.10", @@ -301,7 +356,8 @@ "ext-mbstring": "*", "ext-gd": "*", "ext-mysqli": "*", - "ext-json": "*" + "ext-json": "*", + "ext-yaml": "*" }, "platform-dev": [], "plugin-api-version": "2.3.0" diff --git a/config.yaml.example b/config.yaml.example index 4acc8b4..04fe822 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -14,6 +14,8 @@ mysql: log: false log_stat: false +sphinx: + host: "127.0.0.1" umask: 0002 group: www-data @@ -25,6 +27,7 @@ password_salt: "456" uploads_dir: /home/user/files.example.org uploads_path: /uploads +files_domain: files.example.org # deploy config git_repo: git@github.com:example/example_org.git diff --git a/engine/mysql.php b/engine/mysql.php index cda4c7a..2c97e38 100644 --- a/engine/mysql.php +++ b/engine/mysql.php @@ -24,6 +24,8 @@ class mysql { $arg_val = $args[$i]; if (is_null($arg_val)) { $v = 'NULL'; + } elseif ($arg_val instanceof BackedEnum) { + $v = '\''.$this->escape($arg_val->value).'\''; } else { $v = '\''.$this->escape($arg_val).'\''; } @@ -230,8 +232,6 @@ class mysql_bitfield { if ($bit < 0 || $bit >= $this->size) throw new Exception('invalid bit '.$bit.', allowed range: [0..'.$this->size.')'); } - - } function DB(): mysql|null { diff --git a/engine/request.php b/engine/request.php index e0f3361..b4d160d 100644 --- a/engine/request.php +++ b/engine/request.php @@ -58,7 +58,7 @@ function http_error(HTTPCode $http_code, string $message = ''): void { $data['message'] = $message; ajax_error((object)$data, $http_code->value); } else { - $ctx = new SkinContext('\\skin\\error'); + $ctx = skin('error'); $http_message = preg_replace('/(?name); $html = $ctx->http_error($http_code->value, $http_message, $message); http_response_code($http_code->value); diff --git a/engine/router.php b/engine/router.php index ce02021..d7d7882 100644 --- a/engine/router.php +++ b/engine/router.php @@ -1,6 +1,6 @@ false, 'wide' => false, - 'dynlogo_enabled' => true, 'logo_path_map' => [], 'logo_link_map' => [], 'is_index' => false, @@ -17,13 +16,15 @@ $SkinState = new class { 'articles_lang' => null, ]; public array $static = []; + public array $svg_defs = []; }; function render($f, ...$vars): void { global $SkinState, $config; - $f = '\\skin\\'.str_replace('/', '\\', $f); - $ctx = new SkinContext(substr($f, 0, ($pos = strrpos($f, '\\')))); + add_skin_strings(['4in1']); + + $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; @@ -34,12 +35,12 @@ function render($f, ...$vars): void { if ($theme != 'auto' && !themeExists($theme)) $theme = 'auto'; - $layout_ctx = new SkinContext('\\skin\\base'); + $layout_ctx = skin('base'); $lang = []; foreach ($SkinState->lang as $key) $lang[$key] = lang($key); - $lang = !empty($lang) ? jsonEncode($lang, JSON_UNESCAPED_UNICODE) : ''; + $lang = !empty($lang) ? jsonEncode($lang) : ''; $title = $SkinState->title; if (!$SkinState->options['is_index']) @@ -56,6 +57,7 @@ function render($f, ...$vars): void { unsafe_body: $body, exec_time: exectime(), admin_email: $config['admin_email'], + svg_defs: $SkinState->svg_defs ); echo $html; exit; @@ -127,6 +129,9 @@ class SkinContext { continue; } + if ($plain_args && !isset($arguments[$key])) + break; + if (is_string($arguments[$key]) || $arguments[$key] instanceof SkinString) { if (is_string($arguments[$key])) $arguments[$key] = new SkinString($arguments[$key]); @@ -189,8 +194,10 @@ class SkinContext { return csrf_get($key); } - function bc(array $items, ?string $style = null): string { - $buf = implode(array_map(function(array $i): string { + function bc(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); @@ -201,13 +208,16 @@ class SkinContext { $buf .= htmlescape($i['text']); if ($has_url) - $buf .= ' ›'; + $buf .= ' '.$chevron.''; else $buf .= ''; return $buf; }, $items)); - return '
@@ -280,7 +279,7 @@ $form_url = '/'.$short_name.'/'.($is_edit ? 'edit' : 'create').'/';
if ($is_edit) {
$bc_html = $ctx->bc([
['url' => '/'.$short_name.'/', 'text' => $ctx->lang('view_page')]
- ], 'padding-bottom: 20px');
+ ], 'padding-bottom: 12px');
} else {
$bc_html = '';
}
@@ -408,5 +407,15 @@ return <<{$unsafe_html}
HTML;
+}
+
+function books($ctx) {
+return <<bc([
+ ['text' => $ctx->lang('admin_title'), 'url' => '/admin/'],
+ ['text' => $ctx->lang('admin_books')],
+])}
+
+HTML;
}
\ No newline at end of file
diff --git a/skin/base.phps b/skin/base.phps
index 217e032..03f68b5 100644
--- a/skin/base.phps
+++ b/skin/base.phps
@@ -5,7 +5,7 @@ namespace skin\base;
use SkinContext;
use Stringable;
-function layout($ctx, $title, $unsafe_body, $static, $meta, $js, $opts, $unsafe_lang, $theme, $exec_time, $admin_email) {
+function layout($ctx, $title, $unsafe_body, $static, $meta, $js, $opts, $unsafe_lang, $theme, $exec_time, $admin_email, $svg_defs) {
global $config;
$app_config = jsonEncode([
'domain' => $config['domain'],
@@ -33,6 +33,7 @@ return <<renderStatic($static, $theme)}
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'])}
+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"')}>
+
+ {$unsafe_body}
@@ -61,6 +62,19 @@ return <<';
+ foreach ($svg_defs as $name => $icon) {
+ $buf .= <<';
+ return $buf;
+}
+
function renderScript($ctx, $unsafe_js, $unsafe_lang) {
global $config;
@@ -190,16 +204,23 @@ function renderHeader(SkinContext $ctx,
?string $section,
?string $articles_lang,
bool $show_subtitle): string {
+$icons = svg();
$items = [];
-if (is_admin())
+if (is_admin()) {
$items[] = ['url' => '/articles/'.($articles_lang ? '?lang='.$articles_lang : ''), 'label' => 'articles', 'selected' => $section === 'articles'];
-array_push($items,
- ['url' => 'https://files.4in1.ws', 'label' => 'files', 'selected' => $section === 'files'],
- ['url' => '/info/', 'label' => 'about', 'selected' => $section === 'about']
-);
+}
+$items[] = ['url' => '/files/', 'label' => 'files', 'selected' => $section === 'files'];
+$items[] = ['url' => '/info/', 'label' => 'about', 'selected' => $section === 'about'];
if (is_admin())
- $items[] = ['url' => '/admin/', 'label' => $ctx->renderSettingsIcon(), 'type' => 'settings', 'selected' => $section === 'admin'];
-$items[] = ['url' => 'javascript:void(0)', 'label' => $ctx->renderMoonIcons(), 'type' => 'theme-switcher', 'type_opts' => $theme];
+ $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
@@ -257,32 +278,6 @@ return <<
-
+ {$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) {
+$svg = svg();
+$svg->folder_20(preload_symbol: true);
+$svg->file_20(preload_symbol: true);
+
+return <<bc([
+ ['text' => $ctx->lang('files'), 'url' => '/files/'],
+ ['text' => $folder->title]
+])}
+
+
+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) && $collection->isSearchSupported();
+ $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->collection_files($files)}
+
+
+
+
+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 = <<
+ {$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')}
+
+
+
+
+
+
+
+
+
+
+
+ {$widgets->spinner()}
+ {$ctx->if_then_else($query, fn() => $ctx->lang_num('files_search_results_count', $count), ' ')}
+
+
+
+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) => ' ')}
+
+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/icons.phps b/skin/icons.phps
new file mode 100644
index 0000000..abc2e48
--- /dev/null
+++ b/skin/icons.phps
@@ -0,0 +1,71 @@
+
+SVG;
+}
+
+function settings_28($ctx) {
+return <<
+SVG;
+}
+
+function moon_auto_18($ctx) {
+return <<
+
+ {$title}
+ {$ctx->if_true($file->isFolder() && $file->isTargetBlank(), fn() => ' ')}
+ {$ctx->if_true($subtitle, fn() => ''.htmlescape($subtitle).'')}
+ {$ctx->if_true($meta_is_inline, $ctx->for_each($meta_items, fn($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))}
+ |