Update/merge Newsletter-related code. (Closes #1226, #858, #1047)
[platal.git] / include / newsletter.inc.php
CommitLineData
0337d704 1<?php
2/***************************************************************************
12262f13 3 * Copyright (C) 2003-2011 Polytechnique.org *
0337d704 4 * http://opensource.polytechnique.org/ *
5 * *
6 * This program is free software; you can redistribute it and/or modify *
7 * it under the terms of the GNU General Public License as published by *
8 * the Free Software Foundation; either version 2 of the License, or *
9 * (at your option) any later version. *
10 * *
11 * This program is distributed in the hope that it will be useful, *
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of *
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
14 * GNU General Public License for more details. *
15 * *
16 * You should have received a copy of the GNU General Public License *
17 * along with this program; if not, write to the Free Software *
18 * Foundation, Inc., *
19 * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA *
20 ***************************************************************************/
21
84163d58
RB
22// {{{ class MailNotFound
23
24class MailNotFound extends Exception {
25}
26
27// }}}
0337d704 28
0337d704 29// {{{ class NewsLetter
30
84163d58 31class NewsLetter
0337d704 32{
84163d58
RB
33 public $id; // ID of the NL (in table newsletters)
34 public $group; // Short name of the group corresponding to the NL
35 public $group_id; // ID of that group
36 public $name; // Name of the NL (e.g "Lettre de Polytechnique.org", ...)
37 public $cats; // List of all categories for this NL
38 public $criteria; // PlFlagSet of allowed filters for recipient selection
39
40 protected $custom_css = false;
0337d704 41
84163d58
RB
42 // Base name to use instead of the group short name for NLs without a custom CSS
43 const FORMAT_DEFAULT_GROUP = 'default';
44
45 // Diminutif of X.net groups with a specific NL view
46 const GROUP_XORG = 'Polytechnique.org';
47 const GROUP_AX = 'AX';
48 const GROUP_EP = 'Ecole';
49
50 // {{{ Constructor, NewsLetter retrieval (forGroup, getAll)
51
52 public function __construct($id)
0337d704 53 {
84163d58
RB
54 // Load NL data
55 $res = XDB::query('SELECT nls.group_id, g.diminutif AS group_name,
56 nls.name AS nl_name, nls.custom_css, nls.criteria
57 FROM newsletters AS nls
58 LEFT JOIN groups AS g ON (nls.group_id = g.id)
59 WHERE nls.id = {?}',
60 $id);
61 if (!$res->numRows()) {
62 throw new MailNotFound();
63 }
64
65 $data = $res->fetchOneAssoc();
66 $this->id = $id;
67 $this->group_id = $data['group_id'];
68 $this->group = $data['group_name'];
69 $this->name = $data['nl_name'];
70 $this->custom_css = $data['custom_css'];
71 $this->criteria = new PlFlagSet($data['criteria']);
72
73 // Load the categories
74 $res = XDB::iterRow(
75 'SELECT cid, title
76 FROM newsletter_cat
77 WHERE nlid = {?}
78 ORDER BY pos', $id);
79 while (list($cid, $title) = $res->next()) {
80 $this->cats[$cid] = $title;
81 }
82 }
83
84 /** Retrieve the NL associated with a given group.
85 * @p $group Short name of the group
86 * @return A NewsLetter object, or null if the group doesn't have a NL.
87 */
88 public static function forGroup($group)
89 {
90 $res = XDB::query('SELECT nls.id
91 FROM newsletters AS nls
92 LEFT JOIN groups AS g ON (nls.group_id = g.id)
93 WHERE g.diminutif = {?}', $group);
94 if (!$res->numRows()) {
95 return null;
96 }
97 return new NewsLetter($res->fetchOneCell());
98 }
99
100 /** Retrieve all newsletters
101 * @return An array of $id => NewsLetter objects
102 */
103 public static function getAll()
104 {
105 $res = XDB::query('SELECT id
106 FROM newsletters');
107 $nls = array();
108 foreach ($res->fetchColumn() as $id) {
109 $nls[$id] = new NewsLetter($id);
110 }
111 return $nls;
112 }
113
114 // }}}
115 // {{{ Issue retrieval
116
117 /** Retrieve all issues which should be sent
118 * @return An array of NLIssue objects to send (i.e state = 'new' and send_before <= today)
119 */
120 public static function getIssuesToSend()
121 {
122 $res = XDB::query('SELECT id
123 FROM newsletter_issues
124 WHERE state = \'pending\' AND send_before <= NOW()');
125 $issues = array();
126 foreach ($res->fetchColumn() as $id) {
127 $issues[$id] = new NLIssue($id);
128 }
129 return $issues;
130 }
131
132 /** Retrieve a given issue of this NewsLetter
133 * @p $name Name or ID of the issue to retrieve.
134 * @return A NLIssue object.
135 *
136 * $name may be either a short_name, an ID or the special value 'last' which
137 * selects the latest sent NL.
138 * If $name is null, this will retrieve the current pending NL.
139 */
140 public function getIssue($name = null, $only_sent = true)
141 {
142 if ($name) {
143 if ($name == 'last') {
144 if ($only_sent) {
145 $where = 'state = \'sent\' AND ';
146 } else {
147 $where = '';
148 }
149 $res = XDB::query('SELECT MAX(id)
150 FROM newsletter_issues
151 WHERE ' . $where . ' nlid = {?}',
152 $this->id);
153 } else {
154 $res = XDB::query('SELECT id
155 FROM newsletter_issues
156 WHERE nlid = {?} AND (id = {?} OR short_name = {?})',
157 $this->id, $name, $name);
ed76a506 158 }
ed76a506 159 if (!$res->numRows()) {
84163d58
RB
160 throw new MailNotFound();
161 }
162 $id = $res->fetchOneCell();
163 } else {
164 $query = XDB::format('SELECT id
165 FROM newsletter_issues
166 WHERE nlid = {?} AND state = \'new\'
167 ORDER BY id DESC', $this->id);
168 $res = XDB::query($query);
169 if ($res->numRows()) {
170 $id = $res->fetchOneCell();
171 } else {
172 // Create a new, empty issue, and return it
173 $id = $this->createPending();
ed76a506 174 }
ad6ca77d 175 }
84163d58
RB
176
177 return new NLIssue($id, &$this);
178 }
179
180 /** Create a new, empty, pending newsletter issue
181 * @p $nlid The id of the NL for which a new pending issue should be created.
182 * @return Id of the newly created issue.
183 */
184 public function createPending()
185 {
186 XDB::execute('INSERT INTO newsletter_issues
187 SET nlid = {?}, state=\'new\', date=NOW(),
188 title=\'to be continued\',
189 mail_title=\'to be continued\'',
190 $this->id);
191 return XDB::insertId();
192 }
193
194 /** Return all sent issues of this newsletter.
195 * @return An array of (id => NLIssue)
196 */
197 public function listSentIssues($check_user = false, $user = null)
198 {
199 if ($check_user && $user == null) {
200 $user = S::user();
201 }
202
203 $res = XDB::query('SELECT id
204 FROM newsletter_issues
205 WHERE nlid = {?} AND state = \'sent\'
206 ORDER BY date DESC', $this->id);
207 $issues = array();
208 foreach ($res->fetchColumn() as $id) {
209 $issue = new NLIssue($id, $this, false);
210 if (!$check_user || $issue->checkUser($user)) {
211 $issues[$id] = $issue;
212 }
213 }
214 return $issues;
215 }
216
217 /** Return all issues of this newsletter, including invalid and sent.
218 * @return An array of (id => NLIssue)
219 */
220 public function listAllIssues()
221 {
222 $res = XDB::query('SELECT id
223 FROM newsletter_issues
224 WHERE nlid = {?}
225 ORDER BY FIELD(state, \'pending\', \'new\') DESC, date DESC', $this->id);
226 $issues = array();
227 foreach ($res->fetchColumn() as $id) {
228 $issues[$id] = new NLIssue($id, $this, false);
229 }
230 return $issues;
231 }
232
233 /** Return the latest pending issue of the newsletter.
234 * @return Either null, or a NL object.
235 */
236 public function getPendingIssue()
237 {
238 $res = XDB::query('SELECT MAX(id)
239 FROM newsletter_issues
240 WHERE nlid = {?} AND state = \'new\'',
241 $this->id);
242 if ($res->numRows()) {
243 $id = $res->fetchOneCell();
244 return new NLIssue($id, $this);
245 } else {
246 return null;
247 }
248 }
249
250 // }}}
251 // {{{ Subscription related function
252
253 /** Unsubscribe a user from this newsletter
254 * @p $uid UID to unsubscribe from the newsletter; if null, use current user.
255 * @p $hash True if the uid is actually a hash.
256 * @return True if the user was successfully unsubscribed.
257 */
258 public function unsubscribe($uid = null, $hash = false)
259 {
260 if (is_null($uid) && $hash) {
261 // Unable to unsubscribe from an empty hash
262 return false;
263 }
264 $user = is_null($uid) ? S::user()->id() : $uid;
265 $field = $hash ? 'hash' : 'uid';
266 $res = XDB::query('SELECT uid
267 FROM newsletter_ins
268 WHERE nlid = {?} AND ' . $field . ' = {?}',
269 $this->id, $user);
270 if (!$res->numRows()) {
271 // No subscribed user with that UID/hash
272 return false;
273 }
274 $user = $res->fetchOneCell();
275
276 XDB::execute('DELETE FROM newsletter_ins
277 WHERE nlid = {?} AND uid = {?}',
278 $this->id, $user);
279 return true;
280 }
281
282 /** Subscribe a user to a newsletter
283 * @p $user User to subscribe to the newsletter; if null, use current user.
284 */
285 public function subscribe($user = null)
286 {
287 if (is_null($user)) {
288 $user = S::user();
289 }
290 if (self::maySubscribe($user)) {
291 XDB::execute('INSERT IGNORE INTO newsletter_ins (nlid, uid, last, hash)
292 VALUES ({?}, {?}, NULL, hash)',
293 $this->id, $user->id());
294 }
295 }
296
297 /** Retrieve subscription state of a user
298 * @p $user Target user; if null, use current user.
299 * @return Boolean: true if the user has subscribed to the NL.
300 */
301 public function subscriptionState($user = null)
302 {
303 if (is_null($user)) {
304 $user = S::user();
305 }
306 $res = XDB::query('SELECT 1
307 FROM newsletter_ins
308 WHERE nlid = {?} AND uid = {?}',
309 $this->id, $user->id());
310 return ($res->numRows() == 1);
311 }
312
313 /** Get the count of subscribers to the NL.
314 * @return Number of subscribers.
315 */
316 public function subscriberCount()
317 {
318 return XDB::fetchOneCell('SELECT COUNT(uid)
319 FROM newsletter_ins
320 WHERE nlid = {?}', $this->id);
321 }
322
323 /** Get the number of subscribers to the NL whose last received mailing was $last.
324 * @p $last ID of the issue for which subscribers should be counted.
325 * @return Number of subscribers
326 */
327 public function subscriberCountForLast($last)
328 {
329 return XDB::fetchOneCell('SELECT COUNT(uid)
330 FROM newsletter_ins
331 WHERE nlid = {?} AND last = {?}', $this->id, $last);
332 }
333
334 /** Retrieve the list of newsletters a user has subscribed to
335 * @p $user User whose subscriptions should be retrieved (if null, use session user).
336 * @return Array of newsletter IDs
337 */
338 public static function getUserSubscriptions($user = null)
339 {
340 if (is_null($user)) {
341 $user = S::user();
342 }
343 $res = XDB::query('SELECT nlid
344 FROM newsletter_ins
345 WHERE uid = {?}',
346 $user->id());
347 return $res->fetchColumn();
348 }
349
350 /** Retrieve the UserFilterBuilder for subscribers to this NL.
351 * This is the place where NL-specific filters may be allowed or prevented.
352 * @p $envprefix Prefix to use for env fields (cf. UserFilterBuilder)
353 * @return A UserFilterBuilder object using the given env prefix
354 */
355 public function getSubscribersUFB($envprefix = '')
356 {
357 require_once 'ufbuilder.inc.php';
358 return new UFB_NewsLetter($this->criteria, $envprefix);
359 }
360
361 // }}}
362 // {{{ Permissions related functions
363
364 /** For later use: check whether a given user may subscribe to this newsletter.
365 * @p $user User whose access should be checked
366 * @return Boolean: whether the user may subscribe to the NL.
367 */
368 public function maySubscribe($user = null)
369 {
370 return true;
371 }
372
373 /** Whether a given user may edit this newsletter
374 * @p $uid UID of the user whose perms should be checked (if null, use current user)
375 * @return Boolean: whether the user may edit the NL
376 */
377 public function mayEdit($user = null)
378 {
379 if (is_null($user)) {
380 $user = S::user();
381 }
382 if ($user->checkPerms('admin')) {
383 return true;
384 }
385 $res = XDB::query('SELECT perms
386 FROM group_members
387 WHERE asso_id = {?} AND uid = {?}',
388 $this->group_id, $user->id());
389 return ($res->numRows() && $res->fetchOneCell() == 'admin');
390 }
391
392 /** Whether a given user may submit articles to this newsletter using X.org validation system
393 * @p $user User whose access should be checked (if null, use current user)
394 * @return Boolean: whether the user may submit articles
395 */
396 public function maySubmit($user = null)
397 {
398 // Submission of new articles is only enabled for the X.org NL (and forbidden when viewing issues on X.net)
399 return ($this->group == self::GROUP_XORG && !isset($GLOBALS['IS_XNET_SITE']));
400 }
401
402 // }}}
403 // {{{ Display-related functions: cssFile, tplFile, prefix, admin_prefix, admin_links_enabled, automatic_mailings_enabled
404
405 /** Get the name of the css file used to display this newsletter.
406 */
407 public function cssFile()
408 {
409 if ($this->custom_css) {
410 $base = $this->group;
411 } else {
412 $base = self::FORMAT_DEFAULT_GROUP;
413 }
414 return 'nl.' . $base . '.css';
415 }
416
417 /** Get the name of the template file used to display this newsletter.
418 */
419 public function tplFile()
420 {
421 if ($this->custom_css) {
422 $base = $this->group;
423 } else {
424 $base = self::FORMAT_DEFAULT_GROUP;
425 }
426 return 'newsletter/nl.' . $base . '.mail.tpl';
427 }
428
429 /** Get the prefix leading to the page for this NL
430 * Only X.org / AX / X groups may be seen on X.org.
431 */
432 public function prefix()
433 {
434 if (!empty($GLOBALS['IS_XNET_SITE'])) {
435 return $this->group . '/nl';
436 }
437 switch ($this->group) {
438 case self::GROUP_XORG:
439 return 'nl';
440 case self::GROUP_AX:
441 return 'ax';
442 case self::GROUP_EP:
443 return 'epletter';
444 default:
445 // Don't display groups NLs on X.org
446 assert(false);
447 }
448 }
449
450 /** Get the prefix to use for all 'admin' pages of this NL.
451 */
452 public function adminPrefix()
453 {
454 if (!empty($GLOBALS['IS_XNET_SITE'])) {
455 return $this->group . '/admin/nl';
456 }
457 switch ($this->group) {
458 case self::GROUP_XORG:
459 return 'admin/newsletter';
460 case self::GROUP_AX:
461 return 'ax/admin';
462 case self::GROUP_EP:
463 return 'epletter/admin';
464 default:
465 // Don't display groups NLs on X.org
466 assert(false);
467 }
468 }
469
470 /** Hack used to remove "admin" links on X.org page on X.net
471 * The 'admin' links are enabled for all pages, except for X.org when accessing NL through X.net
472 */
473 public function adminLinksEnabled()
474 {
475 return ($this->group != self::GROUP_XORG || !isset($GLOBALS['IS_XNET_SITE']));
476 }
477
478 /** Automatic mailings are disabled for X.org NL.
479 */
480 public function automaticMailingEnabled()
481 {
482 return $this->group != self::GROUP_XORG;
483 }
484
485 public function hasCustomCss()
486 {
487 return $this->custom_css;
488 }
489
490 // }}}
491}
492
493// }}}
494
495// {{{ class NLIssue
496
497// A NLIssue is an issue of a given NewsLetter
498class NLIssue
499{
500 protected $nlid; // Id of the newsletter
501
502 const STATE_NEW = 'new'; // New, currently being edited
503 const STATE_PENDING = 'pending'; // Ready for mailing
504 const STATE_SENT = 'sent'; // Sent
505
506 public $nl; // Related NL
507
508 public $id; // Id of this issue of the newsletter
509 public $shortname; // Shortname for this issue
510 public $title; // Title of this issue
511 public $title_mail; // Title of the email
512 public $state; // State of the issue (one of the STATE_ values)
513 public $sufb; // Environment to use to generate the UFC through an UserFilterBuilder
514
515 public $date; // Date at which this issue was sent
516 public $send_before; // Date at which issue should be sent
517 public $head; // Foreword of the issue (or body for letters with no articles)
518 public $signature; // Signature of the letter
519 public $arts = array(); // Articles of the issue
520
521 const BATCH_SIZE = 60; // Number of emails to send every minute.
522
523 // {{{ Constructor, id-related functions
524
525 /** Build a NewsLetter.
526 * @p $id: ID of the issue (unique among all newsletters)
527 * @p $nl: Optional argument containing an already built NewsLetter object.
528 */
529 function __construct($id, $nl = null, $fetch_articles = true)
530 {
531 return $this->fetch($id, $nl, $fetch_articles);
532 }
533
534 protected function refresh()
535 {
536 return $this->fetch($this->id, $this->nl, false);
537 }
538
539 protected function fetch($id, $nl = null, $fetch_articles = true)
540 {
541 // Load this issue
542 $res = XDB::query('SELECT nlid, short_name, date, send_before, state, sufb_json,
543 title, mail_title, head, signature
544 FROM newsletter_issues
545 WHERE id = {?}',
546 $id);
547 if (!$res->numRows()) {
9da70671
SJ
548 throw new MailNotFound();
549 }
84163d58
RB
550 $issue = $res->fetchOneAssoc();
551 if ($nl && $nl->id == $issue['nlid']) {
552 $this->nl = $nl;
553 } else {
554 $this->nl = new NewsLetter($issue['nlid']);
555 }
556 $this->id = $id;
557 $this->shortname = $issue['short_name'];
558 $this->date = $issue['date'];
559 $this->send_before = $issue['send_before'];
560 $this->state = $issue['state'];
561 $this->title = $issue['title'];
562 $this->title_mail = $issue['mail_title'];
563 $this->head = $issue['head'];
564 $this->signature = $issue['signature'];
565 $this->sufb = $this->importJSonStoredUFB($issue['sufb_json']);
0337d704 566
84163d58
RB
567 if ($fetch_articles) {
568 $this->fetchArticles();
569 }
570 }
0337d704 571
84163d58
RB
572 protected function fetchArticles($force = false)
573 {
574 if (count($this->arts) && !$force) {
575 return;
ed76a506 576 }
eaf30d86 577
84163d58 578 // Load the articles
ed76a506 579 $res = XDB::iterRow(
84163d58
RB
580 'SELECT a.title, a.body, a.append, a.aid, a.cid, a.pos
581 FROM newsletter_art AS a
582 INNER JOIN newsletter_issues AS ni USING(id)
583 LEFT JOIN newsletter_cat AS c ON (a.cid = c.cid)
584 WHERE a.id = {?}
585 ORDER BY c.pos, a.pos',
586 $this->id);
ed76a506 587 while (list($title, $body, $append, $aid, $cid, $pos) = $res->next()) {
84163d58
RB
588 $this->arts[$cid][$aid] = new NLArticle($title, $body, $append, $aid, $cid, $pos);
589 }
590 }
591
592 protected function importJSonStoredUFB($json = null)
593 {
594 require_once 'ufbuilder.inc.php';
595 $ufb = $this->nl->getSubscribersUFB();
596 if (is_null($json)) {
597 return new StoredUserFilterBuilder($ufb, new PFC_True());
598 }
599 $export = json_decode($json, true);
600 if (is_null($export)) {
601 PlErrorReport::report("Invalid json while reading NL {$this->nlid}, issue {$this->id}: failed to import '''{$json}'''.");
602 return new StoredUserFilterBuilder($ufb, new PFC_True());
603 }
604 $sufb = new StoredUserFilterBuilder($ufb);
605 $sufb->fillFromExport($export);
606 return $sufb;
607 }
608
609 protected function exportStoredUFBAsJSon()
610 {
611 return json_encode($this->sufb->export());
612 }
613
614 public function id()
615 {
616 return is_null($this->shortname) ? $this->id : $this->shortname;
617 }
618
619 protected function selectId($where)
620 {
621 $res = XDB::query("SELECT IFNULL(ni.short_name, ni.id)
622 FROM newsletter_issues AS ni
623 WHERE ni.state != 'new' AND ni.nlid = {?} AND ${where}
624 LIMIT 1", $this->nl->id);
625 if ($res->numRows() != 1) {
626 return null;
627 }
628 return $res->fetchOneCell();
629 }
630
631 /** Delete this issue
632 * @return True if the issue could be deleted, false otherwise.
633 * Related articles will be deleted through cascading FKs.
634 * If this issue was the last issue for at least one subscriber, the deletion will be aborted.
635 */
636 public function delete()
637 {
638 if ($this->state == self::STATE_NEW) {
639 $res = XDB::query('SELECT COUNT(*)
640 FROM newsletter_ins
641 WHERE last = {?}', $this->id);
642 if ($res->fetchOneCell() > 0) {
643 return false;
644 }
645
646 return XDB::execute('DELETE FROM newsletter_issues
647 WHERE id = {?}', $this->id);
648 } else {
649 return false;
650 }
651 }
652
653 /** Schedule a mailing of this NL
654 * If the 'send_before' field was NULL, it is set to the current time.
655 * @return Boolean Whether the date could be set (false if trying to schedule an already sent NL)
656 */
657 public function scheduleMailing()
658 {
659 if ($this->state == self::STATE_NEW) {
660 $success = XDB::execute('UPDATE newsletter_issues
661 SET state = \'pending\', send_before = IFNULL(send_before, NOW())
662 WHERE id = {?}',
663 $this->id);
664 if ($success) {
665 $this->refresh();
666 }
667 return $success;
668 } else {
669 return false;
670 }
671 }
672
673 /** Cancel the scheduled mailing of this NL
674 * @return Boolean: whether the mailing could be cancelled.
675 */
676 public function cancelMailing()
677 {
678 if ($this->state == self::STATE_PENDING) {
679 $success = XDB::execute('UPDATE newsletter_issues
680 SET send_before = NULL, state = \'new\'
681 WHERE id = {?}', $this->id);
682 if ($success) {
683 $this->refresh();
684 }
685 return $success;
686 } else {
687 return false;
688 }
689 }
690
691 /** Helper function for smarty templates: is this issue editable ?
692 */
693 public function isEditable()
694 {
695 return $this->state == self::STATE_NEW;
696 }
697
698 /** Helper function for smarty templates: is the mailing of this issue scheduled ?
699 */
700 public function isPending()
701 {
702 return $this->state == self::STATE_PENDING;
703 }
704
705 /** Helper function for smarty templates: has this issue been sent ?
706 */
707 public function isSent()
708 {
709 return $this->state == self::STATE_SENT;
710 }
711
712 // }}}
713 // {{{ Navigation
714
715 private $id_prev = null;
716 private $id_next = null;
717 private $id_last = null;
718
719 /** Retrieve ID of the previous issue
720 * That value, once fetched, is cached in the private $id_prev variable.
721 * @return ID of the previous issue.
722 */
723 public function prev()
724 {
725 if (is_null($this->id_prev)) {
726 $this->id_prev = $this->selectId(XDB::format("ni.id < {?} ORDER BY ni.id DESC", $this->id));
727 }
728 return $this->id_prev;
729 }
730
731 /** Retrieve ID of the following issue
732 * That value, once fetched, is cached in the private $id_next variable.
733 * @return ID of the following issue.
734 */
735 public function next()
736 {
737 if (is_null($this->id_next)) {
738 $this->id_next = $this->selectId(XDB::format("ni.id > {?} ORDER BY ni.id", $this->id));
739 }
740 return $this->id_next;
741 }
742
743 /** Retrieve ID of the last issue
744 * That value, once fetched, is cached in the private $id_last variable.
745 * @return ID of the last issue.
746 */
747 public function last()
748 {
749 if (is_null($this->id_last)) {
750 $this->id_last = $this->nl->getIssue('last')->id;
ed76a506 751 }
84163d58 752 return $this->id_last;
0337d704 753 }
754
84163d58
RB
755 // }}}
756 // {{{ Edition, articles
757
758 const ERROR_INVALID_SHORTNAME = 'invalid_shortname';
759 const ERROR_INVALID_UFC = 'invalid_ufc';
760 const ERROR_SQL_SAVE = 'sql_error';
761
762 /** Save the global properties of this NL issue (title&co).
763 */
4882f4a5 764 public function save()
0337d704 765 {
84163d58
RB
766 $errors = array();
767
768 // Fill the list of fields to update
769 $fields = array(
770 'title' => $this->title,
771 'mail_title' => $this->title_mail,
772 'head' => $this->head,
773 'signature' => $this->signature,
774 );
775
776 if ($this->isEditable()) {
777 $fields['date'] = $this->date;
778 if (!preg_match('/^[-a-z0-9]+$/i', $this->shortname) || is_numeric($this->shortname)) {
779 $errors[] = self::ERROR_INVALID_SHORTNAME;
780 } else {
781 $fields['short_name'] = $this->shortname;
782 }
783 if ($this->sufb->isValid() || $this->sufb->isEmpty()) {
784 $fields['sufb_json'] = json_encode($this->sufb->export()->dict());
785 } else {
786 $errors[] = self::ERROR_INVALID_UFC;
787 }
788
789 if ($this->nl->automaticMailingEnabled()) {
790 $fields['send_before'] = ($this->send_before ? $this->send_before : null);
791 }
792 }
793
794 if (count($errors)) {
795 return $errors;
796 }
797 $field_sets = array();
798 foreach ($fields as $key => $value) {
799 $field_sets[] = XDB::format($key . ' = {?}', $value);
800 }
801 XDB::execute('UPDATE newsletter_issues
802 SET ' . implode(', ', $field_sets) . '
803 WHERE id={?}',
804 $this->id);
805 if (XDB::affectedRows()) {
806 $this->refresh();
807 } else {
808 $errors[] = self::ERROR_SQL_SAVE;
809 }
810 return $errors;
ed76a506 811 }
eaf30d86 812
84163d58
RB
813 /** Get an article by number
814 * @p $aid Article ID (among articles of the issue)
815 * @return A NLArticle object, or null if there is no article by that number
816 */
4882f4a5 817 public function getArt($aid)
0337d704 818 {
84163d58
RB
819 $this->fetchArticles();
820
821 foreach ($this->arts as $category => $artlist) {
822 if (isset($artlist[$aid])) {
823 return $artlist[$aid];
0337d704 824 }
11f44323 825 }
826 return null;
0337d704 827 }
828
84163d58
RB
829 /** Save an article
830 * @p &$a A reference to a NLArticle object (will be modified once saved)
831 */
4882f4a5 832 public function saveArticle(&$a)
0337d704 833 {
84163d58
RB
834 $this->fetchArticles();
835
836 // Prevent cid to be 0 (use NULL instead)
837 $a->cid = ($a->cid == 0) ? null : $a->cid;
838 if ($a->aid >= 0) {
839 // Article already exists in DB
00ba8a74
SJ
840 XDB::execute('UPDATE newsletter_art
841 SET cid = {?}, pos = {?}, title = {?}, body = {?}, append = {?}
842 WHERE id = {?} AND aid = {?}',
84163d58 843 $a->cid, $a->pos, $a->title, $a->body, $a->append, $this->id, $a->aid);
11f44323 844 } else {
84163d58 845 // New article
00ba8a74
SJ
846 XDB::startTransaction();
847 list($aid, $pos) = XDB::fetchOneRow('SELECT MAX(aid) AS aid, MAX(pos) AS pos
848 FROM newsletter_art AS a
849 WHERE a.id = {?}',
84163d58
RB
850 $this->id);
851 $a->aid = ++$aid;
852 $a->pos = ($a->pos ? $a->pos : ++$pos);
00ba8a74
SJ
853 XDB::execute('INSERT INTO newsletter_art (id, aid, cid, pos, title, body, append)
854 VALUES ({?}, {?}, {?}, {?}, {?}, {?}, {?})',
84163d58
RB
855 $this->id, $a->aid, $a->cid, $a->pos,
856 $a->title, $a->body, $a->append);
00ba8a74 857 XDB::commit();
11f44323 858 }
84163d58
RB
859 // Update local ID of article
860 $this->arts[$a->aid] = $a;
0337d704 861 }
eaf30d86 862
84163d58
RB
863 /** Delete an article by its ID
864 * @p $aid ID of the article to delete
865 */
4882f4a5 866 public function delArticle($aid)
0337d704 867 {
84163d58
RB
868 $this->fetchArticles();
869
870 XDB::execute('DELETE FROM newsletter_art WHERE id={?} AND aid={?}', $this->id, $aid);
871 foreach ($this->arts as $key=>$art) {
872 unset($this->arts[$key][$aid]);
11f44323 873 }
0337d704 874 }
875
84163d58
RB
876 // }}}
877 // {{{ Display
878
879 /** Retrieve the title of this issue
880 * @p $mail Whether we want the normal title or the email subject
881 * @return Title of the issue
882 */
883 public function title($mail = false)
4882f4a5 884 {
84163d58 885 return $mail ? $this->title_mail : $this->title;
4882f4a5 886 }
0337d704 887
84163d58
RB
888 /** Retrieve the head of this issue
889 * @p $user User for <dear> customization (may be null: no customization)
890 * @p $type Either 'text' or 'html'
891 * @return Formatted head of the issue.
892 */
893 public function head($user = null, $type = 'text')
0337d704 894 {
84163d58
RB
895 if (is_null($user)) {
896 return $this->head;
897 } else {
898 $head = $this->head;
899 $head = str_replace(array('<cher>', '<prenom>', '<nom>'),
900 array(($user->isFemale() ? 'Chère' : 'Cher'), $user->displayName(), ''),
901 $head);
902 return format_text($head, $type, 2, 64);
903 }
eaf30d86 904 }
0337d704 905
84163d58
RB
906 /** Retrieve the formatted signature of this issue.
907 */
908 public function signature($type = 'text')
0337d704 909 {
84163d58
RB
910 return format_text($this->signature, $type, 2, 64);
911 }
912
913 /** Get the title of a given category
914 * @p $cid ID of the category to retrieve
915 * @return Name of the category
916 */
917 public function category($cid)
918 {
919 return $this->nl->cats[$cid];
920 }
921
922 /** Add required data to the given $page for proper CSS display
923 * @p $page Smarty object
924 * @return Either 'true' (if CSS was added to a page) or the raw CSS to add (when $page is null)
925 */
926 public function css(&$page = null)
927 {
928 if (!is_null($page)) {
929 $page->addCssLink($this->nl->cssFile());
930 return true;
931 } else {
932 $css = file_get_contents(dirname(__FILE__) . '/../htdocs/css/' . $this->nl->cssFile());
933 return preg_replace('@/\*.*?\*/@us', '', $css);
934 }
935 }
936
937 /** Set up a smarty page for a 'text' mode render of the issue
938 * @p $page Smarty object (using the $this->nl->tplFile() template)
939 * @p $user User to use when rendering the template
940 */
941 public function toText(&$page, $user)
942 {
943 $this->fetchArticles();
944
945 $this->css($page);
946 $page->assign('prefix', null);
947 $page->assign('is_mail', false);
948 $page->assign('mail_part', 'text');
949 $page->assign('user', $user);
950 $page->assign('hash', null);
951 $this->assignData($page);
eaf30d86
PH
952 }
953
84163d58
RB
954 /** Set up a smarty page for a 'html' mode render of the issue
955 * @p $page Smarty object (using the $this->nl->tplFile() template)
956 * @p $user User to use when rendering the template
957 */
958 public function toHtml(&$page, $user)
4882f4a5 959 {
84163d58
RB
960 $this->fetchArticles();
961
962 $this->css($page);
963 $page->assign('prefix', $this->nl->prefix() . '/show/' . $this->id());
964 $page->assign('is_mail', false);
965 $page->assign('mail_part', 'html');
966 $page->assign('user', $user);
967 $page->assign('hash', null);
968 $this->assignData($page);
0337d704 969 }
970
84163d58
RB
971 /** Set all 'common' data for the page (those which are required for both web and email rendering)
972 * @p $smarty Smarty object (e.g page) which should be filled
973 */
974 protected function assignData(&$smarty)
4882f4a5 975 {
84163d58
RB
976 $this->fetchArticles();
977
978 $smarty->assign_by_ref('issue', $this);
979 $smarty->assign_by_ref('nl', $this->nl);
4882f4a5 980 }
981
84163d58
RB
982 // }}}
983 // {{{ Mailing
984
985 /** Retrieve the 'Send before' date, in a clean format.
986 */
987 public function getSendBeforeDate()
4882f4a5 988 {
84163d58 989 return strftime('%Y-%m-%d', strtotime($this->send_before));
4882f4a5 990 }
991
84163d58
RB
992 /** Retrieve the 'Send before' time (i.e hour), in a clean format.
993 */
994 public function getSendBeforeTime()
4882f4a5 995 {
84163d58 996 return strtotime($this->send_before);
4882f4a5 997 }
998
84163d58
RB
999 /** Create a hash based on some additional data
1000 * $line Line-specific data (to prevent two hashes generated at the same time to be the same)
1001 */
1002 protected static function createHash($line)
4882f4a5 1003 {
84163d58
RB
1004 $hash = implode(time(), $line) . rand();
1005 $hash = md5($hash);
1006 return $hash;
4882f4a5 1007 }
1008
84163d58
RB
1009 /** Send this issue to the given user, reusing an existing hash if provided.
1010 * @p $user User to whom the issue should be mailed
1011 * @p $hash Optional hash to use in the 'unsubscribe' link; if null, another one will be generated.
1012 */
1013 public function sendTo($user, $hash = null)
4882f4a5 1014 {
84163d58
RB
1015 $this->fetchArticles();
1016
1017 if (is_null($hash)) {
1018 $hash = XDB::fetchOneCell("SELECT hash
1019 FROM newsletter_ins
1020 WHERE uid = {?} AND nlid = {?}",
1021 $user->id(), $this->nl->id);
1022 }
1023 if (is_null($hash)) {
1024 $hash = self::createHash(array($user->displayName(), $user->fullName(),
1025 $user->isFemale(), $user->isEmailFormatHtml(),
1026 rand(), "X.org rulez"));
1027 XDB::execute("UPDATE newsletter_ins as ni
1028 SET ni.hash = {?}
1029 WHERE ni.uid = {?} AND ni.nlid = {?}",
1030 $hash, $user->id(), $this->nl->id);
1031 }
1032
1033 $mailer = new PlMailer($this->nl->tplFile());
1034 $this->assignData($mailer);
1035 $mailer->assign('is_mail', true);
1036 $mailer->assign('user', $user);
1037 $mailer->assign('prefix', null);
1038 $mailer->assign('hash', $hash);
1039 $mailer->sendTo($user);
4882f4a5 1040 }
84163d58
RB
1041
1042 /** Select a subset of subscribers which should receive the newsletter.
1043 * NL-Specific selections (not yet received, is subscribed) are done when sending.
1044 * @return A PlFilterCondition.
1045 */
1046 protected function getRecipientsUFC()
1047 {
1048 return $this->sufb->getUFC();
1049 }
1050
1051 /** Check whether a given user may see this issue.
1052 * @p $user User whose access should be checked
1053 * @return Whether he may access the issue
1054 */
1055 public function checkUser($user = null)
1056 {
1057 if ($user == null) {
1058 $user = S::user();
1059 }
1060 $uf = new UserFilter($this->getRecipientsUFC());
1061 return $uf->checkUser($user);
1062 }
1063
1064 /** Sent this issue to all valid recipients
1065 * @return Number of issues sent
1066 */
1067 public function sendToAll()
1068 {
1069 $this->fetchArticles();
1070
1071 XDB::execute('UPDATE newsletter_issues
1072 SET state = \'sent\', date=CURDATE()
1073 WHERE id = {?}',
1074 $this->id);
1075
1076 $ufc = new PFC_And($this->getRecipientsUFC(), new UFC_NLSubscribed($this->nl->id, $this->id), new UFC_HasEmailRedirect());
1077 $emailsCount = 0;
1078 $uf = new UserFilter($ufc, new PlLimit(self::BATCH_SIZE));
1079
1080 while (true) {
1081 $sent = array();
1082 $users = $uf->getUsers();
1083 if (count($users) == 0) {
1084 return $emailsCount;
1085 }
1086 foreach ($users as $user) {
1087 $sent[] = $user->id();
1088 $this->sendTo($user, $hash);
1089 ++$emailsCount;
1090 }
1091 XDB::execute("UPDATE newsletter_ins
1092 SET last = {?}
1093 WHERE nlid = {?} AND uid IN {?}", $this->id, $this->nl->id, $sent);
1094
1095 sleep(60);
1096 }
1097 return $emailsCount;
1098 }
1099
1100 // }}}
0337d704 1101}
1102
1103// }}}
1104// {{{ class NLArticle
1105
1106class NLArticle
1107{
84163d58
RB
1108 // Maximum number of lines per article
1109 const MAX_LINES_PER_ARTICLE = 9;
1110
0337d704 1111 // {{{ properties
eaf30d86 1112
84163d58
RB
1113 public $aid;
1114 public $cid;
1115 public $pos;
1116 public $title;
1117 public $body;
1118 public $append;
0337d704 1119
1120 // }}}
1121 // {{{ constructor
eaf30d86 1122
4882f4a5 1123 function __construct($title='', $body='', $append='', $aid=-1, $cid=0, $pos=0)
0337d704 1124 {
84163d58
RB
1125 $this->body = $body;
1126 $this->title = $title;
1127 $this->append = $append;
1128 $this->aid = $aid;
1129 $this->cid = $cid;
1130 $this->pos = $pos;
0337d704 1131 }
1132
1133 // }}}
1134 // {{{ function title()
1135
4882f4a5 1136 public function title()
84163d58 1137 { return trim($this->title); }
0337d704 1138
1139 // }}}
1140 // {{{ function body()
eaf30d86 1141
4882f4a5 1142 public function body()
84163d58 1143 { return trim($this->body); }
eaf30d86 1144
0337d704 1145 // }}}
1146 // {{{ function append()
eaf30d86 1147
4882f4a5 1148 public function append()
84163d58 1149 { return trim($this->append); }
0337d704 1150
1151 // }}}
1152 // {{{ function toText()
1153
e0e77c5a 1154 public function toText($hash = null, $login = null)
0337d704 1155 {
4882f4a5 1156 $title = '*'.$this->title().'*';
84163d58
RB
1157 $body = MiniWiki::WikiToText($this->body, true);
1158 $app = MiniWiki::WikiToText($this->append, false, 4);
e0e77c5a
FB
1159 $text = trim("$title\n\n$body\n\n$app")."\n";
1160 if (!is_null($hash) && !is_null($login)) {
1161 $text = str_replace('%HASH%', "$hash/$login", $text);
1162 } else {
1163 $text = str_replace('%HASH%', '', $text);
1164 }
1165 return $text;
0337d704 1166 }
1167
1168 // }}}
1169 // {{{ function toHtml()
1170
e0e77c5a 1171 public function toHtml($hash = null, $login = null)
0337d704 1172 {
84163d58
RB
1173 $title = "<h2 class='xorg_nl'><a id='art{$this->aid}'></a>".pl_entities($this->title()).'</h2>';
1174 $body = MiniWiki::WikiToHTML($this->body);
1175 $app = MiniWiki::WikiToHTML($this->append);
eaf30d86 1176
4882f4a5 1177 $art = "$title\n";
1178 $art .= "<div class='art'>\n$body\n";
1179 if ($app) {
0337d704 1180 $art .= "<div class='app'>$app</div>";
1181 }
4882f4a5 1182 $art .= "</div>\n";
e0e77c5a
FB
1183 if (!is_null($hash) && !is_null($login)) {
1184 $art = str_replace('%HASH%', "$hash/$login", $art);
1185 } else {
1186 $art = str_replace('%HASH%', '', $art);
1187 }
eaf30d86 1188
4882f4a5 1189 return $art;
0337d704 1190 }
1191
1192 // }}}
1193 // {{{ function check()
1194
4882f4a5 1195 public function check()
0337d704 1196 {
84163d58 1197 $text = MiniWiki::WikiToText($this->body);
4882f4a5 1198 $arr = explode("\n",wordwrap($text,68));
1199 $c = 0;
1200 foreach ($arr as $line) {
0337d704 1201 if (trim($line)) {
1202 $c++;
1203 }
1204 }
84163d58 1205 return $c < self::MAX_LINES_PER_ARTICLE;
0337d704 1206 }
1207
1208 // }}}
9e2a6a32
SJ
1209 // {{{ function parseUrlsFromArticle()
1210
84163d58 1211 protected function parseUrlsFromArticle()
9e2a6a32
SJ
1212 {
1213 $email_regex = '([a-z0-9.\-+_\$]+@([\-.+_]?[a-z0-9])+)';
1214 $url_regex = '((https?|ftp)://[a-zA-Z0-9._%#+/?=&~-]+)';
1215 $regex = '{' . $email_regex . '|' . $url_regex . '}i';
1216
1217 $matches = array();
1218 $body_matches = array();
1219 if (preg_match_all($regex, $this->body(), $body_matches)) {
1220 $matches = array_merge($matches, $body_matches[0]);
1221 }
1222
1223 $append_matches = array();
1224 if (preg_match_all($regex, $this->append(), $append_matches)) {
1225 $matches = array_merge($matches, $append_matches[0]);
1226 }
1227
1228 return $matches;
1229 }
1230
1231 // }}}
1232 // {{{ function getLinkIps()
1233
955109ba 1234 public function getLinkIps(&$blacklist_host_resolution_count)
9e2a6a32
SJ
1235 {
1236 $matches = $this->parseUrlsFromArticle();
1237 $article_ips = array();
1238
1239 if (!empty($matches)) {
1240 global $globals;
1241
1242 foreach ($matches as $match) {
1243 $host = parse_url($match, PHP_URL_HOST);
1244 if ($host == '') {
1245 list(, $host) = explode('@', $match);
1246 }
1247
955109ba 1248 if ($blacklist_host_resolution_count >= $globals->mail->blacklist_host_resolution_limit) {
9e2a6a32
SJ
1249 break;
1250 }
1251
955109ba 1252 if (!preg_match('/^(' . str_replace(' ', '|', $globals->mail->domain_whitelist) . ')$/i', $host)) {
9e2a6a32 1253 $article_ips = array_merge($article_ips, array(gethostbyname($host) => $host));
955109ba 1254 ++$blacklist_host_resolution_count;
9e2a6a32
SJ
1255 }
1256 }
1257 }
1258
1259 return $article_ips;
1260 }
1261
1262 // }}}
0337d704 1263}
1264
1265// }}}
0337d704 1266
84163d58
RB
1267// {{{ Functions
1268
1269function format_text($input, $format, $indent = 0, $width = 68)
1270{
1271 if ($format == 'text') {
1272 return MiniWiki::WikiToText($input, true, $indent, $width, "title");
1273 }
1274 return MiniWiki::WikiToHTML($input, "title");
1275}
1276
1277// function enriched_to_text($input,$html=false,$just=false,$indent=0,$width=68)
1278
1279// }}}
1280
a7de4ef7 1281// vim:set et sw=4 sts=4 sws=4 enc=utf-8:
0337d704 1282?>