<?php

error_reporting(E_ALL | E_STRICT);

/**
 * Exception class for the XmlPrinter class.
 * @author Peter Goodman
 */
class XmlPrinterException extends Exception { }

/**
 * A simple stack-based XML string constructor for creating XML.
 * @author Peter Goodman
 */
class XmlPrinter {
    
    // what type of tag is this?
    const TAG_CLOSING = 1,
          TAG_NON_CLOSING = 0;

    // exception error codes
    const ERROR_UNRECOVERABLE = 1, // these are usually programmer errors that
          ERROR_EMPTY_STACK = 2,   // need to be fixed in the code
          ERROR_UNEXPECTED_TAG = 4,
          ERROR_UNFINISHED_DOCUMENT = 8;
    
    // stack of tags and the current indent level
    private $tags = array(),
            $indent = -1;
    
    /**
     * Construct the class and put in the base <xml> tag. This tag is non-
     * removable from the stack and isn't built in the usual way :) It's an
     * exception to the rule.
     */
    public function __construct() { 
        $this->open(' ');
        $xml =& $this->tags[$this->indent];
        
        $xml['children'] = -1;
        $xml['content'] = '<?xml version="1.0"?>';
        $this->indent = -1;
    }
    
    // destructor, obvious.
    public function __destruct() {
        unset($this->stack);
    }
    
    /**
     * Add some content to the current tag on the top of the stack.
     */
    public function hasContent($content = "") {
        
        $tag =& $this->tags[$this->indent+1];
        $nl = FALSE === strstr($content, "\n") ? '' : "\n";
        
        // deal with ampersands
        $content = preg_replace("~&(?!amp)~", "&amp;", $content);
        
        // wrap the content in a CDATA block if it contains tags
        if(preg_match("~[<>]~i", $content))
            $content = "<![CDATA[{$nl}{$content}{$nl}]]>";
        
        // what should the indent prefix be?
        $prefix = str_repeat("\t", $this->indent+1);
        
        // prefix the content
        $c = "";
        foreach(explode("\n", $content) as $line) {
            if(strlen($line) > 0) {
                $tag['content_num_lines']++;
                $c .= $nl . $prefix . $line;
            }
        }
        
        // only one line, lets remove the prefix
        if($tag['content_num_lines'] == 1)
            $c = substr($c, strlen($prefix));
        
        $tag['content'] .= $c;
        
        return $this;
    }
    
    /**
     * Push a tag onto the XML tag stack.
     */
    public function open($name = '', $type = self::TAG_CLOSING) {
        
        // we can't push no tag onto the tag stack
        if(empty($name) || !is_string($name))
            throw new XmlPrinterException("Tag name expected in push operation.",
                                           self::ERROR_UNRECOVERABLE);
        
        $this->indent++;
        $this->tags[] = array('name' => $name,
                              'closing' => (bool)$type,
                              'attr' => '',
                              'content' => '',
                              'children' => 0,
                              'content_num_lines' => 0);
        
        // get the parent tag and tell it that it has one more child. The
        // children is also incremented in the pop method; however, these
        // don't conflict because the lowest level child will have 0 children
        // and this will have already been called. Thus, 1 + 0 accurately
        // represents the number of children the node at the top of the stack
        // has when a leaf node is popped, and so it all bubbles up nicely.
        $parent =& $this->tags[$this->indent];
        $parent['children']++;
        
        return $this;
    }
    
    /**
     * Pop the current tag off of the tag stack.
     */
    public function close($expect = NULL) {
        
        // we've closed all tags, this is a guard against popping the xml
        // tag off the stack
        if($this->indent < 0) {
            $extra = !empty($expect) ? self::ERROR_UNRECOVERABLE : 0;
            throw new XmlPrinterException("No tags left to close.",
                                           self::ERROR_EMPTY_STACK | $extra);
        }
        
        // get the tag that we're popping off the stack
        $tag = array_pop($this->tags);
        
        // did we expect to close a certain tag name and it's not the same one
        // that the class expects?
        if($expect !== NULL && $tag['name'] != $expect)
            throw new XmlPrinterException("Malformed XML. Expected [{$expect}] " 
                                          ."but found [{$tag['name']}].",
                                          self::ERROR_UNEXPECTED_TAG);
    
        // this builds the output from the inside out by putting the content
        // into the parent tag's content.
        $prefix = $this->indent > 0 ? str_repeat("\t", $this->indent) : '';
        $suffix = !$tag['closing'] ? ' /' : '';
        $content = "\n{$prefix}<". $tag['name'] . $tag['attr'] . $suffix .">";
        
        // do we need to build the contents of this tag?
        if($tag['closing']) {
            
            // write this tag over multiple lines
            if($tag['content_num_lines'] > 1 || $tag['children'] > 0)
                $content .= "{$prefix}". $tag['content'] ."\n{$prefix}";

            // write this tag over a single line
            else
                $content .= $tag['content'];
                
            $content .= '</'. $tag['name'] .">";
        }
        
        // add stuff into the parent and modify its content and children
        // counter
        $parent =& $this->tags[$this->indent];
        $parent['content'] .= $content;
        $parent['children'] += $tag['children'];
        
        $this->indent--;
        
        return $this;
    }
    
    /**
     * Append an attribute to the top tag in the stack. This is really just a
     * convenient shortcut.
     */
    public function __call($attr, array $args = array()) {
        
        if(empty($args))
            $args = array("");
        
        return $this->hasAttr($attr, (string)$args[0]);
    }

    /**
     * Secondary function for attributes if an attribute, for example, has a 
     * colon in its name or has the name push, pop, or one of the other names
     * of one of this class' functions.
     */
    public function hasAttr($attr, $val = '') {

        // unrecoverable programmer error, attributes need to have a name
        if(empty($attr))
            throw new XmlPrinterException("XML tag attribute must has a name.",
                                           self::ERROR_UNRECOVERABLE);
        
        $tag =& $this->tags[$this->indent+1];

        // clean up any quotes in the attribute value so that they don't
        // conflict with. Single quotes are allowed to pass because the
        // attribute value is delimited with doubles
        $attr = (string)$attr;
        $val = preg_replace('~[\\\]*"~', '\"', (string)$val);
        $tag['attr'] .= " {$attr}=\"{$val}\"";
        
        return $this;
    }
    
    /**
     * Do the header call.
     */
    public function doHeader() {
        header("Content-Type: text/xml");
    }
    
    /**
     * Return the built XML string.
     */
    public function __toString() {
        
        // whoops, we're not done building the xml document yet!
        if($this->indent > 0)
            throw new XmlPrinterException("Cannot export malformed XML document.",
                                           self::ERROR_UNFINISHED_DOCUMENT);
        
        return (string)$this->tags[0]['content'];
    }
}

/**
 * RSS Feed Printer.
 * @author Peter Goodman
 */
class RssFeedPrinter extends XmlPrinter {
    
    // some other helpful stuff
    const DATE_FORMAT = "D, d M Y H:i:s T";
    
    private $done = FALSE, // are we done building the feed?
            $context = 'rss', // what is the parent tag?
            
            // obvious
            $required_tags = array('rss'     => array('channel' => FALSE),
                                   'channel' => array('title' => FALSE,
                                                      'link' => FALSE,
                                                      'description' => FALSE),
                                   'item'    => array());
    
    /**
     * Construct and set up the rss top level tag.
     */
    public function __construct() {
        parent::__construct();
        $this->open('rss')->version("2.0")
             ->hasAttr("xmlns:atom", "http://www.w3.org/2005/Atom");
        
        $this->context = 'rss';
    }
    
    /**
     * Open the <channel> tag, set up the atom:link, and set the context.
     */
    public function open_channel() {
        $this->open('channel');
        $this->open('atom:link', XmlPrinter::TAG_NON_CLOSING)
                    ->href(self::encodeURL())->rel("self")
                    ->type("application/rss+xml")->close();
        
        // if we have multiple channels in one feed
        foreach($this->required_tags['channel'] as $key => $val)
            $this->required_tags['channel'][$key] = FALSE;
        
        // set the context
        $this->context = 'channel';
        
        return $this;
    }
    
    /**
     * Create the </channel>
     */
    public function close_channel() {
        $this->close('channel');
        $this->checkContext();
        $this->context = 'rss';
        return $this;
    }
    
    /**
     * Create the <item> and set the context.
     */
    public function open_item() {
        $this->open('item');
        $this->context = 'item';
        return $this;
    }
    
    /**
     * Create the </item>.
     */
    public function close_item() {
        $this->close('item');
        $this->context = 'channel';
        return $this;
    }
    
    /**
     * Print out the RSS.
     */
    public function __toString() {
        if(!$this->done)
            $this->close('rss');
        
        $this->done = TRUE;
        $this->context = 'rss';
        $this->checkContext();
        
        return parent::__toString();
    }
    
    /**
     * Open a tag, if it's one of the required tags for this context then
     * mark it off that we've met that requirement.
     */
    public function open($name = '', $type = parent::TAG_CLOSING) {
        
        // check off this required tag
        if(isset($this->required_tags[$this->context][$name]))
            $this->required_tags[$this->context][$name] = TRUE;
        
        return parent::open($name, $type);
    }
    
    /**
     * Check if all required tags have been used in this context and if not
     * throw an exception.
     */
    public function checkContext() {
        $tags =& $this->required_tags[$this->context];
        
        foreach($tags as $name => $used) {
            if(!$used)
                throw new XmlPrinterException("The tag [{$name}] is required ".
                                               "in the [{$this->context}] section.");
            
            // reset it
            $tags[$name] = FALSE;
        }
    }
    
    
    /**
     * Helper function to make sure URLs are nicely encoded for RSS.
     */
    static public function encodeURL($url = NULL) {
        
        // default to the current url
        if($url === NULL)
            $url = "http://". $_SERVER["HTTP_HOST"] . $_SERVER["REQUEST_URI"];
        
        // split up the url into its parts
        $url = parse_url($url);
        $get = array();
        parse_str(isset($url['query']) ? $url['query'] : '', $get);
                
        // encode each GET parameter
        foreach($get as $key => $val)
            $get[$key] = rawurlencode($val);
        
        // rebuild the query string
        $url['query'] = http_build_query($get);
        
        // rebuild the url
        $ret = '';
        $ret .= isset($url['scheme']) ? $url['scheme'] . '://' : '';
        $ret .= isset($url['user']) ? $url['user'] : '';
        $ret .= isset($url['pass']) ? ':'. $url['pass'] : '';
        $ret .= isset($url['user']) ? '@' : '';
        $ret .= isset($url['host']) ? $url['host'] : '';
        $ret .= isset($url['path']) ? $url['path'] : '';
        $ret .= isset($url['query']) ? $url['query'] : '';
        $ret .= isset($url['fragment']) ? '#'. $url['fragment'] : '';
        
        return $ret;
    }
}

/**
 * Function to create and output a basic RSS feed using a simple mapping
 * notation.
 * @author Peter Goodman
 */
function create_rss_feed(array $channel_map, $items, array $item_map = NULL) {
        
    // make sure we're getting something that we can loop over for $items
    if(!is_array($items) && !($items instanceof Traversable))
        throw new UnexpectedValueException("Function [create_rss_feed] expected ".
                                           "array or iterator for RSS items.");
    
    // the default item mapping, includes all RSS 2.0 standard optional item
    // tags
    if(empty($item_map)) {
        $tags = array('title','link','description','author','category',
                      'comments','enclosure','guid','pubDate','source');
        $item_map = array_combine($tags, $tags); // yes I'm lazy.
    }
    
    // bring in the feed builder/printer
    $rss = new RssFeedPrinter;
    $rss->open_channel();
    
    // deal with <channel> and the top level tags in it
    foreach($channel_map as $tag => $value)     
        __rss_tag($rss, $tag, $value);
    
    // deal with each <item> tag
    foreach($items as $item) {
        $rss->open_item();
        
        // deal with tags within <item> by mapping properties/indexes in each
        // $item to the tags that go within the <item>.
        foreach($item_map as $tag => $index) {
            
            $value = NULL;
            
            // is this an object and does it have the property we're looking
            // for?
            if(is_object($item) && property_exists($item, $index))
                $value = $item->$index;
            
            // is this an array, or does it have array access?
            if(NULL === $value && (is_array($item) || $item instanceof ArrayAccess))
                if(isset($item[$index]))
                    $value = $item[$index];
            
            // if the value is null it means that the mapping didn't find
            // anything to map to in the item, so don't include this tag in
            // the item
            if(NULL === $value)
                continue;
            
            // deal with the tag and any special cases that need to be applied
            // to its values
            __rss_tag($rss, $tag, $value);  
        }

        $rss->close_item();
    }
    $rss->close_channel();
    
    // output the feed
    $rss->doHeader();
    echo $rss;  
}

/**
 * Create the internal tags of an RSS feed while being aware of special cases.
 * @author Peter Goodman
 * @internal
 */
function __rss_tag(RssFeedPrinter &$rss, $tag, $value = '') {
    $rss->open($tag);
    
    // deal with special cases
    switch($tag) {
        
        // make sure the link query parameters are escaped properly
        case 'link':
            $rss->hasContent(RssFeedPrinter::encodeURL($value));
            break;
        
        // make sure we format the date correctly
        case 'pubDate':
            if(is_string($value))
                $value = strtotime($value);
            
            $rss->hasContent(date(RssFeedPrinter::DATE_FORMAT, 
                             (int)$value));
            break;
        
        // assumption: this is a permalink
        case 'guid':
            $rss->isPermaLink("true")
                ->hasContent(RssFeedPrinter::encodeURL($value));
            break;
            
        // everything else, flat assumption
        default:
            $rss->hasContent($value);
            break;
    }
    
    $rss->close();
}

