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