Subversion Repositories web.creative

Rev

Blame | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire Markup RSS Module
 *
 * Given a PageArray of pages, this module will render an RSS feed from them. 
 * This is intended to be used directly from a template file. See usage below.
 *
 * USAGE
 * ~~~~~~
 * $rss = $modules->get("MarkupRSS"); 
 * $rss->title = "Latest updates";
 * $rss->description = "The most recent pages updated on my site"; 
 * $items = $pages->find("limit=10, sort=-modified"); // or any pages you want
 * $rss->render($items); 
 * ~~~~~~
 *
 * See also the $defaultConfigData below (first thing in the class) to see what
 * options you can change at runtime. 
 *
 * 
 * ProcessWire 3.x, Copyright 2016 by Ryan Cramer
 * https://processwire.com
 * 
 * @property string $title
 * @property string $url
 * @property string $description
 * @property string $xsl
 * @property string $css
 * @property string $copyright
 * @property int $ttl
 * @property bool $stripTags
 * @property string $itemTitleField
 * @property string $itemDateField
 * @property string $itemDescriptionField
 * @property string $itemDescriptionLength
 * @property string $itemAuthorField
 * @property string $itemAuthorElement
 * @property string $header
 * @property array|PageArray $feedPages
 *
 *
 */

class MarkupRSS extends WireData implements Module, ConfigurableModule {
  
  /**
   * Return general info about the module for ProcessWire
   *
   */
  public static function getModuleInfo() {
    return array(
      'title' => 'Markup RSS Feed',
      'version' => 103,
      'summary' => 'Renders an RSS feed. Given a PageArray, renders an RSS feed of them.',
    );
  }

  protected static $defaultConfigData = array(
    'title' => 'Untitled RSS Feed',
    'url' => '', 
    'description' => '', 
    'xsl' => '', 
    'css' => '',
    'copyright' => '',
    'ttl' => 60,
    'stripTags' => true, 
    'itemTitleField' => 'title',
    'itemDescriptionField' => 'summary',
    'itemDescriptionLength' => 1024, 
    'itemDateField' => 'created',
    'itemAuthorField' => '', // i.e. createdUser.title or leave blank to not use
    'itemAuthorElement' => 'dc:creator', // may be 'dc:creator' or 'author'
    'header' => 'Content-Type: application/xml; charset=utf-8;',
    'feedPages' => array(), 
  ); 

  /**
   * Set the default config data
   *
   */
  public function __construct() {
    parent::__construct();
    $this->setArray(self::$defaultConfigData);
  }

  /**
   * Module init
   *
   */
  public function init() { }

  /**
   * @param string $str
   * @return string
   * 
   */
  protected function ent1($str) {
    return $this->wire('sanitizer')->entities1($str);
  }

  /**
   * @param string $str
   * @return string
   *
   */
  protected function ent($str) {
    return $this->wire('sanitizer')->entities($str);
  }

  /**
   * Render RSS header
   * 
   * @return string
   *
   */
  protected function renderHeader() {
    
    if(!$this->url) $this->url = $this->page->httpUrl;

    $xsl = $this->ent1($this->xsl); 
    $css = $this->ent1($this->css); 
    $title = $this->ent1($this->title);
    $url = $this->ent1($this->url);
    $description = $this->ent1($this->description);
    $pubDate = date(\DATE_RSS); 
    $ttl = (int) $this->ttl; 
    $copyright = $this->ent1($this->copyright);

    $out = "<?xml version='1.0' encoding='utf-8' ?>\n";
    if($xsl) $out .= "<?xml-stylesheet type='text/xsl' href='$xsl' ?>\n";
    if($css) $out .= "<?xml-stylesheet type='text/css' href='$css' ?>\n";
    
    $out .= 
      "<rss version='2.0' xmlns:dc='http://purl.org/dc/elements/1.1/'>\n" .
      "<channel>\n" .
      "\t<title>$title</title>\n" . 
      "\t<link>$url</link>\n" . 
      "\t<description>$description</description>\n" . 
      "\t<pubDate>$pubDate</pubDate>\n";

    if($copyright) $out .= "\t<copyright>$copyright</copyright>\n";
    if($ttl) $out .= "\t<ttl>$ttl</ttl>\n";

    return $out; 
  }

  /**
   * Render individual RSS item
   * 
   * @param Page $page
   * @return string
   *
   */
  protected function renderItem(Page $page) {

    $title = strip_tags($page->get($this->itemTitleField));
    if(empty($title)) return '';
    
    $title = html_entity_decode($title, ENT_QUOTES, 'UTF-8'); 
    $title = htmlspecialchars($title, ENT_QUOTES, 'UTF-8'); 
    $title = str_replace('&#039;', '&apos;', $title); 

    if($this->itemDateField && ($ts = $page->getUnformatted($this->itemDateField))) {
      $pubDate = "\t\t<pubDate>" . date(DATE_RSS, $ts) . "</pubDate>\n";
    } else {
      $pubDate = '';
    }
  
    $author = '';
    if($this->itemAuthorField) {
      $author = $page->getUnformatted($this->itemAuthorField); 
      if($author instanceof Page) $author = $page->get('title|name');
      $author = (string) $author;
      if(strlen($author)) {
        $author = $this->ent($author); 
        $author = "\t\t<$this->itemAuthorElement>$author</$this->itemAuthorElement>\n"; 
      } else {
        $author = '';
      }
    }

    $description = $page->get($this->itemDescriptionField); 
    $description = $description === null ? '' : $this->ent1($this->truncateDescription($description)); 
    $description = '<![CDATA[' . $description . ']]>';

    $out = 
      "\t<item>\n" .
      "\t\t<title>$title</title>\n" .
      "\t\t<description>$description</description>\n" .
      $pubDate . 
      $author . 
      "\t\t<link>$page->httpUrl</link>\n" .
      "\n\t<guid>$page->httpUrl</guid>\n" . 
      "\t</item>\n";

    return $out; 
  }

  /**
   * Render the feed and return it
   * 
   * @param PageArray|null $feedPages
   * @return string
   *
   */
  public function renderFeed(PageArray $feedPages = null) {

    if(!is_null($feedPages)) $this->feedPages = $feedPages;

    $out = $this->renderHeader();

    foreach($this->feedPages as $page) {
      if(!$page->viewable()) continue;
      $out .= $this->renderItem($page);
    }

    $out .= "</channel>\n</rss>\n";

    return $out; 
  }

  /**
   * Render the feed and echo it (with proper http header)
   * 
   * @param PageArray|null $feedPages
   * @return bool
   *
   */
  public function render(PageArray $feedPages = null) {
    header($this->header); 
    echo $this->renderFeed($feedPages);
    return true; 
  }

  /**
   * Truncate the description to a specific length and then truncate to avoid splitting any words.
   * 
   * @param string $str
   * @return string
   *
   */
  protected function truncateDescription($str) {

    $str = trim($str);
    $maxlen = $this->itemDescriptionLength;
    
    if(!$maxlen) return $str;

    // note: tags are not stripped if itemDescriptionLength == 0 and stripTags == true
    if($this->stripTags) $str = strip_tags($str); 

    if(strlen($str) < $maxlen) return $str; 

    $str = trim(substr($str, 0, $maxlen)); 

    // boundaries that we can end the summary with
    $boundaries = array('. ', '? ', '! ', ', ', '; ', '-');
    $bestPos = 0;

    foreach($boundaries as $boundary) {
      if(($pos = strrpos($str, $boundary)) !== false) {
        // find the boundary that is furthest in string
        if($pos > $bestPos) $bestPos = $pos;
      }
    }

    // determine if we should truncate to last punctuation or last space.
    // if the last punctuation is further away then 1/4th the total length, then we'll 
    // truncate to the last space. Otherwise, we'll truncate to the last punctuation.
    $spacePos = strrpos($str, ' '); 
    if($spacePos > $bestPos && (($spacePos - ($maxlen / 4)) > $bestPos)) $bestPos = $spacePos; 

    if(!$bestPos) $bestPos = $maxlen;

    return trim(substr($str, 0, $bestPos+1)); 
  }

  /**
   * Provide fields for configuring this module
   * 
   * @param array $data
   * @return InputfieldWrapper
   *
   */
  public function getModuleConfigInputfields(array $data) {
  
    /** @var Modules $modules */
    $modules = $this->wire('modules');

    /** @var InputfieldWrapper $inputfields */
    $inputfields = $this->wire(new InputfieldWrapper());
    $inputfields->description = 
      "Select the default options for any given feed. Each of these may be overridden in the API, " . 
      "so the options you select below should be considered defaults, unless you only have 1 feed. " . 
      "If you only need to support 1 feed, then you will not need to override any of these in the API.";

    foreach(self::$defaultConfigData as $key => $value) {
      if(!isset($data[$key])) $data[$key] = $value; 
    }

    /** @var InputfieldText $f */
    $f = $modules->get('InputfieldText');
    $f->attr('name', 'title');
    $f->attr('value', $data['title']);
    $f->label = "Default Feed Title";
    $inputfields->add($f);

    /** @var InputfieldURL $f */
    $f = $modules->get('InputfieldURL');
    $f->attr('name', 'url');
    $f->attr('value', $data['url']);
    $f->label = "Default Feed URL";
    $f->description = "The URL on your site that serves as a feed index. May also be left blank.";
    $inputfields->add($f);

    /** @var InputfieldText $f */
    $f = $modules->get('InputfieldText');
    $f->attr('name', 'description');
    $f->attr('value', $data['description']);
    $f->label = "Default Feed Description";
    $f->description = "The default description for a feed. May also be left blank.";
    $inputfields->add($f);

    /** @var InputfieldURL $f */
    $f = $modules->get('InputfieldURL');
    $f->attr('name', 'xsl');
    $f->attr('value', $data['xsl']);
    $f->label = "Default Link to XSL Stylesheet";
    $f->description = "Optional URL/link to an XSL stylesheet. Default is none.";
    $inputfields->add($f);

    $f = $modules->get('InputfieldURL');
    $f->attr('name', 'css');
    $f->attr('value', $data['css']);
    $f->label = "Default Link to CSS Stylesheet";
    $f->description = "Optional URL/link to a CSS stylesheet. Default is none.";
    $inputfields->add($f);

    /** @var InputfieldText $f */
    $f = $modules->get('InputfieldText');
    $f->attr('name', 'copyright');
    $f->attr('value', $data['copyright']);
    $f->label = "Default Feed Copyright";
    $f->description = "The default copyright statement for a feed. Default is blank.";
    $inputfields->add($f);

    /** @var InputfieldInteger $f */
    $f = $modules->get('InputfieldInteger');
    $f->attr('name', 'ttl');
    $f->attr('value', (int) $data['ttl']);
    $f->label = "Default Feed TTL";
    $f->description = "TTL stands for \"time to live\" in minutes. It indicates how long a channel can be cached before refreshing from the source. Default is 60.";
    $inputfields->add($f);

    /** @var InputfieldSelect $f1 */
    $f1 = $modules->get('InputfieldSelect');
    $f1->attr('name', 'itemTitleField');
    $f1->attr('value', $data['itemTitleField']);
    $f1->label = "Default Feed Item Title Field";
    $f1->description = "The default field to use as an individual feed item's title.";

    /** @var InputfieldSelect $f2 */
    $f2 = $modules->get('InputfieldSelect');
    $f2->attr('name', 'itemDescriptionField');
    $f2->attr('value', $data['itemDescriptionField']);
    $f2->label = "Default Feed Item Description Field";
    $f2->description = "The default field to use as an individual feed item's description (typically a summary or body field).";

    /** @var InputfieldInteger $f2a */
    $f2a = $modules->get('InputfieldInteger');
    $f2a->attr('name', 'itemDescriptionLength');
    $f2a->attr('value', (int) $data['itemDescriptionLength']);
    $f2a->label = "Maximum Characters for Item Description Field";
    $f2a->description = "The item description will be truncated to be no longer than the max length provided. Specify '0' for no max length. When there is no max length, markup tags will not be stripped.";

    /** @var InputfieldSelect $f3 */
    $f3 = $modules->get('InputfieldSelect');
    $f3->attr('name', 'itemDateField');
    $f3->attr('value', $data['itemDateField']);
    $f3->label = "Default Feed Item Date Field";
    $f3->description = "The default field to use as an individual feed item's date.";
    $f3->addOption('created'); 
    $f3->addOption('modified');
    $f3->addOption('published');

    foreach($this->wire('fields') as $field) {

      if($field->type instanceof FieldtypeText) {
        $f1->addOption($field->name); 
        $f2->addOption($field->name); 

      } else if($field->type instanceof FieldtypeDatetime) {
        $f3->addOption($field->name); 
      }
    }

    $inputfields->add($f1);
    $inputfields->add($f2);
    $inputfields->add($f2a);
    $inputfields->add($f3);

    return $inputfields;

  }

}