Revision [21740]

This is an old revision of WatchedPages made by skrap on 2012-08-27 21:44:51.

 

Watched Pages Extension


This is yet another extension which will allow your site to email users when there are changes to pages which they're interested in. Additionally, users can watch entire categories, and will be notified whenever the set of pages belonging to that category change.

Users can configure which pages they're interested in watching via a watchedpages action, which would typically be added to their UserSettings page.

Emails include a word-based text diff of the raw page source, which allows users to easily see what's changed.

Rationale


Why another one of these change-emailing extensions? What's wrong with NotifyOnChange and WikkaWikiEMailNotifications?

The short answer: nothing's wrong with those other extensions. The authors of those extensions were designing for other needs. However, I think my extension has a bit more general-purpose applicability, and it's easier to use.

This plugin:
* allows users to watch pages which they don't own
* makes minimal use of the config file, and instead makes use of the DB to track watched pages and categories
* allows users to watch entire categories, and be notified when their page set changes
* allows normal users to watch the entire wiki, if they want to
* uses normal read ACLs to determine access
* encapsulates almost all of the code in a library file, leaving it minimally invasive to install

Current Status


This code is being used in one "production" website right now, with around 700 pages of content. I consider it stable for my usage, but please report any bugs you see to me at jonah at petri dot us.

Bugs:
* Sends emails to watchers of all pages on page creation, which could leak a sensitive page name before an ACL could be set up for it.
* There's no flood protection - 1000 changes will mean 1000 emails.

Installation


WARNING: I've not tried this installation on a "clean" wikkawiki install. These steps are my recollection of the correct order of installation.

STILL UNDER CONSTRUCTION.

Install PHP/FineDiff

The code uses PHP/FineDiff to put high quality diffs into the emails. I've got a version of PHP/FineDiff which is utf8-aware, and is available here: https://github.com/skrap/PHP-FineDiff/blob/master/finediff.php

You should put that file into 3rdparty/plugins/finediff/finediff.php

Install watchedpages lib


Place the following into libs/watchedpages.class.php:
<?php
   
    /**
     * Core support code for Watched Pages
     *
     * @package Wikka
     * @subpackage Libs
     * @version $Id$
     * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
     * @filesource
     *
     * @author {@link http://wikkawiki.org/skrap skrap}
     *
     */

   
    /**
     * To create the table for this feature (substitute [PREFIX_] to your db prefix):
     CREATE TABLE `[PREFIX_]watchedpages` (
     `name` varchar(75) NOT NULL default '',
     `allpages` enum('Y','N') NOT NULL default 'N',
     `pageslist` text NOT NULL default '',
     `categorieslist` text NOT NULL default '',
     PRIMARY KEY (`name`)
     );
     *
     */

   
    /**
     * Public access methods for watched pages info.
     *
     * @name WatchedPages
     * @package Wikka
     * @subpackage Libs
     */

    class WatchedPages
    {
        var $username;
        var $pageslist;
        var $categorieslist;
        var $allpages;
        var $loaded;
       
        /**
         * Constructor
         *
         * @access  public
         * @param   object  $wakka  Provides access to the main Wakka object
         */

        function WatchedPages($wakka)
        {
            $this->wakka= $wakka;
            $user = $wakka->GetUser();
            $this->username = $user['name'];
            $this->pageslist= array();
            $this->categorieslist= array();
            $this->loaded= FALSE;
        }
       
        /**
         * Load the watched pages info from the DB
         *
         * @access  public
         * @param   none
         * @return  TRUE if load successful, FALSE otherwise
         */

        function Load()
        {
            if ($this->username) {
                $queryresult= $this->wakka->LoadSingle( 'SELECT name,allpages,pageslist,categorieslist FROM '
                                                 . $this->wakka->config['table_prefix']
                                                 . 'watchedpages WHERE name = \''
                                                 . $this->username . '\' LIMIT 1' );
               
                if (is_array($queryresult)) {
                    $this->pageslist = array_filter( explode( ',', $queryresult['pageslist'] ), array($this->wakka,"IsWikiName") );
                    $this->categorieslist = array_filter( explode( ',', $queryresult['categorieslist'] ), array($this->wakka,"IsWikiName") );
                    $this->allpages = $queryresult['allpages'] == 'Y';
                    $this->loaded = TRUE;
                }
            }
        }
       
        /**
         * Gets the stored array of watched page names, loading from DB if needed.
         *
         * @access public
         * @param none
         * @return an array, which will be empty if load failed.
         */

        function GetWatchedPages() {
            if ($this->loaded != TRUE) {
                $this->Load();
            }
            if ($this->loaded) {
                return $this->pageslist;
            } else {
                return array();
            }
        }
       
        /**
         * Sets the array of watched page names.  Does not commit to the DB.
         *
         * @access public
         * @param watchedpageslist, an array of page names to watch.
         * @return TRUE
         *
         */

        function SetWatchedPages( $watchedpageslist ) {
            if (is_array($watchedpageslist)) {
                $this->pageslist= array_filter( $watchedpageslist, array($this->wakka,"IsWikiName") );
            }
        }
       
        /**
         * Gets stored array of watched categories, loading from DB if needed
         *
         */

        function GetWatchedCategories() {
            if (!$this->loaded) $this->Load();
            if ($this->loaded) return $this->categorieslist;
            else return array();
        }
       
        /**
         * Sets watched categories, but does not commit to DB.
         */

        function SetWatchedCategories( $watchedcategories ) {
            if (is_array($watchedcategories)) {
                $this->categorieslist= array_filter( $watchedcategories, array($this->wakka,"IsWikiName") );
            }
        }
       
        /**
         * Gets whether the user prefers to watch all pages, loading from DB
         * if needed.
         *
         * @access public
         * @param none
         * @return TRUE or FALSE
         */

        function GetWatchesAllPages() {
            if ($this->loaded != TRUE) {
                $this->Load();
            }
            if ($this->loaded) {
                return $this->allpages;
            } else {
                return FALSE;
            }
        }
       
       
        /**
         * Sets the watches all pages flag.  Does not commit to the DB.
         *
         * @access public
         * @param allpages, TRUE or FALSE
         * @return TRUE
         *
         */

        function SetWatchesAllPages( $allpages ) {
            $this->allpages= $allpages == TRUE;
        }
       
        /**
         * Saves the current data to the DB
         *
         * @access  public
         * @return  TRUE on success, otherwise FALSE
         */

        function Save()
        {
            if ($this->username) {
                $updateresult = $this->wakka->Query("REPLACE INTO "
                                                    .$this->wakka->GetConfigValue('table_prefix')
                                                    ."watchedpages (name,allpages,pageslist,categorieslist) VALUES ('"
                                                    .mysql_real_escape_string($this->username)
                                                    ."','"
                                                    .mysql_real_escape_string($this->allpages==TRUE?'Y':'N')
                                                    ."','"
                                                    .mysql_real_escape_string(implode(',',$this->pageslist))
                                                    ."','"
                                                    .mysql_real_escape_string(implode(',',$this->categorieslist))
                                                    ."')");
                if ($updateresult) {
                    return TRUE;
                }
            }
            return FALSE;
        }
       
        /**
         * Renders an array of pages to a string
         *
         * @access public
         * @return a string
         */

        function WatchedPagesListToString($watchedpagesarray) {
            return implode( ', ', $watchedpagesarray );
        }
       
        /**
         * Takes a string containing a list of watched pages, and separates it into an array.
         *
         * @access public
         * @return an array
         */

        function WatchedPagesStringToArray($watchedpagesstring) {
            return preg_split( '/\s*,\s*/', $watchedpagesstring );
        }
       
    }
   
    /**
     * Get the usernames who would watch the last diff for the current page
     *
     * @param wakka instance of wakka
     * @param before_page_text
     * @param after_page_text
     * @return array of UserName => "reason"
     */

   
    function getWatchers($wakka,$before_page_text,$after_page_text) {
        $emailuserslist= array();

        if ($before_page_text != '' || $after_page_text != '') {
            if (is_array($watchers = $wakka->LoadAll('SELECT name,allpages,pageslist,categorieslist FROM '
                                                     . $wakka->GetConfigValue('table_prefix')
                                                     . 'watchedpages;'))) {
                foreach ($watchers as $watcher) {
                    if (!$wakka->HasAccess('read', $wakka->GetPageTag(), $watcher['name'])) continue;
                   
                    if ($watcher['allpages'] == 'Y') {
                        $emailuserslist[$watcher['name']] = 'you watch all changes.';
                        continue;
                    }
                   
                    if( preg_match('/(^|,)'.preg_quote($wakka->GetPageTag()).'(,|$)/', $watcher['pageslist']) ) {
                        // push onto the end of the array
                        $emailuserslist[$watcher['name']] = 'you watch the page "' . $wakka->GetPageTag() . '".';
                        continue;
                    }
                    // Look at the category list to see if any category name is present in the added or deleted text.
                    foreach (explode(',', $watcher['categorieslist']) as $categoryname) {
                        $pattern = '/\b' . preg_quote($categoryname) . '\b/';
                        $in_before_text = preg_match($pattern, $before_page_text);
                        $in_after_text = preg_match($pattern, $after_page_text);
                        if( $in_before_text != $in_after_text ) {
                            $emailuserslist[$watcher['name']] = 'you watch the category "' . $categoryname . '".';
                            continue;
                        }
                    }
                }
            }
        }
        return $emailuserslist;
    }
   
   
    /**
     * Send email to users watching the given page.  Call this during the edit handler.
     *
     *
     */

    function EmailPageWatchers($wakka) {
        $message_debug = '';
       
        $page_to_diff_a = FALSE;
        $page_to_diff_b = FALSE;
       
        // Step through and check this page id and previous revision page id
        if ($pages = $wakka->LoadRevisions($wakka->GetPageTag()))
        {
            $c = 0;
            $diff = $wakka->GetPageTag() . "/diff";
            foreach ($pages as $page)
            {
                $c++;
                if ($c <= 3)
                {
                    if ($c == 1)
                    {
                        $diff .= "&amp;a=".$page["id"];
                        $page_to_diff_a = $page["id"];
                    }
                    if ($c == 2)
                    {
                        $diff .= "&amp;b=".$page["id"];
                        $page_to_diff_b = $page["id"];
                    }
                }
            }
        }
       
        $emailuserslist= array();
        if( $page_to_diff_a != FALSE && $page_to_diff_b != FALSE ) {
           
            $pageA = $wakka->LoadPageById($page_to_diff_a);
            $pageB = $wakka->LoadPageById($page_to_diff_b);
           
            $beforeText = $pageB['body'];
            $afterText = $pageA['body'];

            $emailuserslist= getWatchers($wakka,$beforeText,$afterText);
        }
       
        if ( count($emailuserslist) && $pageA['body'] != '' && $pageB['body'] != '' )
        {
            // Produce email headers text
            $headers  = "From: " . $wakka->config["email_notifications_sender_name"];
            $headers .= " <" . $wakka->config["email_notifications_sender_email"] . ">\n";
            $headers .= "X-Mailer: PHP/".phpversion()."\n";       //mailer name
            $headers .= "X-Priority: 3\n";                        //1 = UrgentMessage, 3 =Normal
            $headers .= 'Content-Type: text/html; charset="UTF-8"'."\n";  //comment this to send text format
           
            $subject = "[" . $wakka->config["email_notifications_subject"] . "] ";
            $subject .= $wakka->GetPageTag() . " has been edited by " . $wakka->GetUserName();
           
            // html message prologue
            $message_a  = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
            "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
            <!-- <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> -->
            <html xmlns="http://www.w3.org/1999/xhtml">
            <head>
            <meta http-equiv="Content-Type" content="text/html;charset=UTF-8" />
            <title></title></head><body>'
;
            $message_a .=  '<div style="color: #000000; font-family: \'Lucida Grande\', Verdana, Arial, Sans-Serif;';
            $message_a .= 'background-color: #E9F9E9;border: 1px solid #ACA;padding: 5px 10px;font-size: 90%;margin-bottom: 1em;">'."\n";
           
            //$message_a .= print_r($emailnotify_user_list, true); //debug
           
            $message_c  = "<p>The page <a href=\"" . $wakka->Href("",$wakka->GetPageTag(),"")."\">";
            $message_c .= $wakka->GetPageTag()."</a> has been edited by " . $wakka->GetUserName() . "</p>\n";
            $message_c .= '<div style="font-size: 70%; margin-bottom: 1.5em;">';
            $message_c .= '<a href="' . $wakka->Href("",$diff,"") . '">[Full Differences]</a>&nbsp;';
            $message_c .= '<a href="' . $wakka->Href("",$diff,"") . '&amp;fastdiff=1">[Simple Differences]</a>&nbsp;';
            $message_c .= '<a href="' . $wakka->Href('revisions') . '">[Revisions]</a>&nbsp;';
            $message_c .= '<a href="' . $wakka->Href('history') . '">[Page History]</a>&nbsp;';
            $message_c .= '<a href="' . $wakka->Href('acls') . '">[Page ACLs]</a>&nbsp;';
            $message_c .= '</div>';
           
            // message epilogue - close out the HTML
            $message_epilogue = '</div></body></html>';

           
            // Clear this in case we don't install the diff part of the code
            $message_diff = '';
           
            if ($wakka->config["email_notifications_include_diff"] == 1) // Check config file to see if we are doing diffs in email
            {
                // These should really go at the top of the page, but it would add an extra step to this mod...
                if (!defined('ERROR_BAD_PARAMETERS')) define ('ERROR_BAD_PARAMETERS', 'Sorry, no revisions to compare were specified.');
                if (!defined('CONTENT_ADDITIONS_HEADER')) define ('CONTENT_ADDITIONS_HEADER', 'Additions:');
                if (!defined('CONTENT_DELETIONS_HEADER')) define ('CONTENT_DELETIONS_HEADER', 'Deletions:');
                if (!defined('CONTENT_NO_DIFFERENCES')) define ('CONTENT_NO_DIFFERENCES', 'No Differences');
                if (!defined('WHEN_BY_WHO')) define('WHEN_BY_WHO', '%1$s by %2$s');
                if (!defined('UNREGISTERED_USER')) define('UNREGISTERED_USER', 'unregistered user');
                if (!defined('DIFF_CONTEXT_CHARS')) define('DIFF_CONTEXT_CHARS', 200);
               
                $info = '';
               
                // Do the simple diff check line by line
                $finediff_lib = '3rdparty'.DIRECTORY_SEPARATOR.'plugins'.DIRECTORY_SEPARATOR.'finediff'.DIRECTORY_SEPARATOR.'finediff.php';
                require_once($finediff_lib);
               
                $diff = new FineDiff($beforeText, $afterText, FineDiff::$wordGranularity);
                $rendered_diff = '';
               
                $in_offset = 0;    
                $last_in_written = 0;
                ob_start();
                foreach ($diff->getOps() as $edit) {
                    $n = $edit->getFromLen();
                    $first = isset($first) ? FALSE : TRUE;
                    if ( $edit instanceof FineDiffCopyOp ) {
                        // This only handles the diff epilogue - prologue is handled below.
                        if (!$first) {
                            $write_len = min($n,DIFF_CONTEXT_CHARS);
                            echo htmlspecialchars(mb_substr($beforeText,$in_offset,$write_len,'UTF-8'));
                            $last_in_written = $in_offset+$write_len;
                        }
                    }
                    else {
                        // Handle the diff prologue, if needed
                        if ( $last_in_written < $in_offset ) {
                            $write_offset = max($last_in_written,$in_offset-DIFF_CONTEXT_CHARS);
                            if( $write_offset > $last_in_written && !$first ) echo '<hr />'; // separate discontinuous sections with a HR
                            $write_len = $in_offset-$write_offset;
                            echo htmlspecialchars(mb_substr($beforeText,$write_offset,$write_len,'UTF-8'));
                            $last_in_written += $write_len;
                        }
                        if ( $edit instanceof FineDiffInsertOp || $edit instanceof FineDiffReplaceOp ) {
                            echo '<ins style="background-color: #CFC; text-decoration: none;">', htmlspecialchars(mb_substr($edit->getText(), 0, $edit->getToLen(),'UTF-8')), '</ins>';
                        }
                        if ( $edit instanceof FineDiffDeleteOp || $edit instanceof FineDiffReplaceOp ) {
                            $deletion = mb_substr($beforeText,$in_offset,$n,'UTF-8');
                            echo '<del style="color: #876; background-color: #FC9;text-decoration: line-through;">', htmlspecialchars($deletion), '</del>';
                        }
                    }
                    $in_offset += $n;
                }
                $rendered_diff = ob_get_clean();
               
                $pageA_edited_by = $pageA['user'];
                if (!$wakka->LoadUser($pageA_edited_by)) $pageA_edited_by .= ' ('.UNREGISTERED_USER.')';
                if ($pageA['note']) $noteA='['.$wakka->htmlspecialchars_ent($pageA['note']).']'; else $noteA ='';
               
                $pageB_edited_by = $pageB['user'];  
                if (!$wakka->LoadUser($pageB_edited_by)) $pageB_edited_by .= ' ('.UNREGISTERED_USER.')';
                if ($pageB['note']) $noteB='['.$wakka->htmlspecialchars_ent($pageB['note']).']'; else $noteB ='';
               
                $info =  '<div style="color: #000000;background-color: #F9F9F9;font-family: monospace;';
                $info .= 'border: 1px solid #ACA;padding: 5px 10px;margin-bottom: 1em;">'."\n";
                $info .= '<b>Comparing <a title="Display the revision list for '.$pageA['tag'].'" href="'.$wakka->Href('revisions');
                $info .= '">revisions</a> for <a title="Return to the current revision of the page" href="';
                $info .= $wakka->Href().'">'.$pageA['tag'].'</a></b>'."\n";
                $info .= '<ul style="margin: 10px 0;monospace;">'."\n";
                $info .= '  <li><a href="'.$wakka->Href('show', '', 'time='.urlencode($pageA['time'])).'">['.$pageA['id'].']</a> ';
                $info .= sprintf(WHEN_BY_WHO, '<a style="color: #666;monospace;" href="'.
                                 $wakka->Href('show','','time='.urlencode($pageA["time"])).'">'.$pageA['time'].'</a>', $pageA_edited_by);
                $info .= ' <span style="color: #888;">'.$noteA.'</span></li>'."\n";
                $info .= '  <li><a href="'.$wakka->Href('show', '', 'time='.urlencode($pageB['time'])).'">['.$pageB['id'].']</a> ';
                $info .= sprintf(WHEN_BY_WHO, '<a style="color: #666;monospace;" href="'.
                                 $wakka->Href('show','','time='.urlencode($pageB["time"])).'">'.$pageB['time'].'</a>', $pageB_edited_by);
                $info .= ' <span style="color: #888;">'.$noteB.'</span></li>'."\n";
                $info .= '</ul>'."\n";
                $info .= '<strong>'.HIGHLIGHTING_LEGEND.'</strong>&nbsp;<ins style="background-color: #CFC; text-decoration: none;">'.DIFF_SAMPLE_ADDITION.'</ins>&nbsp;<del style="color: #876; background-color: #FC9;text-decoration: line-through;">'.DIFF_SAMPLE_DELETION.'</del>';
                $info .= '</div>';
               
                $message_diff .=  $info;
                $message_diff .= '<div style="font-family: monospace;color: #666;background-color: #F9F9F9;border: 1px solid #ACA;padding: 0.5em;">';
                $message_diff .= nl2br($rendered_diff);
                $message_diff .= '</div>';
            }
           
            foreach ($emailuserslist as $emailusername => $reason) {
                if ($wakka->GetUserName() == $emailusername) continue; // don't send watch emails for a user's own edits.
                // find the user's email address
                $useremail  = $wakka->LoadSingle("select email from " .$wakka->config["table_prefix"]."users where name = '".mysql_escape_string($emailusername)."'");
               
                if( $useremail ) {
                    // make personallised part of email message
                    $message_b  = "<p>Hello " . $emailusername . ", this is an automated response from <a href=\"";
                    $message_b  .= $wakka->config["base_url"] ."\">". $wakka->config["wakka_name"] . "</a></p>";
                   
                    $message_reason = "<p>You are receiving this message because " . $reason . "</p>";
                   
                    $message = $message_prologue . $message_debug . $message_a . $message_b . $message_reason . $message_c . $message_diff . $message_epilogue;
                   
                    // Send out the email to the user
                    mail( $useremail['email'], $subject, $message, $headers, '-f' . $wakka->config["email_notifications_sender_email"] );
                }
            }
        }
    }    
   
    ?>


Install Page Modification Handler


               // Load the watched pages lib if needed, and email this page's watchers.
               include_once('libs/watchedpages.class.php');
               if (function_exists('EmailPageWatchers')) {
                   EmailPageWatchers($this);
               }
There are no comments on this page.
Valid XHTML :: Valid CSS: :: Powered by WikkaWiki