From f2d222ed37709fbad331a60ebb7c73b569eb68b9 Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Mon, 12 Oct 2026 13:25:16 -0500 Subject: [PATCH 1/2] Let members watch an article and be told when a new version goes live A Watch article button for signed-in members, a wiki_watch table, and a notification type that tells watchers when a version of the article goes live, whether a moderator approved it or a trusted editor's edit went live directly. The editor is never told about their own edit. With the new ACP setting Watch edited articles (on by default) saving an edit also watches the article. Watches are removed with the article and with the member. The existing awaiting-approval notification type also gets its English label. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 1 + acp/main_module.php | 3 + adm/style/acp_wiki_settings.html | 7 + config/services.yml | 32 ++- controller/main.php | 13 +- docs/TODO.md | 8 - event/listener.php | 19 +- ext.php | 9 +- language/de/acp_wiki.php | 2 + language/de/common.php | 4 + language/de/email/mail_article_watch.txt | 14 ++ language/de/link.php | 2 + language/en/acp_wiki.php | 2 + language/en/common.php | 4 + language/en/email/mail_article_watch.txt | 14 ++ language/en/link.php | 3 + migrations/add_article_watch.php | 62 +++++ notification/article_watch.php | 82 +++++++ styles/prosilver/template/article_body.html | 7 +- tests/unit/watch_test.php | 114 +++++++++ wiki/delete.php | 8 +- wiki/edit.php | 54 ++++- wiki/view.php | 8 +- wiki/watch.php | 246 ++++++++++++++++++++ 24 files changed, 700 insertions(+), 18 deletions(-) create mode 100644 language/de/email/mail_article_watch.txt create mode 100644 language/en/email/mail_article_watch.txt create mode 100644 migrations/add_article_watch.php create mode 100644 notification/article_watch.php create mode 100644 tests/unit/watch_test.php create mode 100644 wiki/watch.php diff --git a/README.md b/README.md index 6dfef17..7542727 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A wiki for phpBB with version history, side-by-side compare, and a moderation qu - 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. +- **Watch article** on every article page, for signed-in members: they are notified when a new version of a watched article goes live (a moderator approved an edit, or a trusted editor's edit went live directly), never about their own edit. It is a notification type in phpBB's notification settings, so members choose the bell, email or both. With the ACP setting **Watch edited articles** (on by default, under **Extensions > Wiki > Settings**), saving an edit also starts watching the article. Watches are removed with the article and with the member. - 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. diff --git a/acp/main_module.php b/acp/main_module.php index 05e37e0..ede38d4 100644 --- a/acp/main_module.php +++ b/acp/main_module.php @@ -54,6 +54,7 @@ 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', ''); + $auto_watch = $request->variable('wiki_auto_watch', 0) ? 1 : 0; if (!\phpbbmodders\wiki\wiki\feed::is_valid_setting($feed_format)) { @@ -85,6 +86,7 @@ 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); + $config->set('wiki_auto_watch', $auto_watch); trigger_error($language->lang('CONFIG_UPDATED') . adm_back_link($this->u_action)); } @@ -98,6 +100,7 @@ public function main($id, $mode) $template->assign_vars(array( 'S_WIKI_FEED_OPTIONS' => $feed_options, + 'S_WIKI_AUTO_WATCH' => !isset($config['wiki_auto_watch']) || !empty($config['wiki_auto_watch']), '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 5472513..20aec56 100644 --- a/adm/style/acp_wiki_settings.html +++ b/adm/style/acp_wiki_settings.html @@ -12,6 +12,13 @@

{L_ACP_WIKI_SETTINGS}


{L_WIKI_ICON_EXPLAIN}
+
+

{L_WIKI_AUTO_WATCH_EXPLAIN}
+
+ + +
+

{L_WIKI_FEED_FORMAT_EXPLAIN}
diff --git a/config/services.yml b/config/services.yml index 5e0c483..9161bf9 100644 --- a/config/services.yml +++ b/config/services.yml @@ -1,6 +1,7 @@ parameters: tables.wiki.article: '%core.table_prefix%wiki_article' tables.wiki.file: '%core.table_prefix%wiki_file' + tables.wiki.watch: '%core.table_prefix%wiki_watch' services: _defaults: @@ -52,6 +53,18 @@ services: - '%core.root_path%' - '%core.php_ext%' + phpbbmodders.wiki.watch: + class: phpbbmodders\wiki\wiki\watch + arguments: + - '@auth' + - '@dbal.conn' + - '@controller.helper' + - '@request' + - '@template' + - '@user' + - '%tables.wiki.watch%' + - '%tables.wiki.article%' + phpbbmodders.wiki.files: class: phpbbmodders\wiki\wiki\files arguments: @@ -83,6 +96,7 @@ services: - '@phpbbmodders.wiki.view' - '@phpbbmodders.wiki.discussion' - '@phpbbmodders.wiki.files' + - '@phpbbmodders.wiki.watch' - '%core.root_path%' - '%core.php_ext%' phpbbmodders.wiki.listener: @@ -94,6 +108,7 @@ services: - '@notification_manager' - '@template' - '@user' + - '@phpbbmodders.wiki.watch' tags: - { name: event.listener } phpbbmodders.wiki.edit: @@ -110,6 +125,7 @@ services: - '%tables.wiki.article%' - '%core.root_path%' - '%core.php_ext%' + - '@phpbbmodders.wiki.watch' phpbbmodders.wiki.compare: class: phpbbmodders\wiki\wiki\compare arguments: @@ -134,6 +150,7 @@ services: - '%tables.wiki.article%' - '@phpbbmodders.wiki.files' - '@request' + - '@phpbbmodders.wiki.watch' phpbbmodders.wiki.view: class: phpbbmodders\wiki\wiki\view arguments: @@ -150,6 +167,7 @@ services: - '@phpbbmodders.wiki.links' - '@phpbbmodders.wiki.discussion' - '@phpbbmodders.wiki.files' + - '@phpbbmodders.wiki.watch' phpbbmodders.wiki.discussion: class: phpbbmodders\wiki\wiki\discussion arguments: @@ -196,4 +214,16 @@ services: calls: - [set_name, [phpbbmodders_wiki_article]] tags: - - { name: text_reparser.plugin} \ No newline at end of file + - { name: text_reparser.plugin} + phpbbmodders.wiki.notification.type.article_watch: + class: phpbbmodders\wiki\notification\article_watch + parent: notification.type.base + # Symfony 3.4 (phpBB 3.3.x) does not let a service with a parent take "public" from _defaults. + public: true + shared: false + calls: + - [set_user_loader, ['@user_loader']] + - [set_helper, ['@controller.helper']] + - [set_watch, ['@phpbbmodders.wiki.watch']] + tags: + - { name: notification.type } diff --git a/controller/main.php b/controller/main.php index 8a6c54f..0610f81 100644 --- a/controller/main.php +++ b/controller/main.php @@ -45,6 +45,9 @@ class main /** @var \phpbbmodders\wiki\wiki\files */ protected $files; + /** @var \phpbbmodders\wiki\wiki\watch */ + protected $watch; + /** @var string phpbb_root_path */ protected $phpbb_root_path; @@ -65,10 +68,11 @@ class main * @param \phpbbmodders\wiki\wiki\view $view View Wiki object * @param \phpbbmodders\wiki\wiki\discussion $discussion Start discussion object * @param \phpbbmodders\wiki\wiki\files $files Uploaded files object + * @param \phpbbmodders\wiki\wiki\watch $watch Watched articles object * @param string $phpbb_root_path * @param string $php_ext */ - public function __construct(\phpbb\auth\auth $auth, \phpbb\controller\helper $helper, \phpbb\request\request $request, \phpbb\template\template $template, \phpbb\user $user, \phpbbmodders\wiki\wiki\delete $delete, \phpbbmodders\wiki\wiki\edit $edit, \phpbbmodders\wiki\wiki\compare $compare, \phpbbmodders\wiki\wiki\view $view, \phpbbmodders\wiki\wiki\discussion $discussion, \phpbbmodders\wiki\wiki\files $files, $phpbb_root_path, $php_ext) + public function __construct(\phpbb\auth\auth $auth, \phpbb\controller\helper $helper, \phpbb\request\request $request, \phpbb\template\template $template, \phpbb\user $user, \phpbbmodders\wiki\wiki\delete $delete, \phpbbmodders\wiki\wiki\edit $edit, \phpbbmodders\wiki\wiki\compare $compare, \phpbbmodders\wiki\wiki\view $view, \phpbbmodders\wiki\wiki\discussion $discussion, \phpbbmodders\wiki\wiki\files $files, \phpbbmodders\wiki\wiki\watch $watch, $phpbb_root_path, $php_ext) { $this->auth = $auth; $this->helper = $helper; @@ -81,6 +85,7 @@ public function __construct(\phpbb\auth\auth $auth, \phpbb\controller\helper $he $this->view = $view; $this->discussion = $discussion; $this->files = $files; + $this->watch = $watch; $this->phpbb_root_path = $phpbb_root_path; $this->php_ext = $php_ext; } @@ -154,6 +159,12 @@ public function article($article) case 'start_discussion': return $this->discussion->start($article); + case 'watch': + return $this->watch->handle($article, true); + + case 'unwatch': + return $this->watch->handle($article, false); + case 'upload': return $this->files->upload($article); diff --git a/docs/TODO.md b/docs/TODO.md index 05227d1..6f2163a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -53,14 +53,6 @@ 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. -## Watch/subscribe to an article - -The existing notification type only fires for "pending approval" (aimed at -moderators). Regular readers have no way to ask "notify me when this -specific article changes" the way forum topic-watching works. Would need a -second notification type plus a subscribe/unsubscribe action on the article -view page. - ## Categories/tags Browsing is flat today — Popular/Latest/All/Sticky/Pending on the overview diff --git a/event/listener.php b/event/listener.php index 684825f..050b97e 100644 --- a/event/listener.php +++ b/event/listener.php @@ -26,6 +26,9 @@ class listener implements EventSubscriberInterface /** @var \phpbb\config\config */ protected $config; + /** @var \phpbbmodders\wiki\wiki\watch */ + protected $watch; + /** @var \phpbb\controller\helper */ protected $helper; @@ -48,8 +51,9 @@ class listener implements EventSubscriberInterface * @param \phpbb\template $template Template object * @param \phpbb\user $user User object */ - public function __construct(\phpbb\auth\auth $auth, \phpbb\config\config $config, \phpbb\controller\helper $helper, \phpbb\notification\manager $notification_manager, \phpbb\template\template $template, \phpbb\user $user) + public function __construct(\phpbb\auth\auth $auth, \phpbb\config\config $config, \phpbb\controller\helper $helper, \phpbb\notification\manager $notification_manager, \phpbb\template\template $template, \phpbb\user $user, \phpbbmodders\wiki\wiki\watch $watch) { + $this->watch = $watch; $this->auth = $auth; $this->config = $config; $this->helper = $helper; @@ -64,6 +68,7 @@ public static function getSubscribedEvents() 'core.page_header' => 'page_header', 'core.permissions' => 'permissions', 'core.user_setup' => 'user_setup', + 'core.delete_user_after' => 'delete_user_after', ); } @@ -193,4 +198,16 @@ public function user_setup($event) $event['lang_set_ext'] = $lang_ary; } + + /** + * A deleted member no longer watches anything. + * + * @param \phpbb\event\data $event The event object + * @return void + */ + public function delete_user_after($event) + { + $this->watch->delete_for_users(array_map('intval', (array) $event['user_ids'])); + } } + diff --git a/ext.php b/ext.php index dc9a01e..c9c344e 100644 --- a/ext.php +++ b/ext.php @@ -46,6 +46,9 @@ class ext extends \phpbb\extension\base ]; const NEW_NOTIFICATION_TYPE = 'phpbbmodders.wiki.notification.type.article_edit'; + /** Notification type that tells watchers a new version went live */ + const WATCH_NOTIFICATION_TYPE = 'phpbbmodders.wiki.notification.type.article_watch'; + /** Text reparser service name (the key of its CLI resume data) before and after the rename */ const OLD_REPARSER_NAME = 'tas2580.wiki.text_reparser.article_text'; const NEW_REPARSER_NAME = 'phpbbmodders.wiki.text_reparser.article_text'; @@ -120,7 +123,7 @@ public function enable_step($old_state) { case false: case '': - return $this->notification_handler('enable', [self::NEW_NOTIFICATION_TYPE]); + return $this->notification_handler('enable', [self::NEW_NOTIFICATION_TYPE, self::WATCH_NOTIFICATION_TYPE]); default: return parent::enable_step($old_state); @@ -139,7 +142,7 @@ public function disable_step($old_state) { case false: case '': - return $this->notification_handler('disable', [self::NEW_NOTIFICATION_TYPE]); + return $this->notification_handler('disable', [self::NEW_NOTIFICATION_TYPE, self::WATCH_NOTIFICATION_TYPE]); default: return parent::disable_step($old_state); @@ -158,7 +161,7 @@ public function purge_step($old_state) { case false: case '': - return $this->notification_handler('purge', [self::NEW_NOTIFICATION_TYPE]); + return $this->notification_handler('purge', [self::NEW_NOTIFICATION_TYPE, self::WATCH_NOTIFICATION_TYPE]); default: return parent::purge_step($old_state); diff --git a/language/de/acp_wiki.php b/language/de/acp_wiki.php index df043ba..4c7ba51 100644 --- a/language/de/acp_wiki.php +++ b/language/de/acp_wiki.php @@ -23,6 +23,8 @@ '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_AUTO_WATCH' => 'Bearbeitete Artikel beobachten', + 'WIKI_AUTO_WATCH_EXPLAIN' => 'Wenn aktiv, beobachtet ein Mitglied einen Artikel automatisch, sobald es eine Bearbeitung speichert, und wird benachrichtigt, wenn eine neue Version aktiv wird. Das Beobachten kann im Artikel beendet werden.', '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', diff --git a/language/de/common.php b/language/de/common.php index d0cf3c5..b426858 100644 --- a/language/de/common.php +++ b/language/de/common.php @@ -61,6 +61,10 @@ 'CONTRIBUTIONS_SAVED' => 'Gespeichert', 'CONTRIBUTIONS_STATUS' => 'Status', 'CONTRIBUTIONS_NONE' => 'Es gibt keine Versionen, die angezeigt werden können.', + 'WIKI_WATCH' => 'Artikel beobachten', + 'WIKI_UNWATCH' => 'Nicht mehr beobachten', + 'WIKI_NOW_WATCHING' => 'Du beobachtest diesen Artikel jetzt. Du wirst benachrichtigt, wenn eine neue Version aktiv wird.', + 'WIKI_NO_LONGER_WATCHING' => 'Du beobachtest diesen Artikel nicht mehr.', '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', diff --git a/language/de/email/mail_article_watch.txt b/language/de/email/mail_article_watch.txt new file mode 100644 index 0000000..3358527 --- /dev/null +++ b/language/de/email/mail_article_watch.txt @@ -0,0 +1,14 @@ +Subject: "{SITENAME}" — Beobachteter Wiki-Artikel aktualisiert + +Hallo {USERNAME}, + +{NOTIFICATION_SUBJECT} + +Artikel ansehen: {U_LINK} + +Du erhältst diese Nachricht, weil du den Artikel beobachtest. Beende das Beobachten auf der Artikelseite oder ändere die Benachrichtigungen in deinen Benachrichtigungseinstellungen im Kontrollzentrum. + +Zur Forenübersicht: +{U_INDEX} + +{EMAIL_SIG} diff --git a/language/de/link.php b/language/de/link.php index 6e6f2be..435cec5 100644 --- a/language/de/link.php +++ b/language/de/link.php @@ -36,5 +36,7 @@ $lang = array_merge($lang, array( 'WIKI' => 'KB', 'WIKI_NEW_ARTICLE_UPDATE' => 'Ein neue Version zum Artikel %1$s in der KB wartet auf Freischaltung.', + 'WIKI_WATCHED_ARTICLE' => 'Ein Wiki-Artikel, den du beobachtest, hat eine neue Version', + 'WIKI_WATCHED_ARTICLE_LIVE' => 'Eine neue Version des Wiki-Artikels %1$s ist jetzt aktiv.', )); diff --git a/language/en/acp_wiki.php b/language/en/acp_wiki.php index 1fe82ad..627d73b 100644 --- a/language/en/acp_wiki.php +++ b/language/en/acp_wiki.php @@ -23,6 +23,8 @@ '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_AUTO_WATCH' => 'Watch edited articles', + 'WIKI_AUTO_WATCH_EXPLAIN' => 'When on, a member who saves an edit also starts watching that article and is notified when a new version goes live. They can stop watching from the article.', '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', diff --git a/language/en/common.php b/language/en/common.php index c9a83a9..83ba920 100644 --- a/language/en/common.php +++ b/language/en/common.php @@ -101,6 +101,10 @@ 'CONTRIBUTIONS_SAVED' => 'Saved', 'CONTRIBUTIONS_STATUS' => 'Status', 'CONTRIBUTIONS_NONE' => 'There are no versions to show.', + 'WIKI_WATCH' => 'Watch article', + 'WIKI_UNWATCH' => 'Stop watching', + 'WIKI_NOW_WATCHING' => 'You are now watching this article. You will be notified when a new version goes live.', + 'WIKI_NO_LONGER_WATCHING' => 'You are no longer watching this article.', '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', diff --git a/language/en/email/mail_article_watch.txt b/language/en/email/mail_article_watch.txt new file mode 100644 index 0000000..b34a3a3 --- /dev/null +++ b/language/en/email/mail_article_watch.txt @@ -0,0 +1,14 @@ +Subject: "{SITENAME}" — Watched wiki article updated + +Hello {USERNAME}, + +{NOTIFICATION_SUBJECT} + +View the article: {U_LINK} + +You are receiving this because you watch the article. Stop watching it from the article page, or change how you are notified under Notification settings in your control panel. + +Board index: +{U_INDEX} + +{EMAIL_SIG} diff --git a/language/en/link.php b/language/en/link.php index 34bad89..cefa566 100644 --- a/language/en/link.php +++ b/language/en/link.php @@ -36,6 +36,9 @@ $lang = array_merge($lang, array( 'WIKI' => 'Wiki', 'WIKI_NEW_ARTICLE_UPDATE' => 'A new version of the article %1$s in the Wiki is awaiting approval.', + 'WIKI_ARTICLE_UPDATE' => 'An edit of a wiki article is awaiting approval', + 'WIKI_WATCHED_ARTICLE' => 'A wiki article you watch has a new version', + 'WIKI_WATCHED_ARTICLE_LIVE' => 'A new version of the wiki article %1$s is now live.', 'WIKI_LINK_BUTTON' => 'Wiki link: [[Article name]] or [[Article name|link text]]', 'WIKI_LINK_NEW_ARTICLE' => 'This article doesn’t exist yet. Follow the link to create it.', 'WIKI_WHAT_LINKS_HERE' => 'What links here', diff --git a/migrations/add_article_watch.php b/migrations/add_article_watch.php new file mode 100644 index 0000000..50d9ec9 --- /dev/null +++ b/migrations/add_article_watch.php @@ -0,0 +1,62 @@ + array( + $this->table_prefix . 'wiki_watch' => array( + 'COLUMNS' => array( + 'watch_id' => array('UINT', null, 'auto_increment'), + 'user_id' => array('UINT', 0), + // The article's URL name: a watch follows the article, not one version of it + 'article_url' => array('VCHAR:255', ''), + 'watch_time' => array('TIMESTAMP', 0), + ), + 'PRIMARY_KEY' => 'watch_id', + 'KEYS' => array( + 'user_id' => array('INDEX', 'user_id'), + ), + ), + ), + ); + } + + public function revert_schema() + { + return array( + 'drop_tables' => array( + $this->table_prefix . 'wiki_watch', + ), + ); + } + + public function update_data() + { + return array( + // 1: saving an edit also watches the article; an administrator can turn it off + array('config.add', array('wiki_auto_watch', 1)), + ); + } +} diff --git a/notification/article_watch.php b/notification/article_watch.php new file mode 100644 index 0000000..4200ae5 --- /dev/null +++ b/notification/article_watch.php @@ -0,0 +1,82 @@ + 'WIKI_WATCHED_ARTICLE', + 'group' => 'NOTIFICATION_GROUP_MISCELLANEOUS', + ); + + /** @var \phpbbmodders\wiki\wiki\watch */ + protected $watch; + + /** + * @param \phpbbmodders\wiki\wiki\watch $watch + * @return void + */ + public function set_watch(\phpbbmodders\wiki\wiki\watch $watch) + { + $this->watch = $watch; + } + + /** + * Who is told: the article's watchers who may view the wiki, minus the editor. + * + * @param array $notification_data article_id, article_title, article_url, user_id (the editor) + * @param array $options + * @return array + */ + public function find_users_for_notification($notification_data, $options = array()) + { + $options = array_merge(array( + 'ignore_users' => array(), + ), $options); + + $watchers = array_diff($this->watch->get_watchers($notification_data['article_url']), array((int) $notification_data['user_id'])); + if (!$watchers) + { + return array(); + } + + $allowed = $this->auth->acl_get_list($watchers, $this->permission); + $users = !empty($allowed[0][$this->permission]) ? $allowed[0][$this->permission] : array(); + + return $this->check_user_notification_options(array_unique($users), $options); + } + + /** + * @return string + */ + public function get_type() + { + return 'phpbbmodders.wiki.notification.type.article_watch'; + } + + /** + * @return string + */ + public function get_email_template() + { + return '@phpbbmodders_wiki/mail_article_watch'; + } +} diff --git a/styles/prosilver/template/article_body.html b/styles/prosilver/template/article_body.html index 8cfcf1c..2df1dfb 100644 --- a/styles/prosilver/template/article_body.html +++ b/styles/prosilver/template/article_body.html @@ -9,7 +9,7 @@

{ARTICLE_TITLE} - {L_VER {L_ARTICLE_HAS_NEW} {L_SET_ACTIVE}

- +
  • @@ -26,6 +26,11 @@

    {ARTICLE_TITLE} - {L_VER {L_VERSIONS_WIKI}

  • + +
  • + {L_WIKI_UNWATCH}{L_WIKI_WATCH} +
  • +
  • {L_START_DISCUSSION} diff --git a/tests/unit/watch_test.php b/tests/unit/watch_test.php new file mode 100644 index 0000000..12790a1 --- /dev/null +++ b/tests/unit/watch_test.php @@ -0,0 +1,114 @@ +createMock(\phpbb\auth\auth::class); + $auth->method('acl_get')->willReturnCallback(function ($option) use ($can_view) { + return $option === 'u_wiki_view' && $can_view; + }); + + $user = $this->createMock(\phpbb\user::class); + $user->data = array('user_id' => $user_id, 'is_bot' => $is_bot); + + return new watch( + $auth, + $this->createMock(\phpbb\db\driver\driver_interface::class), + $this->createMock(\phpbb\controller\helper::class), + $this->createMock(\phpbb\request\request::class), + $this->createMock(\phpbb\template\template::class), + $user, + 'phpbb_wiki_watch', + 'phpbb_wiki_article' + ); + } + + public function test_a_signed_in_member_who_may_view_the_wiki_can_watch() + { + $this->assertTrue($this->watch(5)->can_watch()); + } + + public function test_a_guest_cannot_watch() + { + $this->assertFalse($this->watch(1)->can_watch()); + } + + public function test_a_bot_cannot_watch() + { + $this->assertFalse($this->watch(5, true)->can_watch()); + } + + public function test_a_member_who_may_not_view_the_wiki_cannot_watch() + { + $this->assertFalse($this->watch(5, false, false)->can_watch()); + } + + public function test_the_notification_is_its_own_type_with_the_version_as_its_item() + { + $this->assertSame('phpbbmodders.wiki.notification.type.article_watch', (new \ReflectionClass(article_watch::class))->newInstanceWithoutConstructor()->get_type()); + $this->assertSame(17, article_watch::get_item_id(array('article_id' => '17'))); + $this->assertSame(0, article_watch::get_item_parent_id(array('article_id' => '17'))); + $this->assertSame('WIKI_WATCHED_ARTICLE', article_watch::$notification_option['lang']); + } + + /** @dataProvider languages */ + public function test_the_notification_has_its_words_and_an_email_in_each_language(string $language) + { + $lang = array(); + if (!defined('IN_PHPBB')) + { + define('IN_PHPBB', true); + } + include __DIR__ . '/../../language/' . $language . '/link.php'; + + foreach (array('WIKI_WATCHED_ARTICLE', 'WIKI_WATCHED_ARTICLE_LIVE') as $key) + { + $this->assertArrayHasKey($key, $lang, $language . ' ' . $key); + } + + $email = file_get_contents(__DIR__ . '/../../language/' . $language . '/email/mail_article_watch.txt'); + foreach (array('Subject:', '{NOTIFICATION_SUBJECT}', '{U_LINK}', '{EMAIL_SIG}', '{USERNAME}') as $needle) + { + $this->assertStringContainsString($needle, $email, $language . ' email ' . $needle); + } + } + + public static function languages(): array + { + return array('en' => array('en'), 'de' => array('de')); + } + + public function test_the_existing_awaiting_approval_type_has_an_english_label_too() + { + $lang = array(); + if (!defined('IN_PHPBB')) + { + define('IN_PHPBB', true); + } + include __DIR__ . '/../../language/en/link.php'; + + $this->assertArrayHasKey('WIKI_ARTICLE_UPDATE', $lang); + } +} diff --git a/wiki/delete.php b/wiki/delete.php index b6a6b14..ceee4bf 100644 --- a/wiki/delete.php +++ b/wiki/delete.php @@ -34,6 +34,9 @@ class delete extends \phpbbmodders\wiki\wiki\functions /** @var \phpbbmodders\wiki\wiki\files */ protected $files; + /** @var \phpbbmodders\wiki\wiki\watch */ + protected $watch; + /** @var string article_table */ protected $article_table; @@ -48,11 +51,13 @@ class delete extends \phpbbmodders\wiki\wiki\functions * @param string $article_table * @param \phpbbmodders\wiki\wiki\files $files Uploaded files * @param \phpbb\request\request $request Request object + * @param \phpbbmodders\wiki\wiki\watch $watch Watched articles */ - public function __construct(\phpbb\auth\auth $auth, \phpbb\db\driver\driver_interface $db, \phpbb\controller\helper $helper, \phpbb\user $user, $article_table, \phpbbmodders\wiki\wiki\files $files, \phpbb\request\request $request) + public function __construct(\phpbb\auth\auth $auth, \phpbb\db\driver\driver_interface $db, \phpbb\controller\helper $helper, \phpbb\user $user, $article_table, \phpbbmodders\wiki\wiki\files $files, \phpbb\request\request $request, \phpbbmodders\wiki\wiki\watch $watch) { $this->files = $files; $this->request = $request; + $this->watch = $watch; $this->auth = $auth; $this->db = $db; $this->helper = $helper; @@ -123,6 +128,7 @@ public function article($article) WHERE article_url = '" . $this->db->sql_escape($article) . "'"; $this->db->sql_query($sql); $this->files->delete_all($article); + $this->watch->delete_for_article($article); trigger_error($this->user->lang('DELETE_ARTICLE_SUCCESS') . '

    ' . $this->user->lang('BACK_TO_WIKI') . ''); } else diff --git a/wiki/edit.php b/wiki/edit.php index 0850f29..2116b89 100644 --- a/wiki/edit.php +++ b/wiki/edit.php @@ -40,6 +40,9 @@ class edit extends \phpbbmodders\wiki\wiki\functions /** @var \phpbb\user */ protected $user; + /** @var \phpbbmodders\wiki\wiki\watch */ + protected $watch; + /** @var string article_table */ protected $article_table; @@ -72,8 +75,9 @@ class edit extends \phpbbmodders\wiki\wiki\functions * @param string $php_ext */ - public function __construct(\phpbb\auth\auth $auth, \phpbb\config\config $config, \phpbb\db\driver\driver_interface $db, \phpbb\controller\helper $helper, \phpbb\notification\manager $notification_manager, \phpbb\request\request $request, \phpbb\template\template $template, \phpbb\user $user, $article_table, $phpbb_root_path, $php_ext) + public function __construct(\phpbb\auth\auth $auth, \phpbb\config\config $config, \phpbb\db\driver\driver_interface $db, \phpbb\controller\helper $helper, \phpbb\notification\manager $notification_manager, \phpbb\request\request $request, \phpbb\template\template $template, \phpbb\user $user, $article_table, $phpbb_root_path, $php_ext, \phpbbmodders\wiki\wiki\watch $watch) { + $this->watch = $watch; $this->auth = $auth; $this->config = $config; $this->db = $db; @@ -288,6 +292,12 @@ public function edit_article($article) $this->db->sql_query($sql); $article_id = $this->db->sql_nextid(); + // Saving an edit also watches the article, when the board does that + if (!empty($this->config['wiki_auto_watch'])) + { + $this->watch->add($this->user->data['user_id'], $article); + } + if ($this->auth->acl_get('u_wiki_set_active') && ($this->data['article_approved'] <> 0)) { $this->set_active_version($article_id); @@ -316,4 +326,46 @@ public function edit_article($article) } return $this->helper->render('article_edit.html', $this->user->lang('EDIT_WIKI')); } + /** + * Make a version live, then tell the members watching the article. + * + * @param int $id Version ID + * @return string URL of the article + */ + protected function set_active_version($id) + { + $article = parent::set_active_version($id); + $this->notify_watchers((int) $id); + + return $article; + } + + /** + * Tell the article's watchers that a version went live. The member who saved + * that version is left out by the notification type. + * + * @param int $id Version ID + * @return void + */ + protected function notify_watchers($id) + { + $sql = 'SELECT article_id, article_title, article_url, article_user_id + FROM ' . $this->article_table . ' + WHERE article_id = ' . (int) $id; + $result = $this->db->sql_query($sql); + $version = $this->db->sql_fetchrow($result); + $this->db->sql_freeresult($result); + + if (!$version) + { + return; + } + + $this->notification_manager->add_notifications('phpbbmodders.wiki.notification.type.article_watch', array( + 'article_id' => (int) $version['article_id'], + 'article_title' => $version['article_title'], + 'article_url' => $version['article_url'], + 'user_id' => (int) $version['article_user_id'], + )); + } } diff --git a/wiki/view.php b/wiki/view.php index bdc8bce..bd9c7a4 100644 --- a/wiki/view.php +++ b/wiki/view.php @@ -45,6 +45,9 @@ class view extends \phpbbmodders\wiki\wiki\functions /** @var \phpbbmodders\wiki\wiki\files */ protected $files; + /** @var \phpbbmodders\wiki\wiki\watch */ + protected $watch; + /** @var string phpbb_root_path */ protected $phpbb_root_path; @@ -70,11 +73,13 @@ class view extends \phpbbmodders\wiki\wiki\functions * @param \phpbbmodders\wiki\wiki\links $links Wiki links object * @param \phpbbmodders\wiki\wiki\discussion $discussion Start discussion object * @param \phpbbmodders\wiki\wiki\files $files Uploaded files object + * @param \phpbbmodders\wiki\wiki\watch $watch Watched articles object */ - public function __construct(\phpbb\auth\auth $auth, \phpbb\db\driver\driver_interface $db, \phpbb\controller\helper $helper, \phpbb\template\template $template, \phpbb\user $user, \phpbbmodders\wiki\wiki\compare $compare, \phpbbmodders\wiki\wiki\edit $edit, $article_table, $phpbb_root_path, $php_ext, \phpbbmodders\wiki\wiki\links $links, \phpbbmodders\wiki\wiki\discussion $discussion, \phpbbmodders\wiki\wiki\files $files) + public function __construct(\phpbb\auth\auth $auth, \phpbb\db\driver\driver_interface $db, \phpbb\controller\helper $helper, \phpbb\template\template $template, \phpbb\user $user, \phpbbmodders\wiki\wiki\compare $compare, \phpbbmodders\wiki\wiki\edit $edit, $article_table, $phpbb_root_path, $php_ext, \phpbbmodders\wiki\wiki\links $links, \phpbbmodders\wiki\wiki\discussion $discussion, \phpbbmodders\wiki\wiki\files $files, \phpbbmodders\wiki\wiki\watch $watch) { $this->discussion = $discussion; $this->files = $files; + $this->watch = $watch; $this->links = $links; $this->auth = $auth; $this->db = $db; @@ -235,6 +240,7 @@ public function view_article($article, $id = 0) )); $this->files->assign_to_template($this->data['article_url']); + $this->watch->assign_to_template($this->data['article_url']); // Other articles whose live version links here with [[ ]]. foreach ($this->links->what_links_here($this->data['article_url']) as $linking) diff --git a/wiki/watch.php b/wiki/watch.php new file mode 100644 index 0000000..1c730c0 --- /dev/null +++ b/wiki/watch.php @@ -0,0 +1,246 @@ +auth = $auth; + $this->db = $db; + $this->helper = $helper; + $this->request = $request; + $this->template = $template; + $this->user = $user; + $this->watch_table = $watch_table; + $this->article_table = $article_table; + } + + /** + * Whether the current visitor is a signed-in member who may use the wiki. + * + * @return bool + */ + public function can_watch() + { + return (int) $this->user->data['user_id'] > ANONYMOUS + && empty($this->user->data['is_bot']) + && $this->auth->acl_get('u_wiki_view'); + } + + /** + * @param int $user_id + * @param string $article_url + * @return bool + */ + public function is_watching($user_id, $article_url) + { + $sql = 'SELECT watch_id + FROM ' . $this->watch_table . ' + WHERE user_id = ' . (int) $user_id . " + AND article_url = '" . $this->db->sql_escape($article_url) . "'"; + $result = $this->db->sql_query_limit($sql, 1); + $found = $this->db->sql_fetchfield('watch_id') !== false; + $this->db->sql_freeresult($result); + + return $found; + } + + /** + * Start watching; does nothing when the member already is. + * + * @param int $user_id + * @param string $article_url + * @return void + */ + public function add($user_id, $article_url) + { + if ((int) $user_id <= ANONYMOUS || $this->is_watching($user_id, $article_url)) + { + return; + } + + $this->db->sql_query('INSERT INTO ' . $this->watch_table . ' ' . $this->db->sql_build_array('INSERT', array( + 'user_id' => (int) $user_id, + 'article_url' => $article_url, + 'watch_time' => time(), + ))); + } + + /** + * @param int $user_id + * @param string $article_url + * @return void + */ + public function remove($user_id, $article_url) + { + $this->db->sql_query('DELETE FROM ' . $this->watch_table . ' + WHERE user_id = ' . (int) $user_id . " + AND article_url = '" . $this->db->sql_escape($article_url) . "'"); + } + + /** + * The members watching an article. + * + * @param string $article_url + * @return int[] User ids + */ + public function get_watchers($article_url) + { + $sql = 'SELECT user_id + FROM ' . $this->watch_table . " + WHERE article_url = '" . $this->db->sql_escape($article_url) . "'"; + $result = $this->db->sql_query($sql); + $ids = array(); + while ($row = $this->db->sql_fetchrow($result)) + { + $ids[] = (int) $row['user_id']; + } + $this->db->sql_freeresult($result); + + return $ids; + } + + /** + * Forget everyone watching an article, as its deletion does. + * + * @param string $article_url + * @return void + */ + public function delete_for_article($article_url) + { + $this->db->sql_query('DELETE FROM ' . $this->watch_table . " + WHERE article_url = '" . $this->db->sql_escape($article_url) . "'"); + } + + /** + * Forget what deleted members were watching. + * + * @param int[] $user_ids + * @return void + */ + public function delete_for_users(array $user_ids) + { + if ($user_ids) + { + $this->db->sql_query('DELETE FROM ' . $this->watch_table . ' WHERE ' . $this->db->sql_in_set('user_id', array_map('intval', $user_ids))); + } + } + + /** + * Put the Watch or Unwatch button on an article page. + * + * @param string $article_url + * @return void + */ + public function assign_to_template($article_url) + { + $can_watch = $this->can_watch(); + $watching = $can_watch && $this->is_watching($this->user->data['user_id'], $article_url); + + $this->template->assign_vars(array( + 'S_WIKI_CAN_WATCH' => $can_watch, + 'S_WIKI_WATCHING' => $watching, + 'U_WIKI_WATCH' => $can_watch ? $this->helper->route('phpbbmodders_wiki_article', array( + 'article' => $article_url, + 'action' => $watching ? 'unwatch' : 'watch', + 'hash' => generate_link_hash(self::LINK_HASH), + )) : '', + )); + } + + /** + * Handle a Watch or Unwatch click. + * + * @param string $article_url + * @param bool $watching True to watch, false to stop + * @return void + */ + public function handle($article_url, $watching) + { + if (!$this->can_watch()) + { + trigger_error('NOT_AUTHORISED'); + } + + if (!check_link_hash($this->request->variable('hash', ''), self::LINK_HASH)) + { + trigger_error('FORM_INVALID'); + } + + $sql = 'SELECT article_id + FROM ' . $this->article_table . " + WHERE article_url = '" . $this->db->sql_escape($article_url) . "'"; + $result = $this->db->sql_query_limit($sql, 1); + $exists = $this->db->sql_fetchfield('article_id') !== false; + $this->db->sql_freeresult($result); + + if (!$exists) + { + trigger_error('NO_ARTICLE'); + } + + $user_id = (int) $this->user->data['user_id']; + if ($watching) + { + $this->add($user_id, $article_url); + } + else + { + $this->remove($user_id, $article_url); + } + + $back = $this->helper->route('phpbbmodders_wiki_article', array('article' => $article_url)); + meta_refresh(3, $back); + trigger_error($this->user->lang($watching ? 'WIKI_NOW_WATCHING' : 'WIKI_NO_LONGER_WATCHING') . '

    ' . $this->user->lang('BACK_TO_ARTICLE') . ''); + } +} From f946dad643058e863523e931f1139fae8ff38ef3 Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Mon, 12 Oct 2026 13:42:46 -0500 Subject: [PATCH 2/2] Remove the extra blank line at the end of the listener The code sniffer (PSR2.Files.EndFileNewline) found two newlines at the end of event/listener.php. Co-Authored-By: Claude Sonnet 5.5 --- event/listener.php | 1 - 1 file changed, 1 deletion(-) diff --git a/event/listener.php b/event/listener.php index 050b97e..55d8726 100644 --- a/event/listener.php +++ b/event/listener.php @@ -210,4 +210,3 @@ public function delete_user_after($event) $this->watch->delete_for_users(array_map('intval', (array) $event['user_ids'])); } } -