From a1df7f52df8fcd8c0afae571561eb5dd341137eb Mon Sep 17 00:00:00 2001 From: Will Rossiter Date: Tue, 30 Nov 2010 11:24:17 +1300 Subject: [PATCH] Initial commit with transfer of existing comments functionality --- LICENSE | 17 + README.md | 21 + code/CommentAdmin.php | 321 +++++++++++++++ code/controllers/CommentInterface.php | 379 ++++++++++++++++++ code/dataobjects/Comment.php | 228 +++++++++++ code/extensions/CommentExtension.php | 16 + code/formfields/CommentTableField.php | 107 +++++ javascript/CommentsInterface.js | 246 ++++++++++++ templates/PageCommentInterface.ss | 65 +++ .../PageCommentInterface_singlecomment.ss | 29 ++ tests/CommentsTest.php | 101 +++++ tests/CommentsTest.yml | 62 +++ 12 files changed, 1592 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 code/CommentAdmin.php create mode 100755 code/controllers/CommentInterface.php create mode 100755 code/dataobjects/Comment.php create mode 100644 code/extensions/CommentExtension.php create mode 100644 code/formfields/CommentTableField.php create mode 100755 javascript/CommentsInterface.js create mode 100755 templates/PageCommentInterface.ss create mode 100755 templates/PageCommentInterface_singlecomment.ss create mode 100644 tests/CommentsTest.php create mode 100644 tests/CommentsTest.yml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..256f77d --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +Copyright (c) 2007-2010, SilverStripe Limited - www.silverstripe.com +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of SilverStripe nor the names of its contributors may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..35c4847 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Comments + +## Maintainers + + * Will Rossiter (Nickname: willr, wrossiter) + + +## Introduction + +This module provides a commenting functionality for Pages and other DataObjects. It replaces the built in +commenting functionality with a more flexible plug and play version. + +For more documentation about the module see the docs/ folder. + +## Requirements + + * SilverStripe 3.0 (SilverStripe Trunk as of Dec '10) + +## Installation + +See docs/en/Installing.md diff --git a/code/CommentAdmin.php b/code/CommentAdmin.php new file mode 100644 index 0000000..457e47f --- /dev/null +++ b/code/CommentAdmin.php @@ -0,0 +1,321 @@ +getLastFormIn($this->renderWith('CommentAdmin_right')); + } + + public function Section() { + $url = rtrim($_SERVER['REQUEST_URI'], '/'); + if(strrpos($url, '&')) { + $url = substr($url, 0, strrpos($url, '&')); + } + $section = substr($url, strrpos($url, '/') + 1); + + if($section != 'approved' && $section != 'unmoderated' && $section != 'spam') { + $section = Session::get('CommentsSection'); + } + + if($section != 'approved' && $section != 'unmoderated' && $section != 'spam') { + $section = 'approved'; + } + + return $section; + } + + public function EditForm() { + $section = $this->Section(); + + if($section == 'approved') { + $filter = "\"IsSpam\" = 0 AND \"NeedsModeration\" = 0"; + $title = "

". _t('CommentAdmin.APPROVEDCOMMENTS', 'Approved Comments')."

"; + } else if($section == 'unmoderated') { + $filter = '"NeedsModeration" = 1'; + $title = "

"._t('CommentAdmin.COMMENTSAWAITINGMODERATION', 'Comments Awaiting Moderation')."

"; + } else { + $filter = '"IsSpam" = 1'; + $title = "

"._t('CommentAdmin.SPAM', 'Spam')."

"; + } + + $filter .= ' AND "ParentID">0'; + + $tableFields = array( + "Name" => _t('CommentAdmin.AUTHOR', 'Author'), + "Comment" => _t('CommentAdmin.COMMENT', 'Comment'), + "Parent.Title" => _t('CommentAdmin.PAGE', 'Page'), + "CommenterURL" => _t('CommentAdmin.COMMENTERURL', 'URL'), + "Created" => _t('CommentAdmin.DATEPOSTED', 'Date Posted') + ); + + $popupFields = new FieldSet( + new TextField('Name', _t('CommentAdmin.NAME', 'Name')), + new TextField('CommenterURL', _t('CommentAdmin.COMMENTERURL', 'URL')), + new TextareaField('Comment', _t('CommentAdmin.COMMENT', 'Comment')) + ); + + $idField = new HiddenField('ID', '', $section); + $table = new CommentTableField($this, "Comments", "PageComment", $section, $tableFields, $popupFields, array($filter), 'Created DESC'); + + $table->setParentClass(false); + $table->setFieldCasting(array( + 'Created' => 'SSDatetime->Full', + 'Comment' => array('HTMLText->LimitCharacters', 150) + )); + + $table->setPageSize(self::get_comments_per_page()); + $table->addSelectOptions(array('all'=>'All', 'none'=>'None')); + $table->Markable = true; + + $fields = new FieldSet( + new LiteralField("Title", $title), + $idField, + $table + ); + + $actions = new FieldSet(); + + if($section == 'unmoderated') { + $actions->push(new FormAction('acceptmarked', _t('CommentAdmin.ACCEPT', 'Accept'))); + } + + if($section == 'approved' || $section == 'unmoderated') { + $actions->push(new FormAction('spammarked', _t('CommentAdmin.SPAMMARKED', 'Mark as spam'))); + } + + if($section == 'spam') { + $actions->push(new FormAction('hammarked', _t('CommentAdmin.MARKASNOTSPAM', 'Mark as not spam'))); + } + + $actions->push(new FormAction('deletemarked', _t('CommentAdmin.DELETE', 'Delete'))); + + if($section == 'spam') { + $actions->push(new FormAction('deleteall', _t('CommentAdmin.DELETEALL', 'Delete All'))); + } + + $form = new Form($this, "EditForm", $fields, $actions); + + return $form; + } + + function deletemarked() { + $numComments = 0; + $folderID = 0; + $deleteList = ''; + + if($_REQUEST['Comments']) { + foreach($_REQUEST['Comments'] as $commentid) { + $comment = DataObject::get_by_id('PageComment', $commentid); + if($comment) { + $comment->delete(); + $numComments++; + } + } + } else { + user_error("No comments in $commentList could be found!", E_USER_ERROR); + } + + echo <<Count(); + + foreach($spam as $comment) { + $comment->delete(); + } + } + + $msg = sprintf(_t('CommentAdmin.DELETED', 'Deleted %s comments.'), $numComments); + echo <<IsSpam = true; + $comment->NeedsModeration = false; + $comment->write(); + + if(SSAkismet::isEnabled()) { + try { + $akismet = new SSAkismet(); + $akismet->setCommentAuthor($comment->getField('Name')); + $akismet->setCommentContent($comment->getField('Comment')); + + $akismet->submitSpam(); + } catch (Exception $e) { + // Akismet didn't work, most likely the service is down. + } + } + $numComments++; + } + } + } else { + user_error("No comments in $commentList could be found!", E_USER_ERROR); + } + + $msg = sprintf(_t('CommentAdmin.MARKEDSPAM', 'Marked %s comments as spam.'), $numComments); + echo <<IsSpam = false; + $comment->NeedsModeration = false; + $comment->write(); + + if(SSAkismet::isEnabled()) { + try { + $akismet = new SSAkismet(); + $akismet->setCommentAuthor($comment->getField('Name')); + $akismet->setCommentContent($comment->getField('Comment')); + + $akismet->submitSpam(); + } catch (Exception $e) { + // Akismet didn't work, most likely the service is down. + } + } + + $numComments++; + } + } + } else { + user_error("No comments in $commentList could be found!", E_USER_ERROR); + } + + $msg = sprintf(_t('CommentAdmin.MARKEDNOTSPAM', 'Marked %s comments as not spam.'), $numComments); + echo <<IsSpam = false; + $comment->NeedsModeration = false; + $comment->write(); + $numComments++; + } + } + } else { + user_error("No comments in $commentList could be found!", E_USER_ERROR); + } + + $msg = sprintf(_t('CommentAdmin.APPROVED', 'Accepted %s comments.'), $numComments); + echo <<value(); + } + + /** + * Return the number of unmoderated comments + */ + function NumUnmoderated() { + return DB::query("SELECT COUNT(*) FROM \"PageComment\" WHERE \"IsSpam\"=0 AND \"NeedsModeration\"=1")->value(); + } + + /** + * Return the number of comments marked as spam + */ + function NumSpam() { + return DB::query("SELECT COUNT(*) FROM \"PageComment\" WHERE \"IsSpam\"=1")->value(); + } + + /** + * @param $num int + */ + function set_comments_per_page($num){ + self::$comments_per_page = $num; + } + + /** + * @return int + */ + function get_comments_per_page(){ + return self::$comments_per_page; + } +} + +?> diff --git a/code/controllers/CommentInterface.php b/code/controllers/CommentInterface.php new file mode 100755 index 0000000..467c1ba --- /dev/null +++ b/code/controllers/CommentInterface.php @@ -0,0 +1,379 @@ + '$Item', + ); + static $allowed_actions = array( + 'PostCommentForm', + ); + + protected $controller, $methodName, $page; + + /** + * If this is true, you must be logged in to post a comment + * (and therefore, you don't need to specify a 'Your name' field unless + * your name is blank) + * + * @var bool + */ + static $comments_require_login = false; + + /** + * If this is a valid permission code, you must be logged in + * and have the appropriate permission code on your account before you can + * post a comment. + * + * @var string + */ + static $comments_require_permission = ""; + + /** + * If this is true it will include the javascript for AJAX + * commenting. If it is set to false then it will not load + * the files required and it will fall back + * + * @var bool + */ + static $use_ajax_commenting = true; + + /** + * If this is true then we should show the existing comments on + * the page even when we have disabled the comment form. + * + * If this is false the form + existing comments will be hidden + * + * @var bool + * @since 2.4 - Always show them by default + */ + static $show_comments_when_disabled = true; + + /** + * Define how you want to order page comments by. By default order by newest + * to oldest. + * + * @var String - used as $orderby in DB query + * @since 2.4 + */ + static $order_comments_by = "\"Created\" DESC"; + + /** + * Create a new page comment interface + * @param controller The controller that the interface is used on + * @param methodName The method to return this PageCommentInterface object + * @param page The page that we're commenting on + */ + function __construct($controller, $methodName, $page) { + $this->controller = $controller; + $this->methodName = $methodName; + $this->page = $page; + parent::__construct(); + } + + function Link() { + return Controller::join_links($this->controller->Link(), $this->methodName); + } + + /** + * See {@link PageCommentInterface::$comments_require_login} + * + * @param boolean state The new state of this static field + */ + static function set_comments_require_login($state) { + self::$comments_require_login = (boolean) $state; + } + + /** + * See {@link PageCommentInterface::$comments_require_permission} + * + * @param string permission The permission to check against. + */ + static function set_comments_require_permission($permission) { + self::$comments_require_permission = $permission; + } + + /** + * See {@link PageCommentInterface::$show_comments_when_disabled} + * + * @param bool - show / hide the existing comments when disabled + */ + static function set_show_comments_when_disabled($state) { + self::$show_comments_when_disabled = $state; + } + + /** + * See {@link PageCommentInterface::$order_comments_by} + * + * @param String + */ + static function set_order_comments_by($order) { + self::$order_comments_by = $order; + } + + /** + * See {@link PageCommentInterface::$use_ajax_commenting} + * + * @param bool + */ + static function set_use_ajax_commenting($state) { + self::$use_ajax_commenting = $state; + } + + function forTemplate() { + return $this->renderWith('PageCommentInterface'); + } + + /** + * @return boolean true if the currently logged in user can post a comment, + * false if they can't. Users can post comments by default, enforce + * security by using + * @link PageCommentInterface::set_comments_require_login() and + * @link {PageCommentInterface::set_comments_require_permission()}. + */ + static function CanPostComment() { + $member = Member::currentUser(); + if(self::$comments_require_permission && $member && Permission::check(self::$comments_require_permission)) { + return true; // Comments require a certain permission, and the user has the correct permission + } elseif(self::$comments_require_login && $member && !self::$comments_require_permission) { + return true; // Comments only require that a member is logged in + } elseif(!self::$comments_require_permission && !self::$comments_require_login) { + return true; // Comments don't require anything - anyone can add a comment + } + + return false; + } + + /** + * if this page comment form requires users to have a + * valid permission code in order to post (used to customize the error + * message). + * + * @return bool + */ + function PostingRequiresPermission() { + return self::$comments_require_permission; + } + + function Page() { + return $this->page; + } + + function PostCommentForm() { + if(!$this->page->ProvideComments){ + return false; + } + $fields = new FieldSet( + new HiddenField("ParentID", "ParentID", $this->page->ID) + ); + + $member = Member::currentUser(); + + if((self::$comments_require_login || self::$comments_require_permission) && $member && $member->FirstName) { + // note this was a ReadonlyField - which displayed the name in a span as well as the hidden field but + // it was not saving correctly. Have changed it to a hidden field. It passes the data correctly but I + // believe the id of the form field is wrong. + $fields->push(new ReadonlyField("NameView", _t('PageCommentInterface.YOURNAME', 'Your name'), $member->getName())); + $fields->push(new HiddenField("Name", "", $member->getName())); + } else { + $fields->push(new TextField("Name", _t('PageCommentInterface.YOURNAME', 'Your name'))); + } + + // optional commenter URL + $fields->push(new TextField("CommenterURL", _t('PageCommentInterface.COMMENTERURL', "Your website URL"))); + + if(MathSpamProtection::isEnabled()){ + $fields->push(new TextField("Math", sprintf(_t('PageCommentInterface.SPAMQUESTION', "Spam protection question: %s"), MathSpamProtection::getMathQuestion()))); + } + + $fields->push(new TextareaField("Comment", _t('PageCommentInterface.YOURCOMMENT', "Comments"))); + + $form = new PageCommentInterface_Form($this, "PostCommentForm", $fields, new FieldSet( + new FormAction("postcomment", _t('PageCommentInterface.POST', 'Post')) + )); + + // Set it so the user gets redirected back down to the form upon form fail + $form->setRedirectToFormOnValidationError(true); + + // Optional Spam Protection. + if(class_exists('SpamProtectorManager')) { + SpamProtectorManager::update_form($form, null, array('Name' => 'author_name', 'CommenterURL' => 'author_url', 'Comment' => 'post_body')); + self::set_use_ajax_commenting(false); + } + + // Shall We use AJAX? + if(self::$use_ajax_commenting) { + Requirements::javascript(SAPPHIRE_DIR . '/thirdparty/behaviour/behaviour.js'); + Requirements::javascript(SAPPHIRE_DIR . '/thirdparty/prototype/prototype.js'); + Requirements::javascript(THIRDPARTY_DIR . '/scriptaculous/effects.js'); + Requirements::javascript(CMS_DIR . '/javascript/PageCommentInterface.js'); + } + + // Load the data from Session + $form->loadDataFrom(array( + "Name" => Cookie::get("PageCommentInterface_Name"), + "Comment" => Cookie::get("PageCommentInterface_Comment"), + "CommenterURL" => Cookie::get("PageCommentInterface_CommenterURL") + )); + + return $form; + } + + function Comments() { + // Comment limits + $limit = array(); + $limit['start'] = isset($_GET['commentStart']) ? (int)$_GET['commentStart'] : 0; + $limit['limit'] = PageComment::$comments_per_page; + + $spamfilter = isset($_GET['showspam']) ? '' : "AND \"IsSpam\" = 0"; + $unmoderatedfilter = Permission::check('CMS_ACCESS_CommentAdmin') ? '' : "AND \"NeedsModeration\" = 0"; + $order = self::$order_comments_by; + $comments = DataObject::get("PageComment", "\"ParentID\" = '" . Convert::raw2sql($this->page->ID) . "' $spamfilter $unmoderatedfilter", $order, "", $limit); + + if(is_null($comments)) { + return; + } + + // This allows us to use the normal 'start' GET variables as well (In the weird circumstance where you have paginated comments AND something else paginated) + $comments->setPaginationGetVar('commentStart'); + + return $comments; + } + + function CommentRssLink() { + return Director::absoluteBaseURL() . "PageComment/rss?pageid=" . $this->page->ID; + } + + /** + * A link to PageComment_Controller.deleteallcomments() which deletes all + * comments on a page referenced by the url param pageid + */ + function DeleteAllLink() { + if(Permission::check('CMS_ACCESS_CommentAdmin')) { + return Director::absoluteBaseURL() . "PageComment/deleteallcomments?pageid=" . $this->page->ID; + } + } + +} + +/** + * @package cms + * @subpackage comments + */ +class PageCommentInterface_Form extends Form { + function postcomment($data) { + // Spam filtering + Cookie::set("PageCommentInterface_Name", $data['Name']); + Cookie::set("PageCommentInterface_CommenterURL", $data['CommenterURL']); + Cookie::set("PageCommentInterface_Comment", $data['Comment']); + + if(SSAkismet::isEnabled()) { + try { + $akismet = new SSAkismet(); + + $akismet->setCommentAuthor($data['Name']); + $akismet->setCommentContent($data['Comment']); + + if($akismet->isCommentSpam()) { + if(SSAkismet::getSaveSpam()) { + $comment = Object::create('PageComment'); + $this->saveInto($comment); + $comment->setField("IsSpam", true); + $comment->write(); + } + echo ""._t('PageCommentInterface_Form.SPAMDETECTED', 'Spam detected!!') . "

"; + printf("If you believe this was in error, please email %s.", ereg_replace("@", " _(at)_", Email::getAdminEmail())); + echo "

"._t('PageCommentInterface_Form.MSGYOUPOSTED', 'The message you posted was:'). "

"; + echo $data['Comment']; + + return; + } + } catch (Exception $e) { + // Akismet didn't work, continue without spam check + } + } + + //check if spam question was right. + if(MathSpamProtection::isEnabled()){ + if(!MathSpamProtection::correctAnswer($data['Math'])){ + if(!Director::is_ajax()) { + Director::redirectBack(); + } + return "spamprotectionfailed"; //used by javascript for checking if the spam question was wrong + } + } + + // If commenting can only be done by logged in users, make sure the user is logged in + $member = Member::currentUser(); + if(PageCommentInterface::CanPostComment() && $member) { + $this->Fields()->push(new HiddenField("AuthorID", "Author ID", $member->ID)); + } elseif(!PageCommentInterface::CanPostComment()) { + echo "You're not able to post comments to this page. Please ensure you are logged in and have an appropriate permission level."; + return; + } + + $comment = Object::create('PageComment'); + $this->saveInto($comment); + + // Store the Session ID if needed for Spamprotection + if($session = Session::get('mollom_user_session_id')) { + $comment->SessionID = $session; + Session::clear('mollom_user_session_id'); + } + $comment->IsSpam = false; + $comment->NeedsModeration = PageComment::moderationEnabled(); + $comment->write(); + + Cookie::set("PageCommentInterface_Comment", ''); + + $moderationMsg = _t('PageCommentInterface_Form.AWAITINGMODERATION', "Your comment has been submitted and is now awaiting moderation."); + + if(Director::is_ajax()) { + if($comment->NeedsModeration){ + echo $moderationMsg; + } else{ + echo $comment->renderWith('PageCommentInterface_singlecomment'); + } + } else { + if($comment->NeedsModeration){ + $this->sessionMessage($moderationMsg, 'good'); + } + + if($comment->ParentID) { + $page = DataObject::get_by_id("Page", $comment->ParentID); + if($page) { + // if it needs moderation then it won't appear in the list. Therefore + // we need to link to the comment holder rather than the individual comment + $url = ($comment->NeedsModeration) ? $page->Link() . '#PageComments_holder' : $page->Link() . '#PageComment_' . $comment->ID; + + return Director::redirect($url); + } + } + + return Director::redirectBack(); + } + } +} + +/** + * @package cms + * @subpackage comments + */ +class PageCommentInterface_Controller extends ContentController { + function __construct() { + parent::__construct(null); + } + + function newspamquestion() { + if(Director::is_ajax()) { + echo Convert::raw2xml(sprintf(_t('PageCommentInterface_Controller.SPAMQUESTION', "Spam protection question: %s"),MathSpamProtection::getMathQuestion())); + } + } +} + +?> diff --git a/code/dataobjects/Comment.php b/code/dataobjects/Comment.php new file mode 100755 index 0000000..479426a --- /dev/null +++ b/code/dataobjects/Comment.php @@ -0,0 +1,228 @@ + "Varchar(200)", + "Comment" => "Text", + "Email" => "Varchar(200)" + "URL" => "Varchar(255)", + "SessionID" => "Varchar(255)", + ); + + static $has_one = array( + "Parent" => "DataObject", + "Author" => "Member" + ); + + static $has_many = array(); + + static $many_many = array(); + + static $defaults = array(); + + static $casting = array( + "RSSTitle" => "Varchar", + ); + + static $comments_per_page = 10; + + static $moderate = false; + + static $bbcode = false; + + /** + * Return a link to this comment + * @return string link to this comment. + */ + function Link() { + return $this->Parent()->Link() . '#PageComment_'. $this->ID; + } + + function getRSSName() { + if($this->Name) { + return $this->Name; + } elseif($this->Author()) { + return $this->Author()->getName(); + } + } + + function ParsedBBCode(){ + $parser = new BBCodeParser($this->Comment); + return $parser->parse(); + } + + function DeleteLink() { + return ($this->canDelete()) ? "PageComment_Controller/deletecomment/$this->ID" : false; + } + + function CommentTextWithLinks() { + $pattern = '|([a-zA-Z]+://)([a-zA-Z0-9?&%.;:/=+_-]*)|is'; + $replace = '$1$2'; + return preg_replace($pattern, $replace, $this->Comment); + } + + function SpamLink() { + return ($this->canEdit() && !$this->IsSpam) ? "PageComment_Controller/reportspam/$this->ID" : false; + } + + function HamLink() { + return ($this->canEdit() && $this->IsSpam) ? "PageComment_Controller/reportham/$this->ID" : false; + } + + function ApproveLink() { + return ($this->canEdit() && $this->NeedsModeration) ? "PageComment_Controller/approve/$this->ID" : false; + } + + function SpamClass() { + if($this->getField('IsSpam')) { + return 'spam'; + } else if($this->getField('NeedsModeration')) { + return 'unmoderated'; + } else { + return 'notspam'; + } + } + + + function RSSTitle() { + return sprintf( + _t('PageComment.COMMENTBY', "Comment by '%s' on %s", PR_MEDIUM, 'Name, Page Title'), + Convert::raw2xml($this->getRSSName()), + $this->Parent()->Title + ); + } + + + + + function PageTitle() { + return $this->Parent()->Title; + } + + static function enableModeration() { + self::$moderate = true; + } + + static function moderationEnabled() { + return self::$moderate; + } + + static function enableBBCode() { + self::$bbcode = true; + } + + static function bbCodeEnabled() { + return self::$bbcode; + } + + /** + * + * @param boolean $includerelations a boolean value to indicate if the labels returned include relation fields + * + */ + function fieldLabels($includerelations = true) { + $labels = parent::fieldLabels($includerelations); + $labels['Name'] = _t('PageComment.Name', 'Author Name'); + $labels['Comment'] = _t('PageComment.Comment', 'Comment'); + $labels['IsSpam'] = _t('PageComment.IsSpam', 'Spam?'); + $labels['NeedsModeration'] = _t('PageComment.NeedsModeration', 'Needs Moderation?'); + + return $labels; + } + + /** + * This method is called just before this object is + * written to the database. + * + * Specifically, make sure "http://" exists at the start + * of the URL, if it doesn't have https:// or http:// + */ + public function onBeforeWrite() { + parent::onBeforeWrite(); + + $url = $this->CommenterURL; + + if($url) { + if(strtolower(substr($url, 0, 8)) != 'https://' && strtolower(substr($url, 0, 7)) != 'http://') { + $this->CommenterURL = 'http://' . $url; + } + } + } + + /** + * This always returns true, and should be handled by {@link PageCommentInterface->CanPostComment()}. + * + * @todo Integrate with PageCommentInterface::$comments_require_permission and $comments_require_login + * + * @param Member $member + * @return Boolean + */ + function canCreate($member = null) { + return true; + } + + /** + * Checks for association with a page, + * and {@link SiteTree->ProvidePermission} flag being set to TRUE. + * Note: There's an additional layer of permission control + * in {@link PageCommentInterface}. + * + * @param Member $member + * @return Boolean + */ + function canView($member = null) { + if(!$member) $member = Member::currentUser(); + + // Standard mechanism for accepting permission changes from decorators + $extended = $this->extendedCan('canView', $member); + if($extended !== null) return $extended; + + $page = $this->Parent(); + return ( + ($page && $page->ProvideComments) + || (bool)Permission::checkMember($member, 'CMS_ACCESS_CommentAdmin') + ); + } + + /** + * Checks for "CMS_ACCESS_CommentAdmin" permission codes + * and {@link canView()}. + * + * @param Member $member + * @return Boolean + */ + function canEdit($member = null) { + if(!$member) $member = Member::currentUser(); + + // Standard mechanism for accepting permission changes from decorators + $extended = $this->extendedCan('canEdit', $member); + if($extended !== null) return $extended; + + if(!$this->canView($member)) return false; + + return (bool)Permission::checkMember($member, 'CMS_ACCESS_CommentAdmin'); + } + + /** + * Checks for "CMS_ACCESS_CommentAdmin" permission codes + * and {@link canEdit()}. + * + * @param Member $member + * @return Boolean + */ + function canDelete($member = null) { + if(!$member) $member = Member::currentUser(); + + // Standard mechanism for accepting permission changes from decorators + $extended = $this->extendedCan('canDelete', $member); + if($extended !== null) return $extended; + + return $this->canEdit($member); + } +} \ No newline at end of file diff --git a/code/extensions/CommentExtension.php b/code/extensions/CommentExtension.php new file mode 100644 index 0000000..54e3e03 --- /dev/null +++ b/code/extensions/CommentExtension.php @@ -0,0 +1,16 @@ +owner->ID ."' AND \"RecordClass\" = '". $this->ownerBaseClass ."'"); + } + + function CommentsForm() { + die(); + } +} \ No newline at end of file diff --git a/code/formfields/CommentTableField.php b/code/formfields/CommentTableField.php new file mode 100644 index 0000000..f380f2f --- /dev/null +++ b/code/formfields/CommentTableField.php @@ -0,0 +1,107 @@ +mode = $mode; + + Session::set('CommentsSection', $mode); + + parent::__construct($controller, $name, $sourceClass, $fieldList, $detailFormFields, $sourceFilter, $sourceSort, $sourceJoin); + + $this->Markable = true; + + // Note: These keys have special behaviour associated through TableListField.js + $this->selectOptions = array( + 'all' => _t('CommentTableField.SELECTALL', 'All'), + 'none' => _t('CommentTableField.SELECTNONE', 'None') + ); + + // search + $search = isset($_REQUEST['CommentSearch']) ? Convert::raw2sql($_REQUEST['CommentSearch']) : null; + if(!empty($_REQUEST['CommentSearch'])) { + $this->sourceFilter[] = "( \"Name\" LIKE '%$search%' OR \"Comment\" LIKE '%$search%')"; + } + } + + function FieldHolder() { + $ret = parent::FieldHolder(); + + Requirements::javascript(CMS_DIR . '/javascript/CommentTableField.js'); + + return $ret; + } + + function Items() { + $this->sourceItems = $this->sourceItems(); + + if(!$this->sourceItems) { + return null; + } + + $pageStart = (isset($_REQUEST['ctf'][$this->Name()]['start']) && is_numeric($_REQUEST['ctf'][$this->Name()]['start'])) ? $_REQUEST['ctf'][$this->Name()]['start'] : 0; + $this->sourceItems->setPageLimits($pageStart, $this->pageSize, $this->totalCount); + + $output = new DataObjectSet(); + foreach($this->sourceItems as $pageIndex=>$item) { + $output->push(Object::create('CommentTableField_Item',$item, $this, $pageStart+$pageIndex)); + } + return $output; + } + + function HasSpamButton() { + return $this->mode == 'approved' || $this->mode == 'unmoderated'; + } + + function HasApproveButton() { + return $this->mode == 'unmoderated'; + } + + function HasHamButton() { + return $this->mode == 'spam'; + } + + function SearchForm() { + $query = isset($_GET['CommentSearch']) ? $_GET['CommentSearch'] : null; + + $searchFields = new FieldGroup( + new TextField('CommentSearch', _t('CommentTableField.SEARCH', 'Search'), $query), + new HiddenField("ctf[ID]",'',$this->mode), + new HiddenField('CommentFieldName','',$this->name) + ); + + $actionFields = new LiteralField('CommentFilterButton',''); + + $fieldContainer = new FieldGroup( + $searchFields, + $actionFields + ); + + return $fieldContainer->FieldHolder(); + } +} + +/** + * Single row of a {@link CommentTableField} + * + * @package comments + */ +class CommentTableField_Item extends ComplexTableField_Item { + function HasSpamButton() { + return $this->parent()->HasSpamButton(); + } + + function HasApproveButton() { + return $this->parent()->HasApproveButton(); + } + + function HasHamButton() { + return $this->parent()->HasHamButton(); + } +} \ No newline at end of file diff --git a/javascript/CommentsInterface.js b/javascript/CommentsInterface.js new file mode 100755 index 0000000..84248ef --- /dev/null +++ b/javascript/CommentsInterface.js @@ -0,0 +1,246 @@ +/** + * Ajax to support the comment posting system + */ + +PageCommentInterface = Class.create(); + +PageCommentInterface.prototype = { + initialize: function() { + Behaviour.register({ + '#PageCommentInterface_Form_PostCommentForm_action_postcomment' : { + onclick : this.postComment + }, + + '#PageComments a.deletelink' : { + onclick : this.deleteComment + }, + '#PageComments a.spamlink' : { + onclick : this.reportSpam + }, + '#PageComments a.hamlink' : { + onclick : this.reportHam + }, + '#PageComments a.approvelink' : { + onclick : this.approveComment + } + }); + }, + + loadSpamQuestion: function(response) { + var spamQuestionDiv = $('Math'); + var mathLabel = spamQuestionDiv.getElementsByTagName('label')[0]; + mathLabel.innerHTML = response.responseText; + var mathQuestion = spamQuestionDiv.getElementsByTagName('input')[0]; + mathQuestion.value = ''; + }, + + postComment: function() { + var form = $("PageCommentInterface_Form_PostCommentForm"); + var message = $("PageCommentInterface_Form_PostCommentForm_error"); + + if(form.elements.Name.value && form.elements.Comment.value) { + if(noComments = $('NoComments')) { + Element.remove(noComments); + var pageComments = document.createElement('ul'); + pageComments.id = 'PageComments'; + $('CommentHolder').appendChild(pageComments); + } + + message.style.display = 'none'; + + // Create a new
  • for the post + var pageComments = $('PageComments').getElementsByTagName('li'); + var __newComment = document.createElement('li'); + + + // Add it to the list with a 'loading' message + $('PageComments').insertBefore(__newComment, pageComments[0]); + __newComment.innerHTML = '

    Loading...

    '; + + + // Submit the form via ajax + Ajax.SubmitForm(form, "action_postcomment", { + onSuccess : function(response) { + + // Create an Ajax request to regenerate the spam protection question + //need to check if there is actually a spam question to change first + if(form.elements.Math){ + new Ajax.Request(document.getElementsByTagName('base')[0].href+'PageCommentInterface_Controller/newspamquestion', { + onSuccess: loadSpamQuestion, + onFailure: Ajax.Evaluator + }); + } + + if(response.responseText != "spamprotectionfailed"){ + __newComment.className ="even"; + // Load the response into the new
  • + __newComment.innerHTML = response.responseText; + Behaviour.apply(__newComment); + + // Flash it using Scriptaculous + new Effect.Highlight(__newComment, { endcolor: '#e9e9e9' } ); + if(response.responseText.match('Spam detected!!')) { + __newComment.className = 'spam'; + } + + }else{ + __newComment.innerHTML = ""; + Behaviour.apply(__newComment); + message.style.display = ''; + message.innerHTML = "You got the spam question wrong."; + + } + + + }, + onFailure : function(response) { + alert(response.responseText); + } + }); + } else { + message.style.display = ''; + message.innerHTML = "Please enter your name and a comment to be posted to the site."; + } + + return false; + }, + + /** + * Ajax handler of moderation removal + */ + deleteComment: function() { + var __comment = this.parentNode.parentNode.parentNode; + + __comment.getElementsByTagName('span')[0].innerHTML = "Removing..."; + + new Ajax.Request(this.href + '?ajax=1', { + onSuccess : function(response) { + // Clear our wee status message + __comment.getElementsByTagName('span')[0].innerHTML = "Removing..."; + + // Remove it using Scriptaculous + new Effect.Highlight(__comment, { + startcolor: '#cc9999' , endcolor: '#e9e9e9', duration: 0.5, + afterFinish : function () { + var commentList = __comment.parentNode; + commentList.removeChild(__comment); + if(!commentList.firstChild) { + $('CommentHolder').innerHTML = "

    No one has commented on this page yet.

    "; + } + } + } ); + }, + + onFailure : function(response) { + alert(response.responseText); + } + }); + + return false; + }, + + /** + * Ajax handler of spam reporting + */ + reportSpam: function() { + var __comment = this.parentNode.parentNode.parentNode.parentNode; + + __comment.getElementsByTagName('span')[0].innerHTML = "Reporting spam..."; + + + new Ajax.Request(this.href + '?ajax=1', { + onSuccess : function(response) { + if(response.responseText != '') { + // Load the response into the
  • + __comment.innerHTML = response.responseText; + Behaviour.apply(__comment); + + // Flash it using Scriptaculous + new Effect.Highlight(__comment, { endcolor: '#cc9999' } ); + + __comment.className = 'spam'; + } else { + new Effect.Highlight(__comment, { + startcolor: '#cc9999' , endcolor: '#e9e9e9', duration: 0.5, + afterFinish : function() { + var commentList = __comment.parentNode; + commentList.removeChild(__comment); + if(!commentList.firstChild) { + $('CommentHolder').innerHTML = "

    No one has commented on this page yet.

    "; + } + } + } ); + } + }, + + onFailure : function(response) { + alert(response.responseText); + } + }); + + return false; + }, + + /** + * Ajax handler of ham reporting + */ + reportHam: function() { + var __comment = this.parentNode.parentNode.parentNode.parentNode; + + __comment.getElementsByTagName('span')[0].innerHTML = "Reporting as not spam..."; + + new Ajax.Request(this.href + '?ajax=1', { + onSuccess : function(response) { + // Load the response into the
  • + __comment.innerHTML = response.responseText; + Behaviour.apply(__comment); + + // Flash it using Scriptaculous + new Effect.Highlight(__comment, { endcolor: '#e9e9e9' } ); + __comment.className = 'notspam'; + }, + + onFailure : function(response) { + alert(response.responseText); + } + }); + + return false; + }, + + /** + * Ajax handler of ham reporting + */ + approveComment: function() { + var __comment = this.parentNode.parentNode.parentNode.parentNode; + + __comment.getElementsByTagName('span')[0].innerHTML = "Marking comment as approved..."; + + new Ajax.Request(this.href + '?ajax=1', { + onSuccess : function(response) { + // Load the response into the
  • + __comment.innerHTML = response.responseText; + Behaviour.apply(__comment); + + // Flash it using Scriptaculous + new Effect.Highlight(__comment, { endcolor: '#e9e9e9' } ); + __comment.className = 'notspam'; + }, + + onFailure : function(response) { + alert(response.responseText); + } + }); + + return false; + } +} + +PageCommentInterface.applyTo("#PageComments_holder"); +function loadSpamQuestion(response) { + var spamQuestionDiv = $('Math'); + var mathLabel = spamQuestionDiv.getElementsByTagName('label')[0]; + mathLabel.innerHTML = response.responseText; + var mathQuestion = spamQuestionDiv.getElementsByTagName('input')[0]; + mathQuestion.value = ''; +} diff --git a/templates/PageCommentInterface.ss b/templates/PageCommentInterface.ss new file mode 100755 index 0000000..483acc2 --- /dev/null +++ b/templates/PageCommentInterface.ss @@ -0,0 +1,65 @@ +
    + +

    <% _t('POSTCOM','Post your comment') %>

    + <% if PostCommentForm %> + <% if CanPostComment %> + $PostCommentForm + <% else %> +

    <% _t('COMMENTLOGINERROR', 'You cannot post comments until you have logged in') %><% if PostingRequiresPermission %>,<% _t('COMMENTPERMISSIONERROR', 'and that you have an appropriate permission level') %><% end_if %>. + <% _t('COMMENTPOSTLOGIN', 'Login Here') %>. +

    + <% end_if %> + <% else %> +

    <% _t('COMMENTSDISABLED', 'Posting comments has been disabled') %>.

    + <% end_if %> + +

    <% _t('COMMENTS','Comments') %>

    + +
    + <% if Comments %> +
      + <% control Comments %> +
    • + <% include PageCommentInterface_singlecomment %> +
    • + <% end_control %> +
    + + <% if Comments.MoreThanOnePage %> +
    +

    + <% if Comments.PrevLink %> + « <% _t('PREV','previous') %> + <% end_if %> + + <% if Comments.Pages %> + <% control Comments.Pages %> + <% if CurrentBool %> + $PageNum + <% else %> + $PageNum + <% end_if %> + <% end_control %> + <% end_if %> + + <% if Comments.NextLink %> + <% _t('NEXT','next') %> » + <% end_if %> +

    +
    + <% end_if %> + <% else %> +

    <% _t('NOCOMMENTSYET','No one has commented on this page yet.') %>

    + <% end_if %> +
    + <% if DeleteAllLink %> +

    + <% _t('PageCommentInterface.DELETEALLCOMMENTS','Delete all comments on this page') %> +

    + <% end_if %> +

    + <% _t('RSSFEEDCOMMENTS', 'RSS feed for comments on this page') %> | + <% _t('RSSFEEDALLCOMMENTS', 'RSS feed for all comments') %> +

    +
    + diff --git a/templates/PageCommentInterface_singlecomment.ss b/templates/PageCommentInterface_singlecomment.ss new file mode 100755 index 0000000..97dc880 --- /dev/null +++ b/templates/PageCommentInterface_singlecomment.ss @@ -0,0 +1,29 @@ +

    + <% if bbCodeEnabled %> + $ParsedBBCode + <% else %> + $Comment.XML + <% end_if %> +

    +

    + <% if CommenterURL %> + <% _t('PBY','Posted by') %> $Name.XML, $Created.Nice ($Created.Ago) + <% else %> + <% _t('PBY','Posted by') %> $Name.XML, $Created.Nice ($Created.Ago) + <% end_if %> +

    + + diff --git a/tests/CommentsTest.php b/tests/CommentsTest.php new file mode 100644 index 0000000..fd3ef59 --- /dev/null +++ b/tests/CommentsTest.php @@ -0,0 +1,101 @@ +objFromFixture('Member', 'visitor'); + $admin = $this->objFromFixture('Member', 'commentadmin'); + $comment = $this->objFromFixture('PageComment', 'firstComA'); + + $this->assertTrue($comment->canView($visitor), + 'Unauthenticated members can view comments associated to a page with ProvideComments=1' + ); + $this->assertTrue($comment->canView($admin), + 'Admins with CMS_ACCESS_CommentAdmin permissions can view comments associated to a page with ProvideComments=1' + ); + + $disabledComment = $this->objFromFixture('PageComment', 'disabledCom'); + + $this->assertFalse($disabledComment->canView($visitor), + 'Unauthenticated members can not view comments associated to a page with ProvideComments=0' + ); + $this->assertTrue($disabledComment->canView($admin), + 'Admins with CMS_ACCESS_CommentAdmin permissions can view comments associated to a page with ProvideComments=0' + ); + } + + function testCanEdit() { + $visitor = $this->objFromFixture('Member', 'visitor'); + $admin = $this->objFromFixture('Member', 'commentadmin'); + $comment = $this->objFromFixture('PageComment', 'firstComA'); + + $this->assertFalse($comment->canEdit($visitor)); + $this->assertTrue($comment->canEdit($admin)); + } + + function testCanDelete() { + $visitor = $this->objFromFixture('Member', 'visitor'); + $admin = $this->objFromFixture('Member', 'commentadmin'); + $comment = $this->objFromFixture('PageComment', 'firstComA'); + + $this->assertFalse($comment->canEdit($visitor)); + $this->assertTrue($comment->canEdit($admin)); + } + + function testDeleteComment() { + $firstPage = $this->objFromFixture('Page', 'first'); + $this->autoFollowRedirection = false; + $this->logInAs('commentadmin'); + + $firstComment = $this->objFromFixture('PageComment', 'firstComA'); + $firstCommentID = $firstComment->ID; + Director::test($firstPage->RelativeLink(), null, $this->session()); + Director::test('PageComment/deletecomment/'.$firstComment->ID, null, $this->session()); + + $this->assertFalse(DataObject::get_by_id('PageComment', $firstCommentID)); + } + + function testDeleteAllCommentsOnPage() { + $second = $this->objFromFixture('Page', 'second'); + $this->autoFollowRedirection = false; + $this->logInAs('commentadmin'); + + Director::test('second-page', null, $this->session()); + Director::test('PageComment/deleteallcomments?pageid='.$second->ID, + null, $this->session()); + Director::test('second-page', null, $this->session()); + + $secondComments = DataObject::get('PageComment', '"ParentID" = '.$second->ID); + $this->assertNull($secondComments); + + $first = $this->objFromFixture('Page', 'first'); + $firstComments = DataObject::get('PageComment', '"ParentID" = '.$first->ID); + $this->assertNotNull($firstComments); + + $third = $this->objFromFixture('Page', 'third'); + $thirdComments = DataObject::get('PageComment', '"ParentID" = '.$third->ID); + $this->assertEquals($thirdComments->Count(), 3); + } + + function testCommenterURLWrite() { + $comment = new PageComment(); + // We only care about the CommenterURL, so only set that + // Check a http and https URL. Add more test urls here as needed. + $protocols = array( + 'Http', + 'Https', + ); + $url = '://example.com'; + foreach($protocols as $protocol) { + $comment->CommenterURL = $protocol . $url; + // The protocol should stay as if, assuming it is valid + $comment->write(); + $this->assertEquals($comment->CommenterURL, $protocol . $url, $protocol . ':// is a valid protocol'); + } + } +} diff --git a/tests/CommentsTest.yml b/tests/CommentsTest.yml new file mode 100644 index 0000000..5ec73a0 --- /dev/null +++ b/tests/CommentsTest.yml @@ -0,0 +1,62 @@ +Member: + commentadmin: + FirstName: admin + visitor: + FirstName: visitor + +Group: + commentadmins: + Title: Admin + Members: =>Member.commentadmin + +Permission: + admin: + Code: CMS_ACCESS_CommentAdmin + Group: =>Group.commentadmins + +Page: + first: + Title: First page + URLSegment: first-page + ProvideComments: 1 + second: + Title: Second page + URLSegment: second-page + ProvideComments: 1 + third: + Title: Third page + URLSegment:third-page + ProvideComments: 1 + pageNoComments: + Title: No comments + URLSegment: no-comments + ProvideComments: 0 + +PageComment: + firstComA: + ParentID: =>Page.first + Name: FA + Comment: textFA + secondComA: + ParentID: =>Page.second + Name: SA + Comment: textSA + secondComB: + ParentID: =>Page.second + Name: SB + Comment: textSB + thirdComA: + ParentID: =>Page.third + Name: TA + Comment: textTA + thirdComB: + ParentID: =>Page.third + Name: TB + Comment: textTB + thirdComC: + ParentID: =>Page.third + Name: TC + Comment: textTC + disabledCom: + ParentID: =>Page.pageNoComments + Name: Disabled \ No newline at end of file