<?php
/**
 * api: php
 * type: handler
 * title: Follow The Thread
 * description: Straightforward threaded discussion forum
 * version: 0.3
 * category: discussion
 * depends: HTMLPurifier, input
 * config:
 *    <var name="forum_cfg[categories]" type="list" default="discussion,documentation" help="Comma-separated list of thread classifiers"/>
 *
 *
 * Implements a minimalistic web forum.
 * Primary goals are:
 *   → Threaded discussions in place of bulletin board blabber.
 *   → Contemporary security over restriction gimmicks.
 *   → Usability instead of UI featuritis.
 *   → Open access in lieu of accounteritis.
 *
 * Single table database:
 *    [id] INT PRIMARY KEY NOT NULL UNIQUE,
 *    [pid] INT NOT NULL DEFAULT(0) REFERENCES [forum] ([id]),
 *    [gid] INT NOT NULL REFERENCES [forum] ([id]),
 *    [tag] VARCHAR (0, 32) NOT NULL DEFAULT('discussion'),
 *    [summary] VARCHAR (0, 200) NOT NULL,
 *    [source] TEXT,
 *    [html] TEXT,
 *    [excerpt] TEXT,
 *    [author] VARCHAR (0, 80),
 *    [miniature] TEXT,
 *    [t_published] INT NOT NULL,
 *    [edit_token] VARCHAR (16, 64)
 *
 * Templating
 *   → Requires a template wrapper with <ul class=forum> prepared.
 *   → Index page must provide a quirky switch block, to dispatch depending
 *     on $_GET[name] function (one of post, edit, submit).
 *   → But just $forum->index() for output.
 *   → forum_entry.php for rendering a table entry.
 *   → forum_submit_form.php for post editing.
 *
 * Behaviour
 *   → Forum index is grouped by threads. Threads with new additions go on top now.
 *     But within threads the oldest entries come first.
 *   → Editing token is valid for 48 hours.
 *   → Admin settings must be inported manually from main application (->$is_admin).
 *
 * Javascript
 *   → AJAX is mainly used for submission to divert spambots.
 *   → Posts are folded to an excerpt, until clicked.
 *
 * Configuration
 *   → Basically just the categories are configurable.
 *
 *
 *
 */


/**
 * Decorative classification/grouping of threads.
 *
 */
global $forum_cfg;
$forum_cfg["categories"] = "discussion,projects,announcement,code,documentation,autoupdate";



/**
 * Callbacks, dispatcher and handler.
 *
 */
class forum {


    /**
     * Can be set externally, depending on application logic.
     *
     */
    var $is_admin = 0;
    var $can_edit = 1;



    /**
     * NOP
     *
     */
    function __construct() {
    }


    /**
     * Show forum listing
     *
     */
    function index($page=0) {
    
        // Fetch thread groups (attached to root gid=0)
        $entries = db("
          WITH grouped
            AS (SELECT gid, (MAX(t_published) + gid/1000) AS gid_group
                  FROM forum
              GROUP BY gid
                 LIMIT 50
                OFFSET ?*50
          )
          SELECT *
            FROM forum
       LEFT JOIN grouped
              ON forum.gid=grouped.gid
        ORDER BY gid_group DESC, forum.gid DESC, t_published ASC;
        ", $page);

        
        // Iterate over groups
        $last = 0;
        $group = array();
        foreach ($entries as $e) {
            if ($e["gid"] != $last) {
                $this->show_thread($group);
                $group = array();
            }
            $group[] = $e;
            $last = $e["gid"];
        }
        $this->show_thread($group);
    }


    /**
     * Iterate over grouped entry list
     * and recursively output posts.
     *
     */
    function show_thread($group, $pid=0) {
    
        #-- find available parent ids
        $parents = array_column($group, "pid");
    
        #-- step throuh
        foreach ($group as $entry) {
        
            #-- show if associated
            if ($entry["pid"] == $pid) {
                $entry["miniature"] or $entry["miniature"] = "/img/user.png";
                include("template/forum_entry.php");
                
                #-- Nest its children
                if (in_array($entry["id"], $parents)) {
                    print "    <ul>\n";
                    $this->show_thread($group, $entry["id"]);
                    print "    </ul>\n";
                }

                print "       </li>\n";
            }
        }
    }

    
    
    /**
     * Load a single entry.
     *
     */
    function entry($id) {
    }



    /**
     * Accept POST input and populate new forum post.
     * Adds an reply if ?pid= is not zero.
     * Doubles as edit function if ?id= is present.
     *
     */
    function submit($INSERT="INSERT") {
    
        #-- Prepare some fields
        $data = array(
            "id" => NULL,
            "pid" => $_POST->int["pid"],
            "gid" => NULL,
            "author" => $_POST->text->length…30->html["author"],
            "miniature" => $_POST->text->length…200->html["image"],
            "tag" => $_POST->text->length…20->html["tag"],
            "summary" => $_POST->text->length…120->html["summary"],
            "source" => $_POST->nocontrol->length…12000["source"],
            "html" => "",
            "excerpt" => "",
            "t_published" => time(),
            "edit_token" => $this->edit_token(),
        );

        #-- Source to HTML
        $data = $this->prepare_output($data);
        
        #-- Reject too minor submisions
        if (strlen("$data[source]$data[summary]") < 100) {
            exit("<p class=warning>Your post was a little too coarse. Please elaborate to keep discussions going.</p>");
        }

        #-- Edit
        if ($id = $_POST->int["id"]) {
            $prev = $this->edit_keep($this->edit_entry($id));
            $data = array_merge($data, $prev);
            $INSERT = "REPLACE";
#            var_dump($INSERT, $data, $prev);
        }
        #-- Reply
        elseif ($data["pid"]) {
            $data["gid"] = $this->group_id($data["pid"]);
        }

        /**
         * Store entry
         *  → Find maximum ID
         *  → Use as new id, and group id if new
         *  → Else keep previous pid and gid/id
         *
         * $data and $ids are split up before, so the :? and ::
         * placeholders don't consume all fields.
         *
         */
        $ids = array_splice($data, 0, 3);  // extract id,pid,gid
        $ok = db("
            $INSERT INTO forum (id, pid, gid, :?)
            VALUES (
                IFNULL(:id, (SELECT IFNULL(MAX(id), 0) + 1 AS id FROM forum)),
                IFNULL(:pid, 0),
                COALESCE(:gid, :id, (SELECT IFNULL(MAX(id), 0) + 1 AS id FROM forum)),
                ::
            );
        ", $data, $data, $ids);
        
        #-- return rendered
        if ($ok) {
            $data["id"] = db()->lastInsertId();
            $data["pid"] = 0;
            $this->show_thread([$data], 0);
        }
    }


    #-- Editing timeout / permission
    function edit_permission($prev) {
        return
            $this->is_admin
        or
            $_COOKIE->name["edit_token"] == $prev["edit_token"]
        and
            $prev["t_published"] + 48*3600 > time();
    }


    #-- Retrieves or sets edit_token
    function edit_token() {
        if (empty($token = $_COOKIE->name["edit_token"])) {
            setcookie("edit_token", $token = sha1(serialize($_SERVER)), time()+7*24*3600);
        }
        return $token;
    }


    /**
     * Retrieve post entry, check edit permission, and/or set defaults
     *
     *  → For forum/edit requests fetches the existing post content.
     *  → Also checks editing permissions (token),
     *    or e.g. existing OpenID logon ($this->can_edit is set from main site).
     *
     */
    function edit_entry($id) {
        if ($prev = db("SELECT * FROM forum WHERE id=?", $id)->fetch()) {

            if (!$this->can_edit) {
                exit("<p class=warning>You aren't logged in on the main site. Please associate an OpenID account.</p>");
            }
            if (!$this->edit_permission($prev)) {
                exit("<p class=warning>Entry not editable. (The edit token does not match, or the article is too old for editing.)</p>");
            }
            
            return $prev;
        }
        exit("<p class=error>Post #$id does not exist.</p>");
    }

    
    #-- Unsets a few fields for replying.
    function edit_keep($prev) {
        $copy = array_flip(str_getcsv("id,pid,gid,miniature,t_published,edit_token"));
        return array_intersect_key($prev, $copy);
    }


    #-- Copy thread/group id from parent post etc.
    function group_id($parent_id) {
        if ($prev = db("SELECT gid FROM forum WHERE id = ?", $parent_id)) {
            return $prev->gid;
        }
        return 0;
    }    


    #-- Convert Markdown/BB source into HTML, create excerpt, prepare user avatar
    function prepare_output($data) {

        #-- Content
        define("HTMLPURIFIER_PREFIX", "phar://lib/htmlpurifier.phar/standalone/");
        $md = new Parsedown();
        $data["html"] = input::purify($md->parse($data["source"]));
        $data["excerpt"] = input::html(substr(strip_tags($data["html"]), 0, 320));
        
        #-- Author
        if (strlen($data["miniature"]) and $type = $_POST->in_array("img_type", "gravatar,identicon,monsterid,wavatar,retro"))
        {
            $data["miniature"] = "http://www.gravatar.com/avatar/"
                               . md5($data["miniature"])
                               . ".jpeg?s=16" . ($type == "gravatar" ? "" : "&d=$type");
        }
        return $data;
    }




    /**
     * Output submit <form>
     *
     *  → Provides a few blank fields.
     *  → Injects cookie defaults if fresh submission / not an edit.
     *  → Escapes previous content for edit_form() calls.
     *
     */
    function submit_form($pid=0, $id=0, $data=array()) {
        global $forum_cfg;

        extract(array_merge(
            array_fill_keys(str_getcsv("author,image,tag,summary,source"), ""),
            $data ? array() : $_COOKIE->list->text->html["author,image"],
            $data  // all fields excerpt `$source` are already HTML-escaped
        ));
        $source = input::html($source);

        include("template/forum_submit_form.php");
    }


    /**
     * Show editing <form> instead with prefilled previous data.
     *
     *  → Retrieves previous post content, checks permissions at that.
     *  → Outputs edit form (replied for AJAX $.load() request)
     *
     */
    function edit_form($pid=0, $id=0) {
        $data = $this->edit_entry($id);
        $this->submit_form(0, 0, $data);
    }

}




