From 6ae34d647e42bfbcb718fb22643aaabccaedb3a4 Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Thu, 15 Oct 2026 00:27:57 -0500 Subject: [PATCH 1/2] Add more parser functions and magic words #titleparts, #rel2abs, #tag, #timel, formatnum, plural and gender, the LOCAL... date words in the board's time zone, and the REVISION... words for the article's current approved version. #timel is #time in the reader's time zone and language; #time stays UTC and English. #tag writes HTML that is then parsed with the article, so only what articles may use takes effect. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 ++ config/services.yml | 1 + docs/TODO/todo-wiki-markup.md | 14 +- event/wikilink_listener.php | 2 +- tests/unit/parser_functions_test.php | 76 ++++++++++- tests/unit/text_tools_test.php | 47 +++++++ wiki/templates.php | 190 ++++++++++++++++++++++++++- wiki/text_tools.php | 120 ++++++++++++++++- 8 files changed, 444 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8a69e12..756045d 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ An article | `{{#iferror: test \| error \| ok}}` | `error` when `test` is one of the red messages below (a failed `#expr`, a template loop, an unknown function), otherwise `ok`, or `test` itself when `ok` is left out | | `{{#time: j F Y, H:i \| date}}` | a date in a format, in UTC: `Y y n m M F d j D l N w z W t L H G h g i s a A U c r e` as in PHP's `date()`, `"quoted text"` and `\c` as written; `date` is anything PHP reads (`2026-10-12`, `@1791500000`, `yesterday`, `+1 week`), and the time now when left out | | `{{CURRENTYEAR}}`, `{{CURRENTMONTH}}`, `{{CURRENTMONTHNAME}}`, `{{CURRENTMONTHABBREV}}`, `{{CURRENTDAY}}`, `{{CURRENTDAY2}}`, `{{CURRENTDAYNAME}}`, `{{CURRENTDOW}}`, `{{CURRENTWEEK}}`, `{{CURRENTHOUR}}`, `{{CURRENTTIME}}`, `{{CURRENTTIMESTAMP}}` | the date and time now, in UTC, with English month and day names | + | `{{LOCALYEAR}}`, `{{LOCALTIME}}` and the other `LOCAL...` words | the same, in the board's time zone | + | `{{#timel: j F Y, H:i \| date}}` | `#time` in the reader's time zone, with month and day names in the reader's language | + | `{{REVISIONID}}`, `{{REVISIONUSER}}`, `{{REVISIONTIMESTAMP}}`, `{{REVISIONYEAR}}`, `{{REVISIONMONTH}}`, `{{REVISIONMONTH1}}`, `{{REVISIONDAY}}`, `{{REVISIONDAY2}}` | the article's current approved version: its number, who saved it and when (UTC); empty before the article is first approved | | `{{#len: text}}` | the number of characters | | `{{#pos: text \| find \| from}}`, `{{#rpos: text \| find}}` | where `find` first or last occurs, counted from 0; `#pos` gives nothing and `#rpos` gives `-1` when it is not there; an empty `find` is a space | | `{{#sub: text \| start \| length}}` | part of `text`; a negative `start` counts from the end and a negative `length` leaves that many off the end | @@ -108,6 +111,12 @@ An article | `{{#explode: text \| separator \| position \| limit}}` | one piece of `text` cut at `separator`, counted from 0 (negative from the end); an empty `separator` is a space | | `{{padleft: text \| length \| fill}}`, `{{padright: text \| length \| fill}}` | `text` filled up to `length` with `fill` (default `0`) | | `{{urlencode: text}}`, `{{anchorencode: text}}` | `text` as a web address part, and as a heading anchor | + | `{{#titleparts: A/B/C \| count \| first}}` | parts of a name split at `/`: `count` parts (all when 0, a negative number leaves that many off the end) from part `first` (1 is the first, negative counts from the end) | + | `{{#rel2abs: ../Other \| base}}` | a name worked out from `./`, `../` and `/` against `base`, or the article being shown | + | `{{formatnum: 1234567}}`, `{{formatnum: 1,234 \| R}}` | `1,234,567`; `R` takes the commas out again | + | `{{plural: count \| one \| many}}` | `one` when `count` is 1 or -1, otherwise `many` | + | `{{gender: name \| he \| she \| they}}` | always the third form (or the first when there is only one), as phpBB keeps no gender | + | `{{#tag: center \| text \| attribute=value}}` | an HTML tag written out, parsed with the article, so only the HTML articles may use works (tables, `center`, `details` and the others above); anything else shows as text | A call that cannot be worked out, such as `{{#expr: 1/0}}`, shows a red message where it stands. An unknown `{{#function:}}` does too. diff --git a/config/services.yml b/config/services.yml index 8a3c7d9..a57c590 100644 --- a/config/services.yml +++ b/config/services.yml @@ -317,6 +317,7 @@ services: - '@dbal.conn' - '@language' - '%tables.wiki.article%' + - '@user' phpbbmodders.wiki.wikilink_listener: class: phpbbmodders\wiki\event\wikilink_listener arguments: diff --git a/docs/TODO/todo-wiki-markup.md b/docs/TODO/todo-wiki-markup.md index c67373c..fcc2916 100644 --- a/docs/TODO/todo-wiki-markup.md +++ b/docs/TODO/todo-wiki-markup.md @@ -152,17 +152,17 @@ like *Version*, *Author* and *Requires* that each article fills in. Built so far: the syntax and parameters described above, the `Template:` pages, expansion on display, the loop and depth limits, **What links here** for templates, and the parser functions -`#if`, `#ifeq`, `#switch`, `#ifexist`, `#expr`, `#ifexpr`, `#iferror`, `#time`, the string functions -`#len`, `#pos`, `#rpos`, `#sub`, `#replace` and `#explode`, `lc`, `uc`, `lcfirst`, `ucfirst`, -`padleft`, `padright`, `urlencode`, `anchorencode`, the magic words `PAGENAME`, `FULLPAGENAME`, -`NAMESPACE` and `SITENAME`, and the `CURRENT...` date words (see `wiki/templates.php`, +`#if`, `#ifeq`, `#switch`, `#ifexist`, `#expr`, `#ifexpr`, `#iferror`, `#time`, `#timel`, the +string functions `#len`, `#pos`, `#rpos`, `#sub`, `#replace` and `#explode`, `#titleparts`, +`#rel2abs`, `#tag`, `lc`, `uc`, `lcfirst`, `ucfirst`, `padleft`, `padright`, `urlencode`, +`anchorencode`, `formatnum`, `plural`, `gender`, the magic words `PAGENAME`, `FULLPAGENAME`, +`NAMESPACE` and `SITENAME`, and the `CURRENT...`, `LOCAL...` and `REVISION...` words (see `wiki/templates.php`, `wiki/expression.php` and `wiki/text_tools.php`). Calls are worked out as text, before the article is parsed, so a parameter can be another call and only the chosen branch of a function is worked out. Still to do: -- Parser functions Wikipedia's templates use that are not here yet: `#titleparts`, `#rel2abs`, - `#tag`, `formatnum`, `plural`, `gender`, `#invoke` (Lua, not planned), the - `LOCAL...` and `REVISION...` magic words, and `#time` in the viewer's time zone and language. +- Not planned: `#invoke` (Lua modules). `plural` uses the English rule and `gender` always gives the + neutral form, since phpBB keeps no gender. - Deeper nesting: a template call holds other calls in its parameters only three levels deep, and a default value holds only one level of `{{template}}`; anything deeper stays text. Lift both limits, keeping a cap so one article still cannot slow the board down. diff --git a/event/wikilink_listener.php b/event/wikilink_listener.php index 74534e1..3bec75a 100644 --- a/event/wikilink_listener.php +++ b/event/wikilink_listener.php @@ -102,7 +102,7 @@ protected static function template_pattern() $inner = '(?:' . $plain . '|\\{\\{(?![{])' . $inner . '\\}\\})*?'; } - return '/(?#[a-z]+(?=\\s*:)|(?:lc|uc|lcfirst|ucfirst|urlencode|anchorencode|padleft|padright)(?=\\s*:)|[^|{}\\[\\]<>"#\\s][^|{}\\[\\]<>"\\n]{0,254}?(?=\\s*(?:\\||\\}\\}))))(?' . $inner . ')\\}\\}(?![}])/'; + return '/(?#[a-z][a-z0-9]*(?=\\s*:)|(?:lc|uc|lcfirst|ucfirst|urlencode|anchorencode|padleft|padright|formatnum|plural|gender)(?=\\s*:)|[^|{}\\[\\]<>"#\\s][^|{}\\[\\]<>"\\n]{0,254}?(?=\\s*(?:\\||\\}\\}))))(?' . $inner . ')\\}\\}(?![}])/'; } /** diff --git a/tests/unit/parser_functions_test.php b/tests/unit/parser_functions_test.php index 5d17fae..d6430de 100644 --- a/tests/unit/parser_functions_test.php +++ b/tests/unit/parser_functions_test.php @@ -19,6 +19,12 @@ class parser_functions_test extends \PHPUnit\Framework\TestCase /** @var \phpbbmodders\wiki\wiki\templates */ protected $templates; + /** @var \phpbb\language\language */ + protected $language; + + /** @var \phpbb\config\config */ + protected $config; + protected function setUp(): void { $language = $this->createMock(\phpbb\language\language::class); @@ -26,7 +32,9 @@ protected function setUp(): void return $key . ($args ? '(' . implode(',', $args) . ')' : ''); }); - $config = new \phpbb\config\config(array('sitename' => 'Test Board')); + $config = new \phpbb\config\config(array('sitename' => 'Test Board', 'board_timezone' => 'Pacific/Auckland')); + $this->language = $language; + $this->config = $config; $this->templates = new class($config, $this->createMock(\phpbb\db\driver\driver_interface::class), $language, 'phpbb_wiki_article') extends \phpbbmodders\wiki\wiki\templates { /** @var array Template page text by article name */ @@ -67,6 +75,14 @@ protected function current_article() return $this->article; } + /** @var array The live version the REVISION words read */ + public $revision = array('article_id' => 42, 'article_last_edit' => 1772892309, 'username' => 'Editor'); + + protected function live_revision($article) + { + return $this->revision; + } + /** Saturday 7 March 2026, 14:05:09 UTC */ protected function now() { @@ -384,4 +400,62 @@ public function test_a_flood_of_calls_is_cut_off() $this->assertStringContainsString('WIKI_TEMPLATE_TOO_DEEP', $out); } + + public function test_titleparts_and_rel2abs() + { + $this->templates->article = 'Guide/Install/Linux'; + + $this->assertSame('Guide/Install', $this->run_text('{{#titleparts: Guide/Install/Linux | 2 }}')); + $this->assertSame('Install', $this->run_text('{{#titleparts: Guide/Install/Linux | 1 | 2 }}')); + $this->assertSame('Guide/Install/Windows', $this->run_text('{{#rel2abs: ../Windows }}')); + $this->assertSame('Other/Page', $this->run_text('{{#rel2abs: ../Page | Other/X }}')); + } + + public function test_formatnum_plural_and_gender() + { + $this->assertSame('1,234', $this->run_text('{{formatnum: 1234 }}')); + $this->assertSame('1234', $this->run_text('{{formatnum: 1,234 | R }}')); + $this->assertSame('3 pages', $this->run_text('3 {{plural: 3 | page | pages }}')); + $this->assertSame('1 page', $this->run_text('1 {{plural: {{#expr: 2 - 1}} | page | pages }}')); + $this->assertSame('they', $this->run_text('{{gender: Someone | he | she | they }}')); + $this->assertSame('he', $this->run_text('{{gender: Someone | he }}')); + } + + public function test_tag_writes_an_html_tag() + { + $this->assertSame('
Hi
', $this->run_text('{{#tag: center | Hi }}')); + $this->assertSame('x', $this->run_text('{{#tag: td | x | colspan = 2 }}')); + $this->assertSame('x', $this->run_text('{{#tag: span | x | title=a "b}}')); + $this->assertSame('

', $this->run_text('{{#tag: br }}')); + $this->assertStringContainsString('WIKI_TEMPLATE_UNKNOWN_FUNCTION', $this->run_text('{{#tag: bad name | x }}')); + } + + public function test_local_and_revision_words() + { + // 14:05 UTC on 7 March is 03:05 on 8 March in Auckland (daylight time) + $this->assertSame('8', $this->run_text('{{LOCALDAY}}')); + $this->assertSame('7', $this->run_text('{{CURRENTDAY}}')); + $this->assertSame('03', $this->run_text('{{LOCALHOUR}}')); + + $this->assertSame('42', $this->run_text('{{REVISIONID}}')); + $this->assertSame('Editor', $this->run_text('{{REVISIONUSER}}')); + $this->assertSame('2026', $this->run_text('{{REVISIONYEAR}}')); + $this->assertSame('20260307140509', $this->run_text('{{REVISIONTIMESTAMP}}')); + + $this->templates->revision = array(); + $this->assertSame('', $this->run_text('{{REVISIONID}}'), 'an article not saved yet'); + } + + public function test_timel_uses_the_readers_zone_and_language() + { + $this->language->method('lang_raw')->willReturn(array('Saturday' => 'Samstag', 'Sunday' => 'Sonntag', 'March' => 'März')); + $user = $this->createMock(\phpbb\user::class); + $user->timezone = new \DateTimeZone('Asia/Tokyo'); + $class = get_class($this->templates); + $templates = new $class($this->config, $this->createMock(\phpbb\db\driver\driver_interface::class), $this->language, 'phpbb_wiki_article', $user); + + // 14:05 UTC is 23:05 in Tokyo, the same Saturday + $this->assertSame('Samstag 23:05, 7 März', $templates->expand_text('{{#timel: l H:i, j F | now}}')); + $this->assertSame('Saturday 14:05', $templates->expand_text('{{#time: l H:i | now}}'), '#time stays in UTC and English'); + } } diff --git a/tests/unit/text_tools_test.php b/tests/unit/text_tools_test.php index e23d1a0..1187305 100644 --- a/tests/unit/text_tools_test.php +++ b/tests/unit/text_tools_test.php @@ -66,4 +66,51 @@ public function test_positions_count_characters_not_bytes() $this->assertSame('3', text_tools::rpos('é b c', '')); $this->assertSame('x', text_tools::sub('éxé', 1, 1)); } + + public function test_titleparts_takes_parts_of_a_name() + { + $this->assertSame('A/B/C', text_tools::titleparts('A/B/C')); + $this->assertSame('A/B', text_tools::titleparts('A/B/C', 2)); + $this->assertSame('B', text_tools::titleparts('A/B/C', 1, 2)); + $this->assertSame('C', text_tools::titleparts('A/B/C', 0, -1)); + $this->assertSame('A', text_tools::titleparts('A/B/C', -2)); + $this->assertSame('', text_tools::titleparts('A/B/C', 1, 5)); + } + + public function test_rel2abs_works_out_a_path() + { + $this->assertSame('A/B/C', text_tools::rel2abs('./C', 'A/B')); + $this->assertSame('A/C', text_tools::rel2abs('../C', 'A/B')); + $this->assertSame('A/B/C', text_tools::rel2abs('/C', 'A/B')); + $this->assertSame('X/Y', text_tools::rel2abs('X/Y', 'A/B')); + $this->assertSame('A', text_tools::rel2abs('..', 'A/B')); + $this->assertSame('', text_tools::rel2abs('../../..', 'A/B')); + } + + public function test_formatnum_adds_and_removes_separators() + { + $this->assertSame('1,234,567', text_tools::formatnum('1234567')); + $this->assertSame('-1,234.5', text_tools::formatnum('-1234.5')); + $this->assertSame('999', text_tools::formatnum('999')); + $this->assertSame('1234567', text_tools::formatnum('1,234,567', 'R')); + $this->assertSame('abc', text_tools::formatnum('abc')); + } + + public function test_plural_picks_the_english_form() + { + $this->assertSame('item', text_tools::plural('1', array('item', 'items'))); + $this->assertSame('item', text_tools::plural('-1', array('item', 'items'))); + $this->assertSame('items', text_tools::plural('2', array('item', 'items'))); + $this->assertSame('items', text_tools::plural('0', array('item', 'items'))); + $this->assertSame('item', text_tools::plural('5', array('item'))); + $this->assertSame('items', text_tools::plural('1,000', array('item', 'items'))); + } + + public function test_date_words_can_take_a_time_zone() + { + $now = gmmktime(23, 30, 0, 3, 7, 2026); + + $this->assertSame('7', text_tools::date_word('CURRENTDAY', $now)); + $this->assertSame('8', text_tools::date_word('CURRENTDAY', $now, new \DateTimeZone('Europe/Berlin'))); + } } diff --git a/wiki/templates.php b/wiki/templates.php index 95513cc..e0f8b2f 100644 --- a/wiki/templates.php +++ b/wiki/templates.php @@ -82,14 +82,22 @@ class templates /** @var array Raw template text by article name, for this request */ protected $sources = array(); + /** @var \phpbb\user|null The reader, for #timel; none in the tests that leave it out */ + protected $user; + + /** @var array The live version of each article read for the REVISION words, this request */ + protected $revisions = array(); + /** * @param \phpbb\config\config $config Config object * @param \phpbb\db\driver\driver_interface $db Database object * @param \phpbb\language\language $language Language object * @param string $article_table Wiki article table + * @param \phpbb\user|null $user The reader, for #timel */ - public function __construct(\phpbb\config\config $config, \phpbb\db\driver\driver_interface $db, \phpbb\language\language $language, $article_table) + public function __construct(\phpbb\config\config $config, \phpbb\db\driver\driver_interface $db, \phpbb\language\language $language, $article_table, ?\phpbb\user $user = null) { + $this->user = $user; $this->config = $config; $this->db = $db; $this->language = $language; @@ -289,7 +297,7 @@ protected function evaluate($inner) protected function evaluate_call($inner) { $trimmed = ltrim($inner); - if (preg_match('/^#([a-z]+)\s*:(.*)$/is', $trimmed, $match)) + if (preg_match('/^#([a-z][a-z0-9]*)\s*:(.*)$/is', $trimmed, $match)) { return $this->parser_function(strtolower($match[1]), self::split_pieces($match[2])); } @@ -297,6 +305,10 @@ protected function evaluate_call($inner) { return $this->text_function(strtolower($match[1]), trim($this->expand_text($match[2]))); } + if (preg_match('/^(formatnum|plural|gender)\s*:(.*)$/is', $trimmed, $match)) + { + return $this->colon_function(strtolower($match[1]), self::split_pieces($match[2])); + } if (preg_match('/^(padleft|padright)\s*:(.*)$/is', $trimmed, $match)) { return $this->pad_function(strtolower($match[1]) === 'padleft', self::split_pieces($match[2])); @@ -436,6 +448,18 @@ protected function parser_function($function, array $args) case 'explode': return text_tools::explode($arg(0), $arg(1), (int) $arg(2), ($arg(3) === '') ? null : (int) $arg(3)); + + case 'titleparts': + return text_tools::titleparts($arg(0), (int) $arg(1), ($arg(2) === '') ? 1 : (int) $arg(2)); + + case 'rel2abs': + return text_tools::rel2abs($arg(0), (isset($args[1]) && $arg(1) !== '') ? $arg(1) : (string) $this->current_article()); + + case 'tag': + return $this->tag_function($arg(0), isset($args[1]) ? $arg(1) : null, array_slice($args, 2)); + + case 'timel': + return $this->local_time($arg(0), $arg(1)); } return $this->error($this->language->lang('WIKI_TEMPLATE_UNKNOWN_FUNCTION', '#' . $function)); @@ -531,6 +555,148 @@ protected function now() return time(); } + /** + * {{formatnum:...}}, {{plural:...}} and {{gender:...}}. + * + * @param string $function + * @param array $pieces The parts after the colon, as written + * @return string + */ + protected function colon_function($function, array $pieces) + { + $pieces = array_map(function ($piece) { + return trim($this->expand_text($piece)); + }, $pieces); + + switch ($function) + { + case 'formatnum': + return text_tools::formatnum($pieces[0], isset($pieces[1]) ? $pieces[1] : ''); + + case 'plural': + return text_tools::plural($pieces[0], array_slice($pieces, 1)); + + default: + // gender: phpBB keeps no gender, so the neutral form (the third), or else the first, + // as MediaWiki gives for a member who has not said + return isset($pieces[3]) ? $pieces[3] : (isset($pieces[1]) ? $pieces[1] : ''); + } + } + + /** + * {{#tag: name | content | attribute=value ...}}: an HTML tag written out. It is parsed with + * the article afterwards, so only what the wiki allows written by hand works (tables, + *
,
); anything else stays text. + * + * @param string $name + * @param string|null $content Null for an empty tag + * @param array $attributes Pieces of the form key=value, as written + * @return string + */ + protected function tag_function($name, $content, array $attributes) + { + $name = strtolower(trim($name)); + if (!preg_match('/^[a-z][a-z0-9]*$/', $name)) + { + return $this->error($this->language->lang('WIKI_TEMPLATE_UNKNOWN_FUNCTION', '#tag:' . $name)); + } + + $html = '<' . $name; + foreach ($attributes as $attribute) + { + $pair = self::split_equals($this->expand_text($attribute)); + if ($pair !== null && preg_match('/^[a-z][a-z0-9-]*$/i', trim($pair[0]))) + { + $value = trim(trim($pair[1]), '"\''); + $html .= ' ' . strtolower(trim($pair[0])) . '="' . str_replace('"', '"', $value) . '"'; + } + } + + return $html . '>' . (($content === null) ? '' : $content) . ''; + } + + /** + * {{#timel:...}}: like #time, in the reader's time zone, with month and day names in the + * reader's language. Without a reader (as in some tests) it is #time. + * + * @param string $format + * @param string $date + * @return string + */ + protected function local_time($format, $date) + { + $zone = ($this->user !== null && isset($this->user->timezone) && $this->user->timezone instanceof \DateTimeZone) ? $this->user->timezone : new \DateTimeZone('UTC'); + $time = text_tools::time($format, $date, $this->now(), $zone); + if ($time === null) + { + return $this->error($this->language->lang('WIKI_TIME_INVALID')); + } + $names = $this->language->lang_raw('datetime'); + + return is_array($names) ? strtr($time, $names) : $time; + } + + /** + * The REVISION words: the live version of the article being shown. + * + * @param string $word + * @return string|null Null when it is not one of them + */ + protected function revision_word($word) + { + static $formats = array( + 'REVISIONDAY' => 'j', + 'REVISIONDAY2' => 'd', + 'REVISIONMONTH' => 'm', + 'REVISIONMONTH1' => 'n', + 'REVISIONYEAR' => 'Y', + 'REVISIONTIMESTAMP' => 'YmdHis', + ); + if (!isset($formats[$word]) && $word !== 'REVISIONID' && $word !== 'REVISIONUSER') + { + return null; + } + + $row = $this->live_revision((string) $this->current_article()); + if (!$row) + { + return ''; + } + + switch ($word) + { + case 'REVISIONID': + return (string) $row['article_id']; + case 'REVISIONUSER': + return (string) $row['username']; + } + + return (new \DateTimeImmutable('@' . (int) $row['article_last_edit']))->setTimezone(new \DateTimeZone('UTC'))->format($formats[$word]); + } + + /** + * The live version of an article, read once per request. + * + * @param string $article + * @return array The row (article_id, article_last_edit, username), empty when there is none + */ + protected function live_revision($article) + { + if (!isset($this->revisions[$article])) + { + $sql = 'SELECT a.article_id, a.article_last_edit, u.username + FROM ' . $this->article_table . ' a + LEFT JOIN ' . USERS_TABLE . ' u ON u.user_id = a.article_user_id + WHERE ' . $this->live_where('a', '') . " + AND a.article_url = '" . $this->db->sql_escape($article) . "'"; + $result = $this->db->sql_query_limit($sql, 1); + $this->revisions[$article] = $this->db->sql_fetchrow($result) ?: array(); + $this->db->sql_freeresult($result); + } + + return $this->revisions[$article]; + } + /** * {{PAGENAME}}, {{FULLPAGENAME}}, {{NAMESPACE}}, {{SITENAME}} and the date words such as * {{CURRENTYEAR}}. @@ -555,6 +721,26 @@ protected function magic_word($word) return (string) $this->config['sitename']; } + $revision = $this->revision_word($word); + if ($revision !== null) + { + return $revision; + } + // LOCAL... is the CURRENT... word in the board's time zone + if (strpos($word, 'LOCAL') === 0) + { + try + { + $zone = new \DateTimeZone(!empty($this->config['board_timezone']) ? $this->config['board_timezone'] : 'UTC'); + } + catch (\Exception $e) + { + $zone = new \DateTimeZone('UTC'); + } + + return text_tools::date_word('CURRENT' . substr($word, 5), $this->now(), $zone); + } + return text_tools::date_word($word, $this->now()); } diff --git a/wiki/text_tools.php b/wiki/text_tools.php index d38ccf5..a30e62e 100644 --- a/wiki/text_tools.php +++ b/wiki/text_tools.php @@ -137,6 +137,118 @@ public static function pad($text, $length, $pad, $left) return $left ? $fill . $text : $text . $fill; } + /** + * {{#titleparts: name | count | first}}: some of the parts of a name split at "/". + * first counts from 1, or from the end when negative; count 0 (or none) takes the rest, + * and a negative count leaves that many parts off the end, as on MediaWiki. + * + * @param string $name + * @param int $count + * @param int $first + * @return string + */ + public static function titleparts($name, $count = 0, $first = 1) + { + $parts = explode('/', $name); + $total = count($parts); + $start = ($first > 0) ? $first - 1 : (($first < 0) ? max(0, $total + $first) : 0); + $parts = array_slice($parts, $start); + if ($count > 0) + { + $parts = array_slice($parts, 0, $count); + } + else if ($count < 0) + { + $parts = array_slice($parts, 0, max(0, count($parts) + $count)); + } + + return implode('/', $parts); + } + + /** + * {{#rel2abs: path | base}}: a path relative to a page ("./x", "../x", "/x" or a plain + * name) made into a full page name, as on MediaWiki. Too many ".." gives ''. + * + * @param string $path + * @param string $base The page it is relative to + * @return string + */ + public static function rel2abs($path, $base) + { + $path = trim($path); + if ($path === '' || ($path[0] !== '.' && $path[0] !== '/')) + { + // An absolute name, kept as it is + return trim(preg_replace('#/{2,}#', '/', $path), '/'); + } + + $parts = explode('/', trim($base, '/')); + if ($path[0] === '/') + { + $path = '.' . $path; + } + foreach (explode('/', $path) as $piece) + { + if ($piece === '..') + { + if (!$parts || (count($parts) === 1 && $parts[0] === '')) + { + return ''; + } + array_pop($parts); + } + else if ($piece !== '.' && $piece !== '') + { + $parts[] = $piece; + } + } + + return implode('/', array_filter($parts, 'strlen')); + } + + /** + * {{formatnum: number}}: a number with commas between the thousands, as English Wikipedia + * writes it, or with "R" the other way: the separators taken out. Text that is not a + * number comes back as it was. + * + * @param string $number + * @param string $raw 'R' to take the separators out + * @return string + */ + public static function formatnum($number, $raw = '') + { + $number = trim($number); + if (strtoupper(trim($raw)) === 'R') + { + return str_replace(',', '', $number); + } + if (!preg_match('/^([+-]?)(\d+)(\.\d+)?$/', $number, $match)) + { + return $number; + } + + return $match[1] . strrev(implode(',', str_split(strrev($match[2]), 3))) . (isset($match[3]) ? $match[3] : ''); + } + + /** + * {{plural: n | one | other}}: the form for a count, by English rules: the first for 1 (or + * -1), the second otherwise. One form only is used whatever the count. + * + * @param string $count The count, commas allowed + * @param array $forms + * @return string + */ + public static function plural($count, array $forms) + { + if (!$forms) + { + return ''; + } + $value = (float) str_replace(',', '', trim($count)); + + return (abs($value) == 1 || count($forms) === 1) ? $forms[0] : $forms[1]; + } + /** * {{#time:}}: turn a format written for the wiki into one for PHP's date(). * Letters work as in PHP (Y year, n month, j day, H hour, ...), "quoted text" and @@ -187,14 +299,14 @@ public static function time_format($format) * @param int $now The time now as a Unix timestamp * @return string|null Null when the date cannot be read */ - public static function time($format, $date, $now) + public static function time($format, $date, $now, ?\DateTimeZone $zone = null) { if ($date !== '' && date_parse($date)['error_count'] > 0) { return null; } - $utc = new \DateTimeZone('UTC'); + $utc = $zone ?: new \DateTimeZone('UTC'); try { $base = (new \DateTimeImmutable('@' . $now))->setTimezone($utc); @@ -232,7 +344,7 @@ public static function time($format, $date, $now) * @param int $now The time now as a Unix timestamp * @return string|null Null when it is not a date word */ - public static function date_word($word, $now) + public static function date_word($word, $now, ?\DateTimeZone $zone = null) { static $formats = array( 'CURRENTYEAR' => 'Y', @@ -254,7 +366,7 @@ public static function date_word($word, $now) return null; } - return (new \DateTimeImmutable('@' . $now))->setTimezone(new \DateTimeZone('UTC'))->format($formats[$word]); + return (new \DateTimeImmutable('@' . $now))->setTimezone($zone ?: new \DateTimeZone('UTC'))->format($formats[$word]); } /** From 6902a218bb27d1738fa5f14727b7236d012f1dc9 Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Thu, 15 Oct 2026 00:51:25 -0500 Subject: [PATCH 2/2] Use union types for the new optional parameters phpBB 4.0's code sniffer refuses the ?Type shortcut. Co-Authored-By: Claude Opus 5.5 --- wiki/templates.php | 2 +- wiki/text_tools.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/wiki/templates.php b/wiki/templates.php index e0f8b2f..87acd12 100644 --- a/wiki/templates.php +++ b/wiki/templates.php @@ -95,7 +95,7 @@ class templates * @param string $article_table Wiki article table * @param \phpbb\user|null $user The reader, for #timel */ - public function __construct(\phpbb\config\config $config, \phpbb\db\driver\driver_interface $db, \phpbb\language\language $language, $article_table, ?\phpbb\user $user = null) + public function __construct(\phpbb\config\config $config, \phpbb\db\driver\driver_interface $db, \phpbb\language\language $language, $article_table, \phpbb\user|null $user = null) { $this->user = $user; $this->config = $config; diff --git a/wiki/text_tools.php b/wiki/text_tools.php index a30e62e..babf7b2 100644 --- a/wiki/text_tools.php +++ b/wiki/text_tools.php @@ -299,7 +299,7 @@ public static function time_format($format) * @param int $now The time now as a Unix timestamp * @return string|null Null when the date cannot be read */ - public static function time($format, $date, $now, ?\DateTimeZone $zone = null) + public static function time($format, $date, $now, \DateTimeZone|null $zone = null) { if ($date !== '' && date_parse($date)['error_count'] > 0) { @@ -344,7 +344,7 @@ public static function time($format, $date, $now, ?\DateTimeZone $zone = null) * @param int $now The time now as a Unix timestamp * @return string|null Null when it is not a date word */ - public static function date_word($word, $now, ?\DateTimeZone $zone = null) + public static function date_word($word, $now, \DateTimeZone|null $zone = null) { static $formats = array( 'CURRENTYEAR' => 'Y',