X-Git-Url: http://git.polytechnique.org/?a=blobdiff_plain;f=include%2Fnewsletter.inc.php;h=b8c248e527116433c08e1fb25bba3a6e6af60828;hb=d53c12097f4e82ad4657de86f199699973dee29f;hp=7e4b13b740a621dbd00607d16be9e9e87d27f359;hpb=736dcfda7006f30d6150722a40c94cafa1a3f112;p=platal.git diff --git a/include/newsletter.inc.php b/include/newsletter.inc.php index 7e4b13b..b8c248e 100644 --- a/include/newsletter.inc.php +++ b/include/newsletter.inc.php @@ -1,6 +1,6 @@ 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'"); + 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()) { - insert_new_nl(); - } - $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); - } + 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 - function setSent() + /** 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() { - XDB::execute("UPDATE newsletter SET bits='sent' WHERE id={?}", $this->_id); + $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(); + } + + $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() + { + $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($enforce_xnet=true) + { + 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(!$enforce_xnet); + } + } + + /** Get the prefix to use for all 'admin' pages of this NL. + */ + public function adminPrefix($enforce_xnet=true) + { + 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(!$enforce_xnet); + } + } + + /** 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('issue', $this); + $mailer->assign('base', $globals->baseurl); + $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() { - 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"]); - } + 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() + { + 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 = 'https://www.polytechnique.org'; - - if ($html) { - return '
Cette lettre est envoyée à tous les Polytechniciens sur Internet par l\'intermédiaire de Polytechnique.org.
' - . '
' - . "[archives | " - . "écrire dans la NL | " - . "ne plus recevoir]" - . '
'; - } 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, + ); - function toText($prenom,$nom,$sexe) - { - $res = "====================================================================\n"; - $res .= ' '.$this->title()."\n"; - $res .= "====================================================================\n\n"; - - $head = $this->head(); - $head = str_replace('', $sexe ? 'Chère' : 'Cher', $head); - $head = str_replace('', $prenom, $head); - $head = str_replace('', $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; + 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(); + + 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, $urlprefix = false) - { - $u = $urlprefix ? 'nl/show/'.$this->_id : ''; - $res = '
'.$this->title().'
'; - - $head = $this->head(); - $head = str_replace('', $sexe ? 'Chère' : 'Cher', $head); - $head = str_replace('', $prenom, $head); - $head = str_replace('', $nom, $head); - $head = enriched_to_text($head, true); - - if($head) { - $res .= "
$head
"; - } - - $i = 1; - $res .= ""; - foreach ($this->_arts as $cid=>$arts) { - $res .= "
$i. {$this->_cats[$cid]}"; - foreach ($arts as $art) { - $res .= "  - ".htmlentities($art->title()).""; - } - $res .= '
'; - $i ++; - } - - foreach ($this->_arts as $cid=>$arts) { - $res .= "

".$this->_cats[$cid].'

'; - foreach($arts as $art) { - $res .= $art->toHtml(); - $res .= "

Revenir au sommaire

"; - } - } - - $res .= $this->footer(true); - - if ($body) { - $res = << - - - - -
- $res -
- - -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 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('', '', ''), + 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; } // }}} @@ -344,207 +1123,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 = "

".htmlentities($this->title()).'

'; - $body = enriched_to_text($this->_body,true); - $app = enriched_to_text($this->_append,true); - - $art = "$title\n"; - $art .= "
\n$body\n"; - if ($app) { + $title = "

".pl_entities($this->title()).'

'; + $body = MiniWiki::WikiToHTML($this->body); + $app = MiniWiki::WikiToHTML($this->append); + + $art = "$title\n"; + $art .= "
\n$body\n"; + if ($app) { $art .= "
$app
"; } - $art .= "
\n"; - - return $art; + $art .= "
\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]','', $text); - $text = str_replace('[/b]','', $text); - $text = str_replace('[i]','', $text); - $text = str_replace('[/i]','', $text); - $text = str_replace('[u]','', $text); - $text = str_replace('[/u]','', $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 ]*)?)!','\2', $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: ?>