From 379d70bd7933c5ca81fbc46871bf15eda6018c4b Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Mon, 12 Oct 2026 12:43:18 -0500 Subject: [PATCH] Add a Recent changes page and RSS and Atom feeds /wiki/recent-changes lists every version that is or was live, newest first, with paging. /wiki/recent-changes.rss and .atom carry the latest 50. A new ACP setting offers RSS, Atom or both, and the header advertises the offered feeds. Versions waiting for approval never appear, since the page and feeds are public. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 1 + acp/main_module.php | 15 + adm/style/acp_wiki_settings.html | 4 + config/routing.yml | 10 + config/services.yml | 15 + controller/recent_changes.php | 277 ++++++++++++++++++ docs/TODO.md | 9 - event/listener.php | 4 + language/de/acp_wiki.php | 6 + language/de/common.php | 8 + language/en/acp_wiki.php | 6 + language/en/common.php | 8 + migrations/add_recent_changes.php | 33 +++ .../event/overall_header_head_append.html | 2 + styles/prosilver/template/overview.html | 5 + styles/prosilver/template/recent_changes.html | 49 ++++ tests/unit/feed_test.php | 121 ++++++++ wiki/feed.php | 189 ++++++++++++ 18 files changed, 753 insertions(+), 9 deletions(-) create mode 100644 controller/recent_changes.php create mode 100644 migrations/add_recent_changes.php create mode 100644 styles/prosilver/template/recent_changes.html create mode 100644 tests/unit/feed_test.php create mode 100644 wiki/feed.php diff --git a/README.md b/README.md index 2d979ce..6dfef17 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A wiki for phpBB with version history, side-by-side compare, and a moderation qu - Every edit is saved as a new version; compare any two versions side by side. - Edits can wait for approval: moderators get a **Pending articles** list with Approve and Disapprove. Approving a version older than the live one is blocked, so a stale draft can't overwrite newer content. - Moderators can take an article offline or delete it, and articles can be made sticky or set to redirect. +- A **Recent changes** page at `/wiki/recent-changes` lists every version saved to the wiki that is or was live, newest first, with RSS and Atom feeds at `/wiki/recent-changes.rss` and `/wiki/recent-changes.atom` (the latest 50). The ACP setting **Recent changes feed** under **Extensions > Wiki > Settings** offers RSS, Atom or both, and the header advertises the offered feeds to feed readers. Versions waiting for approval never appear. - A contributions page for every member, at `/wiki/contributions/`, lists every version they saved across all articles, newest first. Author names on articles and in the version history link to it. Versions waiting for approval, and those of switched-off articles, show only to moderators and to the author. - Files can be uploaded to an article by members who hold the **Can upload files to articles** permission. They are checked against the board's allowed file types and size limit, stored outside public view under `store/phpbbmodders_wiki/`, and read only through the wiki, which checks that the reader may see the article. In the text, `[[File:Diagram.png]]` shows an uploaded image and `[[File:notes.zip|the notes]]` links to any other file. The member who uploaded a file, or a moderator who may delete versions, can remove it, and deleting an article deletes its files. - Notifications when an edit is waiting for approval. diff --git a/acp/main_module.php b/acp/main_module.php index e340038..05e37e0 100644 --- a/acp/main_module.php +++ b/acp/main_module.php @@ -53,6 +53,12 @@ public function main($id, $mode) $forum_id = $request->variable('wiki_discussion_forum_id', 0); $icon = trim($request->variable('wiki_icon', '')); + $feed_format = $request->variable('wiki_feed_format', ''); + + if (!\phpbbmodders\wiki\wiki\feed::is_valid_setting($feed_format)) + { + trigger_error($language->lang('WIKI_FEED_FORMAT_INVALID') . adm_back_link($this->u_action), E_USER_WARNING); + } if (!\phpbbmodders\wiki\wiki\icon::is_valid($icon)) { @@ -78,11 +84,20 @@ public function main($id, $mode) $config->set('wiki_discussion_forum_id', $forum_id); $config->set('wiki_icon', $icon); + $config->set('wiki_feed_format', $feed_format); trigger_error($language->lang('CONFIG_UPDATED') . adm_back_link($this->u_action)); } + $feed_format = isset($config['wiki_feed_format']) ? $config['wiki_feed_format'] : \phpbbmodders\wiki\wiki\feed::BOTH; + $feed_options = ''; + foreach (array(\phpbbmodders\wiki\wiki\feed::BOTH => 'WIKI_FEED_FORMAT_BOTH', \phpbbmodders\wiki\wiki\feed::RSS => 'WIKI_FEED_FORMAT_RSS', \phpbbmodders\wiki\wiki\feed::ATOM => 'WIKI_FEED_FORMAT_ATOM') as $value => $label) + { + $feed_options .= ''; + } + $template->assign_vars(array( + 'S_WIKI_FEED_OPTIONS' => $feed_options, 'WIKI_ICON' => isset($config['wiki_icon']) ? $config['wiki_icon'] : \phpbbmodders\wiki\wiki\icon::DEFAULT_ICON, 'U_ACTION' => $this->u_action, 'S_WIKI_FORUM_OPTIONS' => '' diff --git a/adm/style/acp_wiki_settings.html b/adm/style/acp_wiki_settings.html index b2998ec..5472513 100644 --- a/adm/style/acp_wiki_settings.html +++ b/adm/style/acp_wiki_settings.html @@ -12,6 +12,10 @@

{L_ACP_WIKI_SETTINGS}


{L_WIKI_ICON_EXPLAIN}
+
+

{L_WIKI_FEED_FORMAT_EXPLAIN}
+
+

{L_WIKI_DISCUSSION_FORUM_EXPLAIN}
diff --git a/config/routing.yml b/config/routing.yml index 266a475..a741e24 100644 --- a/config/routing.yml +++ b/config/routing.yml @@ -2,6 +2,16 @@ phpbbmodders_wiki_index: path: /wiki/ defaults: { _controller: phpbbmodders.wiki.overview:base } +phpbbmodders_wiki_recent_changes_feed: + path: /wiki/recent-changes.{_format} + defaults: { _controller: phpbbmodders.wiki.recent_changes:feed } + requirements: + _format: rss|atom + +phpbbmodders_wiki_recent_changes: + path: /wiki/recent-changes + defaults: { _controller: phpbbmodders.wiki.recent_changes:page } + phpbbmodders_wiki_contributions: path: /wiki/contributions/{username} defaults: { _controller: phpbbmodders.wiki.contributions:view } diff --git a/config/services.yml b/config/services.yml index 1ede67a..7a16e38 100644 --- a/config/services.yml +++ b/config/services.yml @@ -22,6 +22,21 @@ services: - '%core.php_ext%' - '%tables.wiki.article%' + phpbbmodders.wiki.recent_changes: + class: phpbbmodders\wiki\controller\recent_changes + arguments: + - '@auth' + - '@config' + - '@dbal.conn' + - '@controller.helper' + - '@pagination' + - '@request' + - '@template' + - '@user' + - '%tables.wiki.article%' + - '%core.root_path%' + - '%core.php_ext%' + phpbbmodders.wiki.contributions: class: phpbbmodders\wiki\controller\contributions arguments: diff --git a/controller/recent_changes.php b/controller/recent_changes.php new file mode 100644 index 0000000..a0fa5dd --- /dev/null +++ b/controller/recent_changes.php @@ -0,0 +1,277 @@ +auth = $auth; + $this->config = $config; + $this->db = $db; + $this->helper = $helper; + $this->pagination = $pagination; + $this->request = $request; + $this->template = $template; + $this->user = $user; + $this->article_table = $article_table; + $this->phpbb_root_path = $phpbb_root_path; + $this->php_ext = $php_ext; + } + + /** + * The feed formats the ACP offers. + * + * @return string The feed class's RSS, ATOM or BOTH + */ + protected function setting() + { + return isset($this->config['wiki_feed_format']) ? $this->config['wiki_feed_format'] : feed::BOTH; + } + + /** + * SQL condition for the versions anyone may see: the live one, and the ones a + * newer live version replaced. + * + * @return string + */ + protected function visible_where() + { + return '(a.article_approved = 1 OR EXISTS ( + SELECT 1 FROM ' . $this->article_table . ' b + WHERE b.article_url = a.article_url + AND b.article_approved = 1 + AND b.article_last_edit > a.article_last_edit))'; + } + + /** + * Versions, newest first. + * + * @param int $limit + * @param int $start + * @return array Rows with the editor's name + */ + protected function get_changes($limit, $start = 0) + { + $sql = 'SELECT a.article_id, a.article_title, a.article_url, a.article_last_edit, a.article_approved, a.article_edit_reason, + u.user_id, u.username, u.user_colour + FROM ' . $this->article_table . ' a + LEFT JOIN ' . USERS_TABLE . ' u ON (u.user_id = a.article_user_id) + WHERE ' . $this->visible_where() . ' + ORDER BY a.article_last_edit DESC, a.article_id DESC'; + $result = $this->db->sql_query_limit($sql, $limit, $start); + $rows = $this->db->sql_fetchrowset($result); + $this->db->sql_freeresult($result); + + return $rows; + } + + /** + * The recent changes page. + * + * @return Response + */ + public function page() + { + $this->user->add_lang_ext('phpbbmodders/wiki', 'common'); + + if (!$this->auth->acl_get('u_wiki_view')) + { + trigger_error('NOT_AUTHORISED'); + } + + include_once($this->phpbb_root_path . 'includes/functions_content.' . $this->php_ext); + + $sql = 'SELECT COUNT(a.article_id) AS total + FROM ' . $this->article_table . ' a + WHERE ' . $this->visible_where(); + $result = $this->db->sql_query($sql); + $total = (int) $this->db->sql_fetchfield('total'); + $this->db->sql_freeresult($result); + + $per_page = max(1, (int) $this->config['topics_per_page']); + $start = $this->pagination->validate_start($this->request->variable('start', 0), $per_page, $total); + + $rows = $this->get_changes($per_page, $start); + $live = $this->get_live_times(array_unique(array_column($rows, 'article_url'))); + + foreach ($rows as $row) + { + $status = contributions::status($row['article_approved'], $row['article_last_edit'], isset($live[$row['article_url']]) ? $live[$row['article_url']] : 0); + + $this->template->assign_block_vars('changes', array( + 'ARTICLE_TITLE' => $row['article_title'], + 'EDIT_REASON' => $row['article_edit_reason'], + 'EDIT_TIME' => $this->user->format_date($row['article_last_edit']), + 'USER' => \phpbbmodders\wiki\wiki\author::link($this->helper, $row['user_id'], $row['username'], $row['user_colour']), + 'STATUS' => $this->user->lang('CONTRIBUTION_' . strtoupper($status)), + 'U_VERSION' => $this->helper->route('phpbbmodders_wiki_article', array('id' => $row['article_id'])), + 'U_ARTICLE' => $this->helper->route('phpbbmodders_wiki_article', array('article' => $row['article_url'])), + 'S_LIVE' => $status === contributions::STATUS_LIVE, + )); + } + + $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_recent_changes', array()), 'pagination', 'start', $total, $per_page, $start); + + $this->template->assign_vars(array( + 'TOTAL_ITEMS' => $this->user->lang('TOTAL_ITEMS', $total), + )); + + return $this->helper->render('recent_changes.html', $this->user->lang('WIKI_RECENT_CHANGES')); + } + + /** + * The feed, when its format is offered. + * + * @param string $_format feed::RSS or feed::ATOM + * @return Response + */ + public function feed($_format) + { + $this->user->add_lang_ext('phpbbmodders/wiki', 'common'); + + // A feed reader is a guest, so the guest's right to read the wiki decides + if (!$this->auth->acl_get('u_wiki_view')) + { + return new Response($this->user->lang('NOT_AUTHORISED'), 403, array('Content-Type' => 'text/plain; charset=UTF-8')); + } + + if (!feed::offers($this->setting(), $_format)) + { + return new Response($this->user->lang('PAGE_NOT_FOUND'), 404, array('Content-Type' => 'text/plain; charset=UTF-8')); + } + + $rows = $this->get_changes(feed::LIMIT); + $entries = array(); + foreach ($rows as $row) + { + $link = $this->helper->route('phpbbmodders_wiki_article', array('id' => $row['article_id']), false, false, UrlGeneratorInterface::ABSOLUTE_URL); + $summary = $this->user->lang('WIKI_FEED_SAVED_BY', $row['username'] === null ? '' : $row['username']); + if ($row['article_edit_reason'] !== '') + { + $summary .= ' ' . $this->user->lang('WIKI_FEED_REASON', $row['article_edit_reason']); + } + + $entries[] = array( + 'id' => $link, + 'title' => $row['article_title'], + 'link' => $link, + 'summary' => $summary, + 'author' => $row['username'] === null ? '' : $row['username'], + 'time' => (int) $row['article_last_edit'], + ); + } + + $meta = array( + 'title' => $this->user->lang('WIKI_FEED_TITLE', $this->config['sitename']), + 'description' => $this->user->lang('WIKI_RECENT_CHANGES_EXPLAIN'), + 'link' => $this->helper->route('phpbbmodders_wiki_recent_changes', array(), false, false, UrlGeneratorInterface::ABSOLUTE_URL), + 'self' => $this->helper->route('phpbbmodders_wiki_recent_changes_feed', array('_format' => $_format), false, false, UrlGeneratorInterface::ABSOLUTE_URL), + 'updated' => $rows ? (int) $rows[0]['article_last_edit'] : time(), + ); + + return new Response(feed::build($_format, $meta, $entries), 200, array( + 'Content-Type' => ($_format === feed::ATOM ? 'application/atom+xml' : 'application/rss+xml') . '; charset=UTF-8', + 'Cache-Control' => 'public, max-age=300', + )); + } + + /** + * When the live version of each article was saved. + * + * @param string[] $urls Article URL names + * @return array URL name => timestamp; articles with no live version are left out + */ + protected function get_live_times(array $urls) + { + if (!$urls) + { + return array(); + } + + $sql = 'SELECT article_url, MAX(article_last_edit) AS live_last_edit + FROM ' . $this->article_table . ' + WHERE article_approved = 1 + AND ' . $this->db->sql_in_set('article_url', $urls) . ' + GROUP BY article_url'; + $result = $this->db->sql_query($sql); + + $times = array(); + while ($row = $this->db->sql_fetchrow($result)) + { + $times[$row['article_url']] = (int) $row['live_last_edit']; + } + $this->db->sql_freeresult($result); + + return $times; + } +} diff --git a/docs/TODO.md b/docs/TODO.md index d30b072..05227d1 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -103,15 +103,6 @@ wiki content to visually match the rest of the forum, others will want it to look like an actual wiki. Scope depends heavily on the table of contents item above landing first, since a real wiki layout wants one. -## Recent Changes feed - -A chronological list of *every* edit/approval across all articles, not -just the newest one per article. "Latest Articles" on the overview page -only shows the most recent version per distinct article — it can't answer -"what changed on the wiki in the last day," which is the primary -navigation view on most real wikis. Worth pairing with an RSS/Atom feed -the same way the board already has `feed.php` for forum activity. - ## Per-article edit protection Not built yet. Permissions are board-wide today: `u_wiki_edit` lets a user diff --git a/event/listener.php b/event/listener.php index e21a60a..684825f 100644 --- a/event/listener.php +++ b/event/listener.php @@ -165,8 +165,12 @@ public function page_header($event) if ($this->auth->acl_get('u_wiki_view')) { $this->user->add_lang_ext('phpbbmodders/wiki', 'common'); + $feed_setting = isset($this->config['wiki_feed_format']) ? $this->config['wiki_feed_format'] : \phpbbmodders\wiki\wiki\feed::BOTH; $this->template->assign_vars(array( 'U_WIKI' => $this->helper->route('phpbbmodders_wiki_index', array()), + 'U_WIKI_RECENT_CHANGES' => $this->helper->route('phpbbmodders_wiki_recent_changes', 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/language/de/acp_wiki.php b/language/de/acp_wiki.php index a5a31ea..df043ba 100644 --- a/language/de/acp_wiki.php +++ b/language/de/acp_wiki.php @@ -23,6 +23,12 @@ 'WIKI_ICON' => 'Symbol des Kopfzeilen-Links', 'WIKI_ICON_EXPLAIN' => 'Gib den Namen eines Font-Awesome-Symbols für den Wiki-Link in der Kopfzeile des Forums ein. Standard ist wikipedia-w.
Lass das Feld leer, um kein Symbol anzuzeigen.', 'WIKI_ICON_INVALID' => 'Das Symbol des Wiki-Links enthält ungültige Zeichen.', + 'WIKI_FEED_FORMAT' => 'Feed der letzten Änderungen', + 'WIKI_FEED_FORMAT_EXPLAIN' => 'Welche Feed-Formate die Seite „Letzte Änderungen“ anbietet und die Kopfzeile Feed-Readern mitteilt.', + 'WIKI_FEED_FORMAT_BOTH' => 'RSS und Atom', + 'WIKI_FEED_FORMAT_RSS' => 'Nur RSS', + 'WIKI_FEED_FORMAT_ATOM' => 'Nur Atom', + 'WIKI_FEED_FORMAT_INVALID' => 'Das gewählte Feed-Format steht nicht zur Auswahl.', 'WIKI_DISCUSSION_FORUM' => 'Diskussionsforum', 'WIKI_DISCUSSION_FORUM_EXPLAIN' => 'Das Forum, in dem die Schaltfläche „Diskussion starten“ bei einem Artikel das Diskussionsthema erstellt. Wähle „Keines (deaktiviert)“, um die Schaltfläche auszublenden.', 'WIKI_DISCUSSION_FORUM_NONE' => '-- Keines (deaktiviert) --', diff --git a/language/de/common.php b/language/de/common.php index 0da3efa..d0cf3c5 100644 --- a/language/de/common.php +++ b/language/de/common.php @@ -61,6 +61,14 @@ 'CONTRIBUTIONS_SAVED' => 'Gespeichert', 'CONTRIBUTIONS_STATUS' => 'Status', 'CONTRIBUTIONS_NONE' => 'Es gibt keine Versionen, die angezeigt werden können.', + 'WIKI_RECENT_CHANGES' => 'Letzte Änderungen', + 'WIKI_RECENT_CHANGES_EXPLAIN' => 'Jede im Wiki gespeicherte Version, die aktiv ist oder war, die neuesten zuerst.', + 'WIKI_FEED_RSS_LINK' => 'RSS-Feed', + 'WIKI_FEED_ATOM_LINK' => 'Atom-Feed', + 'WIKI_FEED_TITLE' => '%s – Letzte Änderungen im Wiki', + 'WIKI_FEED_TITLE_SHORT' => 'Letzte Änderungen im Wiki', + 'WIKI_FEED_SAVED_BY' => 'Gespeichert von %s.', + 'WIKI_FEED_REASON' => 'Grund: %s', 'CONTRIBUTION_LIVE' => 'Aktive Version', 'CONTRIBUTION_REPLACED' => 'Durch eine neuere Version ersetzt', 'CONTRIBUTION_PENDING' => 'Wartet auf Freigabe oder inaktiv', diff --git a/language/en/acp_wiki.php b/language/en/acp_wiki.php index 836700e..1fe82ad 100644 --- a/language/en/acp_wiki.php +++ b/language/en/acp_wiki.php @@ -23,6 +23,12 @@ 'WIKI_ICON' => 'Header link icon', 'WIKI_ICON_EXPLAIN' => 'Enter the name of a Font Awesome icon to use for the Wiki link in the board header. The default is wikipedia-w.
Leave this field blank for no icon.', 'WIKI_ICON_INVALID' => 'The Wiki link icon contains invalid characters.', + 'WIKI_FEED_FORMAT' => 'Recent changes feed', + 'WIKI_FEED_FORMAT_EXPLAIN' => 'Which feed formats the Recent changes page offers, and the header advertises to feed readers.', + 'WIKI_FEED_FORMAT_BOTH' => 'RSS and Atom', + 'WIKI_FEED_FORMAT_RSS' => 'RSS only', + 'WIKI_FEED_FORMAT_ATOM' => 'Atom only', + 'WIKI_FEED_FORMAT_INVALID' => 'The feed format you chose is not one of the choices.', 'WIKI_DISCUSSION_FORUM' => 'Discussion forum', 'WIKI_DISCUSSION_FORUM_EXPLAIN' => 'The forum where the “Start discussion” button on an article creates its discussion topic. Choose “None (disabled)” to hide the button.', 'WIKI_DISCUSSION_FORUM_NONE' => '-- None (disabled) --', diff --git a/language/en/common.php b/language/en/common.php index 756b6f5..c9a83a9 100644 --- a/language/en/common.php +++ b/language/en/common.php @@ -101,6 +101,14 @@ 'CONTRIBUTIONS_SAVED' => 'Saved', 'CONTRIBUTIONS_STATUS' => 'Status', 'CONTRIBUTIONS_NONE' => 'There are no versions to show.', + 'WIKI_RECENT_CHANGES' => 'Recent changes', + 'WIKI_RECENT_CHANGES_EXPLAIN' => 'Every version saved to the wiki that is or was live, newest first.', + 'WIKI_FEED_RSS_LINK' => 'RSS feed', + 'WIKI_FEED_ATOM_LINK' => 'Atom feed', + 'WIKI_FEED_TITLE' => '%s – wiki recent changes', + 'WIKI_FEED_TITLE_SHORT' => 'Wiki recent changes', + 'WIKI_FEED_SAVED_BY' => 'Saved by %s.', + 'WIKI_FEED_REASON' => 'Reason: %s', 'CONTRIBUTION_LIVE' => 'Live version', 'CONTRIBUTION_REPLACED' => 'Replaced by a newer version', 'CONTRIBUTION_PENDING' => 'Awaiting approval or inactive', diff --git a/migrations/add_recent_changes.php b/migrations/add_recent_changes.php new file mode 100644 index 0000000..906d705 --- /dev/null +++ b/migrations/add_recent_changes.php @@ -0,0 +1,33 @@ + + + \ No newline at end of file diff --git a/styles/prosilver/template/overview.html b/styles/prosilver/template/overview.html index a15f894..b9333f8 100644 --- a/styles/prosilver/template/overview.html +++ b/styles/prosilver/template/overview.html @@ -1,5 +1,10 @@

{L_OVERVIEW}

+

+ {L_WIKI_RECENT_CHANGES} + • {L_WIKI_FEED_RSS_LINK} + • {L_WIKI_FEED_ATOM_LINK} +

diff --git a/styles/prosilver/template/recent_changes.html b/styles/prosilver/template/recent_changes.html new file mode 100644 index 0000000..3cc2d37 --- /dev/null +++ b/styles/prosilver/template/recent_changes.html @@ -0,0 +1,49 @@ + +

{L_WIKI_RECENT_CHANGES}

+

{L_WIKI_RECENT_CHANGES_EXPLAIN} + • {L_WIKI_FEED_RSS_LINK} + • {L_WIKI_FEED_ATOM_LINK} +

+ +
+
+ + + + + + + + + + + + + + + + + + + + + +
{L_CONTRIBUTIONS_SAVED}{L_CONTRIBUTIONS_ARTICLE}{L_USER}{L_REASON_EDIT}{L_CONTRIBUTIONS_STATUS}
{changes.EDIT_TIME}{changes.ARTICLE_TITLE}{changes.USER}{changes.EDIT_REASON}{changes.STATUS}
+
+
+ +

{L_CONTRIBUTIONS_NONE}

+ + +
+ +
+ + diff --git a/tests/unit/feed_test.php b/tests/unit/feed_test.php new file mode 100644 index 0000000..7a758b4 --- /dev/null +++ b/tests/unit/feed_test.php @@ -0,0 +1,121 @@ +assertSame($rss, feed::offers($setting, feed::RSS), $setting . ' rss'); + $this->assertSame($atom, feed::offers($setting, feed::ATOM), $setting . ' atom'); + } + + public static function offers(): array + { + return array( + 'both' => array('both', true, true), + 'rss only' => array('rss', true, false), + 'atom only' => array('atom', false, true), + 'unknown' => array('json', true, true), + 'empty' => array('', true, true), + ); + } + + public function test_only_the_three_settings_are_valid() + { + foreach (array('rss', 'atom', 'both') as $setting) + { + $this->assertTrue(feed::is_valid_setting($setting), $setting); + } + + foreach (array('', 'RSS', 'json', 'rss,atom') as $setting) + { + $this->assertFalse(feed::is_valid_setting($setting), $setting); + } + } + + private function meta(): array + { + return array( + 'title' => 'My Board – wiki recent changes', + 'description' => 'Every version saved to the wiki.', + 'link' => 'https://example.org/wiki/recent-changes', + 'self' => 'https://example.org/wiki/recent-changes.rss', + 'updated' => 1760200000, + ); + } + + /** + * Titles and reasons are stored HTML-escaped; one of them has markup characters. + */ + private function entries(): array + { + return array( + array('id' => 'https://example.org/wiki?id=2', 'title' => 'Tom & Jerry <b> “quoted” Übersicht', 'link' => 'https://example.org/wiki?id=2', 'summary' => 'Saved by O'Neil. Reason: fix & tidy', 'author' => 'O'Neil', 'time' => 1760200000), + array('id' => 'https://example.org/wiki?id=1', 'title' => 'Plain', 'link' => 'https://example.org/wiki?id=1', 'summary' => 'Saved by admin.', 'author' => 'admin', 'time' => 1760100000), + ); + } + + public function test_the_rss_document_is_well_formed_and_escapes_values_once() + { + $xml = feed::build(feed::RSS, $this->meta(), $this->entries()); + + $dom = new \DOMDocument(); + $this->assertTrue($dom->loadXML($xml), 'RSS must parse'); + + $rss = $dom->documentElement; + $this->assertSame('rss', $rss->nodeName); + $this->assertSame('2.0', $rss->getAttribute('version')); + + $items = $dom->getElementsByTagName('item'); + $this->assertSame(2, $items->length); + $this->assertSame('Tom & Jerry “quoted” Übersicht', $items->item(0)->getElementsByTagName('title')->item(0)->textContent); + $this->assertSame("Saved by O'Neil. Reason: fix & tidy", $items->item(0)->getElementsByTagName('description')->item(0)->textContent); + $this->assertSame('Sat, 11 Oct 2025 16:26:40 +0000', $items->item(0)->getElementsByTagName('pubDate')->item(0)->textContent); + $this->assertSame('true', $items->item(0)->getElementsByTagName('guid')->item(0)->getAttribute('isPermaLink')); + $this->assertSame('https://example.org/wiki/recent-changes.rss', $dom->getElementsByTagNameNS('http://www.w3.org/2005/Atom', 'link')->item(0)->getAttribute('href')); + } + + public function test_the_atom_document_is_well_formed_and_escapes_values_once() + { + $xml = feed::build(feed::ATOM, $this->meta(), $this->entries()); + + $dom = new \DOMDocument(); + $this->assertTrue($dom->loadXML($xml), 'Atom must parse'); + + $ns = 'http://www.w3.org/2005/Atom'; + $this->assertSame($ns, $dom->documentElement->namespaceURI); + $this->assertSame('feed', $dom->documentElement->localName); + $this->assertSame('2025-10-11T16:26:40Z', $dom->getElementsByTagNameNS($ns, 'updated')->item(0)->textContent); + + $entries = $dom->getElementsByTagNameNS($ns, 'entry'); + $this->assertSame(2, $entries->length); + $this->assertSame('Tom & Jerry “quoted” Übersicht', $entries->item(0)->getElementsByTagNameNS($ns, 'title')->item(0)->textContent); + $this->assertSame("O'Neil", $entries->item(0)->getElementsByTagNameNS($ns, 'name')->item(0)->textContent); + $this->assertSame('https://example.org/wiki?id=2', $entries->item(0)->getElementsByTagNameNS($ns, 'id')->item(0)->textContent); + $this->assertSame('text', $entries->item(0)->getElementsByTagNameNS($ns, 'summary')->item(0)->getAttribute('type')); + } + + public function test_a_feed_with_no_entries_is_still_valid() + { + foreach (array(feed::RSS, feed::ATOM) as $format) + { + $dom = new \DOMDocument(); + $this->assertTrue($dom->loadXML(feed::build($format, $this->meta(), array())), $format); + } + } +} diff --git a/wiki/feed.php b/wiki/feed.php new file mode 100644 index 0000000..245f703 --- /dev/null +++ b/wiki/feed.php @@ -0,0 +1,189 @@ +formatOutput = true; + + $rss = $dom->createElement('rss'); + $rss->setAttribute('version', '2.0'); + $rss->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:atom', 'http://www.w3.org/2005/Atom'); + $rss->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:dc', 'http://purl.org/dc/elements/1.1/'); + $dom->appendChild($rss); + + $channel = $dom->createElement('channel'); + $rss->appendChild($channel); + $channel->appendChild(self::node($dom, null, 'title', $meta['title'])); + $channel->appendChild(self::node($dom, null, 'link', $meta['link'])); + $channel->appendChild(self::node($dom, null, 'description', $meta['description'])); + $channel->appendChild($dom->createElement('lastBuildDate', gmdate('D, d M Y H:i:s', (int) $meta['updated']) . ' +0000')); + + $self = $dom->createElementNS('http://www.w3.org/2005/Atom', 'atom:link'); + $self->setAttribute('href', $meta['self']); + $self->setAttribute('rel', 'self'); + $self->setAttribute('type', 'application/rss+xml'); + $channel->appendChild($self); + + foreach ($entries as $entry) + { + $item = $dom->createElement('item'); + $item->appendChild(self::node($dom, null, 'title', $entry['title'])); + $item->appendChild(self::node($dom, null, 'link', $entry['link'])); + $guid = self::node($dom, null, 'guid', $entry['id']); + $guid->setAttribute('isPermaLink', 'true'); + $item->appendChild($guid); + $item->appendChild($dom->createElement('pubDate', gmdate('D, d M Y H:i:s', (int) $entry['time']) . ' +0000')); + $item->appendChild(self::node($dom, 'http://purl.org/dc/elements/1.1/', 'dc:creator', $entry['author'])); + $item->appendChild(self::node($dom, null, 'description', $entry['summary'])); + $channel->appendChild($item); + } + + return $dom->saveXML(); + } + + /** + * @param array $meta + * @param array $entries + * @return string + */ + protected static function atom(array $meta, array $entries) + { + $ns = 'http://www.w3.org/2005/Atom'; + $dom = new \DOMDocument('1.0', 'UTF-8'); + $dom->formatOutput = true; + + $feed = $dom->createElementNS($ns, 'feed'); + $dom->appendChild($feed); + $feed->appendChild(self::node($dom, $ns, 'title', $meta['title'])); + $feed->appendChild(self::node($dom, $ns, 'subtitle', $meta['description'])); + $feed->appendChild(self::node($dom, $ns, 'id', $meta['self'])); + $feed->appendChild($dom->createElementNS($ns, 'updated', gmdate('Y-m-d\TH:i:s\Z', (int) $meta['updated']))); + + foreach (array('alternate' => $meta['link'], 'self' => $meta['self']) as $rel => $href) + { + $link = $dom->createElementNS($ns, 'link'); + $link->setAttribute('rel', $rel); + $link->setAttribute('href', $href); + $feed->appendChild($link); + } + + foreach ($entries as $entry) + { + $item = $dom->createElementNS($ns, 'entry'); + $item->appendChild(self::node($dom, $ns, 'title', $entry['title'])); + $link = $dom->createElementNS($ns, 'link'); + $link->setAttribute('href', $entry['link']); + $item->appendChild($link); + $item->appendChild(self::node($dom, $ns, 'id', $entry['id'])); + $item->appendChild($dom->createElementNS($ns, 'updated', gmdate('Y-m-d\TH:i:s\Z', (int) $entry['time']))); + $author = $dom->createElementNS($ns, 'author'); + $author->appendChild(self::node($dom, $ns, 'name', $entry['author'])); + $item->appendChild($author); + $summary = self::node($dom, $ns, 'summary', $entry['summary']); + $summary->setAttribute('type', 'text'); + $item->appendChild($summary); + $feed->appendChild($item); + } + + return $dom->saveXML(); + } + + /** + * A value as XML text: the board stores titles and reasons HTML-escaped, so they + * are decoded first and the XML library escapes them once. + * + * @param string $value + * @return string + */ + protected static function text($value) + { + return htmlspecialchars_decode((string) $value, ENT_QUOTES); + } + + /** + * An element holding a text node, so the XML library escapes the value. + * + * @param \DOMDocument $dom + * @param string|null $namespace Namespace URI, or null for none + * @param string $name Element name + * @param string $value Stored (HTML-escaped) text + * @return \DOMElement + */ + protected static function node(\DOMDocument $dom, $namespace, $name, $value) + { + $element = ($namespace === null) ? $dom->createElement($name) : $dom->createElementNS($namespace, $name); + $element->appendChild($dom->createTextNode(self::text($value))); + + return $element; + } +}