From 470e9a6d28105b4c2eeda0dd24df2ada8c8ef9f6 Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Wed, 14 Oct 2026 23:47:33 -0500 Subject: [PATCH] Page sub-categories on their own, with how much each holds A category page lists its sub-categories a page at a time, with their own page links (substart), so moving through them keeps the page of articles and the other way round. Each sub-category shows how many articles, and sub-categories, it holds, counted in one query. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- controller/category.php | 37 +++++++++++++------ docs/TODO.md | 10 ++--- language/en/common.php | 13 +++++++ .../prosilver/template/category_members.html | 24 +++++++++++- styles/prosilver/theme/style.css | 6 +++ tests/wiki/category_members_test.php | 22 +++++++++++ wiki/categories.php | 11 ++++++ wiki/category_members.php | 31 ++++++++++++++++ 9 files changed, 136 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 41cbdc7..9787137 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ Write `[[Category:Name]]` anywhere in a wiki article to put it in a category. Th - `[[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. +- **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, with how many articles (and sub-categories) it holds. Sub-categories have their own pages, so moving through them keeps the page of articles, and the other way round. - **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. diff --git a/controller/category.php b/controller/category.php index 492e3af..a8698fc 100644 --- a/controller/category.php +++ b/controller/category.php @@ -96,20 +96,34 @@ 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) + $per_page = max(1, (int) $this->config['topics_per_page']); + + // Categories that are in this one, by sort key or else name, paged on their own (substart) + // so moving through them keeps the page of articles, and the other way round + $subcategories = $this->categories->subcategories($name); + $sub_total = count($subcategories); + $sub_start = $this->pagination->validate_start($this->request->variable('substart', 0), $per_page, $sub_total); + $members = $this->categories->members($name); + $total = count($members); + $start = $this->pagination->validate_start($this->request->variable('start', 0), $per_page, $total); + + $shown = array_slice($subcategories, $sub_start, $per_page); + $counts = $this->categories->counts(array_map(function ($row) { + return substr($row['article_url'], strlen(categories::PREFIX)); + }, $shown)); + foreach ($shown as $row) { + $sub_name = categories::normalise(substr($row['article_url'], strlen(categories::PREFIX))); + $count = isset($counts[$sub_name]) ? $counts[$sub_name] : array('articles' => 0, 'subcategories' => 0); $this->template->assign_block_vars('category_subcategories', array( - 'NAME' => substr($row['article_url'], strlen(categories::PREFIX)), + 'NAME' => $sub_name, 'DESCRIPTION' => $row['article_description'], + 'COUNT' => $this->user->lang('WIKI_CATEGORY_HOLDS_ARTICLES', $count['articles']) + . ($count['subcategories'] ? ', ' . $this->user->lang('WIKI_CATEGORY_HOLDS_SUBCATEGORIES', $count['subcategories']) : ''), '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']); - $start = $this->pagination->validate_start($this->request->variable('start', 0), $per_page, $total); + $this->pagination->generate_template_pagination($this->helper->route('phpbbmodders_wiki_category', array('name' => $name) + ($start ? array('start' => $start) : array())), 'sub_pagination', 'substart', $sub_total, $per_page, $sub_start); foreach (array_slice($members, $start, $per_page) as $row) { @@ -121,10 +135,11 @@ protected function assign_members($name) )); } - $this->pagination->generate_template_pagination($this->helper->route('phpbbmodders_wiki_category', array('name' => $name)), 'pagination', 'start', $total, $per_page, $start); + $this->pagination->generate_template_pagination($this->helper->route('phpbbmodders_wiki_category', array('name' => $name) + ($sub_start ? array('substart' => $sub_start) : array())), 'pagination', 'start', $total, $per_page, $start); $this->template->assign_vars(array( - 'S_CATEGORY_PAGE' => true, - 'CATEGORY_TOTAL' => $this->user->lang('WIKI_CATEGORY_ARTICLES_TOTAL', $total), + 'S_CATEGORY_PAGE' => true, + 'CATEGORY_TOTAL' => $this->user->lang('WIKI_CATEGORY_ARTICLES_TOTAL', $total), + 'CATEGORY_SUBCATEGORY_TOTAL' => $sub_total ? $this->user->lang('WIKI_CATEGORY_SUBCATEGORIES_TOTAL', $sub_total) : '', )); } diff --git a/docs/TODO.md b/docs/TODO.md index f47eb63..086756a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -23,13 +23,9 @@ 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: - -- Paging the sub-categories of a category separately from its articles, and showing how many - articles each sub-category holds. +Also built (10/15/2026): categories added by a template, sub-categories (paged on their own, +with how many articles each holds), `[[:Category:Name]]` links and sort keys, from a stored +membership table that a cron task fills after the update. Nothing more is planned here. ## A wiki-style UI, not just reskinned forum templates diff --git a/language/en/common.php b/language/en/common.php index 3fab1b6..89e84cc 100644 --- a/language/en/common.php +++ b/language/en/common.php @@ -177,6 +177,19 @@ ), 'WIKI_CATEGORY_MEMBERS' => 'Articles in this category', 'WIKI_CATEGORY_SUBCATEGORIES' => 'Sub-categories', + 'WIKI_CATEGORY_SUBCATEGORIES_TOTAL' => array( + 1 => '1 sub-category', + 2 => '%d sub-categories', + ), + 'WIKI_CATEGORY_HOLDS_ARTICLES' => array( + 0 => 'no articles', + 1 => '1 article', + 2 => '%d articles', + ), + 'WIKI_CATEGORY_HOLDS_SUBCATEGORIES' => array( + 1 => '1 sub-category', + 2 => '%d 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', diff --git a/styles/prosilver/template/category_members.html b/styles/prosilver/template/category_members.html index df4b667..37f2fdc 100644 --- a/styles/prosilver/template/category_members.html +++ b/styles/prosilver/template/category_members.html @@ -2,9 +2,31 @@

{L_WIKI_CATEGORY_SUBCATEGORIES}

+
+ +

{L_WIKI_CATEGORY_MEMBERS}

diff --git a/styles/prosilver/theme/style.css b/styles/prosilver/theme/style.css index e14d723..a15cf80 100644 --- a/styles/prosilver/theme/style.css +++ b/styles/prosilver/theme/style.css @@ -331,3 +331,9 @@ form.wiki-start-from p { .wiki-buttons .button.wiki-protect-on .icon { color: #bc2a4d; } + +/* On a category page: how much each sub-category holds */ +.wiki-subcategory-count { + opacity: 0.8; + font-size: 0.95em; +} diff --git a/tests/wiki/category_members_test.php b/tests/wiki/category_members_test.php index eae0347..f78bb65 100644 --- a/tests/wiki/category_members_test.php +++ b/tests/wiki/category_members_test.php @@ -84,6 +84,28 @@ public function test_members_sort_by_key_or_title_with_sub_categories_apart() $this->assertSame(array('Fruit' => 4, 'Tropical' => 1), $members->in_use()); } + public function test_counts_give_articles_and_sub_categories_apart() + { + $members = $this->members(array( + 'Mango' => array('Tropical' => ''), + 'Apple' => array('Tropical' => ''), + 'Category:Tropical' => array('Fruit' => ''), + 'Zebra' => array('Fruit' => ''), + 'Draft' => array('Tropical' => ''), + )); + foreach (array('Mango', 'Apple', 'Category:Tropical', 'Zebra', 'Draft') as $article) + { + $members->update($article); + } + + $this->assertSame(array( + 'Tropical' => array('articles' => 2, 'subcategories' => 0), + 'Fruit' => array('articles' => 1, 'subcategories' => 1), + 'Empty' => array('articles' => 0, 'subcategories' => 0), + ), $members->counts(array('tropical', 'Fruit', 'Empty')), 'a version that is not live does not count'); + $this->assertSame(array(), $members->counts(array())); + } + public function test_an_article_that_is_not_live_is_not_listed() { $members = $this->members(array('Draft' => array('Fruit' => ''))); diff --git a/wiki/categories.php b/wiki/categories.php index f28904b..e6e4223 100644 --- a/wiki/categories.php +++ b/wiki/categories.php @@ -278,6 +278,17 @@ public function subcategories($category) return $this->category_members->built() ? $this->category_members->members($category)['subcategories'] : array(); } + /** + * How many articles and sub-categories each of these categories holds. + * + * @param array $categories + * @return array Normalised names with 'articles' and 'subcategories' counts + */ + public function counts(array $categories) + { + return $this->category_members->counts($categories); + } + /** * The short description of every category that has a page: the description field of * the live version of its "Category:Name" article. diff --git a/wiki/category_members.php b/wiki/category_members.php index 83b3aa4..da51ef5 100644 --- a/wiki/category_members.php +++ b/wiki/category_members.php @@ -275,6 +275,37 @@ public static function compare(array $a, array $b) return strnatcasecmp($key_a, $key_b) ?: strcmp($a['article_url'], $b['article_url']); } + /** + * How many live articles and sub-categories each of these categories holds, read in one + * query, for the counts beside sub-categories. + * + * @param array $categories Category names + * @return array Normalised names with 'articles' and 'subcategories' counts + */ + public function counts(array $categories) + { + $names = array_values(array_unique(array_map(array(categories::class, 'normalise'), $categories))); + $counts = array_fill_keys($names, array('articles' => 0, 'subcategories' => 0)); + if (!$names) + { + return $counts; + } + + $sql = 'SELECT m.category, a.article_url + FROM ' . $this->member_table . ' m + JOIN ' . $this->article_table . ' a ON a.article_url = m.article_url + WHERE ' . $this->db->sql_in_set('m.category', $names) . ' + AND ' . $this->templates->live_where('a', ''); + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $counts[$row['category']][strpos($row['article_url'], categories::PREFIX) === 0 ? 'subcategories' : 'articles']++; + } + $this->db->sql_freeresult($result); + + return $counts; + } + /** * Every category with a live article in it, and how many (sub-categories included). *