Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,16 @@ An article

Write `[[Category:Name]]` anywhere in a wiki article to put it in a category. The mark shows nothing in the text; the article lists its categories at the bottom, each linking to the category's page.

- `[[Category:boxes]]` and `[[Category:Boxes]]` are the same category: the first letter is capitalised and underscores count as spaces. A sort key after a bar (`[[Category:Boxes|key]]`) is accepted and ignored.
- **A category page** is the article `Category:Name`, at `/wiki/Category:Name`. If nobody wrote it, the page still exists as a list of the articles in the category, A to Z with their descriptions, with a link to write a description. If an article called `Category:Name` exists, its text is shown above the list, and it has the usual edit and version buttons.
- `[[Category:boxes]]` and `[[Category:Boxes]]` are the same category: the first letter is capitalised and underscores count as spaces.
- **Sort keys:** `[[Category:Boxes|Small]]` files the article under "Small" in that category's list instead of under its title. If an article names a category twice, the first key counts.
- **Linking to a category:** `[[:Category:Boxes]]`, with a colon in front, is a plain link to the category's page and does not put the article in it.
- **Sub-categories:** a category page that is itself in a category (`Category:Extensions` with `[[Category:Software]]` in it) is listed as a sub-category on that category's page, above its articles.
- **A category page** is the article `Category:Name`, at `/wiki/Category:Name`. If nobody wrote it, the page still exists as a list of the articles in the category, by sort key or title, with their descriptions, with a link to write a description. If an article called `Category:Name` exists, its text is shown above the list, and it has the usual edit and version buttons.
- **`/wiki/categories`** lists every category with a live article in it, how many, and its description (the description field of its `Category:Name` article), linked from the overview. A **How to use categories** box above the list explains `[[Category:Name]]` and category pages, plus, for members who can edit, how to describe a category. Under each category, **Articles in it and how to add one** opens the line to write and its first five articles. Category pages are left out of the overview's article lists.
- Only the live version of an article counts. A forum post keeps `[[Category:Name]]` as plain text, like the rest of the wiki markup.
- A template page counts for the categories it names outside `<includeonly>`, so `<noinclude>[[Category:Infobox templates]]</noinclude>` puts the template in that category. A category added inside `<includeonly>` by a template shows under the articles that use the template, but those articles are not in the category's list, which is found from the articles' own text.
- After upgrading, disable and enable the extension so the board rebuilds its cache. Articles saved before this build keep `[[Category:Name]]` as text until they are saved again or reparsed with `php bin/phpbbcli.php reparser:reparse phpbbmodders_wiki_article`.
- A template page counts for the categories it names outside `<includeonly>`, so `<noinclude>[[Category:Infobox templates]]</noinclude>` puts the template in that category. A category inside `<includeonly>` puts every article that uses the template in it, as on MediaWiki: `{{Stub}}` can put its articles in "Stubs". When a template changes, the articles that use it are worked out again.
- Which articles are in which category is kept in a table, worked out whenever an article's live version changes. After the update that adds it, a cron task fills it for the articles the board already has, a batch at a time on ordinary page views; until it is done, the category pages read the articles' text as before (without templates' categories, sub-categories or sort keys).
- After upgrading, disable and enable the extension so the board rebuilds its cache. Articles saved before sort keys keep the key they were written with only once they are saved again or reparsed. Articles saved before this build keep `[[Category:Name]]` as text until they are saved again or reparsed with `php bin/phpbbcli.php reparser:reparse phpbbmodders_wiki_article`.

## Search

Expand Down
25 changes: 25 additions & 0 deletions config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ parameters:
tables.wiki.template_install: '%core.table_prefix%wiki_template_install'
tables.wiki.edit_lock: '%core.table_prefix%wiki_edit_lock'
tables.wiki.article_protect: '%core.table_prefix%wiki_article_protect'
tables.wiki.category_member: '%core.table_prefix%wiki_category_member'

services:
_defaults:
Expand Down Expand Up @@ -53,6 +54,7 @@ services:
- '@dbal.conn'
- '@phpbbmodders.wiki.templates'
- '%tables.wiki.article%'
- '@phpbbmodders.wiki.category_members'

phpbbmodders.wiki.category:
class: phpbbmodders\wiki\controller\category
Expand Down Expand Up @@ -164,6 +166,7 @@ services:
- '%tables.wiki.file%'
- '%tables.wiki.watch%'
- '@phpbbmodders.wiki.protection'
- '@phpbbmodders.wiki.category_members'
phpbbmodders.wiki.listener:
class: phpbbmodders\wiki\event\listener
arguments:
Expand Down Expand Up @@ -195,6 +198,26 @@ services:
- '@phpbbmodders.wiki.search_sync'
- '@phpbbmodders.wiki.edit_lock'
- '@phpbbmodders.wiki.protection'
- '@phpbbmodders.wiki.category_members'
# Which articles are in which category, kept current as articles go live, and the cron
# task that fills it for the articles a board already has
phpbbmodders.wiki.category_members:
class: phpbbmodders\wiki\wiki\category_members
arguments:
- '@config'
- '@dbal.conn'
- '@phpbbmodders.wiki.links'
- '@phpbbmodders.wiki.templates'
- '%tables.wiki.article%'
- '%tables.wiki.category_member%'
phpbbmodders.wiki.cron.task.category_members_build:
class: phpbbmodders\wiki\cron\task\category_members_build
arguments:
- '@phpbbmodders.wiki.category_members'
calls:
- [set_name, [cron.task.phpbbmodders.wiki.category_members_build]]
tags:
- { name: cron.task }
# Per-article edit protection: which groups may edit an article, and the page that sets it
phpbbmodders.wiki.protection:
class: phpbbmodders\wiki\wiki\protection
Expand Down Expand Up @@ -249,6 +272,8 @@ services:
- '@phpbbmodders.wiki.watch'
- '@phpbbmodders.wiki.search_sync'
- '@phpbbmodders.wiki.protection'
- '@phpbbmodders.wiki.category_members'
- '@phpbbmodders.wiki.links'
phpbbmodders.wiki.view:
class: phpbbmodders\wiki\wiki\view
arguments:
Expand Down
10 changes: 10 additions & 0 deletions controller/category.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ public function __construct(\phpbb\auth\auth $auth, \phpbb\config\config $config
*/
protected function assign_members($name)
{
// Categories that are in this one, by sort key or else name
foreach ($this->categories->subcategories($name) as $row)
{
$this->template->assign_block_vars('category_subcategories', array(
'NAME' => substr($row['article_url'], strlen(categories::PREFIX)),
'DESCRIPTION' => $row['article_description'],
'U_CATEGORY' => $this->helper->route('phpbbmodders_wiki_article', array('article' => $row['article_url'])),
));
}

$members = $this->categories->members($name);
$total = count($members);
$per_page = max(1, (int) $this->config['topics_per_page']);
Expand Down
49 changes: 49 additions & 0 deletions cron/task/category_members_build.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
/**
*
* Wiki extension for the phpBB Forum Software package
*
* @copyright (c) 2026, phpBB Modders, https://www.phpbbmodders.com/
* @license GNU General Public License, version 2 (GPL-2.0)
*
*/
namespace phpbbmodders\wiki\cron\task;

/**
* Fills the table of category memberships for the articles a board had before it existed,
* a batch at a time on ordinary page views, then stops for good. Until it is done the
* category lists read the articles' text as before.
*/
class category_members_build extends \phpbb\cron\task\base
{
/** @var \phpbbmodders\wiki\wiki\category_members */
protected $category_members;

/**
* @param \phpbbmodders\wiki\wiki\category_members $category_members
*/
public function __construct(\phpbbmodders\wiki\wiki\category_members $category_members)
{
$this->category_members = $category_members;
}

public function run()
{
$this->category_members->build_batch();
}

public function is_runnable()
{
return !$this->category_members->built();
}

/**
* Every request may take the next batch until the table is built.
*
* @return bool
*/
public function should_run()
{
return true;
}
}
14 changes: 5 additions & 9 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,13 @@ Built: `[[Category:Name]]` in an article puts it in a category, a category page
(`/wiki/Category:Name`) lists the articles in it, and `/wiki/categories` lists them all. See
the README.

Also built (10/15/2026): categories added by a template, sub-categories, `[[:Category:Name]]`
links and sort keys, from a stored membership table that a cron task fills after the update.

Not built:

- A category added by a template (`<includeonly>[[Category:Stubs]]</includeonly>`) shows under
the articles that use the template, but those articles are not in the category's member list,
which is found from the articles' own text. Listing them needs a table of memberships that is
worked out when a version goes live and again for every article that uses a template when the
template changes.
- Sub-categories, `[[:Category:Name]]` (a link to a category without joining it), and sort keys
(a sort key after a bar is accepted and ignored).
- The member list reads every live article that holds a category mark, so it is meant for wikis
of moderate size; a membership table would also fix that.
- Paging the sub-categories of a category separately from its articles, and showing how many
articles each sub-category holds.

## A wiki-style UI, not just reskinned forum templates

Expand Down
26 changes: 16 additions & 10 deletions event/wikilink_listener.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,20 @@ public function configure_wikilink($event)
return;
}

// [[Category:Name]] puts the article in a category and shows nothing where it stands. The
// parser reads it only while a wiki article is saved (see toggle_wiki_markup()); a sort key
// after a bar is accepted and ignored.
// [[Category:Name]] puts the article in a category and shows nothing where it stands, and
// [[Category:Name|Key]] files it under Key in that category's list. The parser reads it
// only while a wiki article is saved (see toggle_wiki_markup()).
$category = $configurator->tags->add('WIKICATEGORY');
$category_name = $category->attributes->add('name');
$category_name->filterChain->append('\phpbbmodders\wiki\wiki\markup::decode_entities_in_name');
$category_name->filterChain->append('\phpbbmodders\wiki\wiki\categories::normalise');
$category->template = '<span class="wikicategory" data-wiki-category="{@name}"></span>';
$category_key = $category->attributes->add('key');
$category_key->required = false;
$category_key->filterChain->append('\phpbbmodders\wiki\wiki\markup::decode_entities');
$category->template = '<span class="wikicategory" data-wiki-category="{@name}"><xsl:if test="@key"><xsl:attribute name="data-wiki-sort"><xsl:value-of select="@key"/></xsl:attribute></xsl:if></span>';

$configurator->Preg->match(
'/\[\[\s*Category:\s*(?=[^\s\]|])(?<name>' . self::NAME_CLASS . '{1,255}?)\s*(?:\|[^\[\]\n]*)?\]\]/i',
'/\[\[\s*Category:\s*(?=[^\s\]|])(?<name>' . self::NAME_CLASS . '{1,255}?)\s*(?:\|\s*(?<key>[^\[\]\n]{0,255}?)\s*)?\]\]/i',
'WIKICATEGORY'
);

Expand Down Expand Up @@ -193,6 +196,8 @@ public function configure_wikilink($event)
$link_attribute->required = false;
$link_attribute->filterChain->append('\phpbbmodders\wiki\wiki\markup::' . $decode);
}
// [[:Category:Name]] links to a category's page without putting the article in it
$tag->attributes['name']->filterChain->append('\phpbbmodders\wiki\wiki\markup::strip_link_colon');
$tag->template = '<a class="wikilink" data-wiki-article="{@name}" data-wiki-section="{@section}">'
. '<xsl:choose><xsl:when test="@text"><xsl:value-of select="@text"/></xsl:when>'
. '<xsl:otherwise><xsl:value-of select="@name"/><xsl:if test="@section">#<xsl:value-of select="@section"/></xsl:if></xsl:otherwise></xsl:choose></a>';
Expand Down Expand Up @@ -311,15 +316,16 @@ public function link_wikilinks($event)

if (strpos($html, 'class="wikicategory"') !== false)
{
// Note the categories, for the article's page to list, and take the marks out
if (preg_match_all('/<span class="wikicategory" data-wiki-category="([^"]*)"><\/span>/', $html, $marks))
// Note the categories (and their sort keys), for the article's page to list and the
// category lists to file it under, and take the marks out
if (preg_match_all('/<span class="wikicategory" data-wiki-category="([^"]*)"(?: data-wiki-sort="([^"]*)")?><\/span>/', $html, $marks, PREG_SET_ORDER))
{
foreach ($marks[1] as $mark)
foreach ($marks as $mark)
{
\phpbbmodders\wiki\wiki\categories::add(htmlspecialchars_decode($mark, ENT_QUOTES));
\phpbbmodders\wiki\wiki\categories::add(htmlspecialchars_decode($mark[1], ENT_QUOTES), isset($mark[2]) ? htmlspecialchars_decode($mark[2], ENT_QUOTES) : '');
}
}
$html = preg_replace('/<span class="wikicategory" data-wiki-category="[^"]*"><\/span>/', '', $html);
$html = preg_replace('/<span class="wikicategory" data-wiki-category="[^"]*"(?: data-wiki-sort="[^"]*")?><\/span>/', '', $html);
$event['html'] = $html;
}

Expand Down
1 change: 1 addition & 0 deletions language/en/common.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@
2 => '%d articles',
),
'WIKI_CATEGORY_MEMBERS' => 'Articles in this category',
'WIKI_CATEGORY_SUBCATEGORIES' => 'Sub-categories',
'WIKI_CATEGORY_EMPTY' => 'No article is in this category yet.',
'WIKI_CATEGORY_NO_PAGE' => 'This category has no description yet.',
'WIKI_CATEGORY_WRITE' => 'Write one',
Expand Down
66 changes: 66 additions & 0 deletions migrations/add_category_members.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php
/**
*
* Wiki extension for the phpBB Forum Software package
*
* @copyright (c) 2026, phpBB Modders, https://www.phpbbmodders.com/
* @license GNU General Public License, version 2 (GPL-2.0)
*
*/

namespace phpbbmodders\wiki\migrations;

/**
* Which articles are in which category (see wiki\category_members), with each membership's
* sort key. The table starts empty: working memberships out means rendering the articles,
* which a migration cannot do, so a cron task fills it in batches after the update, and the
* category lists read the articles' text as before until it is done.
*/
class add_category_members extends \phpbb\db\migration\migration
{
public static function depends_on()
{
return array(
'\phpbbmodders\wiki\migrations\add_article_protection',
);
}

public function update_schema()
{
return array(
'add_tables' => array(
$this->table_prefix . 'wiki_category_member' => array(
'COLUMNS' => array(
'member_id' => array('UINT', null, 'auto_increment'),
'article_url' => array('VCHAR_UNI:255', ''),
'category' => array('VCHAR_UNI:255', ''),
'sort_key' => array('VCHAR_UNI:255', ''),
),
// A number for the key: two names would be too long a key for SQL Server
'PRIMARY_KEY' => 'member_id',
'KEYS' => array(
'article_url' => array('INDEX', 'article_url'),
'category' => array('INDEX', 'category'),
),
),
),
);
}

public function revert_schema()
{
return array(
'drop_tables' => array(
$this->table_prefix . 'wiki_category_member',
),
);
}

public function update_data()
{
return array(
array('config.add', array('wiki_category_members_built', 0)),
array('config.add', array('wiki_category_members_cursor', '', true)),
);
}
}
8 changes: 8 additions & 0 deletions styles/prosilver/template/category_members.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
<!-- IF .category_subcategories -->
<h3>{L_WIKI_CATEGORY_SUBCATEGORIES}</h3>
<ul class="wiki-subcategories">
<!-- BEGIN category_subcategories -->
<li><a href="{category_subcategories.U_CATEGORY}"><strong>{category_subcategories.NAME}</strong></a><!-- IF category_subcategories.DESCRIPTION --> &ndash; {category_subcategories.DESCRIPTION}<!-- ENDIF --></li>
<!-- END category_subcategories -->
</ul>
<!-- ENDIF -->
<h3>{L_WIKI_CATEGORY_MEMBERS}</h3>
<!-- IF .category_members -->
<div class="forumbg forumbg-table">
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/categories_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,18 @@ public function test_a_template_page_counts_for_what_it_says_outside_includeonly
$this->assertSame(array(), categories::names_outside_includeonly('<includeonly>[[Category:Boxes]]</includeonly>'));
$this->assertSame(array(), categories::names_outside_includeonly('No marks'));
}

public function test_sort_keys_are_kept_with_the_first_mark()
{
$raw = "[[Category:Boxes| Small box ]] [[Category:boxes|other]] [[Category:Plain]]";
$this->assertSame(array('Boxes' => 'Small box', 'Plain' => ''), categories::keys_outside_includeonly($raw));

categories::reset();
categories::add('boxes', 'B key');
categories::add('Boxes', 'ignored');
categories::add('Plain');
$this->assertSame(array('Boxes' => 'B key', 'Plain' => ''), categories::found_keys());
$this->assertSame(array('Boxes', 'Plain'), categories::found());
categories::reset();
}
}
12 changes: 11 additions & 1 deletion tests/unit/markup_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,19 @@ public function test_a_category_mark_becomes_a_tag_with_a_normalised_name()

$this->assertSame(3, substr_count($xml, '<WIKICATEGORY'), $xml);
$this->assertStringContainsString('<WIKICATEGORY name="Old extensions">', $xml);
$this->assertSame(2, substr_count($xml, '<WIKICATEGORY name="Boxes">'), $xml);
$this->assertStringContainsString('<WIKICATEGORY key="sort key" name="Boxes">', $xml, 'the sort key is kept');
$this->assertStringContainsString('<WIKICATEGORY name="Boxes">', $xml);
$this->assertStringNotContainsString('<WIKILINK', $xml, 'a category is not a link');
}

public function test_a_colon_makes_a_category_a_plain_link()
{
$xml = $this->parse('See [[:Category:Boxes]] and [[:Category:Boxes|all boxes]].');

$this->assertStringNotContainsString('<WIKICATEGORY', $xml, 'the article is not put in the category');
$this->assertSame(2, substr_count($xml, '<WIKILINK name="Category:Boxes"'), $xml);
}

public function test_a_category_mark_stays_text_outside_a_wiki_article_and_when_empty()
{
$this->assertStringNotContainsString('<WIKICATEGORY', $this->parse('[[Category:Boxes]]', false));
Expand All @@ -239,6 +248,7 @@ public function test_a_category_mark_stays_text_outside_a_wiki_article_and_when_
public function test_a_category_mark_renders_as_an_empty_marker()
{
$this->assertSame('a <span class="wikicategory" data-wiki-category="Boxes"></span> b', $this->render('a [[Category:boxes]] b'));
$this->assertSame('<span class="wikicategory" data-wiki-category="Boxes" data-wiki-sort="Small"></span>', $this->render('[[Category:boxes|Small]]'), 'a sort key goes with it');
}

public function test_template_calls_become_tags_with_their_name_and_call()
Expand Down
Loading
Loading