diff --git a/README.md b/README.md index 708da04..4ccb14f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ A wiki for phpBB with version history, side-by-side compare, and a moderation qu - Wikipedia-style headings: `== Heading ==` to `====== Heading ======` in an article become section headings with anchors, `[[Article#Section]]` and `[[#Section]]` link to them, and a table of contents shows after the intro when an article has four or more headings (`__TOC__` places it, `__FORCETOC__` forces it, `__NOTOC__` hides it). A first line `#REDIRECT [[Article]]` sets the redirect for members who may set redirects. This works in wiki articles only; in a forum post `== text ==` stays plain text. - Tables, written as HTML in an article: ``, ``, ``, ``, ``, `
`, `
` and ``, with `colspan` and `rowspan` on a cell. BBCode and `[[links]]` work inside cells. Any other attribute (`style`, `onclick`, `class`) is dropped, and any other element (`script`, `iframe`) stays plain text. Like headings, tables work in wiki articles only; in a forum post `` stays plain text. After upgrading, disable and enable the extension so the board rebuilds its cache. - Templates: `{{Name}}`, `{{Name|first|second}}` and `{{Name|key=value}}` in an article include the live version of the article `Template:Name`, so a notice or infobox is written once and edited in one place. See [Templates](#templates). +- Categories: `[[Category:Name]]` in an article puts it in a category. The article lists its categories at the bottom, `/wiki/Category:Name` lists the articles in a category, and `/wiki/categories` lists every category. See [Categories](#categories). - The Wiki link in the board header shows the Wikipedia icon by default. Under **Extensions > Wiki > Settings** in the ACP the icon can be changed to any [Font Awesome](https://fontawesome.com/v4.7.0/icons/) icon name, or left blank for no icon. - A **Start discussion** button on each article creates its discussion topic in a forum chosen under **Extensions > Wiki > Settings** in the ACP, and links the topic to the article. It shows to members who hold the "Can change the discussion topic for an article" permission and may post in that forum, and stays hidden until a forum is chosen. - Separate permissions for viewing, editing, version history, deleting and moderating. @@ -98,6 +99,17 @@ An article The arguments of a function are trimmed of spaces and line breaks before it works on them. - After upgrading, disable and enable the extension so the board rebuilds its cache. Articles saved earlier hold `{{...}}` as plain text until they are saved again or reparsed with `php bin/phpbbcli.php reparser:reparse phpbbmodders_wiki_article`. +## Categories + +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. +- **`/wiki/categories`** lists every category with a live article in it and how many, linked from the overview. +- 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 ``, so `[[Category:Infobox templates]]` puts the template in that category. A category added inside `` 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`. + ## Linking between articles Write an article's name in double square brackets to link to it, in wiki articles and in forum posts: diff --git a/config/routing.yml b/config/routing.yml index 188d0c3..979873f 100644 --- a/config/routing.yml +++ b/config/routing.yml @@ -12,6 +12,16 @@ phpbbmodders_wiki_recent_changes: path: /wiki/recent-changes defaults: { _controller: phpbbmodders.wiki.recent_changes:page } +phpbbmodders_wiki_categories: + path: /wiki/categories + defaults: { _controller: phpbbmodders.wiki.category:index } + +phpbbmodders_wiki_category: + path: /wiki/Category:{name} + defaults: { _controller: phpbbmodders.wiki.category:page } + requirements: + name: .+ + phpbbmodders_wiki_templates: path: /wiki/templates defaults: { _controller: phpbbmodders.wiki.template_list:page } diff --git a/config/services.yml b/config/services.yml index 87fd76e..086375f 100644 --- a/config/services.yml +++ b/config/services.yml @@ -38,6 +38,28 @@ services: - '%core.root_path%' - '%core.php_ext%' + phpbbmodders.wiki.categories: + class: phpbbmodders\wiki\wiki\categories + arguments: + - '@dbal.conn' + - '@phpbbmodders.wiki.templates' + - '%tables.wiki.article%' + + phpbbmodders.wiki.category: + class: phpbbmodders\wiki\controller\category + arguments: + - '@auth' + - '@config' + - '@dbal.conn' + - '@controller.helper' + - '@pagination' + - '@request' + - '@template' + - '@user' + - '@phpbbmodders.wiki.categories' + - '@phpbbmodders.wiki.main' + - '%tables.wiki.article%' + phpbbmodders.wiki.template_list: class: phpbbmodders\wiki\controller\template_list arguments: diff --git a/controller/category.php b/controller/category.php new file mode 100644 index 0000000..f35ac76 --- /dev/null +++ b/controller/category.php @@ -0,0 +1,219 @@ +auth = $auth; + $this->config = $config; + $this->db = $db; + $this->helper = $helper; + $this->pagination = $pagination; + $this->request = $request; + $this->template = $template; + $this->user = $user; + $this->categories = $categories; + $this->main = $main; + $this->article_table = $article_table; + } + + /** + * Fill the template with a page of a category's members. + * + * @param string $name The category's normalised name + * @return void + */ + protected function assign_members($name) + { + $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); + + foreach (array_slice($members, $start, $per_page) as $row) + { + $this->template->assign_block_vars('category_members', array( + 'TITLE' => $row['article_title'], + 'DESCRIPTION' => $row['article_description'], + 'U_ARTICLE' => $this->helper->route('phpbbmodders_wiki_article', array('article' => $row['article_url'])), + )); + } + + $this->pagination->generate_template_pagination($this->helper->route('phpbbmodders_wiki_category', array('name' => $name)), '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), + )); + } + + /** + * Whether a category has a page of its own: an article called "Category:Name" with a live version. + * + * @param string $name + * @return bool + */ + protected function has_page($name) + { + $sql = 'SELECT article_id + FROM ' . $this->article_table . " + WHERE article_url = '" . $this->db->sql_escape(categories::PREFIX . $name) . "' + AND article_approved = 1"; + $result = $this->db->sql_query_limit($sql, 1); + $found = (bool) $this->db->sql_fetchrow($result); + $this->db->sql_freeresult($result); + + return $found; + } + + /** + * A category's page. Anything but a plain view (editing it, its versions) is the + * ordinary article page of "Category:Name". + * + * @param string $name What follows "Category:" in the address + * @return \Symfony\Component\HttpFoundation\Response + */ + public function page($name) + { + $this->user->add_lang_ext('phpbbmodders/wiki', 'common'); + + if (!$this->auth->acl_get('u_wiki_view')) + { + trigger_error('NOT_AUTHORISED'); + } + + $article = categories::PREFIX . $name; + $normal = categories::normalise($name); + $plain_view = !$this->request->is_set('action') && !$this->request->is_set('id'); + + if ($plain_view && $this->has_page($name)) + { + // The description is the article; the members go below it + $this->assign_members($normal); + + return $this->main->article($article); + } + + if ($plain_view && $this->categories->has_members($normal)) + { + $this->assign_members($normal); + $this->template->assign_block_vars('navlinks', array( + 'FORUM_NAME' => $this->user->lang('WIKI'), + 'U_VIEW_FORUM' => $this->helper->route('phpbbmodders_wiki_index', array()), + )); + $this->template->assign_vars(array( + 'CATEGORY_TITLE' => categories::PREFIX . $normal, + 'U_CREATE' => $this->auth->acl_get('u_wiki_edit') ? $this->helper->route('phpbbmodders_wiki_article', array('article' => $article, 'action' => 'edit')) : '', + )); + + return $this->helper->render('category.html', categories::PREFIX . $normal); + } + + // Nothing in it and no page: the ordinary article flow, which offers to create it + return $this->main->article($article); + } + + /** + * The list of all categories in use, with how many articles each has. + * + * @return \Symfony\Component\HttpFoundation\Response + */ + public function index() + { + $this->user->add_lang_ext('phpbbmodders/wiki', 'common'); + + if (!$this->auth->acl_get('u_wiki_view')) + { + trigger_error('NOT_AUTHORISED'); + } + + $counts = $this->categories->in_use(); + $total = count($counts); + $per_page = max(1, (int) $this->config['topics_per_page']); + $start = $this->pagination->validate_start($this->request->variable('start', 0), $per_page, $total); + + foreach (array_slice($counts, $start, $per_page, true) as $name => $count) + { + $this->template->assign_block_vars('categories', array( + 'NAME' => $name, + 'COUNT' => $count, + 'U_CATEGORY' => $this->helper->route('phpbbmodders_wiki_article', array('article' => categories::PREFIX . $name)), + )); + } + + $this->template->assign_block_vars('navlinks', array( + 'FORUM_NAME' => $this->user->lang('WIKI'), + 'U_VIEW_FORUM' => $this->helper->route('phpbbmodders_wiki_index', array()), + )); + + $this->pagination->generate_template_pagination($this->helper->route('phpbbmodders_wiki_categories', array()), 'pagination', 'start', $total, $per_page, $start); + $this->template->assign_vars(array( + 'TOTAL_ITEMS' => $this->user->lang('TOTAL_ITEMS', $total), + )); + + return $this->helper->render('category_list.html', $this->user->lang('WIKI_CATEGORIES')); + } +} diff --git a/docs/TODO.md b/docs/TODO.md index beb4d24..86b39fb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -27,11 +27,23 @@ new URL and losing the connection to its history. Needs a "move" action permission) that renames `article_url` across all of an article's versions and optionally leaves a redirect stub at the old URL. -## Categories/tags +## Categories -Browsing is flat today — Popular/Latest/All/Sticky/Pending on the overview -page, nothing that groups articles by topic. Would need a taxonomy (even a -simple one-tag-per-article model to start) and a browse-by-category view. +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. + +Not built: + +- A category added by a template (`[[Category:Stubs]]`) 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. ## Wiki search diff --git a/docs/TODO/todo-wiki-markup.md b/docs/TODO/todo-wiki-markup.md index f72f252..768b03a 100644 --- a/docs/TODO/todo-wiki-markup.md +++ b/docs/TODO/todo-wiki-markup.md @@ -16,7 +16,7 @@ and `WIKIMAGIC` tags and the `html:` table tags on in `core.text_formatter_s9e_p `php bin/phpbbcli.php reparser:reparse phpbbmodders_wiki_article`. Templates are built too, in their first form: `{{Name}}`, `{{Name|a|b}}`, `{{Name|key=value}}`, `{{{param}}}` and `{{{param|default}}}`, `` and ``, expanded when an article is shown (see `wiki/templates.php`), nested ten deep with a loop check. What is left below is -the text styles and references (not wanted yet), `[[Category:Name]]`, and the rest of templates. +the text styles and references (not wanted yet), and the rest of templates. ## Scope: wiki articles only @@ -68,7 +68,7 @@ Still to confirm on the test board before building on it. | Syntax | What it does | Notes | |---|---|---| | `#REDIRECT [[Other article]]` | Redirect set in the article text | Redirects already exist as an editor field; this is a familiar shortcut. | -| `[[Category:Name]]` | Put an article in a category | Only useful once categories are built (see the master TODO). | +| `[[Category:Name]]` | Put an article in a category | Built; see the README. | ## Syntax not to add diff --git a/event/listener.php b/event/listener.php index 283c56f..36668af 100644 --- a/event/listener.php +++ b/event/listener.php @@ -175,6 +175,7 @@ public function page_header($event) 'U_WIKI' => $this->helper->route('phpbbmodders_wiki_index', array()), 'U_WIKI_RECENT_CHANGES' => $this->helper->route('phpbbmodders_wiki_recent_changes', array()), 'U_WIKI_TEMPLATES' => $this->helper->route('phpbbmodders_wiki_templates', array()), + 'U_WIKI_CATEGORIES' => $this->helper->route('phpbbmodders_wiki_categories', array()), 'U_WIKI_FEED_RSS' => \phpbbmodders\wiki\wiki\feed::offers($feed_setting, \phpbbmodders\wiki\wiki\feed::RSS) ? $this->helper->route('phpbbmodders_wiki_recent_changes_feed', array('_format' => 'rss')) : '', 'U_WIKI_FEED_ATOM' => \phpbbmodders\wiki\wiki\feed::offers($feed_setting, \phpbbmodders\wiki\wiki\feed::ATOM) ? $this->helper->route('phpbbmodders_wiki_recent_changes_feed', array('_format' => 'atom')) : '', 'WIKI_ICON_CLASSES' => \phpbbmodders\wiki\wiki\icon::classes(isset($this->config['wiki_icon']) ? $this->config['wiki_icon'] : \phpbbmodders\wiki\wiki\icon::DEFAULT_ICON), diff --git a/event/wikilink_listener.php b/event/wikilink_listener.php index 8dee37e..c9e20e7 100644 --- a/event/wikilink_listener.php +++ b/event/wikilink_listener.php @@ -110,6 +110,18 @@ 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 = $configurator->tags->add('WIKICATEGORY'); + $category->attributes->add('name')->filterChain->append('\phpbbmodders\wiki\wiki\categories::normalise'); + $category->template = ''; + + $configurator->Preg->match( + '/\[\[\s*Category:\s*(?=[^\s\]|])(?' . self::NAME_CLASS . '{1,255}?)\s*(?:\|[^\[\]\n]*)?\]\]/i', + 'WIKICATEGORY' + ); + // [[File:name]] and [[File:name|alt text]] embed one of the article's uploaded files. // It is added first, and WIKILINK leaves "File:" names to it. $file_tag = $configurator->tags->add('WIKIFILE'); @@ -169,7 +181,7 @@ public function configure_wikilink($event) . '#'; $configurator->Preg->match( - '/\[\[\s*(?!File:)(?=[^\]|])(?' . self::NAME_CLASS . '{0,255}?)\s*(?:#\s*(?
[^\[\]|<>"\n]{1,255}?)\s*)?(?:\|\s*(?[^\[\]\n]{1,255}?)\s*)?\]\]/', + '/\[\[\s*(?!(?:File|Category):)(?=[^\]|])(?' . self::NAME_CLASS . '{0,255}?)\s*(?:#\s*(?
[^\[\]|<>"\n]{1,255}?)\s*)?(?:\|\s*(?[^\[\]\n]{1,255}?)\s*)?\]\]/', 'WIKILINK' ); @@ -199,7 +211,7 @@ public function toggle_wiki_markup($event) { $parser = $event['parser']->get_parser(); - $tags = array('WIKIHEADING', 'WIKIMAGIC', 'WIKITEMPLATE'); + $tags = array('WIKIHEADING', 'WIKIMAGIC', 'WIKITEMPLATE', 'WIKICATEGORY'); foreach (self::TABLE_ELEMENTS as $element) { $tags[] = 'html:' . $element; @@ -235,6 +247,20 @@ public function link_wikilinks($event) return; } + 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>/', $html, $marks)) + { + foreach ($marks[1] as $mark) + { + \phpbbmodders\wiki\wiki\categories::add(htmlspecialchars_decode($mark, ENT_QUOTES)); + } + } + $html = preg_replace('/<\/span>/', '', $html); + $event['html'] = $html; + } + if (strpos($html, 'class="wikiheading"') !== false || strpos($html, 'class="wikimagic"') !== false) { $html = \phpbbmodders\wiki\wiki\markup::finish($html, $this->language->lang('WIKI_TOC')); diff --git a/language/en/common.php b/language/en/common.php index 482b4e0..3e5d63d 100644 --- a/language/en/common.php +++ b/language/en/common.php @@ -109,6 +109,20 @@ 'WIKI_START_FROM' => 'Start from', 'WIKI_START_BLANK' => 'A blank article', 'WIKI_START_USE' => 'Use', + 'WIKI_CATEGORIES' => 'Categories', + 'WIKI_CATEGORIES_EXPLAIN' => 'Every category with an article in it, A to Z. Write [[Category:Name]] in an article to put it in a category.', + 'WIKI_CATEGORIES_NONE' => 'No article is in a category yet.', + 'WIKI_CATEGORY' => 'Category', + 'WIKI_CATEGORY_ARTICLES' => 'Articles', + 'WIKI_CATEGORY_ARTICLES_TOTAL' => array( + 0 => 'No articles', + 1 => '1 article', + 2 => '%d articles', + ), + 'WIKI_CATEGORY_MEMBERS' => 'Articles in this category', + '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', 'WIKI_TEMPLATES' => 'Templates', 'WIKI_TEMPLATES_EXPLAIN' => 'Every template of the wiki, A to Z. A template is an article called “Template:Name”; write {{Name}} in an article to use it.', 'WIKI_TEMPLATES_NONE' => 'There are no templates yet.', diff --git a/migrations/add_wiki_categories.php b/migrations/add_wiki_categories.php new file mode 100644 index 0000000..40a485d --- /dev/null +++ b/migrations/add_wiki_categories.php @@ -0,0 +1,41 @@ +container->get('cache')->purge(); + } +} diff --git a/styles/prosilver/template/article_body.html b/styles/prosilver/template/article_body.html index 3b4ba21..07870b7 100644 --- a/styles/prosilver/template/article_body.html +++ b/styles/prosilver/template/article_body.html @@ -48,6 +48,16 @@

{ARTICLE_TITLE} - {L_VER + +

{L_WIKI_CATEGORIES}{L_COLON} + {categories.NAME} • +

+ + + + + +

{L_WIKI_TEMPLATE_TEXT}

{TEMPLATE_USED_ON}

diff --git a/styles/prosilver/template/category.html b/styles/prosilver/template/category.html new file mode 100644 index 0000000..d7884ab --- /dev/null +++ b/styles/prosilver/template/category.html @@ -0,0 +1,5 @@ + +

{CATEGORY_TITLE}

+

{L_WIKI_CATEGORY_NO_PAGE} {L_WIKI_CATEGORY_WRITE}

+ + diff --git a/styles/prosilver/template/category_list.html b/styles/prosilver/template/category_list.html new file mode 100644 index 0000000..9df6c3c --- /dev/null +++ b/styles/prosilver/template/category_list.html @@ -0,0 +1,40 @@ + +

{L_WIKI_CATEGORIES}

+

{L_WIKI_CATEGORIES_EXPLAIN}

+ +
+
+
+ + + + + + + + + + + + + + +
{L_WIKI_CATEGORY}{L_WIKI_CATEGORY_ARTICLES}
{categories.NAME}{categories.COUNT}
+ + + +

{L_WIKI_CATEGORIES_NONE}

+ + +
+ +
+ + diff --git a/styles/prosilver/template/category_members.html b/styles/prosilver/template/category_members.html new file mode 100644 index 0000000..ba44205 --- /dev/null +++ b/styles/prosilver/template/category_members.html @@ -0,0 +1,33 @@ +

{L_WIKI_CATEGORY_MEMBERS}

+ +
+
+ + + + + + + + + + + + + + + +
{L_CONTRIBUTIONS_ARTICLE}{L_WIKI_TEMPLATE_DESCRIPTION}
{category_members.TITLE}{category_members.DESCRIPTION}
+
+
+ +

{L_WIKI_CATEGORY_EMPTY}

+ +
+ +
diff --git a/styles/prosilver/template/overview.html b/styles/prosilver/template/overview.html index 26ea4da..f641a83 100644 --- a/styles/prosilver/template/overview.html +++ b/styles/prosilver/template/overview.html @@ -3,6 +3,7 @@

{L_OVERVIEW}

{L_WIKI_RECENT_CHANGES} • {L_WIKI_TEMPLATES} + • {L_WIKI_CATEGORIES} • {L_WIKI_FEED_RSS_LINK} • {L_WIKI_FEED_ATOM_LINK}

diff --git a/tests/unit/categories_test.php b/tests/unit/categories_test.php new file mode 100644 index 0000000..86e66af --- /dev/null +++ b/tests/unit/categories_test.php @@ -0,0 +1,76 @@ +assertSame($kept, categories::normalise($written)); + } + + public static function names(): array + { + return array( + 'lower case' => array('boxes', 'Boxes'), + 'already right' => array('Boxes', 'Boxes'), + 'underscores' => array('old_extensions', 'Old extensions'), + 'extra spaces' => array(" old extensions ", 'Old extensions'), + 'mixed' => array("old _ extensions", 'Old extensions'), + 'not the rest' => array('phpBB extensions', 'PhpBB extensions'), + 'accent' => array('écrits', 'Écrits'), + 'empty' => array(' ', ''), + ); + } + + public function test_names_found_while_rendering_are_listed_once_in_order() + { + categories::add('zebra'); + categories::add('Alpha'); + categories::add('Zebra'); + categories::add(''); + + $this->assertSame(array('Zebra', 'Alpha'), categories::found()); + + categories::reset(); + $this->assertSame(array(), categories::found()); + } + + public function test_the_names_in_stored_text_are_read_back() + { + $stored = 'Text [[Category:boxes]] and ' + . '[[Category:Q&A "x"]] ' + . '[[Category:Boxes]]'; + + $this->assertSame(array('Boxes', 'Q&A "x"'), categories::names_in($stored)); + $this->assertSame(array(), categories::names_in('No categories here')); + } + + public function test_a_template_page_counts_for_what_it_says_outside_includeonly() + { + $raw = "Docs [[Category:Infobox templates]]Body[[Category:Boxes]] [[category:old_stuff|key]] [[Category:Always]]"; + + $this->assertSame(array('Infobox templates', 'Always'), categories::names_outside_includeonly($raw)); + $this->assertSame(array(), categories::names_outside_includeonly('[[Category:Boxes]]')); + $this->assertSame(array(), categories::names_outside_includeonly('No marks')); + } +} diff --git a/tests/unit/markup_test.php b/tests/unit/markup_test.php index ea490ed..1943fcd 100644 --- a/tests/unit/markup_test.php +++ b/tests/unit/markup_test.php @@ -149,7 +149,7 @@ private function parse(string $text, bool $wiki_markup = true): string $listener->configure_wikilink(array('configurator' => $configurator)); extract($configurator->finalize()); - $tags = array('WIKIHEADING', 'WIKIMAGIC', 'WIKITEMPLATE'); + $tags = array('WIKIHEADING', 'WIKIMAGIC', 'WIKITEMPLATE', 'WIKICATEGORY'); foreach (\phpbbmodders\wiki\event\wikilink_listener::TABLE_ELEMENTS as $element) { $tags[] = 'html:' . $element; @@ -226,6 +226,27 @@ public function test_file_embeds_are_still_their_own_tag() $this->assertSame(1, substr_count($xml, 'parse('Text [[Category:old_extensions]] and [[category:Boxes|sort key]] and [[Category: Boxes ]].'); + + $this->assertSame(3, substr_count($xml, 'assertStringContainsString('', $xml); + $this->assertSame(2, substr_count($xml, ''), $xml); + $this->assertStringNotContainsString('assertStringNotContainsString('parse('[[Category:Boxes]]', false)); + $this->assertStringNotContainsString('parse('[[Category:]] [[Category: ]]')); + } + + public function test_a_category_mark_renders_as_an_empty_marker() + { + $this->assertSame('a b', $this->render('a [[Category:boxes]] b')); + } + public function test_template_calls_become_tags_with_their_name_and_call() { $xml = $this->parse('Hi {{Out of date}} and {{Infobox|name=Wiki|v={{Ver|1}}|[[A|b]]}} and {{ Spaced |x}}.'); diff --git a/wiki/categories.php b/wiki/categories.php new file mode 100644 index 0000000..026c4be --- /dev/null +++ b/wiki/categories.php @@ -0,0 +1,255 @@ +db = $db; + $this->templates = $templates; + $this->article_table = $article_table; + } + + /** + * A category name as it is kept: spaces for underscores, runs of spaces made one, and a + * capital first letter, so [[Category:boxes]] and [[Category:Boxes]] are one category. + * Also the filter the text formatter runs on the name in the tag. + * + * @param string $name The name as written + * @return string + */ + public static function normalise($name) + { + $name = trim(preg_replace('/[\s_]+/u', ' ', (string) $name)); + + return mb_strtoupper(mb_substr($name, 0, 1)) . mb_substr($name, 1); + } + + /** + * Forget the names found so far, before an article is rendered. + * + * @return void + */ + public static function reset() + { + self::$found = array(); + } + + /** + * Note that rendered text puts the article in a category. + * + * @param string $name The category's name + * @return void + */ + public static function add($name) + { + $name = self::normalise($name); + if ($name !== '' && !in_array($name, self::$found, true)) + { + self::$found[] = $name; + } + } + + /** + * The categories found in the text rendered since reset(), in the order they were written. + * + * @return array + */ + public static function found() + { + return self::$found; + } + + /** + * The category names in the stored text of an article. + * + * @param string $stored The article text as it is stored (XML) + * @return array Unique names, in the order they were written + */ + public static function names_in($stored) + { + if (!preg_match_all('/templates->live_where('a', '') . ' + AND a.article_text ' . $this->db->sql_like_expression($this->db->get_any_char() . $needle . $this->db->get_any_char()); + } + + /** + * The category names a template page puts itself in: those written outside its + * parts, since those belong to the articles that include it. + * + * @param string $raw The template page's text as it is written + * @return array Normalised names, unique, in order + */ + public static function names_outside_includeonly($raw) + { + $raw = preg_replace('#.*?#is', '', $raw); + if (!preg_match_all('/\[\[\s*Category:\s*([^\[\]|\n]+?)\s*(?:\|[^\[\]\n]*)?\]\]/i', $raw, $matches)) + { + return array(); + } + + $names = array(); + foreach ($matches[1] as $name) + { + $name = self::normalise($name); + if ($name !== '' && !in_array($name, $names, true)) + { + $names[] = $name; + } + } + + return $names; + } + + /** + * The categories a live article is in as far as its own text says. For a template page + * that leaves out what is inside . + * + * @param array $row Row with article_url, article_text and bbcode_uid + * @return array + */ + protected function names_of($row) + { + if (strpos($row['article_url'], templates::PREFIX) !== 0) + { + return self::names_in($row['article_text']); + } + + $edit = generate_text_for_edit($row['article_text'], $row['bbcode_uid'], OPTION_FLAG_BBCODE | OPTION_FLAG_SMILIES | OPTION_FLAG_LINKS); + + return self::names_outside_includeonly(htmlspecialchars_decode($edit['text'], ENT_COMPAT)); + } + + /** + * Whether any live article is in a category. + * + * @param string $category + * @return bool + */ + public function has_members($category) + { + return count($this->members($category)) > 0; + } + + /** + * Every live article in a category, A to Z. + * + * @param string $category + * @return array Rows with article_url, article_title and article_description + */ + public function members($category) + { + $normal = self::normalise($category); + $sql = 'SELECT a.article_url, a.article_title, a.article_description, a.article_text, a.bbcode_uid + FROM ' . $this->article_table . ' a + WHERE ' . $this->members_where($category) . ' + ORDER BY a.article_title ASC, a.article_id ASC'; + $result = $this->db->sql_query($sql); + $members = array(); + while ($row = $this->db->sql_fetchrow($result)) + { + // A template page counts for what it says outside + if (strpos($row['article_url'], templates::PREFIX) === 0 && !in_array($normal, $this->names_of($row), true)) + { + continue; + } + unset($row['article_text'], $row['bbcode_uid']); + $members[] = $row; + } + $this->db->sql_freeresult($result); + + return $members; + } + + /** + * Every category with a live article in it, and how many. + * + * @return array Category names with their member counts, A to Z + */ + public function in_use() + { + $sql = 'SELECT a.article_url, a.article_text, a.bbcode_uid + FROM ' . $this->article_table . ' a + WHERE ' . $this->templates->live_where('a', '') . ' + AND a.article_text ' . $this->db->sql_like_expression($this->db->get_any_char() . '