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