Subversion Repositories web.active

Rev

Rev 22 | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * Comments Manager
 *
 * Manage all comments field data in chronological order. 
 *
 * ProcessWire 3.x 
 * Copyright (C) 2022 by Ryan Cramer 
 * This file licensed under Mozilla Public License v2.0 http://mozilla.org/MPL/2.0/
 * 
 * https://processwire.com
 * 
 * @method string renderComment(Comment $comment, array $options = array())
 *
 */

class ProcessCommentsManager extends Process {

  /**
   * Return information about this module (required)
   *
   */
  public static function getModuleInfo() {
    return array(
      'title' => __('Comments', __FILE__), 
      'summary' => __('Manage comments in your site outside of the page editor.', __FILE__),
      'version' => 12, 
      'author' => 'Ryan Cramer', 
      'icon' => 'comments', 
      'requires' => 'FieldtypeComments',
      'searchable' => 'comments', 
      'permission' => 'comments-manager', 
      'permissions' => array(
        'comments-manager' => 'Use the comments manager', 
        ),
      'page' => array(
        'name' => 'comments',
        'parent' => 'setup', 
        'title' => 'Comments', 
        ),
      'nav' => array(
        array(
          'url' => '?go=approved',
          'label' => __('Approved', __FILE__),
          ),
        array(
          'url' => '?go=pending',
          'label' => __('Pending', __FILE__),
          ), 
        array(
          'url' => '?go=spam',
          'label' => __('Spam', __FILE__),
          ),
        array(
          'url' => '?go=all',
          'label' => __('All', __FILE__),
          )
        )
      ); 
  }


  /**
   * Statuses and names that a Comment can have
   *
   */
  protected $statuses = array();

  /**
   * Translated statuses
   *
   */
  protected $statusTranslations = array();

  /**
   * Translated flags 
   * 
   * @var array
   * 
   */
  protected $notifyFlagsTranslations = array();

  /**
   * Number of comments to show per page
   *
   */
  protected $limit = 10;

  /**
   * Headline for masthead
   *
   */
  protected $headline = '';

  /**
   * Translated 'All' label
   * 
   * @var string
   * 
   */
  protected $labelAll = 'All';

  /**
   * Initialize the comments manager and define the statuses
   *
   */
  public function init() {
    $this->wire()->modules->get('FieldtypeComments');
    parent::init();
    $this->statuses = array(
      Comment::statusFeatured => 'featured',
      Comment::statusApproved => 'approved',
      Comment::statusPending => 'pending',
      Comment::statusSpam => 'spam',
      Comment::statusDelete => 'delete'
    );
    $this->statusTranslations = array(
      Comment::statusFeatured => $this->_('Featured'),
      Comment::statusApproved => $this->_('Approved'),
      Comment::statusPending => $this->_('Pending'),
      Comment::statusSpam => $this->_('Spam'),
      Comment::statusDelete => $this->_('Delete')
    );
    $this->labelAll = $this->_('All');
    $this->notifyFlagsTranslations = array(
      0 => $this->_('No'), 
      Comment::flagNotifyAll => $this->labelAll,
      Comment::flagNotifyReply => $this->_('Replies'), 
    );
    if(wireClassExists("CommentStars")) {
      $config = $this->config;
      $cssFile = $config->urls('FieldtypeComments') . 'comments.css';
      $jsFile = $config->urls('FieldtypeComments') . 'comments.js';
      $config->styles->add($cssFile);
      $config->scripts->add($jsFile);
      CommentStars::setDefault('star', wireIconMarkup('star'));
    }
  }

  /**
   * Ask the user to select which comments field they want to manage
   *
   * Or, redirect to the comments field if there is only 1.
   * 
   * @return string
   *
   */
  public function ___execute() {
    $this->checkInstall();
    // locate all the FieldtypeComments fields
    $fields = array();
    foreach($this->fields as $field) {
      /** @var Field $field */
      if($field->type instanceof FieldtypeComments) $fields[] = $field;
    }

    $count = count($fields);

    if(!$count) {
      $error = $this->_('There are no comments fields installed');
      $this->error($error);
      return "<p>$error</p>";
    }
    
    $go = $this->wire()->sanitizer->pageName($this->wire('input')->get('go')); 

    if($count == 1 || $go) {
      $field = reset($fields);
      $to = 'all';
      if($go && in_array($go, $this->statuses)) $to = $go;
      $this->wire()->session->redirect("./list/$field->name/$to/"); 
      return '';
    }

    $out = "<h2>" . $this->_('Please select a comments field') . "</h2><ul>";
    foreach($fields as $field) {
      $out .= "<li><a href='./list/$field->name/pending/'>$field->name</a></li>";
    }
    $out .= "</ul>";

    return $out;
  } 

  /**
   * Execute the comments list 
   * 
   * @return string
   *
   */
  public function ___executeList() {

    $session = $this->wire('session'); /** @var Session $session */
    $input = $this->wire('input'); /** @var WireInput $input */
    $sanitizer = $this->wire('sanitizer'); /** @var Sanitizer $sanitizer */
    $page = $this->wire('page'); /** @var Page $page */

    $commentID = (int) $input->get('id');
    $name = $sanitizer->fieldName($input->urlSegment2); 
    if(!$name) return $this->error($this->_('No comments field specified in URL')); 
    $field = $this->fields->get($name); 
    if(!$field || !$field->type instanceof FieldtypeComments) return $this->error($this->_('Unrecognized field')); 
    $status = $input->urlSegment3;
    
    if(empty($status) || ($status != 'all' && !in_array($status, $this->statuses))) {
      $redirectUrl = $page->url() . "list/$field->name/all/";
      if($commentID) $redirectUrl .= "?id=$commentID";
      $session->redirect($redirectUrl); 
    }
    
    $statusNum = array_search($status, $this->statuses); 
    $headline = $statusNum !== false ? $this->statusTranslations[$statusNum] : $status; 
    if($headline === 'all') $headline = $this->labelAll;
    $this->breadcrumb('../', $field->getLabel());

    $limit = (int) $input->get('limit');
    if($limit) {
      $session->setFor($this, 'limit', $limit);
      $session->redirect('./');
    } else {
      $limit = (int) $session->getFor($this, 'limit');
      if(!$limit) $limit = (int) $this->limit;
    }
    $sort = $sanitizer->name($input->get('sort'));
    if($sort) {
      $session->setFor($this, 'sort', $sort);
      $session->redirect('./');
    } else {
      $sort = $session->getFor($this, 'sort');
      if(!$sort) $sort = '-created';
    }
  
    $start = ($input->pageNum() - 1) * $limit;
    $selector = "start=$start, limit=$limit, sort=$sort";
    if($status != 'all') $selector .= ", status=$statusNum";

    $filterOut = '';
    $filterLabels = array(
      'id' => $this->_('ID'),
      'parent_id' => $this->_('Replies to'), 
      'pages_id' => $this->_('Page'), 
    );

    $properties = array(
      'cite', 
      'email', 
      'text', 
      'ip', 
      'id', 
      'pages_id',
      'parent_id', 
      'stars'
    );
    
    $q = $input->get('q');
    if($q !== null) {
      // query $q that contain a selector 
      $q = trim($sanitizer->text($q, array('stripTags' => false)));
      $op = Selectors::stringHasOperator($q, true);
      if($op) {
        list($property, $value) = explode($op, $q, 2); 
        $property = $sanitizer->fieldName($property);
        if(!in_array($property, $properties)) $property = '';
      } else {
        $property = 'text';
        $op = strpos($q, ' ') ? '~=' : '%=';
        $value = $q;
      }
      if($property && $value) {
        $selector .= ", $property$op" . $sanitizer->selectorValue($value);
        $input->whitelist('q', "$property$op$value"); 
      }
      $filterOut .= $sanitizer->entities(", $property$op$value"); 
    } 
    
    foreach($properties as $key) {
      $value = $input->get($key);
      if(is_null($value)) continue;
      if($key == 'id' || $key == 'pages_id' || $key == 'parent_id' || $key == 'stars') {
        $value = (int) $value;
      } else {
        $value = trim($sanitizer->text($value));
      }
      $input->whitelist($key, $value);
      $value = $sanitizer->selectorValue($value);
      $selector .= ", $key=$value";
      $filterLabel = isset($filterLabels[$key]) ? $filterLabels[$key] : ucfirst($key);
      $filterOut .= $sanitizer->entities(", $filterLabel: $value");
    }

    /** @var FieldtypeComments $fieldtype */
    $fieldtype = $field->type; 
    $comments = $fieldtype->find($field, $selector); 
    if($input->post('processComments')) {
      $this->processComments($comments, $field);
    }
    if($filterOut) {
      $this->breadcrumb('./', $headline); 
      $headline = trim($filterOut, ", "); 
    }
    $this->headline = $headline; 
    
    return $this->renderComments($comments); 
  }

  /**
   * Execute the comment meta viewer/editor
   * 
   * @return string
   * @since 3.0.203
   * 
   */
  public function ___executeMeta() {
    $input = $this->wire()->input; 
    $commentId = (int) $input->get('comment_id'); 
    $fieldId = (int) $input->get('field_id');
    $pageId = (int) $input->get('page_id'); 
    $modal = (int) $input->get('modal');
    
    if($commentId < 1 || $fieldId < 1 || $pageId < 1) {
      throw new WireException("Missing one of: comment_id, field_id, page_id");
    }
    
    $field = $this->wire()->fields->get($fieldId);
    $fieldtype = $field->type; /** @var FieldtypeComments $fieldtype */
    if(!$field || (!$fieldtype instanceof FieldtypeComments)) {
      throw new WireException("Invalid field specified");
    } 
    
    $page = $this->wire()->pages->get($pageId); 
    if(!$page->id || !$page->hasField($field) || !$page->editable($field)) {
      throw new WireException("Invalid or non-editable page specified");
    }
    
    $comment = $fieldtype->getCommentByID($page, $field, $commentId); 
    if(!$comment) throw new WireException("Cannot find comment $commentId"); 
  
    $meta = $comment->getMeta();
    $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; 
    if(defined('JSON_UNESCAPED_LINE_TERMINATORS')) $flags = $flags | JSON_UNESCAPED_LINE_TERMINATORS;
    $metaJSON = empty($meta) ? '{ }' : json_encode($meta, $flags); 
    $numRows = substr_count($metaJSON, "\n")+1; 
    if($numRows < 10) $numRows = 10;
  
    $this->headline(sprintf($this->_('Meta data for comment #%d'), $commentId)); 
    if(empty($meta)) $this->message($this->_('Comment currently has no meta data')); 
    // $out = "<pre id='meta-preview'>$metaEncodedJSON</pre>";
  
    $modules = $this->wire()->modules;
    
    /** @var InputfieldForm $form */
    $form = $modules->get('InputfieldForm'); 
    $form->attr('method', 'post');
    $form->attr('action', "./?comment_id=$commentId&page_id=$pageId&field_id=$fieldId&modal=$modal");
    
    /** @var InputfieldTextarea $f */
    $f = $modules->get('InputfieldTextarea'); 
    $f->attr('name', 'meta');
    $f->label = $this->_('Meta JSON editor'); 
    $f->val($metaJSON); 
    $f->attr('rows', $numRows); 
    $f->attr('style', "font-family:monospace;"); 
    $form->add($f);
  
    /** @var InputfieldSubmit $f */
    $f = $modules->get('InputfieldSubmit');
    $f->attr('name', 'submit_save'); 
    $f->val($this->_('Save changes')); 
    $form->add($f);
    
    /** @var InputfieldSubmit $f */
    $f = $modules->get('InputfieldButton');
    $f->attr('name', 'submit_cancel'); 
    $f->setSecondary();
    $f->val($this->_('Cancel'));
    $form->add($f);
    
    if($input->post('submit_save')) {
      $session = $this->wire()->session;
      $form->processInput($input->post); 
      $metaInput = $form->getChildByName('meta');
      $metaSaveJSON = $metaInput->val();
      if($metaSaveJSON === $metaJSON) {
        $this->message($this->_('No changes detected'));
        $session->redirect($form->attr('action'));
      } else {
        $meta = json_decode($metaSaveJSON, true);
        if(is_array($meta)) {
          $result = $fieldtype->updateComment($page, $field, $comment, array('meta' => $meta));
          if($result) {
            $this->message(sprintf($this->_('Updated meta for comment #%d'), $commentId));
            $session->redirect($form->attr('action'));
          } else {
            $this->error(sprintf($this->_('Error updating meta for comment #%d'), $commentId));
          }
        } else {
          $this->error($this->_('Cannot save because invalid JSON'));
        }
      }
    }

    $out = $form->render();
    
    return $out;
  }

  /**
   * Edit or add comment
   * 
   * @return string
   * @throws WireException
   * @throws WirePermissionException
   * 
   */
  protected function ___executeEdit() {
  
    $sanitizer = $this->wire()->sanitizer; 
    $input = $this->wire()->input;
    $user = $this->wire()->user;
    $submit = false;
    $out = '';
  
    if($input->post('submit_save_comment')) {
      $submit = true;
      $pageId = (int) $input->post('page_id');
      $fieldId = (int) $input->post('field_id');
      $parentId = (int) $input->post('parent_id');
      $commentId = (int) $input->post('comment_id');
    } else {
      $pageId = (int) $input->get('page_id');
      $fieldId = (int) $input->get('field_id');
      $parentId = (int) $input->get('parent_id');
      $commentId = (int) $input->get('comment_id');
    }
    
    $page = $this->wire()->pages->get($pageId);
    $field = $this->wire()->fields->get($fieldId); /** @var CommentField $field */

    if(!$page->id) throw new WireException("Cannot find page $pageId"); 
    if(!$field) throw new WireException("Cannot find field $fieldId"); 
    if(!$page->hasField($field)) throw new WireException("Page $pageId does not have field $field->name"); 
  
    if($commentId) {
      // editing existing comment
      $comment = $field->getCommentByID($page, $commentId); 
      if(!$comment) throw new WireException('Comment not found');
      if($comment->getField()->id != $field->id) throw new WireException('Invalid field');
      if($comment->getPage()->id != $page->id) throw new WireException('Invalid page');
      $this->headline(sprintf($this->_('Edit comment #%d'), $comment->id)); 
      
    } else {
      // adding new comment
      $comment = new Comment();
      $this->wire($comment);
      $comment->setPage($page);
      $comment->setField($field);
      $comment->cite = $user->get('title|name');
      $comment->email = $user->get('email'); 
      $comment->created = time();
      $comment->status = Comment::statusApproved; 
      if($parentId) {
        // new comment that is reply to existing comment
        $parentComment = $field->getCommentByID($page, $parentId); 
        if(!$parentComment) throw new WireException("Cannot find parent comment $parentId");
        $comment->parent_id = $parentId;
        $this->headline(sprintf($this->_('Comment #%d by %s'), $parentId, $parentComment->getFormatted('cite'))); 
        // show comment being replied to
        $text = $sanitizer->entities1($parentComment->getFormattedCommentText());
        $when = date('Y/m/d H:i', $parentComment->created) . ' • ' . wireRelativeTimeStr($parentComment->created);
        $out .= "<p class='detail'>$when</p>";
        $out .= "<blockquote><p>$text</p></blockquote>";
        $out .= "<h2>" . $this->_('Your reply') . "</h2>";
      }
    }

    $form = $this->buildEditForm($page, $field, $comment);
    
    if($submit) {
      $this->processEditForm($form, $page, $field, $comment);
    }
  
    return $out . $form->render();
  }

  /**
   * Process comment edit form 
   * 
   * @param InputfieldForm $form
   * @param Page $page
   * @param CommentField $field
   * @param Comment $comment
   * @return bool
   * 
   */
  protected function processEditForm(InputfieldForm $form, Page $page, CommentField $field, Comment $comment) {
    
    $input = $this->wire()->input;

    $form->processInput($input->post); 
    if(count($form->getErrors())) return false;

    $values = array();
    $properties = array(
      'cite', 
      'email', 
      'website', 
      'text', 
      'created', 
      'status'
    );
    
    if($field->useVotes) {
      $properties[] = 'upvotes';
      $properties[] = 'downvotes';
    }
    
    foreach($properties as $name) {
      $f = $form->getChildByName($name);
      if(!$f) continue;
      $value = $f->val();
      if($value != $comment->get($name)) {
        $comment->set($name, $value);
        $values[$name] = $value;
      }
    }
    
    if($field->useStars) {
      $stars = (int) $input->post('stars');
      if($stars != $comment->stars && $stars > 0 && $stars <= 5) {
        $comment->stars = $stars;
        $values['stars'] = $stars;
      }
    }

    $f = $field->useNotify ? $form->getChildByName('notify_author') : null; 
    if($f) {
      $notify = (int) $f->val();
      if($this->applyCommentNotifyFlag($comment, $notify)) {
        $values['flags'] = $comment->flags;
      }
    }

    /** @var Inputfield $f */
    $f = $form->getChildByName('page_id');
    $pageId = $f->val();
    if($pageId != $page->id) {
      $f->error('Changing page is not currently supported with this form');
      return false;
      /*
      $newPage = $this->wire()->pages->get($pageId); 
      if(!$newPage->editable()) {
        $f->error($this->_('Selected page is not editable')); 
        return false;
      }
      if(!$newPage->hasField($field)) {
        $f->error(sprintf($this->_('Selected page does not have field: %s'), $field->name)); 
        return false;
      }
      */
    }
    

    if($comment->id) {
      // existing comment
      if($comment->status === Comment::statusDelete) {
        $success = $field->deleteComment($page, $comment);
        if($success) {
          $this->message(sprintf($this->_('Deleted comment #%d'), $comment->id));
          $this->wire()->session->location("../list/$field->name/all/");
        }
      } else {
        $success = $field->updateComment($page, $comment, $values);
        if($success) $this->message(sprintf($this->_('Updated comment #%d'), $comment->id));
      }
    } else {
      // new comment
      /** @var Inputfield $f */
      $f = $form->getChildByName('notify');
      $notify = $f && $f->val();
      $comment->status = Comment::statusApproved;
      $success = $field->addComment($page, $comment, $notify);
      if($success) $this->message(sprintf($this->_('Added comment #%d'), $comment->id));
    }

    if($success) {
      $this->wire()->session->location("../list/$field->name/all/?id=$comment->id");
    } else {
      $this->error($this->_('Error saving comment'));
    }
    
    return $success;
  }

  /**
   * Build comment edit/add form
   * 
   * @param Page $page
   * @param CommentField $field
   * @param Comment $comment
   * @return InputfieldForm
   * 
   */
  protected function buildEditForm(Page $page, CommentField $field, Comment $comment) {
    
    $modules = $this->wire()->modules;
    
    /** @var InputfieldForm $form */
    $form = $modules->get('InputfieldForm');

    /** @var InputfieldText $f */
    $f = $modules->get('InputfieldText');
    $f->attr('name', 'cite');
    $f->label = $this->_('Cite / author');
    $f->val($comment->cite);
    $f->required = true;
    $f->columnWidth = 50;
    $form->add($f);

    /** @var InputfieldEmail $f */
    $f = $modules->get('InputfieldEmail');
    $f->attr('name', 'email');
    $f->label = $this->_('Email');
    $f->val($comment->email);
    $f->columnWidth = 50;
    $f->required = true;
    $form->add($f);
    
    /** @var InputfieldSelect $f */
    $f = $modules->get('InputfieldSelect');
    $f->attr('name', 'status');
    $f->label = $this->_('Status');
    $f->required = true;
    foreach($this->statusTranslations as $status => $statusText) {
      $f->addOption($status, $statusText);
    }
    $f->val($comment->status);
    $f->columnWidth = 50;
    $form->add($f);

    /** @var InputfieldDatetime $f */
    $f = $modules->get('InputfieldDatetime');
    $f->attr('name', 'created');
    $f->label = $this->_('Created');
    $f->inputType = 'html';
    $f->htmlType = 'datetime';
    $f->val($comment->created);
    $f->required = true;
    $f->columnWidth = 50;
    $form->add($f);

    if($field->useWebsite) {
      /** @var InputfieldText $f */
      $f = $modules->get('InputfieldUrl');
      $f->attr('name', 'website');
      $f->label = $this->_('Website/URL');
      $f->val($comment->website);
      $form->add($f);
    }

    /** @var InputfieldTextarea $f */
    $f = $modules->get('InputfieldTextarea');
    $f->attr('name', 'text');
    $f->label = $this->_('Text');
    $f->val($comment->text);
    $f->attr('rows', 10);
    $f->required = true;
    $form->add($f);
    
    if($field->useVotes) {
      /** @var InputfieldInteger $f */
      $f = $modules->get('InputfieldInteger'); 
      $f->attr('name', 'upvotes'); 
      $f->label = $this->_('Upvotes'); 
      $f->val($comment->upvotes); 
      $f->columnWidth = $field->useStars ? 25 : 50;
      $f->inputType = 'number';
      $form->add($f);
      
      /** @var InputfieldInteger $f */
      $f = $modules->get('InputfieldInteger');
      $f->attr('name', 'downvotes');
      $f->label = $this->_('Downvotes');
      $f->val($comment->upvotes);
      $f->columnWidth = $field->useStars ? 25 : 50;
      $f->inputType = 'number';
      $form->add($f);
    }
    
    if($field->useStars) {
      /** @var InputfieldMarkup $f */
      $f = $modules->get('InputfieldMarkup');
      $f->attr('name', 'stars');
      $f->label = $this->_('Stars');
      $f->value =
        "<input type='hidden' name='stars' value='$comment->stars' />" .
        $comment->renderStars(array('input' => true));
      if($field->useVotes) $f->columnWidth = 50; 
      $form->add($f);
    }


    /** @var InputfieldRadios $f */
    $f = $modules->get('InputfieldRadios');
    $f->attr('name', 'notify_author');
    $f->label = $this->_('Notify the email listed above when');
    $f->addOption(0, $this->_('Never'));
    if($field->depth) $f->addOption(Comment::flagNotifyReply, $this->_('There is a reply to this comment'));
    $f->addOption(Comment::flagNotifyAll, $this->_('Anytime a new comment is posted on this page'));
    if($comment->flags & Comment::flagNotifyAll) {
      $f->val(Comment::flagNotifyAll);
    } else if($comment->flags & Comment::flagNotifyReply) {
      $f->val(Comment::flagNotifyReply);
    } else {
      $f->val(0);
    }
    $form->add($f);

    /** @var InputfieldToggle $f */
    if(!$comment->id) {
      $f->columnWidth = 50;
      $f = $modules->get('InputfieldToggle');
      $f->attr('name', 'notify');
      $f->label = $this->_('Allow notifications to others?');
      $f->detail = $this->_('When “Yes”, emails about this new comment will be sent to users that have opted in to receive notifications.'); 
      $f->val(true);
      $f->columnWidth = 50;
      $form->add($f);
    }
    
    /** @var InputfieldPageListSelect $f */
    /*
    $f = $modules->get('InputfieldPageListSelect'); 
    $f->attr('name', 'page_id'); 
    $f->label = $this->_('Page'); 
    $f->val($page->id);
    $form->add($f);
    */

    /** @var InputfieldHidden $f */
    $f = $modules->get('InputfieldHidden');
    $f->attr('name', 'page_id');
    $f->val($page->id);
    $form->add($f);

    /** @var InputfieldHidden $f */
    $f = $modules->get('InputfieldHidden');
    $f->attr('name', 'field_id');
    $f->val($field->id);
    $form->add($f);
    
    /** @var InputfieldHidden $f */
    $f = $modules->get('InputfieldHidden');
    $f->attr('name', 'comment_id');
    $f->val($comment->id);
    $form->add($f);

    /** @var InputfieldHidden $f */
    $f = $modules->get('InputfieldHidden');
    $f->attr('name', 'parent_id');
    $f->val($comment->parent_id);
    $form->add($f);

    /** @var InputfieldSubmit $f */
    $f = $modules->get('InputfieldSubmit');
    $f->attr('name', 'submit_save_comment');
    $f->showInHeader();
    $form->add($f);
    
    return $form;
  }


  /**
   * Process changes to posted comments
   * 
   * @param CommentArray $comments
   * @param CommentField $field
   *
   */
  protected function processComments(CommentArray $comments, Field $field) {
    
    $input = $this->wire()->input;

    $numDeleted = 0;
    $numChanged = 0;
    $isSuperuser = $this->user->isSuperuser();
    $allowChangeParent = $isSuperuser && $field->get('depth') > 0;
    $allowChangePage = $isSuperuser;
    $commentField = $field instanceof CommentField ? $field : null;
    
    /** @var FieldtypeComments $fieldtype */
    $fieldtype = $field->type;

    foreach($comments as $comment) {
      /** @var Comment $comment */

      $properties = array();

      $text = $input->post("CommentText$comment->id"); 
      if(!is_null($text) && $text != $comment->text) {
        $comment->text = $text; // cleans it
        $properties['text'] = $comment->text;
        $numChanged++;
      }

      if($field->get('useVotes')) { 
        foreach(array("upvotes", "downvotes") as $name) {
          $votes = $input->post("Comment" . ucfirst($name) . $comment->id);
          if($votes !== null) {
            $votes = (int) $votes;
            if($votes != $comment->$name) {
              $comment->set($name, $votes);
              $properties[$name] = $comment->$name;
              $numChanged++;
            }
          }
        }
      }
      
      if($field->get('useStars')) {
        $stars = $input->post("CommentStars$comment->id");
        if($stars !== null) {
          $stars = (int) $stars;
          if($stars != $comment->stars) {
            $comment->set('stars', $stars);
            $properties['stars'] = $comment->stars;
            $numChanged++;
          }
        }
      }

      $_status = $input->post("CommentStatus$comment->id"); 
      $status = (int) $_status;
      if($status === Comment::statusDelete && (!$commentField || $commentField->allowDeleteComment($comment))) {
        if($fieldtype->deleteComment($comment->getPage(), $field, $comment)) {
          $this->message(sprintf($this->_('Deleted comment #%d'), $comment->id)); 
          $numDeleted++;
        }
        continue; 
      }
      
      if($_status !== null && $status !== (int) $comment->status && array_key_exists($status, $this->statuses)) {
        $comment->status = $status; 
        $numChanged++;
        $properties['status'] = $comment->status;
      }
    
      $notify = $input->post("CommentNotify$comment->id"); 
      if($notify !== null && $field->useNotify && ctype_digit($notify)) {
        $notify = (int) $notify;
        if($this->applyCommentNotifyFlag($comment, $notify)) {
          $properties['flags'] = $comment->flags;
        }
      }
      
      $changePage = null;
      if($allowChangePage) {
        // check for change of Page ID
        $pageID = (int) $input->post("CommentPage$comment->id"); 
        if($pageID > 0 && "$pageID" !== "$comment->page") {
          $page = $this->wire()->pages->get($pageID); 
          $parentID = $comment->parent_id;
          if($parentID) $comment->parent_id = 0; // temporarily set to 0 for page change
          if(!$page->id) {
            $this->error(
              sprintf($this->_('Unable to find page: %d'), $pageID)
            ); 
          } else if(!$page->hasField($field)) {
            $this->error(
              sprintf($this->_('Page %d does not have field: %s'), $pageID, "$field")
            );
          } else if($commentField && !$commentField->allowCommentPage($comment, $page, true)) {
            // this one reports errors on its own
          } else {
            $this->message(
              sprintf($this->_('Moved comment #%1$d from page %2$d to page %3$d'), $comment->id, $comment->page->id, $pageID)
            );
            $properties['pages_id'] = $pageID;
            if($comment->parent_id) {
              $comment->parent_id = 0;
              $properties['parent_id'] = 0;
            }
            $changePage = $page;
            $numChanged++;
          }
          if($changePage === null) {
            // if page was not changed, restore back to original parent
            if($parentID) $comment->parent_id = $parentID;
          }
        }
      }
    
      $changeParentID = null;
      if($allowChangeParent) {
        // check for change of parent on threaded comment
        $parentID = $input->post("CommentParent$comment->id");
        if(strlen("$parentID") && ctype_digit("$parentID")) {
          // allows for parent_id "0" but ignore blank
          $parentID = (int) $parentID;
          if($parentID != $comment->parent_id) {
            if(!empty($properties['pages_id'])) {
              // we will apply the parent change after the Page change has applied
              $changeParentID = $parentID;
            } else {
              // parent ID has changed to another parent, or to no parent (0)
              if($commentField && $commentField->allowCommentParent($comment, $parentID, true)) {
                $comment->parent_id = $parentID;
                $properties['parent_id'] = $parentID;
                $numChanged++;
              }
            }
          }
        } else {
          $parentID = null;
        }
      }

      if(count($properties)) {
        $fieldtype->updateComment($comment->getPage(), $field, $comment, $properties);  
        $this->message(sprintf($this->_('Updated comment #%d'), $comment->id) . " (" . implode(', ', array_keys($properties)) . ")"); 
      }
      
      if($changeParentID !== null && $changePage !== null) {
        // parent ID has changed at the same time that Page ID changed, so we apply parentID change afterwards
        $comment->setPage($changePage);
        if($commentField && $commentField->allowCommentParent($comment, $changeParentID, true)) {
          $comment->parent_id = $changeParentID;
          $fieldtype->updateComment($changePage, $field, $comment, array('parent_id' => $changeParentID));
          $numChanged++;
        }
      }
    }

    if($numDeleted || $numChanged) {
      $pageNum = $input->pageNum() > 1 ? 'page' . $input->pageNum() : '';
      $this->session->redirect('./' . $pageNum . $this->getQueryString());
    }
  }

  /**
   * Render the markup for a single comment
   * 
   * Hookable since 3.0.204
   * 
   * @param Comment $comment
   * @param array $options Options (since 3.0.204)
   * @return string
   *
   */
  protected function ___renderComment(Comment $comment, array $options = array()) {
    
    $defaults = array(
      'prependMarkup' => '', 
      'appendMarkup' => '', 
      'prependContentMarkup' => '', 
      'appendContentMarkup' => '', 
    );
    
    $options = array_merge($defaults, $options);
    $sanitizer = $this->sanitizer;
    $page = $comment->getPage();
    $pageTitle = $sanitizer->entities1($page->get('title|name'));
    $field = $comment->getField();
    $adminTheme = $this->wire()->adminTheme;
    $isSuperuser = $this->user->isSuperuser();
    $allowDepth = $field->depth > 0;
    $allowDepthChange = $isSuperuser && $allowDepth;
    $allowPageChange = $isSuperuser;
    $parent = $comment->parent();
    $numChildren = 0;
    $text = $this->renderCommentText($comment);
    $id = $comment->id;
  
    /** @var JqueryUI $jQueryUI */
    $jQueryUI = $this->wire()->modules->get('JqueryUI');
    $jQueryUI->use('modal');
    
    $icons = array(
      'edit' => 'edit',
      'upvote' => 'arrow-up',
      'downvote' => 'arrow-down',
      'changed' => 'dot-circle-o',
      'reply' => 'angle-double-down',
      'replies' => 'angle-double-right',
      'pageEdit' => 'pencil', 
      'pageView' => 'external-link-square', 
      'commentEdit' => 'pencil',
      'commentReply' => 'reply',
      'meta' => 'sliders',
    );
    
    $outs = array(
      'status' => '',
      'notify' => '',
      'website' => '',
      'stars' => '',
      'votes' => '',
      'page' => '',
      'parent' => '',
      'reply' => '',
      'where' => '',
      'children' => '',
    );
    
    $classes = array(
      'input' => 'CommentInput',
      'textarea' => '',
      'radio' => '',
      'checkbox' => '',
      'table' => ''
    );
    
    $labels = array(
      'edit' => $this->_('edit'),
      'view' => $this->_('view'),
      'meta' => $this->_('meta'),
      'page' => $this->_('Page'),
      'date' => $this->_('When'),
      'status' => $this->_('Status'),
      'action' => $this->_('Action'),
      'cite' => $this->_('Cite'),
      'website' => $this->_('Web'),
      'email' => $this->_('Mail'),
      'none' => $this->_('None'),
      'parent' => $this->_('Parent'),
      'where' => $this->_('Where'),
      'reply' => $this->_('reply'),
      'replyTo' => $this->_('Reply to %s'),
      'stars' => $this->_('Stars'),
      'votes' => $this->_('Votes'),
      'notify' => $this->_('Notify'), 
      'commentId' => $this->_('Comment ID'), 
      'commentEdit' => $this->_('editor'),
    );

    $values = array(
      'cite' => $comment->cite, 
      'email' => $comment->email,
      'website' => $comment->website, 
      'ip' => $comment->ip, 
      'date' => wireDate($this->_('Y/m/d g:i a'), $comment->created), 
      'dateRelative' => wireDate('relative', $comment->created),
      'parent' => $parent && $parent->id ? $parent->id : '',
      'parentPlaceholder' => $parent && $parent->id ? '' : $labels['none'],
      'parentCite' => sprintf($labels['replyTo'], $labels['commentId']),
      'stars' => $comment->stars ? $comment->stars : '',
      'upvotes' => $comment->upvotes,
      'downvotes' => $comment->downvotes, 
      'page' => (int) "$comment->page",
    );
    
    $urls = array(
      'parent' => $parent ? "../all/?id=$parent->id" : '',
      'children' => "../all/?parent_id=$id",
      'siblings' => "../all/?pages_id=$page->id",
      'pageView' => "$page->url#Comment$id",
      'pageEdit' => $page->editUrl(),
      'metaEdit' => "../../../meta/?comment_id=$comment->id&amp;page_id=$page->id&amp;field_id=$field->id&amp;modal=1",
      'commentReply' => "../../../edit/?parent_id=$comment->id&amp;page_id=$page->id&amp;field_id=$field->id",
      'commentEdit' => "../../../edit/?comment_id=$comment->id&amp;page_id=$page->id&amp;field_id=$field->id",
      'email' => "./?email=" . urlencode($values['email']), 
      'cite' => "../all/?cite=" . urlencode($values['cite']),
      'ip' => "../all/?ip=" . urlencode($values['ip']),
    );
    
    $tooltips = array(
      'parent' => $this->_('ID of the Comment that this one is replying to.'), 
      'page' => $this->_('ID of the Page that this comment lives on.'), 
      'viewAll' => $this->_('View all having value'), 
      'viewPage' => $this->_('View page'), 
      'edit' => $this->_('Edit value'), 
      'editPage' => $this->_('Edit page'), 
      'pageFilter' => $this->_('Show only comments from page'), 
      'pageEdit' => $this->_('Edit page'),
      'pageView' => $this->_('View page'),
      'commentEdit' => $this->_('Comment editor'),
      'commentReply' => $this->_('Reply to this comment'), 
      'meta' => $this->_('View/edit metadata'), 
    );
    
    foreach($values as $key => $value) {
      $values[$key] = $sanitizer->entities($value);
    }
    
    foreach($icons as $key => $value) {
      $icons[$key] = wireIconMarkup($value, 'fw');
    }
    
    if($allowDepth) { 
      $children = $comment->children();
      $numChildren = count($children);
    }
    
    if($adminTheme instanceof AdminThemeFramework) {
      $classes['input'] = trim("$classes[input] " . $adminTheme->getClass('input-small'));
      $classes['textarea'] = $adminTheme->getClass('textarea');
      $classes['radio'] = $adminTheme->getClass('input-radio');
      $classes['checkbox'] = $adminTheme->getClass('input-checkbox');
      $classes['table'] = $adminTheme->getClass('table');
      $classes['select'] = $adminTheme->getClass('select-small');
      // if(strpos($classes['input'], 'uk-input') !== false) $classes['input'] .= " uk-form-blank";
    }
  
    $outs['status'] = "<select name='CommentStatus$id' class='CommentStatus $classes[select]'>";
    foreach($this->statusTranslations as $status => $label) {
      if($status == Comment::statusDelete && $numChildren) continue; 
      $selected = $comment->status == $status ? " selected='selected' " : '';
      $outs['status'] .= "<option value='$status'$selected>$label</option>";
      /*
      // $checked = $comment->status == $status ? "checked='checked' " : '';
      $outs['status'] .= 
        "<label class='CommentStatus'>" . 
          "<input class='$classes[radio]' type='radio' name='CommentStatus$id' value='$status' $checked/>&nbsp;" . 
          "<small>$label</small>" . 
        "</label>&nbsp; ";
      */
    }
    $outs['status'] .= "</select>";

    if($page->editable()) {
      $text =
        "<div class='CommentTextEditable' id='CommentText$id' data-textarea-class='$classes[textarea]'>" .
          "<p>$text <a class='CommentTextEdit' href='#'>$icons[edit]&nbsp;$labels[edit]</a></p>" .
        "</div>";
    } else {
      $text = "<p>$text</p>";
    }
  
    if($allowPageChange) $outs['page'] =
      "<span class='detail ui-priority-secondary'>Page #</span>&nbsp;" .
      "<input class='$classes[input] pw-tooltip' title='$tooltips[page]' name='CommentPage$id' value='$values[page]' /> ";

    if($allowDepthChange) $outs['parent'] =
      "<span class='detail ui-priority-secondary'>" . $this->_('Reply to #') . "</span>&nbsp;" .
      "<input class='$classes[input] pw-tooltip' title='$tooltips[parent]' placeholder='$values[parentPlaceholder]' name='CommentParent$id' value='$values[parent]' />";
      
    if($outs['parent'] || $outs['page']) $outs['where'] =
      "<tr>" . 
        "<th>$labels[where]</th>" . 
        "<td class='CommentWhere'>$outs[page]$outs[parent]</td>" . 
      "</tr>";  
    
    if($values['website']) $outs['website'] = 
      "<tr>" .
        "<th>$labels[website]</th>" .
        "<td><a target='_blank' href='$values[website]'>$values[website]</a></td>" .
      "</tr>";
    
    if($field->useStars) $outs['stars'] =
      "<tr>" .
        "<th>$labels[stars]</th>" .
        "<td class='CommentStars'>" .
          "<input type='hidden' name='CommentStars$id' value='$values[stars]' />" .
          $comment->renderStars(array('input' => true)) .
        "</td>" .
      "</tr>";
    
    if($field->useVotes) $outs['votes'] =
      "<tr>" .
        "<th>$labels[votes]</th>" .
        "<td class='CommentVotes'>" .
          "<label class='CommentUpvotes'>" .
            "<span>$icons[upvote]</span>" .
            "<input class='$classes[input]' title='upvotes' type='number' min='0' name='CommentUpvotes$id' value='$values[upvotes]' />" .
          "</label> " .
          "<label class='CommentDownvotes'>" .
            "<span>$icons[downvote]</span>" .
            "<input class='$classes[input]' title='downvotes' type='number' min='0' name='CommentDownvotes$id' value='$values[downvotes]' />" .
          "</label> " .
        "</td>" .
      "</tr>";

    if($field->useNotify) {
      foreach($this->notifyFlagsTranslations as $flag => $label) {
        $checked = false;
        if($flag && $comment->flags & $flag) {
          $checked = true; // All or Replies
        } else if(!$flag && !($comment->flags & 2) && !($comment->flags & 4) && !($comment->flags & 8)) {
          $checked = true; // None
        } 
        $checked = $checked ? "checked='checked' " : '';
        $outs['notify'] .=
          "<label class='CommentNotify'>" .
            "<input class='$classes[radio]' type='radio' name='CommentNotify$id' value='$flag' $checked/>&nbsp;" .
            "<small>$label</small>" .
          "</label>&nbsp; ";
      }
      $outs['notify'] = "<tr><th>$labels[notify]</th><td>$outs[notify]</td></tr>";
    }
    
    if($parent) {
      $a = $sanitizer->entities($parent->cite) . " <a href='$urls[parent]'>$parent->id</a>";
      $outs['reply'] = // displayed after table
        "<p class='CommentReplyInfo detail'>" .
          "$icons[reply] " .
          sprintf($labels['replyTo'], $a) .
        "</p>";
    }

    if($numChildren) $outs['children'] = // displayed after table
      "<p class='CommentChildrenInfo detail'>" .
        "<a href='$urls[children]'>" .
          "$icons[replies] " .
          sprintf($this->_n('%d reply', '%d replies', $numChildren), $numChildren) .
        "</a>" .
      "</p>";

    /** @var FieldtypeComments $fieldtype */
    // $fieldtype = $field->type;
    // $who = $fieldtype->getNotifyEmails($page, $field, $comment); 
    // $text .= "<pre>" . htmlentities(print_r($who, true)) . "</pre>";
    $numRows = 0;
    foreach($outs as $out) if(!empty($out)) $numRows++;
    $contentClass = 'CommentContent';
    if($numRows >= 7) $contentClass .= ' CommentContentLarge';
    
    $meta = $comment->getMeta();
    $metaCnt = count($meta); 
    $metaTip = $tooltips['meta'] . ' (' . sprintf($this->_n('%d value', '%d values', $metaCnt), $metaCnt) . ')';

    $statusLinks = array(
      "<a class='pw-tooltip' title='$tooltips[commentEdit]' href='$urls[commentEdit]'>$icons[commentEdit] $labels[commentEdit]</a>",
      "<a class='pw-tooltip pw-modal' title='$metaTip' href='$urls[metaEdit]' data-buttons='button' data-close='button[name=submit_cancel]'>$icons[meta] $labels[meta]</a>",
    );
    if($comment->allowChildren()) {
      $statusLinks[] = "<a class='pw-tooltip' title='$tooltips[commentReply]' href='$urls[commentReply]'>$icons[commentReply] $labels[reply]</a>";
    }

    $out =
      "<div class='CommentItem ui-helper-clearfix CommentItemStatus$comment->status'>" . 
        $options['prependMarkup'] . 
        "<table class='CommentItemInfo $classes[table]' cellspacing='0'>" .
          "<tr class='CommentTitle'>" . 
            "<th>" .
              "<label>" .
                "<input class='CommentCheckbox $classes[checkbox]' type='checkbox' name='comment[]' value='$id' />&nbsp;" .
                "<span class='CommentID'>$id</span>" . 
              "</label>" . 
            "</th>" . 
            "<td>" . 
              "<a href='$urls[siblings]' class='pw-tooltip' title='$tooltips[pageFilter]'><strong>$pageTitle</strong></a> " .
              "<span class='detail'>&nbsp;" . 
                "<a class='detail pw-tooltip' title='$tooltips[pageView]' href='$urls[pageView]'>$labels[view]</a>&nbsp;/&nbsp;" . 
                "<a class='detail pw-tooltip' title='$tooltips[pageEdit]' href='$urls[pageEdit]'>$labels[edit]</a>" . 
              "</span>" . 
              "<span class='CommentChangedIcon'>$icons[changed]</span>" . 
            "</td>" . 
          "</tr>" .
          "<tr class='CommentItemStatus'>" .
            "<th>$labels[action]</th>" .
            "<td class='CommentStatus'>" . 
              $outs['status'] . " &nbsp;&nbsp;" . 
              implode('&nbsp;&nbsp;', $statusLinks) . 
            "</td>" .
          "</tr>" .
          $outs['notify'] . 
          "<tr>" . 
            "<th>$labels[date]</th>" . 
            "<td>$values[date] <span class='detail'>$values[dateRelative]</span></td>" . 
          "</tr>" . 
          "<tr>" .
            "<th>$labels[cite]</th>" . 
            "<td class='CommentCite'>" . 
              "<a href='$urls[cite]' class='pw-tooltip' title='$tooltips[viewAll]'>$values[cite]</a> " . 
              //"<input type='text' class='$classes[input]' name='CommentCite$id' value='$values[cite]' hidden /> " . // @todo
              //"<a href='#' class='CommentToggleSiblings pw-tooltip' title='$tooltips[edit]'>$icons[edit]</a> " .
              "<a class='detail pw-tooltip' title='$tooltips[viewAll]' href='$urls[ip]'>$values[ip]</a> " .
            "</td>" . 
          "</tr>" . 
          "<tr>" . 
            "<th>$labels[email]</th>" . 
            "<td>" .
              "<a href='$urls[email]' class='pw-tooltip' title='$tooltips[viewAll]'>$values[email]</a> " .
              //"<input type='email' class='$classes[input]' name='CommentEmail$id' value='$values[email]' hidden /> " . // @todo
              //"<a href='#' class='CommentToggleSiblings pw-tooltip' title='$tooltips[edit]'>$icons[edit]</a>" .
            "</td>" . 
          "</tr>" . 
          $outs['website'] . 
          $outs['stars'] . 
          $outs['votes'] . 
          $outs['where'] . 
        "</table>" .
        "<div class='$contentClass'>" .
          $options['prependContentMarkup'] . 
          "$outs[reply]" .
          "<div class='CommentText'>$text</div>" .
          "$outs[children]" .
          $options['appendContentMarkup'] . 
        "</div>" . 
        $options['appendMarkup'] . 
      "</div>";
  
    /*
    $out =  
      "<div class='CommentItem ui-helper-clearfix CommentItemStatus{$comment->status}'>" . 
        "$out " . 
        "<div class='CommentContent'>" . 
          "$outs[parent]" . 
          "<div class='CommentText'>$text</div>" . 
          "$outs[children]" . 
        "</div>" . 
      "</div>";
    */

    $page->of(false);

    return $out; 
  }

  /**
   * Prep comment text for output in editor
   * 
   * @param Comment $comment
   * @return string
   * 
   */
  protected function renderCommentText(Comment $comment) {
    $text = $this->sanitizer->entities($comment->get('text'));
    $text = str_replace('\r', ' ', $text);
    $text = preg_replace('/\r?(\n)/', '\r', $text);
    $text = str_replace('\r\r', "<br />\n<br />\n", $text);
    $text = str_replace('\r', "<br />\n", $text);
    return $text;
  }

  /**
   * Render the comments list header
   * 
   * @param int $limit
   * @param bool $useVotes
   * @param bool $useStars
   * @return string
   * 
   */
  protected function renderCommentsHeader($limit, $useVotes, $useStars) {
    
    $setStatusLabel = $this->_('Set status:');
    $perPageLabel = $this->_('per page');
    $adminTheme = $this->wire('adminTheme');
    $selectClass = $adminTheme instanceof AdminThemeFramework ? $adminTheme->getClass('select') : '';
    $checkboxClass = $adminTheme instanceof AdminThemeFramework ? $adminTheme->getClass('input-checkbox') : '';
    
    $pagerLimitOut = "
      <select class='$selectClass' id='CommentLimitSelect'>
        <option>10 $perPageLabel</option>
        <option>25 $perPageLabel</option>
        <option>50 $perPageLabel</option>
        <option>100 $perPageLabel</option>
      </select>
    ";
    $pagerLimitOut = str_replace("<option>$limit ", "<option selected>$limit ", $pagerLimitOut);

    $checkAllLabel = $this->_('Check/uncheck all');
    $checkAll =
      "<label class='pw-tooltip' title='$checkAllLabel'><input class='$checkboxClass' type='checkbox' id='CommentCheckAll' /> " .
      "<span class='detail'></span></label>";

    $noCheckedLabel = $this->_('There are no checked items');
    $actionsOut =
      "<select class='$selectClass' id='CommentActions' data-nochecked='$noCheckedLabel'>" .
      "<option value=''>" . $this->_('Actions (checked items)') . "</option>";

    foreach($this->statusTranslations as $status => $label) {
      $actionsOut .= "<option value='$status'>$setStatusLabel $label</option>";
    }

    if($useVotes) {
      $actionsOut .= "<option value='reset-upvotes'>" . $this->_('Reset: Upvotes') . "</option>";
      $actionsOut .= "<option value='reset-downvotes'>" . $this->_('Reset: Downvotes') . "</option>";
    }
    $actionsOut .= "</select>";

    $sorts = array(
      '-created' => $this->_('Date (new–old)'),
      'created' => $this->_('Date (old–new)'),
    );
    if($useStars) {
      $sorts['-stars'] =  $this->_('Stars (high–low)');
      $sorts['stars'] = $this->_('Stars (low–high)');
    }
    if($useVotes) {
      $sorts['upvotes'] = $this->_('Upvotes');
      $sorts['downvotes'] = $this->_('Downvotes');
    }

    $sortByOut = "<select class='$selectClass' id='CommentListSort'>";
    $sortLabelPrefix = $this->_('Sort:');
    foreach($sorts as $sortKey => $sortLabel) {
      $sortByOut .= "<option value='$sortKey'>$sortLabelPrefix $sortLabel</option>";
    }
    $sortByOut .= "</select>";
    $sort = $this->wire('session')->getFor($this, 'sort');
    if(empty($sort)) $sort = "-created";
    $sortByOut = str_replace("'$sort'", "'$sort' selected", $sortByOut);

    return
      "<p class='CommentCheckAll'>$checkAll</p>" .
      "<p class='CommentActions'>$actionsOut</p>" .
      "<p class='CommentSorts'>$sortByOut</p>" .
      "<p class='CommentLimitSelect'>$pagerLimitOut</p>";
  }

  /**
   * Render the markup for a list of comments
   * 
   * @param CommentArray $comments
   * @return string
   *
   */
  protected function renderComments(CommentArray $comments) {
    
    $input = $this->wire()->input;

    $commentsBody = '';
    $cnt = 0;
    $status = $input->urlSegment3;
    $start = $comments->getStart();
    $limit = $comments->getLimit();
    $total = $comments->getTotal();
    $pageNumPrefix = $this->config->pageNumUrlPrefix; 
    $pageNum = $input->pageNum(); 
    $queryString = $this->getQueryString();
    $unsavedChangesLabel = $this->_('You have unsaved changes!');
    $field = $comments->getField();
    $pagesId = (int) $input->get('pages_id');
    
    if($pagesId) $this->breadcrumb("./$queryString", "Page $pagesId"); 

    foreach($comments as $comment) {
      /** @var Comment $comment */
      if($status && $status != 'all' && $this->statuses[$comment->status] != $status) continue; 
      $commentsBody .= $this->renderComment($comment, array()); 
      $cnt++;
      if($cnt >= $limit) break;
    }

    /** @var MarkupPagerNav $pager */
    $pager = $this->wire()->modules->get('MarkupPagerNav'); 
    $pagerOut = $pager->render($comments, array(
      'queryString' => $queryString,
      'baseUrl' => "./"
    ));
    /** @var JqueryWireTabs $wireTabs */
    $wireTabs = $this->modules->get('JqueryWireTabs'); 
    $tabs = array();
    $class = $input->urlSegment3 === 'all' ? 'on' : '';
    $tabs["tabStatusAll"] = "<a class='$class' href='../all/$queryString'>" . $this->labelAll . "</a>";

    foreach($this->statuses as $status => $name) {
      if($status == Comment::statusDelete) continue;
      $class = $input->urlSegment3 === $name ? 'on' : '';
      $label = $this->statusTranslations[$status];
      if($label === $name) $label = ucfirst($label);
        $tabs["tabStatus$status"] = "<a class='$class' href='../$name/$queryString'>$label</a>";
    }

    $tabsOut = $wireTabs->renderTabList($tabs);
    if($total) {
      $this->headline .= ' (' . ($start + 1) . "–" . ($start + $cnt) . " " . sprintf($this->_('of %d'), $total) . ')';
    } else {
      $this->headline .= ' (0)';
    }
    $this->headline($this->headline);

    if($cnt) { 
      /** @var InputfieldSubmit $button */
      $button = $this->modules->get('InputfieldSubmit');
      $button->attr('name', 'processComments');
      $button->showInHeader();
      $button = $button->render();
    } else $button = '';

    if($input->pageNum > 1) {
      $queryString = "./$pageNumPrefix$pageNum$queryString";
    }

    if(!count($comments)) {
      return
        "<form>" . 
          $tabsOut . 
          "<h2>" . $this->_('None to display') . "</h2>" . 
        "</form>";
    }
    
    $commentsHeader = $this->renderCommentsHeader($limit, $field->get('useVotes'), $field->get('useStars'));
    
    return
      "<form id='CommentListForm' action='$queryString' method='post' data-unsaved='$unsavedChangesLabel'>" . 
        $tabsOut . 
        "<div id='CommentListHeader' class='ui-helper-clearfix'>" .   
          $pagerOut . 
          $commentsHeader . 
        "</div>" . 
        "<div class='CommentItems ui-helper-clearfix'>" . 
          $commentsBody . 
        "</div>" . 
        $pagerOut . 
        $button . 
      "</form>"; 
  }
  
  protected function getQueryString() {
    $queryString = '';
    foreach($this->input->whitelist as $key => $value) {
      $queryString .= $this->wire('sanitizer')->entities($key) . "=" . $this->wire('sanitizer')->entities($value) . "&";
    }
    $queryString = trim($queryString, '&');
    if($queryString) $queryString = "?$queryString";
    return $queryString;
  }

  /**
   * Apply the "notify" flag value to comment
   * 
   * @param Comment $comment
   * @param int $notify
   * @return bool Returns true if flags were changed, false if not
   * 
   */
  protected function applyCommentNotifyFlag(Comment $comment, $notify) {
    
    $notify = (int) $notify;
    $flags = $comment->flags;
    $flagsPrev = $flags;
    
    if($notify === 0 && $comment->flags) {
      // remove flag
      if($comment->flags & Comment::flagNotifyAll) $flags = $flags & ~Comment::flagNotifyAll; // remove
      if($comment->flags & Comment::flagNotifyReply) $flags = $flags & ~Comment::flagNotifyReply; // remove
      if($comment->flags & Comment::flagNotifyConfirmed) $flags = $flags & ~Comment::flagNotifyConfirmed; // remove
      
    } else if($notify === Comment::flagNotifyAll && !($flags & Comment::flagNotifyAll)) {
      if($flags & Comment::flagNotifyReply) $flags = $flags & ~Comment::flagNotifyReply; // remove
      $flags = $flags | Comment::flagNotifyAll; // add
      
    } else if($notify === Comment::flagNotifyReply) {
      if($flags & Comment::flagNotifyAll) $flags = $flags & ~Comment::flagNotifyAll; // remove
      $flags = $flags | Comment::flagNotifyReply; // add
    }
  
    if($notify && !$comment->id) {
      // comments added in admin do not need email confirmation
      $flags = $flags | Comment::flagNotifyConfirmed;
    }
    
    if($flags != $flagsPrev) {
      $comment->flags = $flags;
      return true;
    }
  
    return false;
  }

  protected function checkInstall() {
    if($this->wire('modules')->isInstalled('ProcessLatestComments')) {
      $this->warning('Please uninstall the ProcessLatestComments module (this module replaces it).');
    }
  }

  public function ___install() {
    $this->checkInstall();
    parent::___install();
  }
  
  /**
   * Search for items containing $text and return an array representation of them
   *
   * Implementation for SearchableModule interface
   *
   * @param string $text Text to search for
   * @param array $options Options to modify behavior:
   *  - `edit` (bool): True if any 'url' returned should be to edit items rather than view them
   *  - `multilang` (bool): If true, search all languages rather than just current (default=true).
   *  - `start` (int): Start index (0-based), if pagination active (default=0).
   *  - `limit` (int): Limit to this many items, if pagination active (default=0, disabled).
   * @return array
   *
   */
  public function search($text, array $options = array()) {
    
    $sanitizer = $this->wire()->sanitizer;
    $page = $this->getProcessPage();
    $commentFields = array();
    
    foreach($this->wire()->fields as $field) {
      /** @var Field $field */
      if($field->type instanceof FieldtypeComments) $commentFields[$field->name] = $field;
    }

    $result = array(
      'title' => $page->id ? (string) $page->title : $this->className(),
      'total' => 0,
      'url' => '',
      'items' => array(),
      'properties' => array(
        'text',
        'cite',
        'email',
        'status',
        'created',
        'website',
        'ip',
        'user_agent',
        'upvotes',
        'downvotes',
        'stars'
      )
    );
    
    if($options['help']) return $result;
    
    $operator = empty($options['operator']) ? '%=' : $options['operator'];
    $value = $sanitizer->selectorValue($text);  
    $summaryLength = $options['verbose'] ? 1024 : 200;
    
    if(!empty($options['property'])) {
      $selector = "$options[property]$operator$value";
      $q = "$options[property]$operator$text"; 
    } else {
      $selector = "text$operator$value";
      $q = "text$operator$text"; 
    }
    
    if(!empty($options['limit'])) $selector .= ", limit=$options[limit]";
    if(!empty($options['start'])) $selector .= ", start=$options[start]";
  
    foreach($commentFields as $field) {
      /** @var CommentField $field */
      /** @var CommentArray $comments */
      $comments = $field->type->find($selector); 
      if(!count($comments)) continue;
      $result['total'] += $comments->getTotal();
      $url = $page->url() . "list/$field->name/all/";
      if(count($commentFields) === 1) {
        $result['url'] = $url . "?q=" . urlencode($q);
      }
      foreach($comments as $comment) {
        /** @var Comment $comment */
        $commentPage = $comment->getPage();
        $editUrl = $url . "?id=$comment->id";
        $item = array(
          'id' => $comment->id,
          'name' => $comment->cite,
          'title' => $comment->cite, 
          'subtitle' => $commentPage && $commentPage->id ? (string) $commentPage->get('title|name') : '', 
          'summary' => $sanitizer->truncate($comment->text, $summaryLength), 
          'url' => empty($options['edit']) ? $comment->url() : $editUrl
        );
        $result['items'][] = $item;
      }
    }

    /* // for debugging:
    $result['items'][] = array(
      'id' => 0,
      'name' => '',
      'title' => $selector,
      'subtitle' => '',
      'summary' => '',
      'url' => '#'
    );
    */

    return $result;
  }


}