Prevent sending of empty NLs.
[platal.git] / include / newsletter.inc.php
index 32fd34a..26f648f 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 /***************************************************************************
- *  Copyright (C) 2003-2006 Polytechnique.org                              *
+ *  Copyright (C) 2003-2011 Polytechnique.org                              *
  *  http://opensource.polytechnique.org/                                   *
  *                                                                         *
  *  This program is free software; you can redistribute it and/or modify   *
  *  59 Temple Place, Suite 330, Boston, MA  02111-1307  USA                *
  ***************************************************************************/
 
-// {{{ requires + defines
+// {{{ class MailNotFound
 
-require_once("xorg.misc.inc.php");
-require_once("diogenes/diogenes.misc.inc.php");
-
-if (isset($page)) {
-    $page->addCssLink('nl.css');
+class MailNotFound extends Exception {
 }
 
-define('FEMME', 1);
-define('HOMME', 0);
-
 // }}}
+
 // {{{ class NewsLetter
 
 class NewsLetter
 {
-    // {{{ properties
-    
-    var $_id;
-    var $_date;
-    var $_title;
-    var $_head;
-    var $_cats = Array();
-    var $_arts = Array();
+    public $id;  // ID of the NL (in table newsletters)
+    public $group;  // Short name of the group corresponding to the NL
+    public $group_id;  // ID of that group
+    public $name;  // Name of the NL (e.g "Lettre de Polytechnique.org", ...)
+    public $cats;  // List of all categories for this NL
+    public $criteria;  // PlFlagSet of allowed filters for recipient selection
 
-    // }}}
-    // {{{ constructor
-    
-    function NewsLetter($id=null)
-    {
-       if (isset($id)) {
-           if ($id == 'last') {
-               $res = XDB::query("SELECT MAX(id) FROM newsletter WHERE bits!='new'");
-                $id  = $res->fetchOneCell();
-           }
-           $res = XDB::query("SELECT * FROM newsletter WHERE id={?}", $id);
-       } else {
-           $res = XDB::query("SELECT * FROM newsletter WHERE bits='new'");
-       }
-       $nl = $res->fetchOneAssoc();
-
-       $this->_id    = $nl['id'];
-       $this->_date  = $nl['date'];
-       $this->_title = $nl['titre'];
-       $this->_head  = $nl['head'];
-
-       $res = XDB::iterRow("SELECT cid,titre FROM newsletter_cat ORDER BY pos");
-       while (list($cid, $title) = $res->next()) {
-           $this->_cats[$cid] = $title;
-       }
-       
-       $res = XDB::iterRow(
-                "SELECT  a.title,a.body,a.append,a.aid,a.cid,a.pos
-                   FROM  newsletter_art AS a
-             INNER JOIN  newsletter     AS n USING(id)
-             LEFT  JOIN  newsletter_cat AS c ON(a.cid=c.cid)
-                  WHERE  a.id={?}
-               ORDER BY  c.pos,a.pos", $this->_id);
-       while (list($title, $body, $append, $aid, $cid, $pos) = $res->next()) {
-           $this->_arts[$cid]["a$aid"] = new NLArticle($title, $body, $append, $aid, $cid, $pos);
-       }
+    protected $custom_css = false;
+
+    // Base name to use instead of the group short name for NLs without a custom CSS
+    const FORMAT_DEFAULT_GROUP = 'default';
+
+    // Diminutif of X.net groups with a specific NL view
+    const GROUP_XORG = 'Polytechnique.org';
+    const GROUP_AX = 'AX';
+    const GROUP_EP = 'Ecole';
+
+    // {{{ Constructor, NewsLetter retrieval (forGroup, getAll)
+
+    public function __construct($id)
+    {
+        // Load NL data
+        $res = XDB::query('SELECT  nls.group_id, g.diminutif AS group_name,
+                                   nls.name AS nl_name, nls.custom_css, nls.criteria
+                             FROM  newsletters AS nls
+                        LEFT JOIN  groups AS g ON (nls.group_id = g.id)
+                            WHERE  nls.id = {?}',
+                            $id);
+        if (!$res->numRows()) {
+            throw new MailNotFound();
+        }
+
+        $data = $res->fetchOneAssoc();
+        $this->id = $id;
+        $this->group_id = $data['group_id'];
+        $this->group = $data['group_name'];
+        $this->name = $data['nl_name'];
+        $this->custom_css = $data['custom_css'];
+        $this->criteria = new PlFlagSet($data['criteria']);
+
+        // Load the categories
+        $res = XDB::iterRow(
+            'SELECT  cid, title
+               FROM  newsletter_cat
+              WHERE  nlid = {?}
+           ORDER BY  pos', $id);
+        while (list($cid, $title) = $res->next()) {
+            $this->cats[$cid] = $title;
+        }
+    }
+
+    /** Retrieve the NL associated with a given group.
+     * @p $group Short name of the group
+     * @return A NewsLetter object, or null if the group doesn't have a NL.
+     */
+    public static function forGroup($group)
+    {
+        $res = XDB::query('SELECT  nls.id
+                             FROM  newsletters AS nls
+                        LEFT JOIN  groups AS g ON (nls.group_id = g.id)
+                            WHERE  g.diminutif = {?}', $group);
+        if (!$res->numRows()) {
+            return null;
+        }
+        return new NewsLetter($res->fetchOneCell());
+    }
+
+    /** Retrieve all newsletters
+     * @return An array of $id => NewsLetter objects
+     */
+    public static function getAll()
+    {
+        $res = XDB::query('SELECT  id
+                             FROM  newsletters');
+        $nls = array();
+        foreach ($res->fetchColumn() as $id) {
+            $nls[$id] = new NewsLetter($id);
+        }
+        return $nls;
     }
 
     // }}}
-    // {{{ function setSent()
+    // {{{ Issue retrieval
+
+    /** Retrieve all issues which should be sent
+     * @return An array of NLIssue objects to send (i.e state = 'new' and send_before <= today)
+     */
+    public static function getIssuesToSend()
+    {
+        $res = XDB::query('SELECT  id
+                             FROM  newsletter_issues
+                            WHERE  state = \'pending\' AND send_before <= NOW()');
+        $issues = array();
+        foreach ($res->fetchColumn() as $id) {
+            $issues[$id] = new NLIssue($id);
+        }
+        return $issues;
+    }
+
+    /** Retrieve a given issue of this NewsLetter
+     * @p $name Name or ID of the issue to retrieve.
+     * @return A NLIssue object.
+     *
+     * $name may be either a short_name, an ID or the special value 'last' which
+     * selects the latest sent NL.
+     * If $name is null, this will retrieve the current pending NL.
+     */
+    public function getIssue($name = null, $only_sent = true)
+    {
+        if ($name) {
+            if ($name == 'last') {
+                if ($only_sent) {
+                    $where = 'state = \'sent\' AND ';
+                } else {
+                    $where = '';
+                }
+                $res = XDB::query('SELECT  MAX(id)
+                                     FROM  newsletter_issues
+                                    WHERE  ' . $where . ' nlid = {?}',
+                                   $this->id);
+            } else {
+                $res = XDB::query('SELECT  id
+                                     FROM  newsletter_issues
+                                    WHERE  nlid = {?} AND (id = {?} OR short_name = {?})',
+                                  $this->id, $name, $name);
+            }
+            if (!$res->numRows()) {
+                throw new MailNotFound();
+            }
+            $id = $res->fetchOneCell();
+        } else {
+            $query = XDB::format('SELECT  id
+                                    FROM  newsletter_issues
+                                   WHERE  nlid = {?} AND state = \'new\'
+                                ORDER BY  id DESC', $this->id);
+            $res = XDB::query($query);
+            if ($res->numRows()) {
+                $id = $res->fetchOneCell();
+            } else {
+                // Create a new, empty issue, and return it
+                $id = $this->createPending();
+            }
+        }
+
+        return new NLIssue($id, $this);
+    }
+
+    /** Create a new, empty, pending newsletter issue
+     * @p $nlid The id of the NL for which a new pending issue should be created.
+     * @return Id of the newly created issue.
+     */
+    public function createPending()
+    {
+        XDB::execute('INSERT INTO  newsletter_issues
+                              SET  nlid = {?}, state=\'new\', date=NOW(),
+                                   title=\'to be continued\',
+                                   mail_title=\'to be continued\'',
+                                   $this->id);
+        return XDB::insertId();
+    }
+
+    /** Return all sent issues of this newsletter.
+     * @return An array of (id => NLIssue)
+     */
+    public function listSentIssues($check_user = false, $user = null)
+    {
+        if ($check_user && $user == null) {
+            $user = S::user();
+        }
 
-    function setSent()
+        $res = XDB::query('SELECT  id
+                             FROM  newsletter_issues
+                            WHERE  nlid = {?} AND state = \'sent\'
+                         ORDER BY  date DESC', $this->id);
+        $issues = array();
+        foreach ($res->fetchColumn() as $id) {
+            $issue = new NLIssue($id, $this, false);
+            if (!$check_user || $issue->checkUser($user)) {
+                $issues[$id] = $issue;
+            }
+        }
+        return $issues;
+    }
+
+    /** Return all issues of this newsletter, including invalid and sent.
+     * @return An array of (id => NLIssue)
+     */
+    public function listAllIssues()
     {
-       XDB::execute("UPDATE  newsletter SET bits='sent' WHERE id={?}", $this->_id);
+        $res = XDB::query('SELECT  id
+                             FROM  newsletter_issues
+                            WHERE  nlid = {?}
+                         ORDER BY  FIELD(state, \'pending\', \'new\') DESC, date DESC', $this->id);
+        $issues = array();
+        foreach ($res->fetchColumn() as $id) {
+            $issues[$id] = new NLIssue($id, $this, false);
+        }
+        return $issues;
+    }
+
+    /** Return the latest pending issue of the newsletter.
+     * @p $create Whether to create an empty issue if no pending issue exist.
+     * @return Either null, or a NL object.
+     */
+    public function getPendingIssue($create = false)
+    {
+        $res = XDB::query('SELECT  MAX(id)
+                             FROM  newsletter_issues
+                            WHERE  nlid = {?} AND state = \'new\'',
+                            $this->id);
+        $id = $res->fetchOneCell();
+        if ($id != null) {
+            return new NLIssue($id, $this);
+        } else if ($create) {
+            $id = $this->createPending();
+            return new NLIssue($id, $this);
+        } else {
+            return null;
+        }
     }
 
     // }}}
-    // {{{ function save()
+    // {{{ Subscription related function
 
-    function save()
+    /** Unsubscribe a user from this newsletter
+     * @p $uid UID to unsubscribe from the newsletter; if null, use current user.
+     * @p $hash True if the uid is actually a hash.
+     * @return True if the user was successfully unsubscribed.
+     */
+    public function unsubscribe($uid = null, $hash = false)
     {
-       XDB::execute('UPDATE newsletter SET date={?},titre={?},head={?} WHERE id={?}',
-                     $this->_date, $this->_title, $this->_head, $this->_id);
+        if (is_null($uid) && $hash) {
+            // Unable to unsubscribe from an empty hash
+            return false;
+        }
+        $user = is_null($uid) ? S::user()->id() : $uid;
+        $field = $hash ? 'hash' : 'uid';
+        $res = XDB::query('SELECT  uid
+                             FROM  newsletter_ins
+                            WHERE  nlid = {?} AND ' . $field . ' = {?}',
+                            $this->id, $user);
+        if (!$res->numRows()) {
+            // No subscribed user with that UID/hash
+            return false;
+        }
+        $user = $res->fetchOneCell();
+
+        XDB::execute('DELETE FROM  newsletter_ins
+                            WHERE  nlid = {?} AND uid = {?}',
+                            $this->id, $user);
+        return true;
+    }
+
+    /** Subscribe a user to a newsletter
+     * @p $user User to subscribe to the newsletter; if null, use current user.
+     */
+    public function subscribe($user = null)
+    {
+        if (is_null($user)) {
+            $user = S::user();
+        }
+        if (self::maySubscribe($user)) {
+            XDB::execute('INSERT IGNORE INTO  newsletter_ins (nlid, uid, last, hash)
+                                      VALUES  ({?}, {?}, NULL, hash)',
+                         $this->id, $user->id());
+        }
+    }
+
+    /** Retrieve subscription state of a user
+     * @p $user Target user; if null, use current user.
+     * @return Boolean: true if the user has subscribed to the NL.
+     */
+    public function subscriptionState($user = null)
+    {
+        if (is_null($user)) {
+            $user = S::user();
+        }
+        $res = XDB::query('SELECT  1
+                             FROM  newsletter_ins
+                            WHERE  nlid = {?} AND uid = {?}',
+                          $this->id, $user->id());
+        return ($res->numRows() == 1);
+    }
+
+    /** Get the count of subscribers to the NL.
+     * @return Number of subscribers.
+     */
+    public function subscriberCount()
+    {
+        return XDB::fetchOneCell('SELECT  COUNT(uid)
+                                    FROM  newsletter_ins
+                                   WHERE  nlid = {?}', $this->id);
+    }
+
+    /** Get the number of subscribers to the NL whose last received mailing was $last.
+     * @p $last ID of the issue for which subscribers should be counted.
+     * @return Number of subscribers
+     */
+    public function subscriberCountForLast($last)
+    {
+        return XDB::fetchOneCell('SELECT  COUNT(uid)
+                                    FROM  newsletter_ins
+                                   WHERE  nlid = {?} AND last = {?}', $this->id, $last);
+    }
+
+    /** Retrieve the list of newsletters a user has subscribed to
+     * @p $user User whose subscriptions should be retrieved (if null, use session user).
+     * @return Array of newsletter IDs
+     */
+    public static function getUserSubscriptions($user = null)
+    {
+        if (is_null($user)) {
+            $user = S::user();
+        }
+        $res = XDB::query('SELECT  nlid
+                             FROM  newsletter_ins
+                            WHERE  uid = {?}',
+                          $user->id());
+        return $res->fetchColumn();
+    }
+
+    /** Retrieve the UserFilterBuilder for subscribers to this NL.
+     * This is the place where NL-specific filters may be allowed or prevented.
+     * @p $envprefix Prefix to use for env fields (cf. UserFilterBuilder)
+     * @return A UserFilterBuilder object using the given env prefix
+     */
+    public function getSubscribersUFB($envprefix = '')
+    {
+        require_once 'ufbuilder.inc.php';
+        return new UFB_NewsLetter($this->criteria, $envprefix);
     }
 
     // }}}
-    // {{{ function title()
+    // {{{ Permissions related functions
+
+    /** For later use: check whether a given user may subscribe to this newsletter.
+     * @p $user User whose access should be checked
+     * @return Boolean: whether the user may subscribe to the NL.
+     */
+    public function maySubscribe($user = null)
+    {
+        return true;
+    }
+
+    /** Whether a given user may edit this newsletter
+     * @p $uid UID of the user whose perms should be checked (if null, use current user)
+     * @return Boolean: whether the user may edit the NL
+     */
+    public function mayEdit($user = null)
+    {
+        if (is_null($user)) {
+            $user = S::user();
+        }
+        if ($user->checkPerms('admin')) {
+            return true;
+        }
+        $res = XDB::query('SELECT  perms
+                             FROM  group_members
+                            WHERE  asso_id = {?} AND uid = {?}',
+                            $this->group_id, $user->id());
+        return ($res->numRows() && $res->fetchOneCell() == 'admin');
+    }
 
-    function title()
-    { return $this->_title; }
+    /** Whether a given user may submit articles to this newsletter using X.org validation system
+     * @p $user User whose access should be checked (if null, use current user)
+     * @return Boolean: whether the user may submit articles
+     */
+    public function maySubmit($user = null)
+    {
+        // Submission of new articles is only enabled for the X.org NL (and forbidden when viewing issues on X.net)
+        return ($this->group == self::GROUP_XORG && !isset($GLOBALS['IS_XNET_SITE']));
+    }
 
     // }}}
-    // {{{ function head()
-    
-    function head()
-    { return $this->_head; }
+    // {{{ Display-related functions: cssFile, tplFile, prefix, admin_prefix, admin_links_enabled, automatic_mailings_enabled
+
+    /** Get the name of the css file used to display this newsletter.
+     */
+    public function cssFile()
+    {
+        if ($this->custom_css) {
+            $base = $this->group;
+        } else {
+            $base = self::FORMAT_DEFAULT_GROUP;
+        }
+        return 'nl.' . $base . '.css';
+    }
+
+    /** Get the name of the template file used to display this newsletter.
+     */
+    public function tplFile()
+    {
+        if ($this->custom_css) {
+            $base = $this->group;
+        } else {
+            $base = self::FORMAT_DEFAULT_GROUP;
+        }
+        return 'newsletter/nl.' . $base . '.mail.tpl';
+    }
+
+    /** Get the prefix leading to the page for this NL
+     * Only X.org / AX / X groups may be seen on X.org.
+     */
+    public function prefix()
+    {
+        if (!empty($GLOBALS['IS_XNET_SITE'])) {
+            return $this->group . '/nl';
+        }
+        switch ($this->group) {
+        case self::GROUP_XORG:
+            return 'nl';
+        case self::GROUP_AX:
+            return 'ax';
+        case self::GROUP_EP:
+            return 'epletter';
+        default:
+            // Don't display groups NLs on X.org
+            assert(false);
+        }
+    }
+
+    /** Get the prefix to use for all 'admin' pages of this NL.
+     */
+    public function adminPrefix()
+    {
+        if (!empty($GLOBALS['IS_XNET_SITE'])) {
+            return $this->group . '/admin/nl';
+        }
+        switch ($this->group) {
+        case self::GROUP_XORG:
+            return 'admin/newsletter';
+        case self::GROUP_AX:
+            return 'ax/admin';
+        case self::GROUP_EP:
+            return 'epletter/admin';
+        default:
+            // Don't display groups NLs on X.org
+            assert(false);
+        }
+    }
+
+    /** Hack used to remove "admin" links on X.org page on X.net
+     * The 'admin' links are enabled for all pages, except for X.org when accessing NL through X.net
+     */
+    public function adminLinksEnabled()
+    {
+        return ($this->group != self::GROUP_XORG || !isset($GLOBALS['IS_XNET_SITE']));
+    }
+
+    /** Automatic mailings are disabled for X.org NL.
+     */
+    public function automaticMailingEnabled()
+    {
+        return $this->group != self::GROUP_XORG;
+    }
+
+    public function hasCustomCss()
+    {
+        return $this->custom_css;
+    }
 
     // }}}
-    // {{{ function getArt()
-    
-    function getArt($aid)
+}
+
+// }}}
+
+// {{{ class NLIssue
+
+// A NLIssue is an issue of a given NewsLetter
+class NLIssue
+{
+    protected $nlid;  // Id of the newsletter
+
+    const STATE_NEW = 'new';  // New, currently being edited
+    const STATE_PENDING = 'pending';  // Ready for mailing
+    const STATE_SENT = 'sent';  // Sent
+
+    public $nl;  // Related NL
+
+    public $id;  // Id of this issue of the newsletter
+    public $shortname;  // Shortname for this issue
+    public $title;  // Title of this issue
+    public $title_mail;  // Title of the email
+    public $state;  // State of the issue (one of the STATE_ values)
+    public $sufb;  // Environment to use to generate the UFC through an UserFilterBuilder
+
+    public $date;  // Date at which this issue was sent
+    public $send_before;  // Date at which issue should be sent
+    public $head;  // Foreword of the issue (or body for letters with no articles)
+    public $signature;  // Signature of the letter
+    public $arts = array();  // Articles of the issue
+
+    const BATCH_SIZE = 60;  // Number of emails to send every minute.
+
+    // {{{ Constructor, id-related functions
+
+    /** Build a NewsLetter.
+     * @p $id: ID of the issue (unique among all newsletters)
+     * @p $nl: Optional argument containing an already built NewsLetter object.
+     */
+    function __construct($id, $nl = null, $fetch_articles = true)
+    {
+        return $this->fetch($id, $nl, $fetch_articles);
+    }
+
+    protected function refresh()
     {
-       foreach ($this->_arts as $key=>$artlist) {
-           if (isset($artlist["a$aid"])) {
-                return $artlist["a$aid"];
+        return $this->fetch($this->id, $this->nl, false);
+    }
+
+    protected function fetch($id, $nl = null, $fetch_articles = true)
+    {
+        // Load this issue
+        $res = XDB::query('SELECT  nlid, short_name, date, send_before, state, sufb_json,
+                                   title, mail_title, head, signature
+                             FROM  newsletter_issues
+                            WHERE  id = {?}',
+                          $id);
+        if (!$res->numRows()) {
+            throw new MailNotFound();
+        }
+        $issue = $res->fetchOneAssoc();
+        if ($nl && $nl->id == $issue['nlid']) {
+            $this->nl = $nl;
+        } else {
+            $this->nl = new NewsLetter($issue['nlid']);
+        }
+        $this->id = $id;
+        $this->shortname   = $issue['short_name'];
+        $this->date        = $issue['date'];
+        $this->send_before = $issue['send_before'];
+        $this->state       = $issue['state'];
+        $this->title       = $issue['title'];
+        $this->title_mail  = $issue['mail_title'];
+        $this->head        = $issue['head'];
+        $this->signature   = $issue['signature'];
+        $this->sufb        = $this->importJSonStoredUFB($issue['sufb_json']);
+
+        if ($fetch_articles) {
+            $this->fetchArticles();
+        }
+    }
+
+    protected function fetchArticles($force = false)
+    {
+        if (count($this->arts) && !$force) {
+            return;
+        }
+
+        // Load the articles
+        $res = XDB::iterRow(
+            'SELECT  a.title, a.body, a.append, a.aid, a.cid, a.pos
+               FROM  newsletter_art AS a
+         INNER JOIN  newsletter_issues AS ni USING(id)
+         LEFT  JOIN  newsletter_cat AS c ON (a.cid = c.cid)
+              WHERE  a.id = {?}
+           ORDER BY  c.pos, a.pos',
+           $this->id);
+        while (list($title, $body, $append, $aid, $cid, $pos) = $res->next()) {
+            $this->arts[$cid][$aid] = new NLArticle($title, $body, $append, $aid, $cid, $pos);
+        }
+    }
+
+    protected function importJSonStoredUFB($json = null)
+    {
+        require_once 'ufbuilder.inc.php';
+        $ufb = $this->nl->getSubscribersUFB();
+        if (is_null($json)) {
+            return new StoredUserFilterBuilder($ufb, new PFC_True());
+        }
+        $export = json_decode($json, true);
+        if (is_null($export)) {
+            PlErrorReport::report("Invalid json while reading NL {$this->nlid}, issue {$this->id}: failed to import '''{$json}'''.");
+            return new StoredUserFilterBuilder($ufb, new PFC_True());
+        }
+        $sufb = new StoredUserFilterBuilder($ufb);
+        $sufb->fillFromExport($export);
+        return $sufb;
+    }
+
+    protected function exportStoredUFBAsJSon()
+    {
+        return json_encode($this->sufb->export());
+    }
+
+    public function id()
+    {
+        return is_null($this->shortname) ? $this->id : $this->shortname;
+    }
+
+    protected function selectId($where)
+    {
+        $res = XDB::query("SELECT  IFNULL(ni.short_name, ni.id)
+                             FROM  newsletter_issues AS ni
+                            WHERE  ni.state != 'new' AND ni.nlid = {?} AND ${where}
+                            LIMIT  1", $this->nl->id);
+        if ($res->numRows() != 1) {
+            return null;
+        }
+        return $res->fetchOneCell();
+    }
+
+    /** Delete this issue
+     * @return True if the issue could be deleted, false otherwise.
+     * Related articles will be deleted through cascading FKs.
+     * If this issue was the last issue for at least one subscriber, the deletion will be aborted.
+     */
+    public function delete()
+    {
+        if ($this->state == self::STATE_NEW) {
+            $res = XDB::query('SELECT  COUNT(*)
+                                 FROM  newsletter_ins
+                                WHERE  last = {?}', $this->id);
+            if ($res->fetchOneCell() > 0) {
+                return false;
             }
-       }
-       return null;
+
+            return XDB::execute('DELETE FROM  newsletter_issues
+                                       WHERE  id = {?}', $this->id);
+        } else {
+            return false;
+        }
     }
 
-    // }}}
-    // {{{ function saveArticle()
-
-    function saveArticle(&$a)
-    {
-       if ($a->_aid>=0) {
-           XDB::execute('REPLACE INTO  newsletter_art (id,aid,cid,pos,title,body,append)
-                                          VALUES  ({?},{?},{?},{?},{?},{?},{?})',
-                                          $this->_id, $a->_aid, $a->_cid, $a->_pos,
-                                          $a->_title, $a->_body, $a->_append);
-           $this->_arts['a'.$a->_aid] = $a;
-       } else {
-           XDB::execute(
-               'INSERT INTO  newsletter_art
-                     SELECT  {?},MAX(aid)+1,{?},'.($a->_pos ? intval($a->_pos) : 'MAX(pos)+1').',{?},{?},{?}
-                       FROM  newsletter_art AS a
-                      WHERE  a.id={?}',
-                       $this->_id, $a->_cid, $a->_title, $a->_body, $a->_append, $this->_id);
-           $this->_arts['a'.$a->_aid] = $a;
-       }
+    /** Schedule a mailing of this NL
+     * If the 'send_before' field was NULL, it is set to the current time.
+     * @return Boolean Whether the date could be set (false if trying to schedule an already sent NL)
+     */
+    public function scheduleMailing()
+    {
+        if ($this->state == self::STATE_NEW) {
+            $success = XDB::execute('UPDATE  newsletter_issues
+                                        SET  state = \'pending\', send_before = IFNULL(send_before, NOW())
+                                      WHERE  id = {?}',
+                                      $this->id);
+            if ($success) {
+                global $globals;
+                $mailer = new PlMailer('newsletter/notify_scheduled.mail.tpl');
+                $mailer->assign('group', $this->nl->group);
+                $mailer->assign('nl_title', $this->title_mail);
+                $mailer->assign('nl_id', $this->id());
+                $mailer->assign('base', $globals->baseurl);
+                $mailer->assign('send_before', $this->send_before);
+                $mailer->send();
+                $this->refresh();
+            }
+            return $success;
+        } else {
+            return false;
+        }
     }
 
-    // }}}
-    // {{{ function delArticle()
-    
-    function delArticle($aid)
+    /** Cancel the scheduled mailing of this NL
+     * @return Boolean: whether the mailing could be cancelled.
+     */
+    public function cancelMailing()
+    {
+        if ($this->state == self::STATE_PENDING) {
+            $success = XDB::execute('UPDATE  newsletter_issues
+                                        SET  state = \'new\'
+                                      WHERE  id = {?}', $this->id);
+            if ($success) {
+                $this->refresh();
+            }
+            return $success;
+        } else {
+            return false;
+        }
+    }
+
+    /** Helper function for smarty templates: is this issue editable ?
+     */
+    public function isEditable()
+    {
+        return $this->state == self::STATE_NEW;
+    }
+
+    /** Helper function for smarty templates: is the mailing of this issue scheduled ?
+     */
+    public function isPending()
     {
-       XDB::execute('DELETE FROM newsletter_art WHERE id={?} AND aid={?}', $this->_id, $aid);
-       foreach ($this->_arts as $key=>$art) {
-           unset($this->_arts[$key]["a$aid"]);
-       }
+        return $this->state == self::STATE_PENDING;
+    }
+
+    /** Helper function for smarty templates: has this issue been sent ?
+     */
+    public function isSent()
+    {
+        return $this->state == self::STATE_SENT;
     }
 
     // }}}
-    // {{{ function footer
-
-    function footer($html)
-    {
-        global $globals;
-        $url = $globals->baseurl;
-
-       if ($html) {
-           return '<div class="foot">Cette lettre est envoyée Ã  tous les Polytechniciens sur Internet par l\'intermédiaire de Polytechnique.org.</div>'
-           .  '<div class="foot">'
-           .  "[<a href=\"$url/nl\">archives</a>&nbsp;|&nbsp;"
-           .  "<a href=\"$url/nl/submit\">écrire dans la NL</a>&nbsp;|&nbsp;"
-           .  "<a href=\"$url/nl/out\">ne plus recevoir</a>]"
-           .  '</div>';
-       } else {
-           return "\n\n--------------------------------------------------------------------\n"
-                . "Cette lettre est envoyée Ã  tous les Polytechniciens sur Internet par\n"
-                . "l'intermédiaire de Polytechnique.org.\n"
-                . "\n"
-                . "archives : [$url/nl]\n"
-                . "écrire   : [$url/nl/submit]\n"
-                . "ne plus recevoir: [$url/nl/out]\n";
-       }
+    // {{{ Navigation
+
+    private $id_prev = null;
+    private $id_next = null;
+    private $id_last = null;
+
+    /** Retrieve ID of the previous issue
+     * That value, once fetched, is cached in the private $id_prev variable.
+     * @return ID of the previous issue.
+     */
+    public function prev()
+    {
+        if (is_null($this->id_prev)) {
+            $this->id_prev = $this->selectId(XDB::format("ni.id < {?} ORDER BY ni.id DESC", $this->id));
+        }
+        return $this->id_prev;
+    }
+
+    /** Retrieve ID of the following issue
+     * That value, once fetched, is cached in the private $id_next variable.
+     * @return ID of the following issue.
+     */
+    public function next()
+    {
+        if (is_null($this->id_next)) {
+            $this->id_next = $this->selectId(XDB::format("ni.id > {?} ORDER BY ni.id", $this->id));
+        }
+        return $this->id_next;
+    }
+
+    /** Retrieve ID of the last issue
+     * That value, once fetched, is cached in the private $id_last variable.
+     * @return ID of the last issue.
+     */
+    public function last()
+    {
+        if (is_null($this->id_last)) {
+            $this->id_last = $this->nl->getIssue('last')->id;
+        }
+        return $this->id_last;
     }
 
     // }}}
-    // {{{ function toText()
+    // {{{ Edition, articles
+
+    const ERROR_INVALID_SHORTNAME = 'invalid_shortname';
+    const ERROR_INVALID_UFC = 'invalid_ufc';
+    const ERROR_SQL_SAVE = 'sql_error';
+
+    /** Save the global properties of this NL issue (title&co).
+     */
+    public function save()
+    {
+        $errors = array();
+
+        // Fill the list of fields to update
+        $fields = array(
+            'title' => $this->title,
+            'mail_title' => $this->title_mail,
+            'head' => $this->head,
+            'signature' => $this->signature,
+        );
+
+        if ($this->isEditable()) {
+            $fields['date'] = $this->date;
+            if (!preg_match('/^[-a-z0-9]+$/i', $this->shortname) || is_numeric($this->shortname)) {
+                $errors[] = self::ERROR_INVALID_SHORTNAME;
+            } else {
+                $fields['short_name'] = $this->shortname;
+            }
+            if ($this->sufb->isValid() || $this->sufb->isEmpty()) {
+                $fields['sufb_json'] = json_encode($this->sufb->export()->dict());
+            } else {
+                $errors[] = self::ERROR_INVALID_UFC;
+            }
+
+            if ($this->nl->automaticMailingEnabled()) {
+                $fields['send_before'] = ($this->send_before ? $this->send_before : null);
+            }
+        }
+
+        if (count($errors)) {
+            return $errors;
+        }
+        $field_sets = array();
+        foreach ($fields as $key => $value) {
+            $field_sets[] = XDB::format($key . ' = {?}', $value);
+        }
+        XDB::execute('UPDATE  newsletter_issues
+                         SET  ' . implode(', ', $field_sets) . '
+                       WHERE  id={?}',
+                       $this->id);
+        if (XDB::affectedRows()) {
+            $this->refresh();
+        } else {
+            $errors[] = self::ERROR_SQL_SAVE;
+        }
+        return $errors;
+    }
+
+    /** Get an article by number
+     * @p $aid Article ID (among articles of the issue)
+     * @return A NLArticle object, or null if there is no article by that number
+     */
+    public function getArt($aid)
+    {
+        $this->fetchArticles();
 
-    function toText($prenom,$nom,$sexe)
-    {
-       $res  = "====================================================================\n";
-       $res .= ' '.$this->title()."\n";
-       $res .= "====================================================================\n\n";
-
-       $head = $this->head();
-       $head = str_replace('<cher>',   $sexe ? 'Chère' : 'Cher', $head);
-       $head = str_replace('<prenom>', $prenom, $head);
-       $head = str_replace('<nom>',    $nom,    $head);
-       $head = enriched_to_text($head,false,true,2,64);
-
-       if ($head) {
-            $res .= "\n$head\n\n\n";
-        }
-
-       $i = 1;
-       foreach ($this->_arts as $cid=>$arts) {
-           $res .= "\n$i *{$this->_cats[$cid]}*\n";
-           foreach ($arts as $art) {
-               $res .= '- '.$art->title()."\n";
-           }
-           $i ++;
-       }
-       $res .= "\n\n";
-           
-       foreach ($this->_arts as $cid=>$arts) {
-           $res .= "--------------------------------------------------------------------\n";
-           $res .= "*{$this->_cats[$cid]}*\n";
-           $res .= "--------------------------------------------------------------------\n\n";
-           foreach ($arts as $art) {
-               $res .= $art->toText();
-               $res .= "\n\n";
-           }
-       }
-       
-       $res .= $this->footer(false);
-       
-       return $res;
+        foreach ($this->arts as $category => $artlist) {
+            if (isset($artlist[$aid])) {
+                return $artlist[$aid];
+            }
+        }
+        return null;
+    }
+
+    /** Save an article
+     * @p $a A reference to a NLArticle object (will be modified once saved)
+     */
+    public function saveArticle($a)
+    {
+        $this->fetchArticles();
+
+        // Prevent cid to be 0 (use NULL instead)
+        $a->cid = ($a->cid == 0) ? null : $a->cid;
+        if ($a->aid >= 0) {
+            // Article already exists in DB
+            XDB::execute('UPDATE  newsletter_art
+                             SET  cid = {?}, pos = {?}, title = {?}, body = {?}, append = {?}
+                           WHERE  id = {?} AND aid = {?}',
+                         $a->cid, $a->pos, $a->title, $a->body, $a->append, $this->id, $a->aid);
+        } else {
+            // New article
+            XDB::startTransaction();
+            list($aid, $pos) = XDB::fetchOneRow('SELECT  MAX(aid) AS aid, MAX(pos) AS pos
+                                                   FROM  newsletter_art AS a
+                                                  WHERE  a.id = {?}',
+                                                $this->id);
+            $a->aid = ++$aid;
+            $a->pos = ($a->pos ? $a->pos : ++$pos);
+            XDB::execute('INSERT INTO  newsletter_art (id, aid, cid, pos, title, body, append)
+                               VALUES  ({?}, {?}, {?}, {?}, {?}, {?}, {?})',
+                         $this->id, $a->aid, $a->cid, $a->pos,
+                         $a->title, $a->body, $a->append);
+            XDB::commit();
+        }
+        // Update local ID of article
+        $this->arts[$a->aid] = $a;
+    }
+
+    /** Delete an article by its ID
+     * @p $aid ID of the article to delete
+     */
+    public function delArticle($aid)
+    {
+        $this->fetchArticles();
+
+        XDB::execute('DELETE FROM newsletter_art WHERE id={?} AND aid={?}', $this->id, $aid);
+        foreach ($this->arts as $key=>$art) {
+            unset($this->arts[$key][$aid]);
+        }
     }
 
     // }}}
-    // {{{ function toHtml()
-    
-    function toHtml($prenom,$nom,$sexe,$body=false)
-    {
-       $res  = '<div class="title">'.$this->title().'</div>';
-       
-       $head = $this->head();
-       $head = str_replace('<cher>',   $sexe ? 'Chère' : 'Cher', $head);
-       $head = str_replace('<prenom>', $prenom, $head);
-       $head = str_replace('<nom>',    $nom,    $head);
-       $head = enriched_to_text($head,true);
-
-       if($head) {
-            $res .= "<div class='intro'>$head</div>";
-        }
-
-       $i = 1;
-       $res .= "<a id='top_lnk'></a>";
-       foreach ($this->_arts as $cid=>$arts) {
-           $res .= "<div class='lnk'><a href='#cat$cid'><strong>$i. {$this->_cats[$cid]}</strong></a>";
-           foreach ($arts as $art) {
-               $res .= "<a href='#art{$art->_aid}'>&nbsp;&nbsp;- ".htmlentities($art->title())."</a>";
-           }
-           $res .= '</div>';
-           $i ++;
-       }
-
-       foreach ($this->_arts as $cid=>$arts) {
-           $res .= "<h1><a id='cat$cid'></a><span>".$this->_cats[$cid].'</span></h1>';
-           foreach($arts as $art) {
-               $res .= $art->toHtml();
-               $res .= "<p><a href='#top_lnk'>Revenir au sommaire</a></p>";
-           }
-       }
-
-       $res .= $this->footer(true);
-
-       if ($body) {
-           $res = <<<EOF
-<html>
-  <head>
-    <style type="text/css">
-    <!--
-      div.nl    { margin: auto; font-family: "Georgia","times new roman",serif; width: 60ex; text-align: justify; font-size: 10pt; }
-      div.title { margin: 2ex 0ex 2ex 0ex; padding: 1ex; width: 100%; font-size: 140%; text-align: center;
-                 font-weight: bold; border-bottom: 3px red solid; border-top: 3px red solid; }
-      
-      a[href]       { text-decoration: none; }
-      a[href]:hover { text-decoration: underline; }
-      
-      div.lnk   { margin: 2ex 0ex 2ex 0ex; padding: 0ex 2ex 0ex 2ex; }
-      div.lnk a { display: block; }
-      
-      h1 { margin: 6ex 0ex 4ex 0ex; padding: 2px 4ex 2px 0ex; width: 60ex; font-size: 100%;
-           border-bottom: 3px red solid; border-top: 3px red solid; }
-      h2 { width: 100%; margin: 0ex 1ex 0ex 1ex; padding: 2px 0px 2px 0px; font-weight: bold; font-style: italic; font-size: 95%; }
-      h1 span { font-size: 140%; padding: 2px 1ex 2px 1ex; border-bottom: 3px red solid; }
-      h2 span { padding: 2px 4px 2px 4px; border-bottom: 2px yellow solid; }
-      
-      div.art   { padding: 2ex; margin: 0ex 1ex 2ex 1ex; width: 58ex; border-top: 2px yellow solid; }
-      div.app   { padding: 2ex 3ex 0ex 3ex; width: 100%; margin: 0ex; text-align: left; font-size: 95%; }
-      div.intro { padding: 2ex; }
-      div.foot  { border-top: 1px #808080 dashed; font-size: 95%; padding: 1ex; color: #808080; background: inherit;
-                 text-align: center; width: 100% }
-    -->
-    </style>
-  </head>
-  <body>
-    <div class='nl'>
-    $res
-    </div>
-  </body>
-</html>
-EOF;
-       }
-       return $res;
+    // {{{ Display
+
+    /** Retrieve the title of this issue
+     * @p $mail Whether we want the normal title or the email subject
+     * @return Title of the issue
+     */
+    public function title($mail = false)
+    {
+        return $mail ? $this->title_mail : $this->title;
+    }
+
+    /** Retrieve the head of this issue
+     * @p $user User for <dear> customization (may be null: no customization)
+     * @p $type Either 'text' or 'html'
+     * @return Formatted head of the issue.
+     */
+    public function head($user = null, $type = 'text')
+    {
+        if (is_null($user)) {
+            return $this->head;
+        } else {
+            $head = $this->head;
+            $head = str_replace(array('<cher>', '<prenom>', '<nom>'),
+                                array(($user->isFemale() ? 'Chère' : 'Cher'), $user->displayName(), ''),
+                                $head);
+            return format_text($head, $type, 2, 64);
+        }
+    }
+
+    /** Retrieve the formatted signature of this issue.
+     */
+    public function signature($type = 'text')
+    {
+        return format_text($this->signature, $type, 2, 64);
+    }
+
+    /** Get the title of a given category
+     * @p $cid ID of the category to retrieve
+     * @return Name of the category
+     */
+    public function category($cid)
+    {
+        return $this->nl->cats[$cid];
+    }
+
+    /** Add required data to the given $page for proper CSS display
+     * @p $page Smarty object
+     * @return Either 'true' (if CSS was added to a page) or the raw CSS to add (when $page is null)
+     */
+    public function css($page = null)
+    {
+        if (!is_null($page)) {
+            $page->addCssLink($this->nl->cssFile());
+            return true;
+        } else {
+            $css = file_get_contents(dirname(__FILE__) . '/../htdocs/css/' . $this->nl->cssFile());
+            return preg_replace('@/\*.*?\*/@us', '', $css);
+        }
+    }
+
+    /** Set up a smarty page for a 'text' mode render of the issue
+     * @p $page Smarty object (using the $this->nl->tplFile() template)
+     * @p $user User to use when rendering the template
+     */
+    public function toText($page, $user)
+    {
+        $this->fetchArticles();
+
+        $this->css($page);
+        $page->assign('prefix', null);
+        $page->assign('is_mail', false);
+        $page->assign('mail_part', 'text');
+        $page->assign('user', $user);
+        $page->assign('hash', null);
+        $this->assignData($page);
+    }
+
+    /** Set up a smarty page for a 'html' mode render of the issue
+     * @p $page Smarty object (using the $this->nl->tplFile() template)
+     * @p $user User to use when rendering the template
+     */
+    public function toHtml($page, $user)
+    {
+        $this->fetchArticles();
+
+        $this->css($page);
+        $page->assign('prefix', $this->nl->prefix() . '/show/' . $this->id());
+        $page->assign('is_mail', false);
+        $page->assign('mail_part', 'html');
+        $page->assign('user', $user);
+        $page->assign('hash', null);
+        $this->assignData($page);
+    }
+
+    /** Set all 'common' data for the page (those which are required for both web and email rendering)
+     * @p $smarty Smarty object (e.g page) which should be filled
+     */
+    protected function assignData($smarty)
+    {
+        $this->fetchArticles();
+
+        $smarty->assign_by_ref('issue', $this);
+        $smarty->assign_by_ref('nl', $this->nl);
     }
 
     // }}}
-    // {{{ function sendTo()
-    
-    function sendTo($prenom, $nom, $login, $sex, $html)
+    // {{{ Mailing
+
+    /** Check whether this issue is empty
+     * An issue is empty if the email has no title (or the default one), or no articles and an empty head.
+     */
+    public function isEmpty()
     {
-        global $globals;
-       require_once('diogenes/diogenes.hermes.inc.php');
+        return $this->title_mail == '' || $this->title_mail == 'to be continued' || (count($this->arts) == 0 && strlen($this->head) == 0);
+    }
+
+    /** Retrieve the 'Send before' date, in a clean format.
+     */
+    public function getSendBeforeDate()
+    {
+        return strftime('%Y-%m-%d', strtotime($this->send_before));
+    }
+
+    /** Retrieve the 'Send before' time (i.e hour), in a clean format.
+     */
+    public function getSendBeforeTime()
+    {
+        return strtotime($this->send_before);
+    }
 
-       $mailer = new HermesMailer();
-       $mailer->setFrom($globals->newsletter->from);
-       $mailer->setSubject($this->title());
-       $mailer->addTo("\"$prenom $nom\" <$login@{$globals->mail->domain}>");
-        if (!empty($globals->newsletter->replyto)) {
-            $mailer->addHeader('Reply-To',$globals->newsletter->replyto);
+    /** Create a hash based on some additional data
+     * $line Line-specific data (to prevent two hashes generated at the same time to be the same)
+     */
+    protected static function createHash($line)
+    {
+        $hash = implode(time(), $line) . rand();
+        $hash = md5($hash);
+        return $hash;
+    }
+
+    /** Send this issue to the given user, reusing an existing hash if provided.
+     * @p $user User to whom the issue should be mailed
+     * @p $hash Optional hash to use in the 'unsubscribe' link; if null, another one will be generated.
+     */
+    public function sendTo($user, $hash = null)
+    {
+        $this->fetchArticles();
+
+        if (is_null($hash)) {
+            $hash = XDB::fetchOneCell("SELECT  hash
+                                         FROM  newsletter_ins
+                                        WHERE  uid = {?} AND nlid = {?}",
+                                      $user->id(), $this->nl->id);
         }
-        if (!empty($globals->newsletter->retpath)) {
-            $mailer->addHeader('Return-Path',$globals->newsletter->retpath);
+        if (is_null($hash)) {
+            $hash = self::createHash(array($user->displayName(), $user->fullName(),
+                                       $user->isFemale(), $user->isEmailFormatHtml(),
+                                       rand(), "X.org rulez"));
+            XDB::execute("UPDATE  newsletter_ins as ni
+                             SET  ni.hash = {?}
+                           WHERE  ni.uid = {?} AND ni.nlid = {?}",
+                         $hash, $user->id(), $this->nl->id);
         }
-       $mailer->setTxtBody($this->toText($prenom,$nom,$sex));
-       if ($html) {
-           $mailer->setHTMLBody($this->toHtml($prenom,$nom,$sex,true));
-       }
-       $mailer->send();
+
+        $mailer = new PlMailer($this->nl->tplFile());
+        $this->assignData($mailer);
+        $mailer->assign('is_mail', true);
+        $mailer->assign('user', $user);
+        $mailer->assign('prefix',  null);
+        $mailer->assign('hash',    $hash);
+        $mailer->sendTo($user);
+    }
+
+    /** Select a subset of subscribers which should receive the newsletter.
+     * NL-Specific selections (not yet received, is subscribed) are done when sending.
+     * @return A PlFilterCondition.
+     */
+    protected function getRecipientsUFC()
+    {
+        return $this->sufb->getUFC();
+    }
+
+    /** Check whether a given user may see this issue.
+     * @p $user User whose access should be checked
+     * @return Whether he may access the issue
+     */
+    public function checkUser($user = null)
+    {
+        if ($user == null) {
+            $user = S::user();
+        }
+        $uf = new UserFilter($this->getRecipientsUFC());
+        return $uf->checkUser($user);
+    }
+
+    /** Sent this issue to all valid recipients
+     * @return Number of issues sent
+     */
+    public function sendToAll()
+    {
+        $this->fetchArticles();
+
+        XDB::execute('UPDATE  newsletter_issues
+                         SET  state = \'sent\', date=CURDATE()
+                       WHERE  id = {?}',
+                       $this->id);
+
+        $ufc = new PFC_And($this->getRecipientsUFC(), new UFC_NLSubscribed($this->nl->id, $this->id), new UFC_HasEmailRedirect());
+        $emailsCount = 0;
+        $uf = new UserFilter($ufc, array(new UFO_IsAdmin(), new UFO_Uid()));
+        $limit = new PlLimit(self::BATCH_SIZE);
+
+        while (true) {
+            $sent = array();
+            $users = $uf->getUsers($limit);
+            if (count($users) == 0) {
+                return $emailsCount;
+            }
+            foreach ($users as $user) {
+                $sent[] = $user->id();
+                $this->sendTo($user, $hash);
+                ++$emailsCount;
+            }
+            XDB::execute("UPDATE  newsletter_ins
+                             SET  last = {?}
+                           WHERE  nlid = {?} AND uid IN {?}", $this->id, $this->nl->id, $sent);
+
+            sleep(60);
+        }
+        return $emailsCount;
     }
 
     // }}}
@@ -340,207 +1126,178 @@ EOF;
 
 class NLArticle
 {
+    // Maximum number of lines per article
+    const MAX_LINES_PER_ARTICLE = 9;
+
     // {{{ properties
-    
-    var $_aid;
-    var $_cid;
-    var $_pos;
-    var $_title;
-    var $_body;
-    var $_append;
+
+    public $aid;
+    public $cid;
+    public $pos;
+    public $title;
+    public $body;
+    public $append;
 
     // }}}
     // {{{ constructor
-    
-    function NLArticle($title='', $body='', $append='', $aid=-1, $cid=0, $pos=0)
+
+    function __construct($title='', $body='', $append='', $aid=-1, $cid=0, $pos=0)
     {
-       $this->_body   = $body;
-       $this->_title  = $title;
-       $this->_append = $append;
-       $this->_aid    = $aid;
-       $this->_cid    = $cid;
-       $this->_pos    = $pos;
+        $this->body   = $body;
+        $this->title  = $title;
+        $this->append = $append;
+        $this->aid    = $aid;
+        $this->cid    = $cid;
+        $this->pos    = $pos;
     }
 
     // }}}
     // {{{ function title()
 
-    function title()
-    { return trim($this->_title); }
+    public function title()
+    { return trim($this->title); }
 
     // }}}
     // {{{ function body()
-    
-    function body()
-    { return trim($this->_body); }
-    
+
+    public function body()
+    { return trim($this->body); }
+
     // }}}
     // {{{ function append()
-    
-    function append()
-    { return trim($this->_append); }
+
+    public function append()
+    { return trim($this->append); }
 
     // }}}
     // {{{ function toText()
 
-    function toText()
+    public function toText($hash = null, $login = null)
     {
-       $title = '*'.$this->title().'*';
-       $body  = enriched_to_text($this->_body,false,true);
-       $app   = enriched_to_text($this->_append,false,false,4);
-       return trim("$title\n\n$body\n\n$app")."\n";
+        $title = '*'.$this->title().'*';
+        $body = MiniWiki::WikiToText($this->body, true);
+        $app = MiniWiki::WikiToText($this->append, false, 4);
+        $text = trim("$title\n\n$body\n\n$app")."\n";
+        if (!is_null($hash) && !is_null($login)) {
+            $text = str_replace('%HASH%', "$hash/$login", $text);
+        } else {
+            $text = str_replace('%HASH%', '', $text);
+        }
+        return $text;
     }
 
     // }}}
     // {{{ function toHtml()
 
-    function toHtml()
+    public function toHtml($hash = null, $login = null)
     {
-       $title = "<h2><a id='art{$this->_aid}'></a><span>".htmlentities($this->title()).'</span></h2>';
-       $body  = enriched_to_text($this->_body,true);
-       $app   = enriched_to_text($this->_append,true);
-       
-       $art   = "$title\n";
-       $art  .= "<div class='art'>\n$body\n";
-       if ($app) {
+        $title = "<h2 class='xorg_nl'><a id='art{$this->aid}'></a>".pl_entities($this->title()).'</h2>';
+        $body  = MiniWiki::WikiToHTML($this->body);
+        $app   = MiniWiki::WikiToHTML($this->append);
+
+        $art   = "$title\n";
+        $art  .= "<div class='art'>\n$body\n";
+        if ($app) {
             $art .= "<div class='app'>$app</div>";
         }
-       $art  .= "</div>\n";
-       
-       return $art;
+        $art  .= "</div>\n";
+        if (!is_null($hash) && !is_null($login)) {
+            $art = str_replace('%HASH%', "$hash/$login", $art);
+        } else {
+            $art = str_replace('%HASH%', '', $art);
+        }
+
+        return $art;
     }
 
     // }}}
     // {{{ function check()
 
-    function check()
+    public function check()
     {
-       $text = enriched_to_text($this->_body);
-       $arr  = explode("\n",wordwrap($text,68));
-       $c    = 0;
-       foreach ($arr as $line) {
+        $text = MiniWiki::WikiToText($this->body);
+        $arr  = explode("\n",wordwrap($text,68));
+        $c    = 0;
+        foreach ($arr as $line) {
             if (trim($line)) {
                 $c++;
             }
         }
-       return $c<9;
+        return $c < self::MAX_LINES_PER_ARTICLE;
     }
 
     // }}}
-}
+    // {{{ function parseUrlsFromArticle()
 
-// }}}
-// {{{ Functions
+    protected function parseUrlsFromArticle()
+    {
+        $email_regex = '([a-z0-9.\-+_\$]+@([\-.+_]?[a-z0-9])+)';
+        $url_regex = '((https?|ftp)://[a-zA-Z0-9._%#+/?=&~-]+)';
+        $regex = '{' . $email_regex . '|' . $url_regex . '}i';
 
-function insert_new_nl()
-{
-    XDB::execute("INSERT INTO newsletter SET bits='new',date=NOW(),titre='to be continued'");
-}
+        $matches = array();
+        $body_matches = array();
+        if (preg_match_all($regex, $this->body(), $body_matches)) {
+            $matches = array_merge($matches, $body_matches[0]);
+        }
 
-function get_nl_slist()
-{
-    $res = XDB::query("SELECT id,date,titre FROM newsletter ORDER BY date DESC");
-    return $res->fetchAllAssoc();
-}
+        $append_matches = array();
+        if (preg_match_all($regex, $this->append(), $append_matches)) {
+            $matches = array_merge($matches, $append_matches[0]);
+        }
 
-function get_nl_list()
-{
-    $res = XDB::query("SELECT id,date,titre FROM newsletter WHERE bits!='new' ORDER BY date DESC");
-    return $res->fetchAllAssoc();
-}
+        return $matches;
+    }
 
-function get_nl_state()
-{
-    $res = XDB::query('SELECT 1 FROM newsletter_ins WHERE user_id={?}', S::v('uid'));
-    return $res->fetchOneCell();
-}
-function unsubscribe_nl()
-{
-    XDB::execute('DELETE FROM newsletter_ins WHERE user_id={?}', S::v('uid'));
-}
-function subscribe_nl($uid=-1)
-{
-    $user = ($uid == -1) ? S::v('uid') : $uid;
-    XDB::execute('REPLACE INTO  newsletter_ins (user_id,last)
-                        VALUES  ({?}, 0)', $user);
-}
-function justify($text,$n)
-{
-    $arr = explode("\n",wordwrap($text,$n));
-    $arr = array_map('trim',$arr);
-    $res = '';
-    foreach ($arr as $key => $line) {
-       $nxl       = isset($arr[$key+1]) ? trim($arr[$key+1]) : '';
-       $nxl_split = preg_split('! +!',$nxl);
-       $nxw_len   = count($nxl_split) ? strlen($nxl_split[0]) : 0;
-       $line      = trim($line);
-
-       if (strlen($line)+1+$nxw_len < $n) {
-           $res .= "$line\n";
-           continue;
-       }
-       
-       if (preg_match('![.:;]$!',$line)) {
-           $res .= "$line\n";
-           continue;
-       }
-
-       $tmp   = preg_split('! +!',trim($line));
-       $words = count($tmp);
-       if ($words <= 1) {
-           $res .= "$line\n";
-           continue;
-       }
-
-       $len   = array_sum(array_map('strlen',$tmp));
-       $empty = $n - $len;
-       $sw    = floatval($empty) / floatval($words-1);
-       
-       $cur = 0;
-       $l   = '';
-       foreach ($tmp as $word) {
-           $l   .= $word;
-           $cur += $sw + strlen($word);
-           $l    = str_pad($l,intval($cur+0.5));
-       }
-       $res .= trim($l)."\n";
-    }
-    return trim($res);
+    // }}}
+    // {{{ function getLinkIps()
+
+    public function getLinkIps(&$blacklist_host_resolution_count)
+    {
+        $matches = $this->parseUrlsFromArticle();
+        $article_ips = array();
+
+        if (!empty($matches)) {
+            global $globals;
+
+            foreach ($matches as $match) {
+                $host = parse_url($match, PHP_URL_HOST);
+                if ($host == '') {
+                    list(, $host) = explode('@', $match);
+                }
+
+                if ($blacklist_host_resolution_count >= $globals->mail->blacklist_host_resolution_limit) {
+                   break;
+                }
+
+                if (!preg_match('/^(' . str_replace(' ', '|', $globals->mail->domain_whitelist) . ')$/i', $host)) {
+                    $article_ips = array_merge($article_ips, array(gethostbyname($host) => $host));
+                    ++$blacklist_host_resolution_count;
+                }
+            }
+        }
+
+        return $article_ips;
+    }
+
+    // }}}
 }
 
-function enriched_to_text($input,$html=false,$just=false,$indent=0,$width=68)
+// }}}
+
+// {{{ Functions
+
+function format_text($input, $format, $indent = 0, $width = 68)
 {
-    $text = trim($input);
-    if ($html) {
-       $text = htmlspecialchars($text);
-       $text = str_replace('[b]','<strong>', $text);
-       $text = str_replace('[/b]','</strong>', $text);
-       $text = str_replace('[i]','<em>', $text);
-       $text = str_replace('[/i]','</em>', $text);
-       $text = str_replace('[u]','<span style="text-decoration: underline">', $text);
-       $text = str_replace('[/u]','</span>', $text);
-       $text = preg_replace('!((https?|ftp)://[^\r\n\t ]*)!','<a href="\1">\1</a>', $text);
-       $text = preg_replace('!(([a-zA-Z0-9\-_+.]*@[a-zA-Z0-9\-_+.]*)(?:\?[^\r\n\t ]*)?)!','<a href="mailto:\1">\2</a>', $text);
-       return nl2br($text);
-    } else {
-       $text = preg_replace('!\[\/?b\]!','*',$text);
-       $text = preg_replace('!\[\/?u\]!','_',$text);
-       $text = preg_replace('!\[\/?i\]!','/',$text);
-       $text = preg_replace('!((https?|ftp)://[^\r\n\t ]*)!','[\1]', $text);
-       $text = preg_replace('!(([a-zA-Z0-9\-_+.]*@[a-zA-Z0-9\-_+.]*)(?:\?[^\r\n\t ]*)?)!','[mailto:\1]', $text);
-       $text = $just ? justify($text,$width-$indent) : wordwrap($text,$width-$indent);
-       if($indent) {
-           $ind = str_pad('',$indent);
-           $text = $ind.str_replace("\n","\n$ind",$text);
-       }
-       return $text;
+    if ($format == 'text') {
+        return MiniWiki::WikiToText($input, true, $indent, $width, "title");
     }
+    return MiniWiki::WikiToHTML($input, "title");
 }
 
+// function enriched_to_text($input,$html=false,$just=false,$indent=0,$width=68)
+
 // }}}
 
-// vim:set et sw=4 sts=4 sws=4:
+// vim:set et sw=4 sts=4 sws=4 enc=utf-8:
 ?>