Compare commits

...

34 commits

Author SHA1 Message Date
702cce999b search.php: (untested) refactor, use SearchService 2025-06-02 00:46:26 +02:00
567c344e15 context.php: add SearchQueries 2025-06-02 00:46:26 +02:00
b3dcd4c346 context.php: add SearchService 2025-06-02 00:46:26 +02:00
0665e1ff4a context.php: add UserPostQueries 2025-06-02 00:46:26 +02:00
5f5adcab85 Flags.php: add the flags that come embedded with php 2025-06-02 00:46:26 +02:00
8fddd4663a config.php: add search.max_length 2025-06-02 00:46:26 +02:00
e35462a195 config.php: better documentation for search.boards 2025-06-02 00:46:26 +02:00
b2be73cbd3 config.php: add search.max_weight to the configuration options 2025-06-02 00:46:26 +02:00
754d3a5460 FiltersParseResult.php: add 2025-06-02 00:46:26 +02:00
72f0eff37b SearchServiceTest.php: add basic testign for the SearchService 2025-06-02 00:46:26 +02:00
99de3f875e SearchService.php: expose searchable boards 2025-06-02 00:46:26 +02:00
2d9a973edf SearchService.php: add checkFlood 2025-06-02 00:46:26 +02:00
9b47d1fe91 SearchService.php: default to all boards 2025-06-02 00:46:26 +02:00
6c4610a901 SearchService.php: limit the searchable boards 2025-06-02 00:46:26 +02:00
f3b2616d5d SearchService.php: fix flag matching 2025-06-02 00:46:26 +02:00
b9ec0ee697 SearchService.php: rework but untested 2025-06-02 00:46:26 +02:00
202ed4c108 SearchService.php: fix regex, parsing, group word chunks, use class, expose weighting 2025-06-02 00:46:26 +02:00
7b456a67e4 SearchFilters.php: add 2025-06-02 00:46:26 +02:00
c37a459f6c Remove legacy bundled gettext 2025-06-02 00:46:26 +02:00
d65acc0a52 ProtectIPTest.php: remove constant re-definition 2025-06-02 00:46:26 +02:00
902245ae31 polyfill.php: add gettext polyfills 2025-06-02 00:46:26 +02:00
beb940879b GettextWrapper.php: add polyfill 2025-06-02 00:46:26 +02:00
c2cfa515e5 functions.php: do not load instance-config.php during testing 2025-06-02 00:46:26 +02:00
ed42951f0d composer: update geettext dependency 2025-06-02 00:46:26 +02:00
966b53ae4b functions.php: revert ancient temporary (lol) workaround for the gettext polyfill 2025-06-02 00:46:26 +02:00
6aacf33564 functions.php: skip instance-config.php check while testing 2025-06-02 00:46:26 +02:00
b0724259e7 bannersTest.php: remove obsolete test 2025-06-02 00:46:26 +02:00
1f574254f0 composer: autoload anti-bot AFTER bootstrap 2025-06-02 00:46:26 +02:00
46f78cd780 README: add test instructions 2025-06-02 00:46:26 +02:00
460241e6de composer: add PHPUnit 9 2025-06-02 00:46:26 +02:00
5244d5cffb UserPostQueries.php: add searchPost method 2025-06-02 00:46:26 +02:00
91e41a5cdb maintenance.php: add SearchQueries cleanup 2025-06-02 00:46:26 +02:00
92f9653979 SearchQueries.php: extract garbage cleanup 2025-06-02 00:46:26 +02:00
57552eff47 SearchService.php: update 2025-06-02 00:46:23 +02:00
26 changed files with 3015 additions and 1919 deletions

View file

@ -14,7 +14,7 @@ Requirements
PHP 8.0 is explicitly supported. PHP 7.x should be compatable.
2. MySQL/MariaDB server >= 5.5.3
3. [Composer](https://getcomposer.org/) (To install various packages)
4. [mbstring](http://www.php.net/manual/en/mbstring.installation.php)
4. [mbstring](http://www.php.net/manual/en/mbstring.installation.php)
5. [PHP GD](http://www.php.net/manual/en/intro.image.php)
6. [PHP PDO](http://www.php.net/manual/en/intro.pdo.php)
@ -44,7 +44,7 @@ Installation
development version with:
git clone git://git.leftypol.org/leftypol/leftypol.git
2. run ```composer install``` inside the directory
3. Navigate to ```install.php``` in your web browser and follow the
prompts.
@ -80,7 +80,7 @@ find support from a variety of sources:
* For support, reply to the sticky on our [/tech/](https://leftypol.org/tech/) board.
### Tinyboard support
vichan, and by extension lainchan and leftypol, is based on a Tinyboard, so both engines have very much in common. These links may be helpful for you as well:
vichan, and by extension lainchan and leftypol, is based on a Tinyboard, so both engines have very much in common. These links may be helpful for you as well:
* Tinyboard documentation can be found [here](https://web.archive.org/web/20121016074303/http://tinyboard.org/docs/?p=Main_Page).
@ -120,7 +120,10 @@ leftypol API
leftypol provides by default a 4chan-compatible JSON API, just like vichan. For documentation on this, see:
https://github.com/vichan-devel/vichan-API/ .
Testing
----------
You can run PHPUnit tests with `./vendor/bin/phpunit tests` with PHP 7.4 to 8.2.
License
--------
See LICENSE.md.

View file

@ -5,14 +5,14 @@
"require": {
"twig/twig": "^1.44.2",
"lifo/ip": "^1.0",
"gettext/gettext": "^1.0",
"gettext/gettext": "^5.7",
"mrclay/minify": "^2.1.6"
},
"autoload": {
"classmap": ["inc/"],
"files": [
"inc/anti-bot.php",
"inc/bootstrap.php",
"inc/anti-bot.php",
"inc/context.php",
"inc/display.php",
"inc/template.php",
@ -47,5 +47,8 @@
"name": "leftypol contributors",
"homepage": "https://git.leftypol.org/leftypol/leftypol/"
}
]
],
"require-dev": {
"phpunit/phpunit": "^9"
}
}

1882
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
<?php
namespace Vichan\Data;
class FiltersParseResult {
public array $body = [];
public ?string $subject = null;
public ?string $name = null;
public ?string $board = null;
public ?string $flag = null;
public ?int $id = null;
public ?int $thread = null;
}

283
inc/Data/Flags.php Normal file
View file

@ -0,0 +1,283 @@
<?php
class Flags {
/**
* Short names of the flags embedded with vichan.
*/
public const EMBEDDED_FLAGS = [
'a1',
'a2',
'ac',
'ad',
'ae',
'af',
'ag',
'ai',
'al',
'am',
'an',
'ao',
'ap',
'aq',
'ar',
'as',
'at',
'au',
'aw',
'ax',
'az',
'ba',
'bb',
'bd',
'be',
'bf',
'bg',
'bh',
'bi',
'bj',
'bl',
'bm',
'bn',
'bo',
'bq',
'br',
'bs',
'bt',
'bu',
'bv',
'bw',
'by',
'bz',
'ca',
'cat',
'cc',
'cd',
'cf',
'cg',
'ch',
'ci',
'ck',
'cl',
'cm',
'cn',
'co',
'cp',
'cr',
'cs',
'cu',
'cv',
'cw',
'cx',
'cy',
'cz',
'de',
'dg',
'dj',
'dk',
'dm',
'do',
'dz',
'ea',
'ec',
'ee',
'eg',
'eh',
'er',
'es',
'et',
'eu',
'fi',
'fj',
'fk',
'fm',
'fo',
'fr',
'fx',
'ga',
'gb',
'gd',
'ge',
'gf',
'gg',
'gh',
'gi',
'gl',
'gm',
'gn',
'gp',
'gq',
'gr',
'gs',
'gt',
'gu',
'gw',
'gy',
'hk',
'hm',
'hn',
'hr',
'ht',
'hu',
'ic',
'id',
'ie',
'il',
'im',
'in',
'io',
'iq',
'ir',
'is',
'it',
'je',
'jm',
'jo',
'jp',
'ke',
'kg',
'kh',
'ki',
'km',
'kn',
'kp',
'kr',
'kw',
'ky',
'kz',
'la',
'lb',
'lc',
'li',
'lk',
'lr',
'ls',
'lt',
'lu',
'lv',
'ly',
'ma',
'mc',
'md',
'me',
'mf',
'mg',
'mh',
'mk',
'ml',
'mm',
'mn',
'mo',
'mp',
'mq',
'mr',
'ms',
'mt',
'mu',
'mv',
'mw',
'mx',
'my',
'mz',
'na',
'nc',
'ne',
'nf',
'ng',
'ni',
'nl',
'no',
'np',
'nr',
'nt',
'nu',
'nz',
'o1',
'om',
'pa',
'pe',
'pf',
'pg',
'ph',
'pk',
'pl',
'pm',
'pn',
'pr',
'ps',
'pt',
'pw',
'py',
'qa',
're',
'ro',
'rs',
'ru',
'rw',
'sa',
'sb',
'sc',
'sd',
'se',
'sf',
'sg',
'sh',
'si',
'sj',
'sk',
'sl',
'sm',
'sn',
'so',
'sr',
'ss',
'st',
'su',
'sv',
'sx',
'sy',
'sz',
'ta',
'tc',
'td',
'tf',
'tg',
'th',
'ti',
'tj',
'tk',
'tl',
'tm',
'tn',
'to',
'tp',
'tr',
'tt',
'tv',
'tw',
'tz',
'ua',
'ug',
'uk',
'um',
'us',
'uy',
'uz',
'va',
'vc',
've',
'vg',
'vi',
'vn',
'vu',
'wf',
'ws',
'xx',
'ye',
'yt',
'yu',
'za',
'zm',
'zr',
'zw',
];
}

View file

@ -0,0 +1,32 @@
<?php
namespace Vichan\Data;
/**
* POD with the fragments of each filter.
*/
class SearchFilters {
/**
* @var array<array<string>>
*/
public array $body;
/**
* @var array<string>
*/
public array $subject;
/**
* @var array<string>
*/
public array $name;
/**
* @var array<string>
*/
public array $board;
/**
* @var array<string>
*/
public array $flag;
public ?int $id;
public ?int $thread;
public float $weight;
}

View file

@ -6,20 +6,25 @@ class SearchQueries {
private \PDO $pdo;
private int $queries_per_minutes_single;
private int $queries_per_minutes_all;
private bool $auto_gc;
private function checkFloodImpl(string $ip, string $phrase): bool {
$now = time();
$now = \time();
$expiry_limit = \time() - ($this->queries_per_minutes_all * 60);
$query = $this->pdo->prepare("SELECT COUNT(*) FROM `search_queries` WHERE `ip` = :ip AND `time` > :time");
$query = $this->pdo->prepare("SELECT COUNT(*) FROM `search_queries` WHERE `ip` = :ip AND `time` > :time AND `time` <= :expiry_limit");
$query->bindValue(':ip', $ip);
$query->bindValue(':time', $now - ($this->queries_per_minutes_single * 60));
$query->bindValue(':time', $now - ($this->queries_per_minutes_single * 60), \PDO::PARAM_INT);
$query->bindValue(':expiry_limit', $expiry_limit, \PDO::PARAM_INT);
$query->execute();
if ($query->fetchColumn() > $this->queries_per_minutes_single) {
return false;
}
$query = $this->pdo->prepare("SELECT COUNT(*) FROM `search_queries` WHERE `time` > :time");
$query->bindValue(':time', $now - ($this->queries_per_minutes_all * 60));
$query = $this->pdo->prepare("SELECT COUNT(*) FROM `search_queries` WHERE `time` > :time AND `time` <= :expiry_limit");
$query->bindValue(':time', $now - ($this->queries_per_minutes_all * 60), \PDO::PARAM_INT);
$query->bindValue(':expiry_limit', $expiry_limit, \PDO::PARAM_INT);
$query->execute();
if ($query->fetchColumn() > $this->queries_per_minutes_all) {
return false;
@ -27,24 +32,31 @@ class SearchQueries {
$query = $this->pdo->prepare("INSERT INTO `search_queries` VALUES (:ip, :time, :query)");
$query->bindValue(':ip', $ip);
$query->bindValue(':time', $now);
$query->bindValue(':time', $now, \PDO::PARAM_INT);
$query->bindValue(':query', $phrase);
$query->execute();
// Cleanup search queries table
$query = prepare("DELETE FROM `search_queries` WHERE `time` <= :time");
$query->bindValue(':time', time() - ($this->queries_per_minutes_all * 60));
$query->execute();
if ($this->auto_gc) {
$this->purgeExpired();
}
return true;
}
public function __construct(\PDO $pdo, int $queries_per_minutes_single, int $queries_per_minutes_all) {
public function __construct(\PDO $pdo, int $queries_per_minutes_single, int $queries_per_minutes_all, bool $auto_gc) {
$this->pdo = $pdo;
$this->queries_per_minutes_single = $queries_per_minutes_single;
$this->queries_per_minutes_all = $queries_per_minutes_all;
$this->auto_gc = $auto_gc;
}
/**
* Check if the IP-query pair overflows the limit.
*
* @param string $ip Source IP.
* @param string $phrase The search query.
* @return bool True if the request goes over the limit
*/
public function checkFlood(string $ip, string $phrase): bool {
$this->pdo->beginTransaction();
try {
@ -56,4 +68,12 @@ class SearchQueries {
throw $e;
}
}
public function purgeExpired(): int {
// Cleanup search queries table.
$query = prepare("DELETE FROM `search_queries` WHERE `time` <= :expiry_limit");
$query->bindValue(':expiry_limit', \time() - ($this->queries_per_minutes_all * 60), \PDO::PARAM_INT);
$query->execute();
return $query->rowCount();
}
}

View file

@ -13,6 +13,36 @@ class UserPostQueries {
private \PDO $pdo;
/**
* Escapes wildcards from LIKE operators using the default escape character.
*/
private static function escapeLike(string $str): string {
// Escape any existing escape characters.
$str = \str_replace('\\', '\\\\', $str);
// Escape wildcard characters.
$str = \str_replace('%', '\\%', $str);
$str = \str_replace('_', '\\_', $str);
return $str;
}
/**
* Joins the fragments of filter into a single LIKE operand string.
* Given prefix = cat and fragments_count = 3, we get "%:cat0%:cat1%:cat2%"
*
* @param string $prefix The prefix for the parameter binding
* @param int $fragments_count MUST BE >= 1.
* @return string
*/
private static function joinFragments(string $prefix, int $fragments_count): string {
// With prefix = cat and fragments = 2 it becomes: "%:cat0%:cat1%"
$s = '%';
for ($i = 0; $i < $fragments_count; $i++) {
$s .= ":$prefix$i%";
}
return $s;
}
public function __construct(\PDO $pdo) {
$this->pdo = $pdo;
}
@ -156,4 +186,89 @@ class UserPostQueries {
}
});
}
/**
* Search among the user posts with the given filters.
* The subject, name and elements of the bodies filters are fragments which are joined together with wildcards, to
* allow for more flexible filtering.
*
* @param string $board The board where to search in.
* @param array<string> $subject Fragments of the subject filter.
* @param array<string> $name Fragments of the name filter.
* @param array<string> $flags An array of the flag names to search among the HTML.
* @param ?int $id Post id filter.
* @param ?int $thread Thread id filter.
* @param array<array<string>> $bodies An array whose element are arrays containing the fragments of multiple body filters, each
* searched independently from the others
* @param integer $limit The maximum number of results.
* @throws PDOException On error.
* @return array<array>
*/
public function searchPosts(string $board, array $subject, array $name, array $flags, ?int $id, ?int $thread, array $bodies, int $limit): array {
$where_acc = [];
if (!empty($subject)) {
$like_op = self::joinFragments('subj', \count($subject));
$where_acc[] = "WHERE subject LIKE '$like_op'";
}
if (!empty($name)) {
$like_op = self::joinFragments('name', \count($name));
$where_acc[] = "WHERE name LIKE '$like_op'";
}
if (!empty($flags)) {
$flag_acc = [];
for ($i = 0; $i < \count($flags); $i++) {
// Yes, vichan stores the flag inside the generated HTML. Now you know why it's slow as shit.
// English lacks the words to express my feelings about it in a satisfying manner.
$flag_acc[] = "LIKE = '%<tinyboard>:flag$i</tinyboard>%'";
}
$where_acc[] = 'WHERE body_nomarkup (' . \implode(' OR ', $flag_acc) . ')';
}
if ($id !== null) {
$where_acc[] = 'WHERE id = :id';
}
if ($thread !== null) {
$where_acc[] = 'WHERE thread = :thread';
}
for ($i = 0; $i < \count($bodies); $i++) {
$body = $bodies[$i];
$like_op = self::joinFragments("body_{$i}_", \count($body));
$where_acc[] = "WHERE body LIKE '$like_op'";
}
if (empty($where_acc)) {
return [];
}
$sql = "SELECT * FROM `posts_$board` " . \implode(' AND ', $where_acc) . ' LIMIT :limit';
$query = $this->pdo->prepare($sql);
for ($i = 0; $i < \count($subject); $i++) {
$query->bindValue(":subj$i", $subject[$i]);
}
for ($i = 0; $i < \count($name); $i++) {
$query->bindValue(":name$i", $name[$i]);
}
for ($i = 0; $i < \count($flags); $i++) {
$query->bindValue(":flag$i", $flags[$i]);
}
if ($id !== null) {
$query->bindValue(':id', $id, \PDO::PARAM_INT);
}
if ($thread !== null) {
$query->bindValue(':thread', $thread, \PDO::PARAM_INT);
}
for ($body_i = 0; $body_i < \count($bodies); $body_i++) {
$body = $bodies[$body_i];
for ($i = 0; $i < \count($body); $i++) {
$query->bindValue(":body_{$body_i}_{$i}", $body[$i]);
}
}
$query->bindValue(':limit', $limit, \PDO::PARAM_INT);
$query->execute();
return $query->fetchAll(\PDO::FETCH_ASSOC);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace Vichan\Polyfill;
use Gettext\Loader\MoLoader;
use Gettext\Translations;
class GettextWrapper {
private static array $domains = [];
private static array $translations = [];
private static array $codesets = [];
private static string $currentDomain = 'messages';
private static string $locale = 'en_US';
public static function bindTextDomain(string $domain, string $directory): string {
self::$domains[$domain] = rtrim($directory, '/');
return self::$domains[$domain];
}
public static function bindTextDomainCodeset(string $domain, string $codeset): ?string {
self::$codesets[$domain] = $codeset;
return $codeset;
}
public static function textDomain(?string $domain = null): string {
if ($domain !== null) {
self::$currentDomain = $domain;
}
return self::$currentDomain;
}
public static function setLocale(string $locale): void {
self::$locale = $locale;
}
public static function translate(string $message): string {
$domain = self::$currentDomain;
$locale = self::$locale;
$directory = self::$domains[$domain] ?? null;
if (!$directory) {
return $message;
}
$moFile = "$directory/$locale/LC_MESSAGES/$domain.mo";
if (!isset(self::$translations[$domain][$locale])) {
if (!file_exists($moFile)) {
self::$translations[$domain][$locale] = null;
} else {
$loader = new MoLoader();
$translations = $loader->loadFile($moFile);
// Optionally convert to codeset if set (not enforced by the library, just stored)
self::$translations[$domain][$locale] = $translations;
}
}
$translations = self::$translations[$domain][$locale];
if ($translations instanceof Translations) {
$entry = $translations->find(null, $message);
if ($entry === null) {
return $message;
}
return $entry->getTranslation();
}
return $message;
}
}

View file

@ -0,0 +1,392 @@
<?php
namespace Vichan\Service;
use Vichan\Data\Driver\LogDriver;
use Vichan\Data\{FiltersParseResult, UserPostQueries, SearchFilters, SearchQueries};
class SearchService {
private const COMMON_WORDS = [
'anon', 'thread', 'board', 'post', 'reply', 'image', 'topic', 'bump', 'sage', 'tripcode', 'groyper',
'mod', 'admin', 'ban', 'rules', 'sticky', 'archive', 'catalog', 'report', 'captcha', 'proxy', 'the',
'vpn', 'tor', 'doxx', 'spam', 'troll', 'bait', 'flame', 'greentext', 'copypasta', 'meme', 'this',
'shitpost', 'shitposting', 'edgy', 'kek', 'lulz', 'rekt', 'smug', 'lewd', 'nsfw', 'anonymous', 'glowie',
'cringe', 'normie', 'boomer', 'zoomer', 'incel', 'chad', 'stacy', 'simp', 'based', 'redpill', 'color',
'blackpill', 'whitepill', 'bluepill', 'clownworld', 'coomer', 'doomer', 'wojak', 'soyjak', 'pepe',
'style', 'weight', 'size', 'freedom', 'speech', 'censorship', 'moderation', 'community', 'anonymous',
'reply', 'search', 'group', 'merge', 'flatten', 'lock', 'unlock', 'hide', 'uyghur', 'soyshit', 'glow',
'also', 'only', 'just', 'even', 'very', 'than', 'then', 'that', 'this', 'with',
'from', 'into', 'onto', 'over', 'under', 'about', 'after', 'before', 'since', 'while',
'because', 'although', 'though', 'unless', 'until', 'where', 'which', 'whose', 'there', 'their',
'these', 'those', 'being', 'having', 'doing', 'going', 'would', 'could', 'should', 'shall', 'everything',
'might', 'must', 'will', 'have', 'been', 'were', 'wasn', 'aren', 'isn', 'does', 'isnt', 'mustnt',
'didn', 'hadn', 'hasn', 'dont', 'cant', 'wont', 'cannot', 'haven', 'weren', 'didnt', 'since',
'mustn', 'mightn', 'shouldn', 'wouldn', 'mightve', 'wouldve', 'shouldve', 'couldve', 'mustve',
'wasnt', 'werent', 'hasnt', 'hadnt', 'wont', 'wouldnt', 'shouldnt', 'couldnt', 'mightnt',
'each', 'such', 'some', 'most', 'many', 'more', 'much', 'less', 'few', 'none', 'although', 'because',
'both', 'either', 'neither', 'every', 'anyone', 'someone', 'everyone', 'nobody', 'nothing', 'so',
'above', 'below', 'along', 'across', 'among', 'until', 'and', 'but', 'or', 'nor', 'for', 'yet',
];
private const MAX_LENGTH_SUBJECT = 100; // posts.sql
private const MAX_LENGTH_NAME = 35; // posts.sql
private LogDriver $log;
private UserPostQueries $user_queries;
private SearchQueries $search_queries;
private ?array $flag_map;
private float $max_weight;
private int $max_query_length;
private int $post_limit;
private array $searchable_board_uris;
private static function truncateQuery(string $text, int $byteLimit): ?string {
if (\strlen($text) <= $byteLimit) {
return $text;
}
// Cut at byte length, trimming incomplete multibyte character at the end.
$cut = \mb_convert_encoding(\substr($text, 0, $byteLimit), 'UTF-8', 'UTF-8');
// Try the last space.
$spacePos = \strrpos($cut, ' ');
if ($spacePos !== false) {
return \substr($cut, 0, $spacePos);
}
// Fallback to the last word boundary.
if (\preg_match('/^(.+)\b/u', $cut, $m)) {
return $m[1];
}
// Too long but could not cut.
return null;
}
private static function trim(string $str): string {
return \trim($str, "* \n\r\t\v\0");
}
private static function unescape(string $str): string {
return \strtr($str, [
'\\\\' => '\\',
'\\*' => '*',
'\\"' => '"'
]);
}
/**
* Split the filter into fragments along the wildcards, handling escaping.
*
* @param string $str The full filter.
* @return array<string>
*/
private static function split(string $str): array {
// Split the fragments
return \preg_split('/(?:\\\\\\\\)*\\\\\*|(?:\\\\\\\\)*\*+/', $str);
}
private static function weightByContent(array $fragments): float {
$w = 0;
foreach ($fragments as $fragment) {
$short = \strlen($fragment) < 4;
if (\in_array($fragment, self::COMMON_WORDS)) {
$w += $short ? 16 : 6;
} elseif ($short) {
$w += 6;
}
}
return $w;
}
private static function filterAndWeight(string $filter): array {
$fragments = self::split($filter);
$acc = [];
$total_len = 0;
foreach ($fragments as $fragment) {
$fragment = self::trim(self::unescape($fragment));
if (!empty($fragment)) {
$total_len += \strlen($fragment);
$acc[] = $fragment;
}
}
// Interword wildcards
$interword = \min(\count($fragments) - 1, 0);
// Wildcards over the total length of the word. Ergo the number of fragments minus 1.
$perc = $interword / $total_len * 100;
$wildcard_weight = $perc + \count($fragments) * 2;
return [ $acc, $total_len, $wildcard_weight ];
}
/**
* Gets a subset of the flags which match every filter.
*
* @param array<string> $fragments User provided fragments to search in the flags.
* @param array<string> $flags An array of flags.
* @return array<string> An array of flags
*/
private static function matchFlags(array $flags, array $fragments): array {
return \array_filter($flags, function ($str) use ($fragments) {
// Saves the last position. We use this to ensure the fragments are one after the other.
$last_ret = 0;
foreach ($fragments as $fragment) {
if ($last_ret + 1 > \strlen($fragment)) {
// Cannot possibly match.
return false;
}
$last_ret = \stripos($str, $fragment, $last_ret + 1);
if ($last_ret === false) {
// Exclude flags that don't much even a single fragment.
return false;
}
}
return true;
});
}
/**
* Parses a raw search query.
*
* @param string $raw_query Raw user query. Phrases are searched in the post bodies. The user can specify also
* additional filters in the <key>:<value> format.
* Available filters:
* - board: the board, value can be quoted
* - subject: post subject, value can be quoted, supports wildcards
* - name: post name, value can be quoted, supports wildcards
* - flag: post flag, value can be quoted, supports wildcards
* - id: post id, must be numeric
* - thread: thread id, must be numeric
* The remaining text is split into chunks and searched in the post body.
* @return FiltersParseResult
*/
public function parse(string $raw_query): FiltersParseResult{
$tres = self::truncateQuery($raw_query, $this->max_query_length);
if ($tres === null) {
throw new \RuntimeException('Could not truncate query');
}
$pres = \preg_match_all(
'/(?:
\b(board):
(?:
"([^"]+)" # [2] board: "quoted"
|
([^\s"]+) # [3] board: unquoted
)
|
\b(subject|name|flag):
(?:
"((?:\\\\\\\\|\\\\\"|\\\\\*|[^"\\\\])*)" # [5] quoted with wildcards
|
((?:\\\\\\\\|\\\\\*|[^\s\\\\])+) # [6] unquoted with wildcards
)
|
\b(id|thread):
(\d+) # [8] numeric only
|
"((?:\\\\\\\\|\\\\\"|\\\\\*|[^"\\\\])*)" # [9] quoted free text
|
([^"\s]+(?:\s+(?!\b(?:board|subject|name|flag|id|thread):)[^"\s]+)*) # [10] unquoted free text block
)/iux',
$tres,
$matches,
\PREG_SET_ORDER
);
if ($pres === false) {
throw new \RuntimeException('Could not decode the query');
}
$filters = new FiltersParseResult();
foreach ($matches as $m) {
if (!empty($m[1])) {
// board (no wildcards).
$value = \trim(!empty($m[2]) ? $m[2] : $m[3], '/');
$filters->board = $value;
} elseif (!empty($m[4])) {
// subject, name, flag (with wildcards).
$key = \strtolower($m[4]);
$value = !empty($m[5]) ? $m[5] : $m[6];
if ($key === 'name') {
$filters->name = $value;
} elseif ($key === 'subject') {
$filters->subject = $value;
} else {
$filters->flag = $value;
}
} elseif (!empty($m[7])) {
$key = \strtolower($m[7]);
$value = (int)$m[8];
if ($key === 'id') {
$filters->id = $value;
} else {
$filters->thread = $value;
}
} elseif (!empty($m[9]) || !empty($m[10])) {
$value = !empty($m[9]) ? $m[9] : $m[10];
$filters->body[] = $value;
}
}
return $filters;
}
/**
* @param LogDriver $log Log river.
* @param UserPostQueries $user_queries User posts queries.
* @param SearchQueries $search_queries Search queries for flood detection.
* @param ?array $flag_map The key-value map of user flags, or null to disable flag search.
* @param float $max_weight The maximum weight of the parsed user query. Body filters that go beyond this limit are discarded.
* @param int $max_query_length Maximum length of the raw input query before it's truncated.
* @param int $post_limit Maximum number of results.
* @param ?array $searchable_board_uris The uris of the board that can be searched. Null to search all the boards.
*/
public function __construct(
LogDriver $log,
UserPostQueries $user_queries,
?array $flag_map,
float $max_weight,
int $max_query_length,
int $post_limit,
?array $searchable_board_uris
) {
$this->log = $log;
$this->user_queries = $user_queries;
$this->flag_map = $flag_map;
$this->max_weight = $max_weight;
$this->max_query_length = $max_query_length;
$this->post_limit = $post_limit;
$this->searchable_board_uris = $searchable_board_uris ?? listBoards(true);
}
/**
* Reduces the user provided filters and assigns them a total weight.
*
* @param FiltersParseResult $filters The filters to sanitize, reduce and weight.
* @return SearchFilters
*/
public function reduceAndWeight(FiltersParseResult $filters): SearchFilters {
$weighted = new SearchFilters();
if ($filters->subject !== null) {
list($fragments, $total_len, $wildcard_weight) = self::filterAndWeight($filters->subject);
if ($total_len > self::MAX_LENGTH_SUBJECT) {
$weighted->subject = [];
} else {
$weighted->subject = $fragments;
$weighted->weight = $wildcard_weight;
}
}
if ($filters->name !== null) {
list($fragments, $total_len, $wildcard_weight) = self::filterAndWeight($filters->name);
if ($total_len > self::MAX_LENGTH_NAME) {
$weighted->name = [];
} else {
$weighted->name = $fragments;
$weighted->weight += $wildcard_weight;
}
}
if ($filters->flag !== null) {
$weighted->flag = [];
if ($this->flag_map !== null && !empty($this->flag_map)) {
$max_flag_length = \array_reduce($this->flag_map, fn($max, $str) => \max($max, \strlen($str)), 0);
list($fragments, $total_len, $wildcard_weight) = self::filterAndWeight($filters->flag);
// Add 2 to account for possible wildcards on the ends.
if ($total_len <= $max_flag_length + 2) {
$weighted->flag = $fragments;
$weighted->weight += $wildcard_weight;
}
}
}
$weighted->id = $filters->id;
$weighted->thread = $filters->thread;
if ($filters->body !== null) {
foreach ($filters->body as $str) {
list($fragments, $total_len, $wildcard_weight) = self::filterAndWeight($str);
$content_weight = self::weightByContent($fragments);
$str_weight = $content_weight + $wildcard_weight;
if ($str_weight + $weighted->weight <= $this->max_weight) {
$weighted->weight += $str_weight;
$filters->body[] = $fragments;
}
}
}
return $weighted;
}
/**
* Run a search on user posts with the given filters.
*
* @param SearchFilters $filters An array of filters made by {@see self::parse()}.
* @param ?string $fallback_board Fallback board if there isn't a board filter.
* @return array Data array straight from the PDO, with all the fields in posts.sql
*/
public function search(string $ip, string $raw_query, SearchFilters $filters, ?string $fallback_board): array {
$board = $filters->board ?? $fallback_board;
if ($board === null) {
return [];
}
if (!\in_array($board, $this->searchable_board_uris)) {
return [];
}
$weight_perc = ($filters->weight / $this->max_weight) * 100;
if ($weight_perc > 85) {
/// Over 85 of the weight.
$this->log->log(LogDriver::NOTICE, "$ip search: weight $weight_perc ({$filters->weight}) query '$raw_query'");
} else {
$this->log->log(LogDriver::INFO, "$ip search: weight $weight_perc ({$filters->weight}) query '$raw_query'");
}
$flags = $filters->flag !== null ? $this->matchFlags($this->flag_map, $filters->flag) : null;
return $this->user_queries->searchPosts(
$board,
$filters->subject,
$filters->name,
$flags,
$filters->id,
$filters->thread,
$filters->body,
$this->post_limit
);
}
/**
* Check if the IP-query pair passes the limit.
*
* @param string $ip Source IP.
* @param string $phrase The search query.
* @return bool True if the request goes over the limit.
*/
public function checkFlood(string $ip, string $raw_query) {
return $this->search_queries->checkFlood($ip, $raw_query);
}
/**
* Returns the uris of the boards that may be searched.
*/
public function getSearchableBoards(): array {
return $this->searchable_board_uris;
}
}

View file

@ -1856,7 +1856,15 @@
// Limit of search results
$config['search']['search_limit'] = 100;
// Boards for searching
// Maximum weigth of the search query.
// Body search filters are discarded if they make the query heavier than this.
$config['search']['max_weight'] = 80;
// Maximum length of the user sent search query.
// Characters beyond the limit are truncated and ignored.
$config['search']['max_length'] = 768;
// Uncomment to limit the search feature to the given boards by uri.
//$config['search']['boards'] = array('a', 'b', 'c', 'd', 'e');
// Enable public logs? 0: NO, 1: YES, 2: YES, but drop names

View file

@ -1,8 +1,10 @@
<?php
namespace Vichan;
use Vichan\Data\{IpNoteQueries, ReportQueries, UserPostQueries};
use Flags;
use Vichan\Data\{IpNoteQueries, ReportQueries, SearchQueries, UserPostQueries};
use Vichan\Data\Driver\{CacheDriver, ErrorLogLogDriver, FileLogDriver, LogDriver, StderrLogDriver, SyslogLogDriver};
use Vichan\Service\SearchService;
defined('TINYBOARD') or exit;
@ -69,6 +71,28 @@ function build_context(array $config): Context {
sql_open();
return $pdo;
},
SearchService::class => function($c) {
$config = $c->get('config');
if ($config['user_flag']) {
$flags = $config['user_flags'];
} elseif ($config['country_flags']) {
$flags = Flags::EMBEDDED_FLAGS;
} else {
$flags = null;
}
$board_uris = $config['search']['boards'] ?? null;
return new SearchService(
$c->get(LogDriver::class),
$c->get(UserPostQueries::class),
$flags,
$config['search']['max_weight'],
$config['search']['max_length'],
$config['search']['search_limit'],
$board_uris
);
},
ReportQueries::class => function($c) {
$auto_maintenance = (bool)$c->get('config')['auto_maintenance'];
$pdo = $c->get(\PDO::class);
@ -78,5 +102,14 @@ function build_context(array $config): Context {
return new UserPostQueries($c->get(\PDO::class));
},
IpNoteQueries::class => fn($c) => new IpNoteQueries($c->get(\PDO::class), $c->get(CacheDriver::class)),
SearchQueries::class => function($c) {
$config = $c->get('config');
return new SearchQueries(
$c->get(\PDO::class),
$config['search']['queries_per_minutes'],
$config['search']['queries_per_minutes_all'],
$config['auto_maintenance']
);
}
]);
}

View file

@ -21,27 +21,14 @@ mb_internal_encoding('UTF-8');
loadConfig();
function init_locale($locale) {
if (extension_loaded('gettext')) {
if (setlocale(LC_ALL, $locale) === false) {
//$error('The specified locale (' . $locale . ') does not exist on your platform!');
// Fall back to C.UTF-8 instead of normal C, so we support unicode instead of just ASCII
setlocale(LC_ALL, "C.UTF-8");
setlocale(LC_CTYPE, "C.UTF-8");
}
bindtextdomain('tinyboard', './inc/locale');
bind_textdomain_codeset('tinyboard', 'UTF-8');
textdomain('tinyboard');
} else {
if (_setlocale(LC_ALL, $locale) === false) {
error('The specified locale (' . $locale . ') does not exist on your platform!');
// Fall back to C.UTF-8 instead of normal C, so we support unicode instead of just ASCII
_setlocale(LC_ALL, "C.UTF-8");
_setlocale(LC_CTYPE, "C.UTF-8");
}
_bindtextdomain('tinyboard', './inc/locale');
_bind_textdomain_codeset('tinyboard', 'UTF-8');
_textdomain('tinyboard');
if (setlocale(LC_ALL, $locale) === false) {
// Fall back to C.UTF-8 instead of normal C, so we support unicode instead of just ASCII
setlocale(LC_ALL, "C.UTF-8");
setlocale(LC_CTYPE, "C.UTF-8");
}
bindtextdomain('tinyboard', './inc/locale');
bind_textdomain_codeset('tinyboard', 'UTF-8');
textdomain('tinyboard');
}
$current_locale = 'en';
@ -113,8 +100,9 @@ function loadConfig() {
$config[$key] = array();
}
if (!file_exists('inc/instance-config.php'))
if (!file_exists('inc/instance-config.php') && !\defined('PHPUNIT_COMPOSER_INSTALL')) {
error('Tinyboard is not configured! Create inc/instance-config.php.');
}
// Initialize locale as early as possible
@ -149,7 +137,9 @@ function loadConfig() {
require 'inc/config.php';
require 'inc/instance-config.php';
if (file_exists('inc/instance-config.php') && !\defined('PHPUNIT_COMPOSER_INSTALL')) {
require 'inc/instance-config.php';
}
if (isset($board['dir']) && file_exists($board['dir'] . '/config.php')) {
require $board['dir'] . '/config.php';

View file

@ -1,3 +0,0 @@
Danilo Segan <danilo@kvota.net>
Nico Kaiser <nico@siriux.net> (contributed most changes between 1.0.2 and 1.0.3, bugfix for 1.0.5)
Steven Armstrong <sa@c-area.ch> (gettext.inc, leading to 1.0.6)

View file

@ -1,340 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Library General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Library General
Public License instead of this License.

View file

@ -1,38 +0,0 @@
PACKAGE = php-gettext-$(VERSION)
VERSION = 1.0.11
DIST_FILES = \
gettext.php \
gettext.inc \
streams.php \
AUTHORS \
README \
COPYING \
Makefile \
examples/index.php \
examples/pigs_dropin.php \
examples/pigs_fallback.php \
examples/locale/sr_CS/LC_MESSAGES/messages.po \
examples/locale/sr_CS/LC_MESSAGES/messages.mo \
examples/locale/de_CH/LC_MESSAGES/messages.po \
examples/locale/de_CH/LC_MESSAGES/messages.mo \
examples/update \
tests/LocalesTest.php \
tests/ParsingTest.php
check:
phpunit --verbose tests
dist: check
if [ -d $(PACKAGE) ]; then \
rm -rf $(PACKAGE); \
fi; \
mkdir $(PACKAGE); \
if [ -d $(PACKAGE) ]; then \
cp -rp --parents $(DIST_FILES) $(PACKAGE); \
tar cvzf $(PACKAGE).tar.gz $(PACKAGE); \
rm -rf $(PACKAGE); \
fi;
clean:
rm -f $(PACKAGE).tar.gz

View file

@ -1,161 +0,0 @@
PHP-gettext 1.0 (https://launchpad.net/php-gettext)
Copyright 2003, 2006, 2009 -- Danilo "angry with PHP[1]" Segan
Licensed under GPLv2 (or any later version, see COPYING)
[1] PHP is actually cyrillic, and translates roughly to
"works-doesn't-work" (UTF-8: Ради-Не-Ради)
Introduction
How many times did you look for a good translation tool, and
found out that gettext is best for the job? Many times.
How many times did you try to use gettext in PHP, but failed
miserably, because either your hosting provider didn't support
it, or the server didn't have adequate locale? Many times.
Well, this is a solution to your needs. It allows using gettext
tools for managing translations, yet it doesn't require gettext
library at all. It parses generated MO files directly, and thus
might be a bit slower than the (maybe provided) gettext library.
PHP-gettext is a simple reader for GNU gettext MO files. Those
are binary containers for translations, produced by GNU msgfmt.
Why?
I got used to having gettext work even without gettext
library. It's there in my favourite language Python, so I was
surprised that I couldn't find it in PHP. I even Googled for it,
but to no avail.
So, I said, what the heck, I'm going to write it for this
disguisting language of PHP, because I'm often constrained to it.
Features
o Support for simple translations
Just define a simple alias for translate() function (suggested
use of _() or gettext(); see provided example).
o Support for ngettext calls (plural forms, see a note under bugs)
You may also use plural forms. Translations in MO files need to
provide this, and they must also provide "plural-forms" header.
Please see 'info gettext' for more details.
o Support for reading straight files, or strings (!!!)
Since I can imagine many different backends for reading in the MO
file data, I used imaginary abstract class StreamReader to do all
the input (check streams.php). For your convenience, I've already
provided two classes for reading files: FileReader and
StringReader (CachedFileReader is a combination of the two: it
loads entire file contents into a string, and then works on that).
See example below for usage. You can for instance use StringReader
when you read in data from a database, or you can create your own
derivative of StreamReader for anything you like.
Bugs
Report them on https://bugs.launchpad.net/php-gettext
Usage
Put files streams.php and gettext.php somewhere you can load them
from, and require 'em in where you want to use them.
Then, create one 'stream reader' (a class that provides functions
like read(), seekto(), currentpos() and length()) which will
provide data for the 'gettext_reader', with eg.
$streamer = new FileStream('data.mo');
Then, use that as a parameter to gettext_reader constructor:
$wohoo = new gettext_reader($streamer);
If you want to disable pre-loading of entire message catalog in
memory (if, for example, you have a multi-thousand message catalog
which you'll use only occasionally), use "false" for second
parameter to gettext_reader constructor:
$wohoo = new gettext_reader($streamer, false);
From now on, you have all the benefits of gettext data at your
disposal, so may run:
print $wohoo->translate("This is a test");
print $wohoo->ngettext("%d bird", "%d birds", $birds);
You might need to pass parameter "-k" to xgettext to make it
extract all the strings. In above example, try with
xgettext -ktranslate -kngettext:1,2 file.php
what should create messages.po which contains two messages for
translation.
I suggest creating simple aliases for these functions (see
example/pigs.php for how do I do it, which means it's probably a
bad way).
Usage with gettext.inc (standard gettext interfaces emulation)
Check example in examples/pig_dropin.php, basically you include
gettext.inc and use all the standard gettext interfaces as
documented on:
http://www.php.net/gettext
The only catch is that you can check return value of setlocale()
to see if your locale is system supported or not.
Example
See in examples/ subdirectory. There are a couple of files.
pigs.php is an example, serbian.po is a translation to Serbian
language, and serbian.mo is generated with
msgfmt -o serbian.mo serbian.po
There is also simple "update" script that can be used to generate
POT file and to update the translation using msgmerge.
TODO:
o Improve speed to be even more comparable to the native gettext
implementation.
o Try to use hash tables in MO files: with pre-loading, would it
be useful at all?
Never-asked-questions:
o Why did you mark this as version 1.0 when this is the first code
release?
Well, it's quite simple. I consider that the first released thing
should be labeled "version 1" (first, right?). Zero is there to
indicate that there's zero improvement and/or change compared to
"version 1".
I plan to use version numbers 1.0.* for small bugfixes, and to
release 1.1 as "first stable release of version 1".
This may trick someone that this is actually useful software, but
as with any other free software, I take NO RESPONSIBILITY for
creating such a masterpiece that will smoke crack, trash your
hard disk, and make lasers in your CD device dance to the tune of
Mozart's 40th Symphony (there is one like that, right?).
o Can I...?
Yes, you can. This is free software (as in freedom, free speech),
and you might do whatever you wish with it, provided you do not
limit freedom of others (GPL).
I'm considering licensing this under LGPL, but I *do* want
*every* PHP-gettext user to contribute and respect ideas of free
software, so don't count on it happening anytime soon.
I'm sorry that I'm taking away your freedom of taking others'
freedom away, but I believe that's neglible as compared to what
freedoms you could take away. ;-)
Uhm, whatever.

View file

@ -1,536 +0,0 @@
<?php
/*
Copyright (c) 2005 Steven Armstrong <sa at c-area dot ch>
Copyright (c) 2009 Danilo Segan <danilo@kvota.net>
Drop in replacement for native gettext.
This file is part of PHP-gettext.
PHP-gettext is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
PHP-gettext is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with PHP-gettext; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
/*
LC_CTYPE 0
LC_NUMERIC 1
LC_TIME 2
LC_COLLATE 3
LC_MONETARY 4
LC_MESSAGES 5
LC_ALL 6
*/
// LC_MESSAGES is not available if php-gettext is not loaded
// while the other constants are already available from session extension.
if (!defined('LC_MESSAGES')) {
define('LC_MESSAGES', 5);
}
require(dirname(__FILE__) . '/streams.php');
require(dirname(__FILE__) . '/gettext.php');
// Variables
global $text_domains, $default_domain, $LC_CATEGORIES, $EMULATEGETTEXT, $CURRENTLOCALE;
$text_domains = array();
$default_domain = 'messages';
$LC_CATEGORIES = array('LC_CTYPE', 'LC_NUMERIC', 'LC_TIME', 'LC_COLLATE', 'LC_MONETARY', 'LC_MESSAGES', 'LC_ALL');
$EMULATEGETTEXT = 0;
$CURRENTLOCALE = '';
/* Class to hold a single domain included in $text_domains. */
class domain {
var $l10n;
var $path;
var $codeset;
}
// Utility functions
/**
* Return a list of locales to try for any POSIX-style locale specification.
*/
function get_list_of_locales($locale) {
/* Figure out all possible locale names and start with the most
* specific ones. I.e. for sr_CS.UTF-8@latin, look through all of
* sr_CS.UTF-8@latin, sr_CS@latin, sr@latin, sr_CS.UTF-8, sr_CS, sr.
*/
$locale_names = array();
$lang = NULL;
$country = NULL;
$charset = NULL;
$modifier = NULL;
if ($locale) {
if (preg_match("/^(?P<lang>[a-z]{2,3})" // language code
."(?:_(?P<country>[A-Z]{2}))?" // country code
."(?:\.(?P<charset>[-A-Za-z0-9_]+))?" // charset
."(?:@(?P<modifier>[-A-Za-z0-9_]+))?$/", // @ modifier
$locale, $matches)) {
if (isset($matches["lang"])) $lang = $matches["lang"];
if (isset($matches["country"])) $country = $matches["country"];
if (isset($matches["charset"])) $charset = $matches["charset"];
if (isset($matches["modifier"])) $modifier = $matches["modifier"];
if ($modifier) {
if ($country) {
if ($charset)
array_push($locale_names, "${lang}_$country.$charset@$modifier");
array_push($locale_names, "${lang}_$country@$modifier");
} elseif ($charset)
array_push($locale_names, "${lang}.$charset@$modifier");
array_push($locale_names, "$lang@$modifier");
}
if ($country) {
if ($charset)
array_push($locale_names, "${lang}_$country.$charset");
array_push($locale_names, "${lang}_$country");
} elseif ($charset)
array_push($locale_names, "${lang}.$charset");
array_push($locale_names, $lang);
}
// If the locale name doesn't match POSIX style, just include it as-is.
if (!in_array($locale, $locale_names))
array_push($locale_names, $locale);
}
return $locale_names;
}
/**
* Utility function to get a StreamReader for the given text domain.
*/
function _get_reader($domain=null, $category=5, $enable_cache=true) {
global $text_domains, $default_domain, $LC_CATEGORIES;
if (!isset($domain)) $domain = $default_domain;
if (!isset($text_domains[$domain]->l10n)) {
// get the current locale
$locale = _setlocale(LC_MESSAGES, 0);
$bound_path = isset($text_domains[$domain]->path) ?
$text_domains[$domain]->path : './';
$subpath = $LC_CATEGORIES[$category] ."/$domain.mo";
$locale_names = get_list_of_locales($locale);
$input = null;
foreach ($locale_names as $locale) {
$full_path = $bound_path . $locale . "/" . $subpath;
if (file_exists($full_path)) {
$input = new FileReader($full_path);
break;
}
}
if (!array_key_exists($domain, $text_domains)) {
// Initialize an empty domain object.
$text_domains[$domain] = new domain();
}
$text_domains[$domain]->l10n = new gettext_reader($input,
$enable_cache);
}
return $text_domains[$domain]->l10n;
}
/**
* Returns whether we are using our emulated gettext API or PHP built-in one.
*/
function locale_emulation() {
global $EMULATEGETTEXT;
return $EMULATEGETTEXT;
}
/**
* Checks if the current locale is supported on this system.
*/
function _check_locale_and_function($function=false) {
global $EMULATEGETTEXT;
if ($function and !function_exists($function))
return false;
return !$EMULATEGETTEXT;
}
/**
* Get the codeset for the given domain.
*/
function _get_codeset($domain=null) {
global $text_domains, $default_domain, $LC_CATEGORIES;
if (!isset($domain)) $domain = $default_domain;
return (isset($text_domains[$domain]->codeset))? $text_domains[$domain]->codeset : ini_get('mbstring.internal_encoding');
}
/**
* Convert the given string to the encoding set by bind_textdomain_codeset.
*/
function _encode($text) {
$source_encoding = mb_detect_encoding($text);
$target_encoding = _get_codeset();
if ($source_encoding != $target_encoding) {
return mb_convert_encoding($text, $target_encoding, $source_encoding);
}
else {
return $text;
}
}
// Custom implementation of the standard gettext related functions
/**
* Returns passed in $locale, or environment variable $LANG if $locale == ''.
*/
function _get_default_locale($locale) {
if ($locale == '') // emulate variable support
return getenv('LANG');
else
return $locale;
}
/**
* Sets a requested locale, if needed emulates it.
*/
function _setlocale($category, $locale) {
global $CURRENTLOCALE, $EMULATEGETTEXT;
if ($locale === 0) { // use === to differentiate between string "0"
if ($CURRENTLOCALE != '')
return $CURRENTLOCALE;
else
// obey LANG variable, maybe extend to support all of LC_* vars
// even if we tried to read locale without setting it first
return _setlocale($category, $CURRENTLOCALE);
} else {
if (function_exists('setlocale')) {
$ret = setlocale($category, $locale);
if (($locale == '' and !$ret) or // failed setting it by env
($locale != '' and $ret != $locale)) { // failed setting it
// Failed setting it according to environment.
$CURRENTLOCALE = _get_default_locale($locale);
$EMULATEGETTEXT = 1;
} else {
$CURRENTLOCALE = $ret;
$EMULATEGETTEXT = 0;
}
} else {
// No function setlocale(), emulate it all.
$CURRENTLOCALE = _get_default_locale($locale);
$EMULATEGETTEXT = 1;
}
// Allow locale to be changed on the go for one translation domain.
global $text_domains, $default_domain;
if (array_key_exists($default_domain, $text_domains)) {
unset($text_domains[$default_domain]->l10n);
}
return $CURRENTLOCALE;
}
}
/**
* Sets the path for a domain.
*/
function _bindtextdomain($domain, $path) {
global $text_domains;
// ensure $path ends with a slash ('/' should work for both, but lets still play nice)
if (substr(php_uname(), 0, 7) == "Windows") {
if ($path[strlen($path)-1] != '\\' and $path[strlen($path)-1] != '/')
$path .= '\\';
} else {
if ($path[strlen($path)-1] != '/')
$path .= '/';
}
if (!array_key_exists($domain, $text_domains)) {
// Initialize an empty domain object.
$text_domains[$domain] = new domain();
}
$text_domains[$domain]->path = $path;
}
/**
* Specify the character encoding in which the messages from the DOMAIN message catalog will be returned.
*/
function _bind_textdomain_codeset($domain, $codeset) {
global $text_domains;
$text_domains[$domain]->codeset = $codeset;
}
/**
* Sets the default domain.
*/
function _textdomain($domain) {
global $default_domain;
$default_domain = $domain;
}
/**
* Lookup a message in the current domain.
*/
function _gettext($msgid) {
$l10n = _get_reader();
return _encode($l10n->translate($msgid));
}
/**
* Alias for gettext.
*/
function __($msgid) {
return _gettext($msgid);
}
/**
* Plural version of gettext.
*/
function _ngettext($singular, $plural, $number) {
$l10n = _get_reader();
return _encode($l10n->ngettext($singular, $plural, $number));
}
/**
* Override the current domain.
*/
function _dgettext($domain, $msgid) {
$l10n = _get_reader($domain);
return _encode($l10n->translate($msgid));
}
/**
* Plural version of dgettext.
*/
function _dngettext($domain, $singular, $plural, $number) {
$l10n = _get_reader($domain);
return _encode($l10n->ngettext($singular, $plural, $number));
}
/**
* Overrides the domain and category for a single lookup.
*/
function _dcgettext($domain, $msgid, $category) {
$l10n = _get_reader($domain, $category);
return _encode($l10n->translate($msgid));
}
/**
* Plural version of dcgettext.
*/
function _dcngettext($domain, $singular, $plural, $number, $category) {
$l10n = _get_reader($domain, $category);
return _encode($l10n->ngettext($singular, $plural, $number));
}
/**
* Context version of gettext.
*/
function _pgettext($context, $msgid) {
$l10n = _get_reader();
return _encode($l10n->pgettext($context, $msgid));
}
/**
* Override the current domain in a context gettext call.
*/
function _dpgettext($domain, $context, $msgid) {
$l10n = _get_reader($domain);
return _encode($l10n->pgettext($context, $msgid));
}
/**
* Overrides the domain and category for a single context-based lookup.
*/
function _dcpgettext($domain, $context, $msgid, $category) {
$l10n = _get_reader($domain, $category);
return _encode($l10n->pgettext($context, $msgid));
}
/**
* Context version of ngettext.
*/
function _npgettext($context, $singular, $plural) {
$l10n = _get_reader();
return _encode($l10n->npgettext($context, $singular, $plural));
}
/**
* Override the current domain in a context ngettext call.
*/
function _dnpgettext($domain, $context, $singular, $plural) {
$l10n = _get_reader($domain);
return _encode($l10n->npgettext($context, $singular, $plural));
}
/**
* Overrides the domain and category for a plural context-based lookup.
*/
function _dcnpgettext($domain, $context, $singular, $plural, $category) {
$l10n = _get_reader($domain, $category);
return _encode($l10n->npgettext($context, $singular, $plural));
}
// Wrappers to use if the standard gettext functions are available,
// but the current locale is not supported by the system.
// Use the standard impl if the current locale is supported, use the
// custom impl otherwise.
function T_setlocale($category, $locale) {
return _setlocale($category, $locale);
}
function T_bindtextdomain($domain, $path) {
if (_check_locale_and_function()) return bindtextdomain($domain, $path);
else return _bindtextdomain($domain, $path);
}
function T_bind_textdomain_codeset($domain, $codeset) {
// bind_textdomain_codeset is available only in PHP 4.2.0+
if (_check_locale_and_function('bind_textdomain_codeset'))
return bind_textdomain_codeset($domain, $codeset);
else return _bind_textdomain_codeset($domain, $codeset);
}
function T_textdomain($domain) {
if (_check_locale_and_function()) return textdomain($domain);
else return _textdomain($domain);
}
function T_gettext($msgid) {
if (_check_locale_and_function()) return gettext($msgid);
else return _gettext($msgid);
}
function T_($msgid) {
if (_check_locale_and_function()) return _($msgid);
return __($msgid);
}
function T_ngettext($singular, $plural, $number) {
if (_check_locale_and_function())
return ngettext($singular, $plural, $number);
else return _ngettext($singular, $plural, $number);
}
function T_dgettext($domain, $msgid) {
if (_check_locale_and_function()) return dgettext($domain, $msgid);
else return _dgettext($domain, $msgid);
}
function T_dngettext($domain, $singular, $plural, $number) {
if (_check_locale_and_function())
return dngettext($domain, $singular, $plural, $number);
else return _dngettext($domain, $singular, $plural, $number);
}
function T_dcgettext($domain, $msgid, $category) {
if (_check_locale_and_function())
return dcgettext($domain, $msgid, $category);
else return _dcgettext($domain, $msgid, $category);
}
function T_dcngettext($domain, $singular, $plural, $number, $category) {
if (_check_locale_and_function())
return dcngettext($domain, $singular, $plural, $number, $category);
else return _dcngettext($domain, $singular, $plural, $number, $category);
}
function T_pgettext($context, $msgid) {
if (_check_locale_and_function('pgettext'))
return pgettext($context, $msgid);
else
return _pgettext($context, $msgid);
}
function T_dpgettext($domain, $context, $msgid) {
if (_check_locale_and_function('dpgettext'))
return dpgettext($domain, $context, $msgid);
else
return _dpgettext($domain, $context, $msgid);
}
function T_dcpgettext($domain, $context, $msgid, $category) {
if (_check_locale_and_function('dcpgettext'))
return dcpgettext($domain, $context, $msgid, $category);
else
return _dcpgettext($domain, $context, $msgid, $category);
}
function T_npgettext($context, $singular, $plural, $number) {
if (_check_locale_and_function('npgettext'))
return npgettext($context, $singular, $plural, $number);
else
return _npgettext($context, $singular, $plural, $number);
}
function T_dnpgettext($domain, $context, $singular, $plural, $number) {
if (_check_locale_and_function('dnpgettext'))
return dnpgettext($domain, $context, $singular, $plural, $number);
else
return _dnpgettext($domain, $context, $singular, $plural, $number);
}
function T_dcnpgettext($domain, $context, $singular, $plural,
$number, $category) {
if (_check_locale_and_function('dcnpgettext'))
return dcnpgettext($domain, $context, $singular,
$plural, $number, $category);
else
return _dcnpgettext($domain, $context, $singular,
$plural, $number, $category);
}
// Wrappers used as a drop in replacement for the standard gettext functions
if (!function_exists('gettext')) {
function bindtextdomain($domain, $path) {
return _bindtextdomain($domain, $path);
}
function bind_textdomain_codeset($domain, $codeset) {
return _bind_textdomain_codeset($domain, $codeset);
}
function textdomain($domain) {
return _textdomain($domain);
}
function gettext($msgid) {
return _gettext($msgid);
}
function _($msgid) {
return __($msgid);
}
function ngettext($singular, $plural, $number) {
return _ngettext($singular, $plural, $number);
}
function dgettext($domain, $msgid) {
return _dgettext($domain, $msgid);
}
function dngettext($domain, $singular, $plural, $number) {
return _dngettext($domain, $singular, $plural, $number);
}
function dcgettext($domain, $msgid, $category) {
return _dcgettext($domain, $msgid, $category);
}
function dcngettext($domain, $singular, $plural, $number, $category) {
return _dcngettext($domain, $singular, $plural, $number, $category);
}
function pgettext($context, $msgid) {
return _pgettext($context, $msgid);
}
function npgettext($context, $singular, $plural, $number) {
return _npgettext($context, $singular, $plural, $number);
}
function dpgettext($domain, $context, $msgid) {
return _dpgettext($domain, $context, $msgid);
}
function dnpgettext($domain, $context, $singular, $plural, $number) {
return _dnpgettext($domain, $context, $singular, $plural, $number);
}
function dcpgettext($domain, $context, $msgid, $category) {
return _dcpgettext($domain, $context, $msgid, $category);
}
function dcnpgettext($domain, $context, $singular, $plural,
$number, $category) {
return _dcnpgettext($domain, $context, $singular, $plural,
$number, $category);
}
}
?>

View file

@ -1,432 +0,0 @@
<?php
/*
Copyright (c) 2003, 2009 Danilo Segan <danilo@kvota.net>.
Copyright (c) 2005 Nico Kaiser <nico@siriux.net>
This file is part of PHP-gettext.
PHP-gettext is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
PHP-gettext is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with PHP-gettext; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
/**
* Provides a simple gettext replacement that works independently from
* the system's gettext abilities.
* It can read MO files and use them for translating strings.
* The files are passed to gettext_reader as a Stream (see streams.php)
*
* This version has the ability to cache all strings and translations to
* speed up the string lookup.
* While the cache is enabled by default, it can be switched off with the
* second parameter in the constructor (e.g. whenusing very large MO files
* that you don't want to keep in memory)
*/
class gettext_reader {
//public:
var $error = 0; // public variable that holds error code (0 if no error)
//private:
var $BYTEORDER = 0; // 0: low endian, 1: big endian
var $STREAM = NULL;
var $short_circuit = false;
var $enable_cache = false;
var $originals = NULL; // offset of original table
var $translations = NULL; // offset of translation table
var $pluralheader = NULL; // cache header field for plural forms
var $total = 0; // total string count
var $table_originals = NULL; // table for original strings (offsets)
var $table_translations = NULL; // table for translated strings (offsets)
var $cache_translations = NULL; // original -> translation mapping
/* Methods */
/**
* Reads a 32bit Integer from the Stream
*
* @access private
* @return Integer from the Stream
*/
function readint() {
if ($this->BYTEORDER == 0) {
// low endian
$input=unpack('V', $this->STREAM->read(4));
return array_shift($input);
} else {
// big endian
$input=unpack('N', $this->STREAM->read(4));
return array_shift($input);
}
}
function read($bytes) {
return $this->STREAM->read($bytes);
}
/**
* Reads an array of Integers from the Stream
*
* @param int count How many elements should be read
* @return Array of Integers
*/
function readintarray($count) {
if ($this->BYTEORDER == 0) {
// low endian
return unpack('V'.$count, $this->STREAM->read(4 * $count));
} else {
// big endian
return unpack('N'.$count, $this->STREAM->read(4 * $count));
}
}
/**
* Constructor
*
* @param object Reader the StreamReader object
* @param boolean enable_cache Enable or disable caching of strings (default on)
*/
function gettext_reader($Reader, $enable_cache = true) {
// If there isn't a StreamReader, turn on short circuit mode.
if (! $Reader || isset($Reader->error) ) {
$this->short_circuit = true;
return;
}
// Caching can be turned off
$this->enable_cache = $enable_cache;
$MAGIC1 = "\x95\x04\x12\xde";
$MAGIC2 = "\xde\x12\x04\x95";
$this->STREAM = $Reader;
$magic = $this->read(4);
if ($magic == $MAGIC1) {
$this->BYTEORDER = 1;
} elseif ($magic == $MAGIC2) {
$this->BYTEORDER = 0;
} else {
$this->error = 1; // not MO file
return false;
}
// FIXME: Do we care about revision? We should.
$revision = $this->readint();
$this->total = $this->readint();
$this->originals = $this->readint();
$this->translations = $this->readint();
}
/**
* Loads the translation tables from the MO file into the cache
* If caching is enabled, also loads all strings into a cache
* to speed up translation lookups
*
* @access private
*/
function load_tables() {
if (is_array($this->cache_translations) &&
is_array($this->table_originals) &&
is_array($this->table_translations))
return;
/* get original and translations tables */
if (!is_array($this->table_originals)) {
$this->STREAM->seekto($this->originals);
$this->table_originals = $this->readintarray($this->total * 2);
}
if (!is_array($this->table_translations)) {
$this->STREAM->seekto($this->translations);
$this->table_translations = $this->readintarray($this->total * 2);
}
if ($this->enable_cache) {
$this->cache_translations = array ();
/* read all strings in the cache */
for ($i = 0; $i < $this->total; $i++) {
$this->STREAM->seekto($this->table_originals[$i * 2 + 2]);
$original = $this->STREAM->read($this->table_originals[$i * 2 + 1]);
$this->STREAM->seekto($this->table_translations[$i * 2 + 2]);
$translation = $this->STREAM->read($this->table_translations[$i * 2 + 1]);
$this->cache_translations[$original] = $translation;
}
}
}
/**
* Returns a string from the "originals" table
*
* @access private
* @param int num Offset number of original string
* @return string Requested string if found, otherwise ''
*/
function get_original_string($num) {
$length = $this->table_originals[$num * 2 + 1];
$offset = $this->table_originals[$num * 2 + 2];
if (! $length)
return '';
$this->STREAM->seekto($offset);
$data = $this->STREAM->read($length);
return (string)$data;
}
/**
* Returns a string from the "translations" table
*
* @access private
* @param int num Offset number of original string
* @return string Requested string if found, otherwise ''
*/
function get_translation_string($num) {
$length = $this->table_translations[$num * 2 + 1];
$offset = $this->table_translations[$num * 2 + 2];
if (! $length)
return '';
$this->STREAM->seekto($offset);
$data = $this->STREAM->read($length);
return (string)$data;
}
/**
* Binary search for string
*
* @access private
* @param string string
* @param int start (internally used in recursive function)
* @param int end (internally used in recursive function)
* @return int string number (offset in originals table)
*/
function find_string($string, $start = -1, $end = -1) {
if (($start == -1) or ($end == -1)) {
// find_string is called with only one parameter, set start end end
$start = 0;
$end = $this->total;
}
if (abs($start - $end) <= 1) {
// We're done, now we either found the string, or it doesn't exist
$txt = $this->get_original_string($start);
if ($string == $txt)
return $start;
else
return -1;
} else if ($start > $end) {
// start > end -> turn around and start over
return $this->find_string($string, $end, $start);
} else {
// Divide table in two parts
$half = (int)(($start + $end) / 2);
$cmp = strcmp($string, $this->get_original_string($half));
if ($cmp == 0)
// string is exactly in the middle => return it
return $half;
else if ($cmp < 0)
// The string is in the upper half
return $this->find_string($string, $start, $half);
else
// The string is in the lower half
return $this->find_string($string, $half, $end);
}
}
/**
* Translates a string
*
* @access public
* @param string string to be translated
* @return string translated string (or original, if not found)
*/
function translate($string) {
if ($this->short_circuit)
return $string;
$this->load_tables();
if ($this->enable_cache) {
// Caching enabled, get translated string from cache
if (array_key_exists($string, $this->cache_translations))
return $this->cache_translations[$string];
else
return $string;
} else {
// Caching not enabled, try to find string
$num = $this->find_string($string);
if ($num == -1)
return $string;
else
return $this->get_translation_string($num);
}
}
/**
* Sanitize plural form expression for use in PHP eval call.
*
* @access private
* @return string sanitized plural form expression
*/
function sanitize_plural_expression($expr) {
// Get rid of disallowed characters.
$expr = preg_replace('@[^a-zA-Z0-9_:;\(\)\?\|\&=!<>+*/\%-]@', '', $expr);
// Add parenthesis for tertiary '?' operator.
$expr .= ';';
$res = '';
$p = 0;
for ($i = 0; $i < strlen($expr); $i++) {
$ch = $expr[$i];
switch ($ch) {
case '?':
$res .= ' ? (';
$p++;
break;
case ':':
$res .= ') : (';
break;
case ';':
$res .= str_repeat( ')', $p) . ';';
$p = 0;
break;
default:
$res .= $ch;
}
}
return $res;
}
/**
* Parse full PO header and extract only plural forms line.
*
* @access private
* @return string verbatim plural form header field
*/
function extract_plural_forms_header_from_po_header($header) {
if (preg_match("/(^|\n)plural-forms: ([^\n]*)\n/i", $header, $regs))
$expr = $regs[2];
else
$expr = "nplurals=2; plural=n == 1 ? 0 : 1;";
return $expr;
}
/**
* Get possible plural forms from MO header
*
* @access private
* @return string plural form header
*/
function get_plural_forms() {
// lets assume message number 0 is header
// this is true, right?
$this->load_tables();
// cache header field for plural forms
if (! is_string($this->pluralheader)) {
if ($this->enable_cache) {
$header = $this->cache_translations[""];
} else {
$header = $this->get_translation_string(0);
}
$expr = $this->extract_plural_forms_header_from_po_header($header);
$this->pluralheader = $this->sanitize_plural_expression($expr);
}
return $this->pluralheader;
}
/**
* Detects which plural form to take
*
* @access private
* @param n count
* @return int array index of the right plural form
*/
function select_string($n) {
$string = $this->get_plural_forms();
$string = str_replace('nplurals',"\$total",$string);
$string = str_replace("n",$n,$string);
$string = str_replace('plural',"\$plural",$string);
$total = 0;
$plural = 0;
eval("$string");
if ($plural >= $total) $plural = $total - 1;
return $plural;
}
/**
* Plural version of gettext
*
* @access public
* @param string single
* @param string plural
* @param string number
* @return translated plural form
*/
function ngettext($single, $plural, $number) {
if ($this->short_circuit) {
if ($number != 1)
return $plural;
else
return $single;
}
// find out the appropriate form
$select = $this->select_string($number);
// this should contains all strings separated by NULLs
$key = $single . chr(0) . $plural;
if ($this->enable_cache) {
if (! array_key_exists($key, $this->cache_translations)) {
return ($number != 1) ? $plural : $single;
} else {
$result = $this->cache_translations[$key];
$list = explode(chr(0), $result);
return $list[$select];
}
} else {
$num = $this->find_string($key);
if ($num == -1) {
return ($number != 1) ? $plural : $single;
} else {
$result = $this->get_translation_string($num);
$list = explode(chr(0), $result);
return $list[$select];
}
}
}
function pgettext($context, $msgid) {
$key = $context . chr(4) . $msgid;
$ret = $this->translate($key);
if (strpos($ret, "\004") !== FALSE) {
return $msgid;
} else {
return $ret;
}
}
function npgettext($context, $singular, $plural, $number) {
$key = $context . chr(4) . $singular;
$ret = $this->ngettext($key, $plural, $number);
if (strpos($ret, "\004") !== FALSE) {
return $singular;
} else {
return $ret;
}
}
}
?>

View file

@ -1,167 +0,0 @@
<?php
/*
Copyright (c) 2003, 2005, 2006, 2009 Danilo Segan <danilo@kvota.net>.
This file is part of PHP-gettext.
PHP-gettext is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
PHP-gettext is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with PHP-gettext; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
// Simple class to wrap file streams, string streams, etc.
// seek is essential, and it should be byte stream
class StreamReader {
// should return a string [FIXME: perhaps return array of bytes?]
function read($bytes) {
return false;
}
// should return new position
function seekto($position) {
return false;
}
// returns current position
function currentpos() {
return false;
}
// returns length of entire stream (limit for seekto()s)
function length() {
return false;
}
};
class StringReader {
var $_pos;
var $_str;
function StringReader($str='') {
$this->_str = $str;
$this->_pos = 0;
}
function read($bytes) {
$data = substr($this->_str, $this->_pos, $bytes);
$this->_pos += $bytes;
if (strlen($this->_str)<$this->_pos)
$this->_pos = strlen($this->_str);
return $data;
}
function seekto($pos) {
$this->_pos = $pos;
if (strlen($this->_str)<$this->_pos)
$this->_pos = strlen($this->_str);
return $this->_pos;
}
function currentpos() {
return $this->_pos;
}
function length() {
return strlen($this->_str);
}
};
class FileReader {
var $_pos;
var $_fd;
var $_length;
function FileReader($filename) {
if (file_exists($filename)) {
$this->_length=filesize($filename);
$this->_pos = 0;
$this->_fd = fopen($filename,'rb');
if (!$this->_fd) {
$this->error = 3; // Cannot read file, probably permissions
return false;
}
} else {
$this->error = 2; // File doesn't exist
return false;
}
}
function read($bytes) {
if ($bytes) {
fseek($this->_fd, $this->_pos);
// PHP 5.1.1 does not read more than 8192 bytes in one fread()
// the discussions at PHP Bugs suggest it's the intended behaviour
$data = '';
while ($bytes > 0) {
$chunk = fread($this->_fd, $bytes);
$data .= $chunk;
$bytes -= strlen($chunk);
}
$this->_pos = ftell($this->_fd);
return $data;
} else return '';
}
function seekto($pos) {
fseek($this->_fd, $pos);
$this->_pos = ftell($this->_fd);
return $this->_pos;
}
function currentpos() {
return $this->_pos;
}
function length() {
return $this->_length;
}
function close() {
fclose($this->_fd);
}
};
// Preloads entire file in memory first, then creates a StringReader
// over it (it assumes knowledge of StringReader internals)
class CachedFileReader extends StringReader {
function CachedFileReader($filename) {
if (file_exists($filename)) {
$length=filesize($filename);
$fd = fopen($filename,'rb');
if (!$fd) {
$this->error = 3; // Cannot read file, probably permissions
return false;
}
$this->_str = fread($fd, $length);
fclose($fd);
} else {
$this->error = 2; // File doesn't exist
return false;
}
}
};
?>

View file

@ -2,9 +2,37 @@
// PHP 8.0
use Vichan\Polyfill\GettextWrapper;
if (!function_exists('str_starts_with')) {
function str_starts_with(string $haystack, string $needle): bool {
// https://wiki.php.net/rfc/add_str_starts_with_and_ends_with_functions#str_starts_with
return \strncmp($haystack, $needle, \strlen($needle)) === 0;
}
}
if (!\extension_loaded('gettext')) {
function bindtextdomain(string $domain, string $directory): string {
return GettextWrapper::bindTextDomain($domain, $directory);
}
function bind_textdomain_codeset(string $domain, string $codeset): ?string {
return GettextWrapper::bindTextDomainCodeset($domain, $codeset);
}
function textdomain(?string $domain = null) {
return GettextWrapper::textDomain($domain);
}
//function setlocale(string $locale): void {
// GettextWrapper::setLocale($locale);
//}
function translate(string $message): string {
return GettextWrapper::translate($message);
}
function _(string $message): string {
return GettextWrapper::translate($message);
}
}

View file

@ -1,174 +1,70 @@
<?php
use Vichan\Service\SearchService;
require 'inc/bootstrap.php';
if (!$config['search']['enable']) {
die(_("Post search is disabled"));
}
$queries_per_minutes = $config['search']['queries_per_minutes'];
$queries_per_minutes_all = $config['search']['queries_per_minutes_all'];
$search_limit = $config['search']['search_limit'];
$ctx = Vichan\build_context($config);
$search_service = $ctx->get(SearchService::class);
if (isset($config['search']['boards'])) {
$boards = $config['search']['boards'];
} else {
$boards = listBoards(TRUE);
}
if (isset($_GET['search']) && !empty($_GET['search'])) {
$raw_search = $_GET['search'];
$ip = $_SERVER['REMOTE_ADDR'];
$fallback_board = (isset($_GET['board']) && !empty($_GET['board'])) ? $_GET['board'] : null;
$body = Element('search_form.html', Array('boards' => $boards, 'board' => isset($_GET['board']) ? $_GET['board'] : false, 'search' => isset($_GET['search']) ? str_replace('"', '&quot;', utf8tohtml($_GET['search'])) : false));
if (isset($_GET['search']) && !empty($_GET['search']) && isset($_GET['board']) && in_array($_GET['board'], $boards)) {
$phrase = $_GET['search'];
$_body = '';
$query = prepare("SELECT COUNT(*) FROM ``search_queries`` WHERE `ip` = :ip AND `time` > :time");
$query->bindValue(':ip', $_SERVER['REMOTE_ADDR']);
$query->bindValue(':time', time() - ($queries_per_minutes[1] * 60));
$query->execute() or error(db_error($query));
if ($query->fetchColumn() > $queries_per_minutes[0])
if ($search_service->checkFlood($ip, $raw_search)) {
error(_('Wait a while before searching again, please.'));
$query = prepare("SELECT COUNT(*) FROM ``search_queries`` WHERE `time` > :time");
$query->bindValue(':time', time() - ($queries_per_minutes_all[1] * 60));
$query->execute() or error(db_error($query));
if ($query->fetchColumn() > $queries_per_minutes_all[0])
error(_('Wait a while before searching again, please.'));
$query = prepare("INSERT INTO ``search_queries`` VALUES (:ip, :time, :query)");
$query->bindValue(':ip', $_SERVER['REMOTE_ADDR']);
$query->bindValue(':time', time());
$query->bindValue(':query', $phrase);
$query->execute() or error(db_error($query));
_syslog(LOG_NOTICE, 'Searched /' . $_GET['board'] . '/ for "' . $phrase . '"');
// Cleanup search queries table
$query = prepare("DELETE FROM ``search_queries`` WHERE `time` <= :time");
$query->bindValue(':time', time() - ($queries_per_minutes_all[1] * 60));
$query->execute() or error(db_error($query));
openBoard($_GET['board']);
$filters = Array();
function search_filters($m) {
global $filters;
$name = $m[2];
$value = isset($m[4]) ? $m[4] : $m[3];
if (!in_array($name, array('id', 'thread', 'subject', 'name'))) {
// unknown filter
return $m[0];
}
$filters[$name] = $value;
return $m[1];
}
$phrase = trim(preg_replace_callback('/(^|\s)(\w+):("(.*)?"|[^\s]*)/', 'search_filters', $phrase));
// Actually do the search.
$parse_res = $search_service->parse($raw_search);
$filters = $search_service->reduceAndWeight($parse_res);
$search_res = $search_service->search($ip, $raw_search, $filters, $fallback_board);
if (!preg_match('/[^*^\s]/', $phrase) && empty($filters)) {
_syslog(LOG_WARNING, 'Query too broad.');
$body .= '<p class="unimportant" style="text-align:center">(Query too broad.)</p>';
echo Element('page.html', Array(
'config'=>$config,
'title'=>'Search',
'body'=>$body,
));
exit;
}
// Escape escape character
$phrase = str_replace('!', '!!', $phrase);
// Needed to set a global variable further down the stack, plus the template.
$actual_board = $filter->board ?? $fallback_board;
// Remove SQL wildcard
$phrase = str_replace('%', '!%', $phrase);
$body = Element('search_form.html', [
'boards' => $search_service->getSearchableBoards(),
'board' => $_GET['board'],
'search' => \str_replace('"', '&quot;', utf8tohtml($_GET['search']))
]);
// Use asterisk as wildcard to suit convention
$phrase = str_replace('*', '%', $phrase);
// Remove `, it's used by table prefix magic
$phrase = str_replace('`', '!`', $phrase);
$like = '';
$match = Array();
// Find exact phrases
if (preg_match_all('/"(.+?)"/', $phrase, $m)) {
foreach($m[1] as &$quote) {
$phrase = str_replace("\"{$quote}\"", '', $phrase);
$match[] = $pdo->quote($quote);
}
}
$words = explode(' ', $phrase);
foreach($words as &$word) {
if (empty($word)) {
continue;
}
$match[] = $pdo->quote($word);
}
$like = '';
foreach($match as &$phrase) {
if (!empty($like)) {
$like .= ' AND ';
}
$phrase = preg_replace('/^\'(.+)\'$/', '\'%$1%\'', $phrase);
$like .= '`body` LIKE ' . $phrase . ' ESCAPE \'!\'';
}
foreach($filters as $name => $value) {
if (!empty($like)) {
$like .= ' AND ';
}
$like .= '`' . $name . '` = '. $pdo->quote($value);
}
$like = str_replace('%', '%%', $like);
$query = prepare(sprintf("SELECT * FROM ``posts_%s`` WHERE " . $like . " ORDER BY `time` DESC LIMIT :limit", $board['uri']));
$query->bindValue(':limit', $search_limit, PDO::PARAM_INT);
$query->execute() or error(db_error($query));
if ($query->rowCount() == $search_limit) {
_syslog(LOG_WARNING, 'Query too broad.');
$body .= '<p class="unimportant" style="text-align:center">('._('Query too broad.').')</p>';
echo Element('page.html', Array(
'config'=>$config,
'title'=>'Search',
'body'=>$body,
));
exit;
}
$temp = '';
while ($post = $query->fetch()) {
if (!$post['thread']) {
$po = new Thread($post);
} else {
$po = new Post($post);
}
$temp .= $po->build(true) . '<hr/>';
}
if (!empty($temp))
$_body .= '<fieldset><legend>' .
sprintf(ngettext('%d result in', '%d results in', $query->rowCount()),
$query->rowCount()) . ' <a href="/' .
sprintf($config['board_path'], $board['uri']) . $config['file_index'] .
'">' .
sprintf($config['board_abbreviation'], $board['uri']) . ' - ' . $board['title'] .
'</a></legend>' . $temp . '</fieldset>';
$body .= '<hr/>';
if (!empty($_body)) {
$body .= $_body;
if (empty($search_res)) {
$body .= '<hr/><p style="text-align:center" class="unimportant">(' . _('No results.') . ')</p>';
} else {
$body .= '<p style="text-align:center" class="unimportant">('._('No results.').')</p>';
$body .= '<hr/>';
openBoard($actual_board);
$posts_html = '';
foreach ($search_res as $post) {
if (!$post['thread']) {
$po = new Thread($post);
} else {
$po = new Post($post);
}
$posts_html .= $po->build(true) . '<hr/>';
}
$body .= '<fieldset><legend>' .
sprintf(ngettext('%d result in', '%d results in', \count($search_res)), \count($search_res)) . ' <a href="/' .
sprintf($config['board_path'], $board['uri']) . $config['file_index'] . '">' .
sprintf($config['board_abbreviation'], $board['uri']) . ' - ' . $board['title'] .
'</a></legend>' . $posts_html . '</fieldset>';
}
} else {
$body = Element('search_form.html', [
'boards' => $search_service->getSearchableBoards(),
'board' => false,
'search' => false
]);
}
echo Element('page.html', Array(

View file

@ -2,7 +2,6 @@
use PHPUnit\Framework\TestCase;
ob_start();
define('TINYBOARD', true);
require_once "inc/mod/pages.php";
ob_end_clean();

View file

@ -0,0 +1,64 @@
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework;
use Vichan\Data\Driver\{LogDriver, StderrLogDriver};
use Vichan\Data\UserPostQueries;
use Vichan\Service\SearchService;
class SearchServiceTest extends TestCase {
public function testBasicSearch(): void {
$srv = new SearchService(
$this->createMock(LogDriver::class),
$this->createMock(UserPostQueries::class),
null,
100,
250,
100,
);
$filters = $srv->parse("free world all large board:kino board:\"poly\" name:coolie maybe subject:\"subj\" flag:\"pirate\" id:76 thread:8 but not so much");
Framework\assertTrue($filters->body === [ 'free world all large', 'maybe', 'but not so much' ]);
Framework\assertTrue($filters->subject === 'subj');
Framework\assertTrue($filters->name === 'coolie');
Framework\assertTrue($filters->flag === 'pirate');
Framework\assertTrue($filters->id === 76);
Framework\assertTrue($filters->thread === 8);
}
public function testWeight() {
$user_queries = $this->createMock(UserPostQueries::class);
$user_queries->method('escapeSearchPosts')
->willReturnMap([
[ 'abcd', 'abcd' ],
[ 'abc', 'abc' ],
[ 'a*cd', 'a\\*cd' ],
[ 'a*c', 'a\\*c' ],
]);
$srv = new SearchService(
new StderrLogDriver('test', LogDriver::DEBUG),
$user_queries,
null,
100,
250,
100,
);
$f = $srv->parse('abcd');
$no_wildcards = $srv->reduceAndWeight($f)->weight;
$f = $srv->parse('abc*');
$end_wildcard = $srv->reduceAndWeight($f)->weight;
$f = $srv->parse('a*cd');
$middle_wildcard = $srv->reduceAndWeight($f)->weight;
$f = $srv->parse('a*c*');
$wildcards = $srv->reduceAndWeight($f)->weight;
Framework\assertTrue($no_wildcards < $end_wildcard);
Framework\assertTrue($end_wildcard < $middle_wildcard);
Framework\assertTrue($middle_wildcard < $wildcards);
}
}

View file

@ -1,24 +0,0 @@
<?php
use PHPUnit\Framework\TestCase;
ob_start();
require_once "banners.php";
ob_end_clean();
final class bannersTest extends TestCase
{
public function testBanners(){
$expected = '<a href="banners/lain-bottom.png"><img src="banners/lain-bottom.png" alt="lain-bottom.png" style="width:348px;height:128px"></a> ';
//capture input
ob_start();
listBannersInDir("banners");
$output = ob_get_contents();
ob_end_clean();
//end input
//assertion
$this->assertEquals($output,$expected);
}
}

View file

@ -4,6 +4,7 @@
*/
use Vichan\Data\ReportQueries;
use Vichan\Data\SearchQueries;
require dirname(__FILE__) . '/inc/cli.php';
@ -45,9 +46,17 @@ if ($config['cache']['enabled'] === 'fs') {
$fs_cache->collect();
$delta = microtime(true) - $start;
echo "Deleted $deleted_count expired filesystem cache items in $delta seconds!\n";
$time_tot = $delta;
$time_tot += $delta;
$deleted_tot = $deleted_count;
}
echo "Clearing old search log...\n";
$search_queries = $ctx->get(SearchQueries::class);
$start = microtime(true);
$deleted_count = $search_queries->purgeExpired();
$delta = microtime(true) - $start;
$time_tot += $delta;
$deleted_tot = $deleted_count;
$time_tot = number_format((float)$time_tot, 4, '.', '');
modLog("Deleted $deleted_tot expired entries in {$time_tot}s with maintenance tool");