Merge branch 'xorg/maint'
[platal.git] / include / newsletter.inc.php
1 <?php
2 /***************************************************************************
3 * Copyright (C) 2003-2014 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 // Base name to use instead of the group short name for NLs without a custom CSS
41 const FORMAT_DEFAULT_GROUP = 'default';
42
43 // Diminutif of X.net groups with a specific NL view
44 const GROUP_XORG = 'Polytechnique.org';
45 const GROUP_COMMUNITY = 'Annonces';
46 const GROUP_AX = 'AX';
47 const GROUP_EP = 'Ecole';
48 const GROUP_FX = 'FX';
49
50 // Searches on mutiple fields
51 const SEARCH_ALL = 'all';
52 const SEARCH_TITLE = 'title';
53
54
55 // {{{ Constructor, NewsLetter retrieval (forGroup, getAll)
56
57 public function __construct($id)
58 {
59 // Load NL data
60 $res = XDB::query('SELECT nls.group_id, g.diminutif AS group_name,
61 nls.name AS nl_name, nls.criteria
62 FROM newsletters AS nls
63 LEFT JOIN groups AS g ON (nls.group_id = g.id)
64 WHERE nls.id = {?}',
65 $id);
66 if (!$res->numRows()) {
67 throw new MailNotFound();
68 }
69
70 $data = $res->fetchOneAssoc();
71 $this->id = $id;
72 $this->group_id = $data['group_id'];
73 $this->group = $data['group_name'];
74 $this->name = $data['nl_name'];
75 $this->criteria = new PlFlagSet($data['criteria']);
76
77 // Load the categories
78 $res = XDB::iterRow(
79 'SELECT cid, title
80 FROM newsletter_cat
81 WHERE nlid = {?}
82 ORDER BY pos', $id);
83 while (list($cid, $title) = $res->next()) {
84 $this->cats[$cid] = $title;
85 }
86 }
87
88 /** Retrieve the NL associated with a given group.
89 * @p $group Short name of the group
90 * @return A NewsLetter object, or null if the group doesn't have a NL.
91 */
92 public static function forGroup($group)
93 {
94 $res = XDB::query('SELECT nls.id
95 FROM newsletters AS nls
96 LEFT JOIN groups AS g ON (nls.group_id = g.id)
97 WHERE g.diminutif = {?}', $group);
98 if (!$res->numRows()) {
99 return null;
100 }
101 return new NewsLetter($res->fetchOneCell());
102 }
103
104 /** Retrieve all newsletters
105 * @return An array of $id => NewsLetter objects
106 */
107 public static function getAll($sort = 'id', $order = 'ASC')
108 {
109 $res = XDB::fetchAllAssoc('SELECT n.id, g.nom AS group_name, n.name, n.criteria, g.diminutif AS group_link
110 FROM newsletters AS n
111 INNER JOIN groups AS g ON (n.group_id = g.id)
112 ORDER BY ' . $sort . ' ' . $order);
113 return $res;
114 }
115
116 // }}}
117 // {{{ Issue retrieval
118
119 /** Retrieve all issues which should be sent
120 * @return An array of NLIssue objects to send (i.e state = 'new' and send_before <= today)
121 */
122 public static function getIssuesToSend()
123 {
124 $res = XDB::query('SELECT id
125 FROM newsletter_issues
126 WHERE state = \'pending\' AND send_before <= NOW()');
127 $issues = array();
128 foreach ($res->fetchColumn() as $id) {
129 $issues[$id] = new NLIssue($id);
130 }
131 return $issues;
132 }
133
134 /** Retrieve a given issue of this NewsLetter
135 * @p $name Name or ID of the issue to retrieve.
136 * @return A NLIssue object.
137 *
138 * $name may be either a short_name, an ID or the special value 'last' which
139 * selects the latest sent NL.
140 * If $name is null, this will retrieve the current pending NL.
141 */
142 public function getIssue($name = null, $only_sent = true)
143 {
144 if ($name) {
145 if ($name == 'last') {
146 if ($only_sent) {
147 $where = 'state = \'sent\' AND ';
148 } else {
149 $where = '';
150 }
151 $res = XDB::query('SELECT MAX(id)
152 FROM newsletter_issues
153 WHERE ' . $where . ' nlid = {?}',
154 $this->id);
155 } else {
156 $res = XDB::query('SELECT id
157 FROM newsletter_issues
158 WHERE nlid = {?} AND (id = {?} OR short_name = {?})',
159 $this->id, $name, $name);
160 }
161 if (!$res->numRows()) {
162 throw new MailNotFound();
163 }
164 $id = $res->fetchOneCell();
165 } else {
166 $query = XDB::format('SELECT id
167 FROM newsletter_issues
168 WHERE nlid = {?} AND state = \'new\'
169 ORDER BY id DESC', $this->id);
170 $res = XDB::query($query);
171 if ($res->numRows()) {
172 $id = $res->fetchOneCell();
173 } else {
174 // Create a new, empty issue, and return it
175 $id = $this->createPending();
176 }
177 }
178
179 return new NLIssue($id, $this);
180 }
181
182 /** Create a new, empty, pending newsletter issue
183 * @p $nlid The id of the NL for which a new pending issue should be created.
184 * @return Id of the newly created issue.
185 */
186 public function createPending()
187 {
188 XDB::execute('INSERT INTO newsletter_issues
189 SET nlid = {?}, state=\'new\', date=NOW(),
190 title=\'to be continued\',
191 mail_title=\'to be continued\'',
192 $this->id);
193 return XDB::insertId();
194 }
195
196 /** Return all sent issues of this newsletter.
197 * @return An array of (id => NLIssue)
198 */
199 public function listSentIssues($check_user = false, $user = null)
200 {
201 if ($check_user && $user == null) {
202 $user = S::user();
203 }
204
205 $res = XDB::query('SELECT id
206 FROM newsletter_issues
207 WHERE nlid = {?} AND state = \'sent\'
208 ORDER BY date DESC', $this->id);
209 $issues = array();
210 foreach ($res->fetchColumn() as $id) {
211 $issue = new NLIssue($id, $this, false);
212 if (!$check_user || $issue->checkUser($user)) {
213 $issues[$id] = $issue;
214 }
215 }
216 return $issues;
217 }
218
219 /** Return all issues of this newsletter, including invalid and sent.
220 * @return An array of (id => NLIssue)
221 */
222 public function listAllIssues()
223 {
224 $res = XDB::query('SELECT id
225 FROM newsletter_issues
226 WHERE nlid = {?}
227 ORDER BY FIELD(state, \'pending\', \'new\') DESC, date DESC', $this->id);
228 $issues = array();
229 foreach ($res->fetchColumn() as $id) {
230 $issues[$id] = new NLIssue($id, $this, false);
231 }
232 return $issues;
233 }
234
235 /** Return the latest pending issue of the newsletter.
236 * @p $create Whether to create an empty issue if no pending issue exist.
237 * @return Either null, or a NL object.
238 */
239 public function getPendingIssue($create = false)
240 {
241 $res = XDB::query('SELECT MAX(id)
242 FROM newsletter_issues
243 WHERE nlid = {?} AND state = \'new\'',
244 $this->id);
245 $id = $res->fetchOneCell();
246 if ($id != null) {
247 return new NLIssue($id, $this);
248 } else if ($create) {
249 $id = $this->createPending();
250 return new NLIssue($id, $this);
251 } else {
252 return null;
253 }
254 }
255
256 /** Returns a list of either issues or articles corresponding to the search.
257 * @p $search The searched pattern.
258 * @p $field The fields where to search, if none given, search in all possible fields.
259 * @return The list of object found.
260 */
261 public function issueSearch($search, $field, $user)
262 {
263 $search = XDB::formatWildcards(XDB::WILDCARD_CONTAINS, $search);
264 if ($field == self::SEARCH_ALL) {
265 $where = '(title ' . $search . ' OR mail_title ' . $search . ' OR head ' . $search . ' OR signature ' . $search . ')';
266 } elseif ($field == self::SEARCH_TITLE) {
267 $where = '(title ' . $search . ' OR mail_title ' . $search . ')';
268 } else {
269 $where = $field . $search;
270 }
271 $list = XDB::fetchColumn('SELECT DISTINCT(id)
272 FROM newsletter_issues
273 WHERE nlid = {?} AND state = \'sent\' AND ' . $where . '
274 ORDER BY date DESC',
275 $this->id);
276
277 $issues = array();
278 foreach ($list as $id) {
279 $issue = new NLIssue($id, $this, false);
280 if ($issue->checkUser($user)) {
281 $issues[] = $issue;
282 }
283 }
284 return $issues;
285 }
286
287 public function articleSearch($search, $field, $user)
288 {
289 $search = XDB::formatWildcards(XDB::WILDCARD_CONTAINS, $search);
290 if ($field == self::SEARCH_ALL) {
291 $where = '(a.title ' . $search . ' OR a.body ' . $search . ' OR a.append ' . $search . ')';
292 } else {
293 $where = 'a.' . $field . $search;
294 }
295 $list = XDB::fetchAllAssoc('SELECT i.short_name, a.aid, i.id, a.title
296 FROM newsletter_art AS a
297 INNER JOIN newsletter_issues AS i ON (a.id = i.id)
298 WHERE i.nlid = {?} AND i.state = \'sent\' AND ' . $where . '
299 GROUP BY a.id, a.aid
300 ORDER BY i.date DESC, a.aid',
301 $this->id);
302
303 $articles = array();
304 foreach ($list as $item) {
305 $issue = new NLIssue($item['id'], $this, false);
306 if ($issue->checkUser($user)) {
307 $articles[] = $item;
308 }
309 }
310 return $articles;
311 }
312
313 // }}}
314 // {{{ Subscription related function
315
316 /** Unsubscribe a user from this newsletter
317 * @p $uid UID to unsubscribe from the newsletter; if null, use current user.
318 * @p $hash True if the uid is actually a hash.
319 * @return True if the user was successfully unsubscribed.
320 */
321 public function unsubscribe($issue_id = null, $uid = null, $hash = false)
322 {
323 if (is_null($uid) && $hash) {
324 // Unable to unsubscribe from an empty hash
325 return false;
326 }
327 $user = is_null($uid) ? S::user()->id() : $uid;
328 $field = $hash ? 'hash' : 'uid';
329 $res = XDB::query('SELECT uid
330 FROM newsletter_ins
331 WHERE nlid = {?} AND ' . $field . ' = {?}',
332 $this->id, $user);
333 if (!$res->numRows()) {
334 // No subscribed user with that UID/hash
335 return false;
336 }
337 $user = $res->fetchOneCell();
338
339 XDB::execute('DELETE FROM newsletter_ins
340 WHERE nlid = {?} AND uid = {?}',
341 $this->id, $user);
342 if (!is_null($issue_id)) {
343 XDB::execute('UPDATE newsletter_issues
344 SET unsubscribe = unsubscribe + 1
345 WHERE id = {?}',
346 $id);
347 }
348 return true;
349 }
350
351 /** Subscribe a user to a newsletter
352 * @p $user User to subscribe to the newsletter; if null, use current user.
353 */
354 public function subscribe($user = null)
355 {
356 if (is_null($user)) {
357 $user = S::user();
358 }
359 if (self::maySubscribe($user)) {
360 XDB::execute('INSERT IGNORE INTO newsletter_ins (nlid, uid, last, hash)
361 VALUES ({?}, {?}, NULL, hash)',
362 $this->id, $user->id());
363 }
364 }
365
366 /** Subscribe a batch of users to a newsletter.
367 * This skips 'maySubscribe' test.
368 *
369 * @p $user_ids Array of user IDs to subscribe to the newsletter.
370 */
371 public function bulkSubscribe($user_ids)
372 {
373 // TODO: use a 'bulkMaySubscribe'.
374 XDB::execute('INSERT IGNORE INTO newsletter_ins (nlid, uid, last, hash)
375 SELECT {?}, a.uid, NULL, NULL
376 FROM accounts AS a
377 WHERE a.uid IN {?}',
378 $this->id, $user_ids);
379 }
380
381 /** Retrieve subscription state of a user
382 * @p $user Target user; if null, use current user.
383 * @return Boolean: true if the user has subscribed to the NL.
384 */
385 public function subscriptionState($user = null)
386 {
387 if (is_null($user)) {
388 $user = S::user();
389 }
390 $res = XDB::query('SELECT 1
391 FROM newsletter_ins
392 WHERE nlid = {?} AND uid = {?}',
393 $this->id, $user->id());
394 return ($res->numRows() == 1);
395 }
396
397 /** Get the count of subscribers to the NL.
398 * @return Number of subscribers.
399 */
400 public function subscriberCount($lost = null, $sex = null, $grade = null, $first_promo = null, $last_promo = null)
401 {
402 $cond = new PFC_And(new UFC_NLSubscribed($this->id));
403 if (!is_null($sex)) {
404 $cond->addChild(new UFC_Sex($sex));
405 }
406 if (!is_null($grade)) {
407 $cond->addChild(new UFC_Promo('>=', $grade, $first_promo));
408 $cond->addChild(new UFC_Promo('<=', $grade, $last_promo));
409 }
410 if (!($lost === null)) {
411 if ($lost === true) {
412 $cond->addChild(new PFC_Not(new UFC_HasEmailRedirect()));
413 } else {
414 $cond->addChild(new UFC_HasEmailRedirect());
415 }
416 }
417 $uf = new UserFilter($cond);
418 return $uf->getTotalCount();
419 }
420
421 /** Get the count of subscribers with non valid redirection.
422 */
423 public function lostSubscriberCount($sex = null)
424 {
425 return $this->subscriberCount(true, $sex);
426 }
427
428 /** Get the number of subscribers to the NL whose last received mailing was $last.
429 * @p $last ID of the issue for which subscribers should be counted.
430 * @return Number of subscribers
431 */
432 public function subscriberCountForLast($last)
433 {
434 return XDB::fetchOneCell('SELECT COUNT(uid)
435 FROM newsletter_ins
436 WHERE nlid = {?} AND last = {?}', $this->id, $last);
437 }
438
439 /** Retrieve the list of newsletters a user has subscribed to
440 * @p $user User whose subscriptions should be retrieved (if null, use session user).
441 * @return Array of newsletter IDs
442 */
443 public static function getUserSubscriptions($user = null)
444 {
445 if (is_null($user)) {
446 $user = S::user();
447 }
448 $res = XDB::query('SELECT nlid
449 FROM newsletter_ins
450 WHERE uid = {?}',
451 $user->id());
452 return $res->fetchColumn();
453 }
454
455 /** Retrieve the UserFilterBuilder for subscribers to this NL.
456 * This is the place where NL-specific filters may be allowed or prevented.
457 * @p $envprefix Prefix to use for env fields (cf. UserFilterBuilder)
458 * @return A UserFilterBuilder object using the given env prefix
459 */
460 public function getSubscribersUFB($envprefix = '')
461 {
462 require_once 'ufbuilder.inc.php';
463 return new UFB_NewsLetter($this->criteria, $envprefix);
464 }
465
466 // }}}
467 // {{{ Permissions related functions
468
469 /** For later use: check whether a given user may subscribe to this newsletter.
470 * @p $user User whose access should be checked
471 * @return Boolean: whether the user may subscribe to the NL.
472 */
473 public function maySubscribe($user = null)
474 {
475 return true;
476 }
477
478 /** Whether a given user may edit this newsletter
479 * @p $uid UID of the user whose perms should be checked (if null, use current user)
480 * @return Boolean: whether the user may edit the NL
481 */
482 public function mayEdit($user = null)
483 {
484 if (is_null($user)) {
485 $user = S::user();
486 }
487 if ($user->checkPerms('admin')) {
488 return true;
489 }
490 $res = XDB::query('SELECT perms
491 FROM group_members
492 WHERE asso_id = {?} AND uid = {?}',
493 $this->group_id, $user->id());
494 return ($res->numRows() && $res->fetchOneCell() == 'admin');
495 }
496
497 /** Whether a given user may submit articles to this newsletter using X.org validation system
498 * @p $user User whose access should be checked (if null, use current user)
499 * @return Boolean: whether the user may submit articles
500 */
501 public function maySubmit($user = null)
502 {
503 // Submission of new articles is only enabled for the X.org NL and the
504 // community letter (and forbidden when viewing issues on X.net)
505 return (
506 ($this->group == self::GROUP_XORG || $this->group == self::GROUP_COMMUNITY)
507 && !isset($GLOBALS['IS_XNET_SITE']));
508 }
509
510 // }}}
511 // {{{ Display-related functions: cssFile, tplFile, prefix, admin_prefix, admin_links_enabled, automatic_mailings_enabled
512
513 /** Get the name of the css file used to display this newsletter.
514 */
515 public function cssFile()
516 {
517 if ($this->hasCustomCss()) {
518 $base = $this->group;
519 } else {
520 $base = self::FORMAT_DEFAULT_GROUP;
521 }
522 return 'nl.' . $base . '.css';
523 }
524
525 /** Get the name of the template file used to display this newsletter.
526 */
527 public function tplFile()
528 {
529 if ($this->hasCustomCss()) {
530 $base = $this->group;
531 } else {
532 $base = self::FORMAT_DEFAULT_GROUP;
533 }
534 return 'newsletter/nl.' . $base . '.mail.tpl';
535 }
536
537 /** Get the prefix leading to the page for this NL
538 * Only X.org / AX / X groups may be seen on X.org.
539 */
540 public function prefix($enforce_xnet=true, $with_group=true)
541 {
542 if (!empty($GLOBALS['IS_XNET_SITE'])) {
543 if ($with_group) {
544 return $this->group . '/nl';
545 } else {
546 return 'nl';
547 }
548 }
549 switch ($this->group) {
550 case self::GROUP_XORG:
551 return 'nl';
552 case self::GROUP_COMMUNITY:
553 return 'comletter';
554 case self::GROUP_AX:
555 return 'ax';
556 case self::GROUP_EP:
557 return 'epletter';
558 case self::GROUP_FX:
559 return 'fxletter';
560 default:
561 // Don't display groups NLs on X.org
562 assert(!$enforce_xnet);
563 }
564 }
565
566 /** Get the prefix to use for all 'admin' pages of this NL.
567 */
568 public function adminPrefix($enforce_xnet=true, $with_group=true)
569 {
570 if (!empty($GLOBALS['IS_XNET_SITE'])) {
571 if ($with_group) {
572 return $this->group . '/admin/nl';
573 } else {
574 return 'admin/nl';
575 }
576 }
577 switch ($this->group) {
578 case self::GROUP_XORG:
579 return 'admin/newsletter';
580 case self::GROUP_COMMUNITY:
581 return 'comletter/admin';
582 case self::GROUP_AX:
583 return 'ax/admin';
584 case self::GROUP_EP:
585 return 'epletter/admin';
586 case self::GROUP_FX:
587 return 'fxletter/admin';
588 default:
589 // Don't display groups NLs on X.org
590 assert(!$enforce_xnet);
591 }
592 }
593
594 /** Get the prefix to use for all 'stat' pages of this NL.
595 */
596 public function statPrefix($enforce_xnet = true, $with_group = true)
597 {
598 if (!empty($GLOBALS['IS_XNET_SITE'])) {
599 if ($with_group) {
600 return $this->group . '/stat/nl';
601 } else {
602 return 'stat/nl';
603 }
604 }
605 switch ($this->group) {
606 case self::GROUP_XORG:
607 return 'stat/newsletter';
608 case self::GROUP_COMMUNITY:
609 return 'comletter/stat';
610 case self::GROUP_AX:
611 return 'ax/stat';
612 case self::GROUP_EP:
613 return 'epletter/stat';
614 case self::GROUP_FX:
615 return 'fxletter/stat';
616 default:
617 // Don't display groups NLs on X.org
618 assert(!$enforce_xnet);
619 }
620 }
621
622 /** Get a full URL to a newsletter
623 */
624 public function fullUrl()
625 {
626 switch ($this->group) {
627 case self::GROUP_XORG:
628 return 'https://www.polytechnique.org/nl';
629 case self::GROUP_COMMUNITY:
630 return 'https://www.polytechnique.org/comletter';
631 case self::GROUP_AX:
632 return 'https://www.polytechnique.org/ax';
633 case self::GROUP_EP:
634 return 'https://www.polytechnique.org/epletter';
635 case self::GROUP_FX:
636 return 'https://www.polytechnique.org/fxletter';
637 default:
638 return 'http://www.polytechnique.net/' . $this->group . '/nl';
639 }
640 }
641
642 /** Get links for nl pages.
643 */
644 public function adminLinks()
645 {
646 return array(
647 'index' => array('link' => $this->prefix(), 'title' => 'Archives'),
648 'admin' => array('link' => $this->adminPrefix(), 'title' => 'Administrer'),
649 'stats' => array('link' => $this->statPrefix(), 'title' => 'Statistiques')
650 );
651 }
652
653 /** Hack used to remove "admin" links on X.org page on X.net
654 * The 'admin' links are enabled for all pages, except for X.org when accessing NL through X.net
655 */
656 public function adminLinksEnabled()
657 {
658 return ($this->group != self::GROUP_XORG || !isset($GLOBALS['IS_XNET_SITE']));
659 }
660
661 /** Automatic mailings are disabled for X.org NL.
662 */
663 public function automaticMailingEnabled()
664 {
665 return $this->group != self::GROUP_XORG;
666 }
667
668 public function hasCustomCss()
669 {
670 switch ($this->group) {
671 case self::GROUP_XORG:
672 case self::GROUP_COMMUNITY:
673 case self::GROUP_AX:
674 case self::GROUP_EP:
675 case self::GROUP_FX:
676 return true;
677 default:
678 return false;
679 }
680 }
681
682 public function canSyncWithGroup()
683 {
684 switch ($this->group) {
685 case self::GROUP_XORG:
686 case self::GROUP_COMMUNITY:
687 case self::GROUP_AX:
688 case self::GROUP_EP:
689 case self::GROUP_FX:
690 return false;
691 default:
692 return true;
693 }
694 }
695
696 // }}}
697 }
698
699 // }}}
700
701 // {{{ class NLIssue
702
703 // A NLIssue is an issue of a given NewsLetter
704 class NLIssue
705 {
706 protected $nlid; // Id of the newsletter
707
708 const STATE_NEW = 'new'; // New, currently being edited
709 const STATE_PENDING = 'pending'; // Ready for mailing
710 const STATE_SENT = 'sent'; // Sent
711
712 public $nl; // Related NL
713
714 public $id; // Id of this issue of the newsletter
715 public $shortname; // Shortname for this issue
716 public $title; // Title of this issue
717 public $title_mail; // Title of the email
718 public $state; // State of the issue (one of the STATE_ values)
719 public $sufb; // Environment to use to generate the UFC through an UserFilterBuilder
720
721 public $date; // Date at which this issue was sent
722 public $send_before; // Date at which issue should be sent
723 public $head; // Foreword of the issue (or body for letters with no articles)
724 public $signature; // Signature of the letter
725 public $reply_to; // Adress to reply to the message (can be empty)
726 public $arts = array(); // Articles of the issue
727
728 const BATCH_SIZE = 60; // Number of emails to send every minute.
729
730 // {{{ Constructor, id-related functions
731
732 /** Build a NewsLetter.
733 * @p $id: ID of the issue (unique among all newsletters)
734 * @p $nl: Optional argument containing an already built NewsLetter object.
735 */
736 function __construct($id, $nl = null, $fetch_articles = true)
737 {
738 return $this->fetch($id, $nl, $fetch_articles);
739 }
740
741 protected function refresh()
742 {
743 return $this->fetch($this->id, $this->nl, false);
744 }
745
746 protected function fetch($id, $nl = null, $fetch_articles = true)
747 {
748 // Load this issue
749 $res = XDB::query('SELECT nlid, short_name, date, send_before, state, sufb_json,
750 title, mail_title, head, signature, reply_to
751 FROM newsletter_issues
752 WHERE id = {?}',
753 $id);
754 if (!$res->numRows()) {
755 throw new MailNotFound();
756 }
757 $issue = $res->fetchOneAssoc();
758 if ($nl && $nl->id == $issue['nlid']) {
759 $this->nl = $nl;
760 } else {
761 $this->nl = new NewsLetter($issue['nlid']);
762 }
763 $this->id = $id;
764 $this->nlid = $issue['nlid'];
765 $this->shortname = $issue['short_name'];
766 $this->date = $issue['date'];
767 $this->send_before = $issue['send_before'];
768 $this->state = $issue['state'];
769 $this->title = $issue['title'];
770 $this->title_mail = $issue['mail_title'];
771 $this->head = $issue['head'];
772 $this->signature = $issue['signature'];
773 $this->reply_to = $issue['reply_to'];
774 $this->sufb = $this->importJSonStoredUFB($issue['sufb_json']);
775
776 if ($fetch_articles) {
777 $this->fetchArticles();
778 }
779 }
780
781 protected function fetchArticles($force = false)
782 {
783 if (count($this->arts) && !$force) {
784 return;
785 }
786
787 // Load the articles
788 $res = XDB::iterRow(
789 'SELECT a.title, a.body, a.append, a.aid, a.cid, a.pos
790 FROM newsletter_art AS a
791 INNER JOIN newsletter_issues AS ni USING(id)
792 LEFT JOIN newsletter_cat AS c ON (a.cid = c.cid)
793 WHERE a.id = {?}
794 ORDER BY c.pos, a.pos',
795 $this->id);
796 while (list($title, $body, $append, $aid, $cid, $pos) = $res->next()) {
797 $this->arts[$cid][$aid] = new NLArticle($title, $body, $append, $aid, $cid, $pos);
798 }
799 }
800
801 protected function importJSonStoredUFB($json = null)
802 {
803 require_once 'ufbuilder.inc.php';
804 $ufb = $this->nl->getSubscribersUFB();
805 if (is_null($json)) {
806 return new StoredUserFilterBuilder($ufb, new PFC_True());
807 }
808 $export = json_decode($json, true);
809 if (is_null($export)) {
810 PlErrorReport::report("Invalid json while reading NL {$this->nlid}, issue {$this->id}: failed to import '''{$json}'''.");
811 return new StoredUserFilterBuilder($ufb, new PFC_True());
812 }
813 $sufb = new StoredUserFilterBuilder($ufb);
814 $sufb->fillFromExport($export);
815 return $sufb;
816 }
817
818 protected function exportStoredUFBAsJSon()
819 {
820 return json_encode($this->sufb->export());
821 }
822
823 public function id()
824 {
825 return is_null($this->shortname) ? $this->id : $this->shortname;
826 }
827
828 protected function selectId($where)
829 {
830 $res = XDB::query("SELECT IFNULL(ni.short_name, ni.id)
831 FROM newsletter_issues AS ni
832 WHERE ni.state != 'new' AND ni.nlid = {?} AND ${where}
833 LIMIT 1", $this->nl->id);
834 if ($res->numRows() != 1) {
835 return null;
836 }
837 return $res->fetchOneCell();
838 }
839
840 /** Delete this issue
841 * @return True if the issue could be deleted, false otherwise.
842 * Related articles will be deleted through cascading FKs.
843 * If this issue was the last issue for at least one subscriber, the deletion will be aborted.
844 */
845 public function delete()
846 {
847 if ($this->state == self::STATE_NEW) {
848 $res = XDB::query('SELECT COUNT(*)
849 FROM newsletter_ins
850 WHERE last = {?}', $this->id);
851 if ($res->fetchOneCell() > 0) {
852 return false;
853 }
854
855 return XDB::execute('DELETE FROM newsletter_issues
856 WHERE id = {?}', $this->id);
857 } else {
858 return false;
859 }
860 }
861
862 /** Schedule a mailing of this NL
863 * If the 'send_before' field was NULL, it is set to the current time.
864 * @return Boolean Whether the date could be set (false if trying to schedule an already sent NL)
865 */
866 public function scheduleMailing()
867 {
868 if ($this->state == self::STATE_NEW) {
869 $success = XDB::execute('UPDATE newsletter_issues
870 SET state = \'pending\', send_before = IFNULL(send_before, NOW())
871 WHERE id = {?}',
872 $this->id);
873 if ($success) {
874 global $globals;
875 $mailer = new PlMailer('newsletter/notify_scheduled.mail.tpl');
876 $mailer->assign('issue', $this);
877 $mailer->assign('base', $globals->baseurl);
878 $mailer->send();
879 $this->refresh();
880 }
881 return $success;
882 } else {
883 return false;
884 }
885 }
886
887 /** Cancel the scheduled mailing of this NL
888 * @return Boolean: whether the mailing could be cancelled.
889 */
890 public function cancelMailing()
891 {
892 if ($this->state == self::STATE_PENDING) {
893 $success = XDB::execute('UPDATE newsletter_issues
894 SET state = \'new\'
895 WHERE id = {?}', $this->id);
896 if ($success) {
897 $this->refresh();
898 }
899 return $success;
900 } else {
901 return false;
902 }
903 }
904
905 /** Helper function for smarty templates: is this issue editable ?
906 */
907 public function isEditable()
908 {
909 return $this->state == self::STATE_NEW;
910 }
911
912 /** Helper function for smarty templates: is the mailing of this issue scheduled ?
913 */
914 public function isPending()
915 {
916 return $this->state == self::STATE_PENDING;
917 }
918
919 /** Helper function for smarty templates: has this issue been sent ?
920 */
921 public function isSent()
922 {
923 return $this->state == self::STATE_SENT;
924 }
925
926 // }}}
927 // {{{ Navigation
928
929 private $id_prev = null;
930 private $id_next = null;
931 private $id_last = null;
932
933 /** Retrieve ID of the previous issue
934 * That value, once fetched, is cached in the private $id_prev variable.
935 * @return ID of the previous issue.
936 */
937 public function prev()
938 {
939 if (is_null($this->id_prev)) {
940 $this->id_prev = $this->selectId(XDB::format("ni.id < {?} ORDER BY ni.id DESC", $this->id));
941 }
942 return $this->id_prev;
943 }
944
945 /** Retrieve ID of the following issue
946 * That value, once fetched, is cached in the private $id_next variable.
947 * @return ID of the following issue.
948 */
949 public function next()
950 {
951 if (is_null($this->id_next)) {
952 $this->id_next = $this->selectId(XDB::format("ni.id > {?} ORDER BY ni.id", $this->id));
953 }
954 return $this->id_next;
955 }
956
957 /** Retrieve ID of the last issue
958 * That value, once fetched, is cached in the private $id_last variable.
959 * @return ID of the last issue.
960 */
961 public function last()
962 {
963 if (is_null($this->id_last)) {
964 try {
965 $this->id_last = $this->nl->getIssue('last')->id;
966 } catch (MailNotFound $e) {
967 $this->id_last = null;
968 }
969 }
970 return $this->id_last;
971 }
972
973 // }}}
974 // {{{ Edition, articles
975
976 const ERROR_INVALID_REPLY_TO = 'invalid_reply_to';
977 const ERROR_INVALID_SHORTNAME = 'invalid_shortname';
978 const ERROR_INVALID_UFC = 'invalid_ufc';
979 const ERROR_TOO_LONG_UFC = 'too_long_ufc';
980 const ERROR_SQL_SAVE = 'sql_error';
981
982 /** Save the global properties of this NL issue (title&co).
983 */
984 public function save()
985 {
986 $errors = array();
987
988 // Fill the list of fields to update
989 $fields = array(
990 'title' => $this->title,
991 'mail_title' => $this->title_mail,
992 'head' => $this->head,
993 'signature' => $this->signature,
994 );
995
996 if (!empty($this->reply_to) && !isvalid_email($this->reply_to)) {
997 $errors[] = self::ERROR_INVALID_REPLY_TO ;
998 } else {
999 $fields['reply_to'] = $this->reply_to;
1000 }
1001
1002 if ($this->isEditable()) {
1003 $fields['date'] = $this->date;
1004 if (!preg_match('/^[-a-z0-9]+$/i', $this->shortname) || is_numeric($this->shortname)) {
1005 $errors[] = self::ERROR_INVALID_SHORTNAME;
1006 } else {
1007 $fields['short_name'] = $this->shortname;
1008 }
1009 if ($this->sufb->isValid() || $this->sufb->isEmpty()) {
1010 $fields['sufb_json'] = json_encode($this->sufb->export()->dict());
1011 // If sufb_json is too long to be store, we do not store a truncated json and notify the user.
1012 // The limit is LONGTEXT's one, ie 2^32 = 4294967296.
1013 if (strlen($fields['sufb_json']) > 4294967295) {
1014 $errors[] = self::ERROR_TOO_LONG_UFC;
1015 }
1016 } else {
1017 $errors[] = self::ERROR_INVALID_UFC;
1018 }
1019
1020 if ($this->nl->automaticMailingEnabled()) {
1021 $fields['send_before'] = ($this->send_before ? $this->send_before : null);
1022 }
1023 }
1024
1025 if (count($errors)) {
1026 return $errors;
1027 }
1028 $field_sets = array();
1029 foreach ($fields as $key => $value) {
1030 $field_sets[] = XDB::format($key . ' = {?}', $value);
1031 }
1032 XDB::execute('UPDATE newsletter_issues
1033 SET ' . implode(', ', $field_sets) . '
1034 WHERE id={?}',
1035 $this->id);
1036 if (XDB::affectedRows()) {
1037 $this->refresh();
1038 } else {
1039 $errors[] = self::ERROR_SQL_SAVE;
1040 }
1041 return $errors;
1042 }
1043
1044 /** Get an article by number
1045 * @p $aid Article ID (among articles of the issue)
1046 * @return A NLArticle object, or null if there is no article by that number
1047 */
1048 public function getArt($aid)
1049 {
1050 $this->fetchArticles();
1051
1052 foreach ($this->arts as $category => $artlist) {
1053 if (isset($artlist[$aid])) {
1054 return $artlist[$aid];
1055 }
1056 }
1057 return null;
1058 }
1059
1060 /** Save an article
1061 * @p $a A reference to a NLArticle object (will be modified once saved)
1062 */
1063 public function saveArticle($a)
1064 {
1065 $this->fetchArticles();
1066
1067 // Prevent cid to be 0 (use NULL instead)
1068 $a->cid = ($a->cid == 0) ? null : $a->cid;
1069 if ($a->aid >= 0) {
1070 // Article already exists in DB
1071 XDB::execute('UPDATE newsletter_art
1072 SET cid = {?}, pos = {?}, title = {?}, body = {?}, append = {?}
1073 WHERE id = {?} AND aid = {?}',
1074 $a->cid, $a->pos, $a->title, $a->body, $a->append, $this->id, $a->aid);
1075 } else {
1076 // New article
1077 XDB::startTransaction();
1078 list($aid, $pos) = XDB::fetchOneRow('SELECT MAX(aid) AS aid, MAX(pos) AS pos
1079 FROM newsletter_art AS a
1080 WHERE a.id = {?}',
1081 $this->id);
1082 $a->aid = ++$aid;
1083 $a->pos = ($a->pos ? $a->pos : ++$pos);
1084 XDB::execute('INSERT INTO newsletter_art (id, aid, cid, pos, title, body, append)
1085 VALUES ({?}, {?}, {?}, {?}, {?}, {?}, {?})',
1086 $this->id, $a->aid, $a->cid, $a->pos,
1087 $a->title, $a->body, $a->append);
1088 XDB::commit();
1089 }
1090 // Update local ID of article
1091 $this->arts[$a->aid] = $a;
1092 }
1093
1094 /** Delete an article by its ID
1095 * @p $aid ID of the article to delete
1096 */
1097 public function delArticle($aid)
1098 {
1099 $this->fetchArticles();
1100
1101 XDB::execute('DELETE FROM newsletter_art WHERE id={?} AND aid={?}', $this->id, $aid);
1102 foreach ($this->arts as $key=>$art) {
1103 unset($this->arts[$key][$aid]);
1104 }
1105 }
1106
1107 // }}}
1108 // {{{ Display
1109
1110 /** Retrieve the title of this issue
1111 * @p $mail Whether we want the normal title or the email subject
1112 * @return Title of the issue
1113 */
1114 public function title($mail = false)
1115 {
1116 return $mail ? $this->title_mail : $this->title;
1117 }
1118
1119 /** Retrieve the head of this issue
1120 * @p $user User for <dear> customization (may be null: no customization)
1121 * @p $type Either 'text' or 'html'
1122 * @return Formatted head of the issue.
1123 */
1124 public function head($user = null, $type = 'text')
1125 {
1126 if (is_null($user)) {
1127 return $this->head;
1128 } else {
1129 $head = $this->head;
1130 $head = str_replace(array('<cher>', '<prenom>', '<nom>'),
1131 array(($user->isFemale() ? 'Chère' : 'Cher'), $user->displayName(), ''),
1132 $head);
1133 return format_text($head, $type, 2, 64);
1134 }
1135 }
1136
1137 /** Retrieve the formatted signature of this issue.
1138 */
1139 public function signature($type = 'text')
1140 {
1141 return format_text($this->signature, $type, 2, 64);
1142 }
1143
1144 /** Get the title of a given category
1145 * @p $cid ID of the category to retrieve
1146 * @return Name of the category
1147 */
1148 public function category($cid)
1149 {
1150 return $this->nl->cats[$cid];
1151 }
1152
1153 /** Add required data to the given $page for proper CSS display
1154 * @p $page Smarty object
1155 * @return Either 'true' (if CSS was added to a page) or the raw CSS to add (when $page is null)
1156 */
1157 public function css($page = null)
1158 {
1159 if (!is_null($page)) {
1160 $page->addCssLink($this->nl->cssFile());
1161 return true;
1162 } else {
1163 $css = file_get_contents(dirname(__FILE__) . '/../htdocs/css/' . $this->nl->cssFile());
1164 return preg_replace('@/\*.*?\*/@us', '', $css);
1165 }
1166 }
1167
1168 /** Set up a smarty page for a 'text' mode render of the issue
1169 * @p $page Smarty object (using the $this->nl->tplFile() template)
1170 * @p $user User to use when rendering the template
1171 */
1172 public function toText($page, $user)
1173 {
1174 $this->fetchArticles();
1175
1176 $this->css($page);
1177 $page->assign('prefix', null);
1178 $page->assign('is_mail', false);
1179 $page->assign('mail_part', 'text');
1180 $page->assign('user', $user);
1181 $page->assign('hash', null);
1182 $this->assignData($page);
1183 }
1184
1185 /** Set up a smarty page for a 'html' mode render of the issue
1186 * @p $page Smarty object (using the $this->nl->tplFile() template)
1187 * @p $user User to use when rendering the template
1188 */
1189 public function toHtml($page, $user)
1190 {
1191 $this->fetchArticles();
1192
1193 $this->css($page);
1194 $page->assign('prefix', $this->nl->prefix() . '/show/' . $this->id());
1195 $page->assign('is_mail', false);
1196 $page->assign('mail_part', 'html');
1197 $page->assign('user', $user);
1198 $page->assign('hash', null);
1199 $this->assignData($page);
1200 }
1201
1202 /** Set all 'common' data for the page (those which are required for both web and email rendering)
1203 * @p $smarty Smarty object (e.g page) which should be filled
1204 */
1205 protected function assignData($smarty)
1206 {
1207 $this->fetchArticles();
1208
1209 $smarty->assign_by_ref('issue', $this);
1210 $smarty->assign_by_ref('nl', $this->nl);
1211 }
1212
1213 // }}}
1214 // {{{ Mailing
1215
1216 /** Check whether this issue is empty
1217 * An issue is empty if the email has no title (or the default one), or no articles and an empty head.
1218 */
1219 public function isEmpty()
1220 {
1221 return $this->title_mail == '' || $this->title_mail == 'to be continued' || (count($this->arts) == 0 && strlen($this->head) == 0);
1222 }
1223
1224 /** Retrieve the 'Send before' date, in a clean format.
1225 */
1226 public function getSendBeforeDate()
1227 {
1228 return strftime('%Y-%m-%d', strtotime($this->send_before));
1229 }
1230
1231 /** Retrieve the 'Send before' time (i.e hour), in a clean format.
1232 */
1233 public function getSendBeforeTime()
1234 {
1235 return strtotime($this->send_before);
1236 }
1237
1238 /** Create a hash based on some additional data
1239 * $line Line-specific data (to prevent two hashes generated at the same time to be the same)
1240 */
1241 protected static function createHash($line)
1242 {
1243 $hash = implode(time(), $line) . rand();
1244 $hash = md5($hash);
1245 return $hash;
1246 }
1247
1248 /** Send this issue to the given user, reusing an existing hash if provided.
1249 * @p $user User to whom the issue should be mailed
1250 * @p $hash Optional hash to use in the 'unsubscribe' link; if null, another one will be generated.
1251 */
1252 public function sendTo($user, $hash = null)
1253 {
1254 global $globals;
1255
1256 // Don't send email to users without an address
1257 // Note: this would never happen when using sendToAll
1258 if (!$user->bestEmail()) {
1259 return;
1260 }
1261
1262 $this->fetchArticles();
1263
1264 if (is_null($hash)) {
1265 $hash = XDB::fetchOneCell("SELECT hash
1266 FROM newsletter_ins
1267 WHERE uid = {?} AND nlid = {?}",
1268 $user->id(), $this->nl->id);
1269 }
1270 if (is_null($hash)) {
1271 $hash = self::createHash(array($user->displayName(), $user->fullName(),
1272 $user->isFemale(), $user->isEmailFormatHtml(),
1273 rand(), "X.org rulez"));
1274 XDB::execute("UPDATE newsletter_ins as ni
1275 SET ni.hash = {?}
1276 WHERE ni.uid = {?} AND ni.nlid = {?}",
1277 $hash, $user->id(), $this->nl->id);
1278 }
1279
1280 $mailer = new PlMailer($this->nl->tplFile());
1281 $this->assignData($mailer);
1282 $mailer->assign('is_mail', true);
1283 $mailer->assign('user', $user);
1284 $mailer->assign('prefix', null);
1285 $mailer->assign('hash', $hash);
1286 if (!empty($this->reply_to)) {
1287 $mailer->addHeader('Reply-To', $this->reply_to);
1288 }
1289
1290 // Add mailing list headers
1291 // Note: "Precedence: bulk" is known to cause issues on some clients
1292 $mailer->addHeader('Precedence', 'list');
1293 // RFC 2919 header
1294 $mailer->addHeader('List-Id', $this->nl->group .
1295 ' <' . $this->nl->group . '.newsletter.' . $globals->mail->domain . '>');
1296 // RFC 2369 headers
1297 $listurl = $this->nl->fullUrl();
1298 $mailer->addHeader('List-Unsubscribe', '<' . $listurl . '/out/nohash/' . $this->id . '>');
1299 $mailer->addHeader('List-Subscribe', '<' . $listurl. '/in/nohash/' . $this->id . '>');
1300 $mailer->addHeader('List-Archive', '<' . $listurl . '>');
1301 $mailer->addHeader('List-Help', '<' . $listurl . '>');
1302 $mailer->addHeader('List-Owner', '<mailto:support@' . $globals->mail->domain . '>');
1303
1304 $mailer->sendTo($user);
1305 }
1306
1307 /** Select a subset of subscribers which should receive the newsletter.
1308 * NL-Specific selections (not yet received, is subscribed) are done when sending.
1309 * @return A PlFilterCondition.
1310 */
1311 protected function getRecipientsUFC()
1312 {
1313 return $this->sufb->getUFC();
1314 }
1315
1316 /** Check whether a given user may see this issue.
1317 * @p $user User whose access should be checked
1318 * @return Whether he may access the issue
1319 */
1320 public function checkUser($user = null)
1321 {
1322 if ($user == null) {
1323 $user = S::user();
1324 }
1325 $uf = new UserFilter($this->getRecipientsUFC());
1326 return $uf->checkUser($user);
1327 }
1328
1329 /** Sent this issue to all valid recipients
1330 * @return Number of issues sent
1331 */
1332 public function sendToAll()
1333 {
1334 $this->fetchArticles();
1335
1336 XDB::execute('UPDATE newsletter_issues
1337 SET state = \'sent\', date=CURDATE()
1338 WHERE id = {?}',
1339 $this->id);
1340
1341 $ufc = new PFC_And($this->getRecipientsUFC(), new UFC_NLSubscribed($this->nl->id, $this->id), new UFC_HasValidEmail());
1342 $uf = new UserFilter($ufc, array(new UFO_IsAdmin(true), new UFO_Uid()));
1343 $limit = new PlLimit(self::BATCH_SIZE);
1344 $global_sent = array();
1345
1346 while (true) {
1347 $sent = array();
1348 $users = $uf->getUsers($limit);
1349 if (count($users) == 0) {
1350 break;
1351 }
1352 foreach ($users as $user) {
1353 if (array_key_exists($user->id(), $global_sent)) {
1354 Platal::page()->kill('Sending the same newsletter issue ' . $this->id . ' to user ' . $user->id() . ' twice, something must be wrong.');
1355 }
1356 $sent[] = $user->id();
1357 $global_sent[$user->id()] = true;
1358 $this->sendTo($user, $hash);
1359 }
1360 XDB::execute("UPDATE newsletter_ins
1361 SET last = {?}
1362 WHERE nlid = {?} AND uid IN {?}", $this->id, $this->nl->id, $sent);
1363
1364 sleep(60);
1365 }
1366 return count($global_sent);
1367 }
1368
1369 // }}}
1370 }
1371
1372 // }}}
1373 // {{{ class NLArticle
1374
1375 class NLArticle
1376 {
1377 // Maximum number of lines per article
1378 const MAX_LINES_PER_ARTICLE = 8;
1379 const MAX_CHARACTERS_PER_LINE = 68;
1380
1381 // {{{ properties
1382
1383 public $aid;
1384 public $cid;
1385 public $pos;
1386 public $title;
1387 public $body;
1388 public $append;
1389
1390 // }}}
1391 // {{{ constructor
1392
1393 function __construct($title='', $body='', $append='', $aid=-1, $cid=0, $pos=0)
1394 {
1395 $this->body = $body;
1396 $this->title = $title;
1397 $this->append = $append;
1398 $this->aid = $aid;
1399 $this->cid = $cid;
1400 $this->pos = $pos;
1401 }
1402
1403 // }}}
1404 // {{{ function title()
1405
1406 public function title()
1407 { return trim($this->title); }
1408
1409 // }}}
1410 // {{{ function body()
1411
1412 public function body()
1413 { return trim($this->body); }
1414
1415 // }}}
1416 // {{{ function append()
1417
1418 public function append()
1419 { return trim($this->append); }
1420
1421 // }}}
1422 // {{{ function toText()
1423
1424 public function toText($hash = null, $login = null)
1425 {
1426 $title = '*'.$this->title().'*';
1427 $body = MiniWiki::WikiToText($this->body, true);
1428 $app = MiniWiki::WikiToText($this->append, false, 4);
1429 $text = trim("$title\n\n$body\n\n$app")."\n";
1430 if (!is_null($hash) && !is_null($login)) {
1431 $text = str_replace('%HASH%', "$hash/$login", $text);
1432 } else {
1433 $text = str_replace('%HASH%', '', $text);
1434 }
1435 return $text;
1436 }
1437
1438 // }}}
1439 // {{{ function toHtml()
1440
1441 public function toHtml($hash = null, $login = null)
1442 {
1443 $title = "<h2 class='xorg_nl'><a id='art{$this->aid}'></a>".pl_entities($this->title()).'</h2>';
1444 $body = MiniWiki::WikiToHTML($this->body);
1445 $app = MiniWiki::WikiToHTML($this->append);
1446
1447 $art = "$title\n";
1448 $art .= "<div class='art'>\n$body\n";
1449 if ($app) {
1450 $art .= "<div class='app'>$app</div>";
1451 }
1452 $art .= "</div>\n";
1453 if (!is_null($hash) && !is_null($login)) {
1454 $art = str_replace('%HASH%', "$hash/$login", $art);
1455 } else {
1456 $art = str_replace('%HASH%', '', $art);
1457 }
1458
1459 return $art;
1460 }
1461
1462 // }}}
1463 // {{{ function check()
1464
1465 public function check()
1466 {
1467 $rest = $this->remain();
1468
1469 return $rest['remaining_lines'] >= 0;
1470 }
1471
1472 // }}}
1473 // {{{ function remain()
1474
1475 public function remain()
1476 {
1477 $text = MiniWiki::WikiToText($this->body);
1478 $array = explode("\n", wordwrap($text, self::MAX_CHARACTERS_PER_LINE));
1479 $lines_count = 0;
1480 foreach ($array as $line) {
1481 if (trim($line) != '') {
1482 ++$lines_count;
1483 }
1484 }
1485
1486 return array(
1487 'remaining_lines' => self::MAX_LINES_PER_ARTICLE - $lines_count,
1488 'remaining_characters_for_last_line' => self::MAX_CHARACTERS_PER_LINE - strlen($array[count($array) - 1])
1489 );
1490 }
1491 // }}}
1492 // {{{ function parseUrlsFromArticle()
1493
1494 protected function parseUrlsFromArticle()
1495 {
1496 $email_regex = '([a-z0-9.\-+_\$]+@([\-.+_]?[a-z0-9])+)';
1497 $url_regex = '((https?|ftp)://[a-zA-Z0-9._%#+/?=&~-]+)';
1498 $regex = '{' . $email_regex . '|' . $url_regex . '}i';
1499
1500 $matches = array();
1501 $body_matches = array();
1502 if (preg_match_all($regex, $this->body(), $body_matches)) {
1503 $matches = array_merge($matches, $body_matches[0]);
1504 }
1505
1506 $append_matches = array();
1507 if (preg_match_all($regex, $this->append(), $append_matches)) {
1508 $matches = array_merge($matches, $append_matches[0]);
1509 }
1510
1511 return $matches;
1512 }
1513
1514 // }}}
1515 // {{{ function getLinkIps()
1516
1517 public function getLinkIps(&$blacklist_host_resolution_count)
1518 {
1519 $matches = $this->parseUrlsFromArticle();
1520 $article_ips = array();
1521
1522 if (!empty($matches)) {
1523 global $globals;
1524
1525 foreach ($matches as $match) {
1526 $host = parse_url($match, PHP_URL_HOST);
1527 if ($host == '') {
1528 list(, $host) = explode('@', $match);
1529 }
1530
1531 if ($blacklist_host_resolution_count >= $globals->mail->blacklist_host_resolution_limit) {
1532 break;
1533 }
1534
1535 if (!preg_match('/^(' . str_replace(' ', '|', $globals->mail->domain_whitelist) . ')$/i', $host)) {
1536 $article_ips = array_merge($article_ips, array(gethostbyname($host) => $host));
1537 ++$blacklist_host_resolution_count;
1538 }
1539 }
1540 }
1541
1542 return $article_ips;
1543 }
1544
1545 // }}}
1546 }
1547
1548 // }}}
1549
1550 // {{{ Functions
1551
1552 function format_text($input, $format, $indent = 0, $width = 68)
1553 {
1554 if ($format == 'text') {
1555 return MiniWiki::WikiToText($input, true, $indent, $width, "title");
1556 }
1557 return MiniWiki::WikiToHTML($input, "title");
1558 }
1559
1560 // function enriched_to_text($input,$html=false,$just=false,$indent=0,$width=68)
1561
1562 // }}}
1563
1564 // vim:set et sw=4 sts=4 sws=4 fenc=utf-8:
1565 ?>