xref: /plugin/bez/mdl/Thread.php (revision 20e189b9c1da902ba99631ed6573432ac365a1f1)
1<?php
2
3namespace dokuwiki\plugin\bez\mdl;
4
5use dokuwiki\plugin\bez\meta\Mailer;
6use dokuwiki\plugin\bez\meta\PermissionDeniedException;
7use dokuwiki\plugin\bez\meta\ValidationException;
8
9class Thread extends Entity {
10
11    protected $id;
12
13    protected $original_poster, $coordinator, $closed_by;
14
15    protected $private, $lock;
16
17    protected $type, $state;
18
19    protected $create_date, $last_activity_date, $last_modification_date, $close_date;
20
21    protected $title, $content, $content_html;
22
23    protected $task_count, $task_count_closed, $task_sum_cost;
24
25    public static function get_columns() {
26        return array('id',
27                     'original_poster', 'coordinator', 'closed_by',
28                     'private', 'lock',
29                     'type', 'state',
30                     'create_date', 'last_activity_date', 'last_modification_date', 'close_date',
31                     'title', 'content', 'content_html',
32                     'task_count', 'task_count_closed', 'task_sum_cost');
33    }
34
35    public static function get_select_columns() {
36        $cols = parent::get_select_columns();
37        array_push($cols, 'label_id', 'label_name');
38        return $cols;
39    }
40
41    public static function get_states() {
42        return array('proposal', 'opened', 'done', 'closed', 'rejected');
43    }
44
45    public function __get($property) {
46        if($property == 'priority') {
47            return $this->$property;
48        }
49        return parent::__get($property);
50    }
51
52    public function user_is_coordinator() {
53        if ($this->coordinator === $this->model->user_nick ||
54           $this->model->acl->get_level() >= BEZ_AUTH_ADMIN) {
55            return true;
56        }
57    }
58
59	public function __construct($model, $defaults=array()) {
60		parent::__construct($model);
61
62        $this->validator->set_rules(array(
63            'coordinator' => array(array('dw_user'), 'NULL'),
64            'title' => array(array('length', 200), 'NOT NULL'),
65            'content' => array(array('length', 10000), 'NOT NULL'),
66            'type' => array(array('select', array('issue', 'project')), 'NULL')
67        ));
68
69		//we've created empty object (new record)
70		if ($this->id === NULL) {
71			$this->original_poster = $this->model->user_nick;
72			$this->create_date = date('c');
73			$this->last_activity_date = $this->create_date;
74            $this->last_modification_date = $this->create_date;
75
76			$this->state = 'proposal';
77
78
79            if ($this->model->acl->get_level() >= BEZ_AUTH_LEADER) {
80                if (!$this->model->userFactory->exists($defaults['coordinator'])) {
81                    throw new ValidationException('thread', array('coordinator' => 'is_null'));
82                }
83                $this->coordinator = $defaults['coordinator'];
84                $this->state = 'opened';
85            }
86		}
87	}
88
89	public function set_data($data, $filter=NULL) {
90        $val_data = $this->validator->validate($data);
91
92		if ($val_data === false) {
93			throw new ValidationException('thread',	$this->validator->get_errors());
94        }
95
96        //change coordinator at the end(!)
97        if (isset($val_data['coordinator'])) {
98            $val_coordinator = $val_data['coordinator'];
99            unset($val_data['coordinator']);
100        }
101
102        $this->set_property_array($val_data);
103
104        if (isset($val_coordinator)) {
105            $this->set_property('coordinator', $val_coordinator);
106
107            //if we add cooridnator, we change state
108            if ($this->state == 'proposal') {
109                $this->state = 'opened';
110            }
111        }
112
113		$this->content_html = p_render('xhtml',p_get_instructions($this->content), $ignore);
114
115        //update dates
116        $this->last_modification_date = date('c');
117        $this->last_activity_date = $this->last_modification_date;
118    }
119
120    public function set_state($state) {
121        if ($this->acl_of('state') < BEZ_PERMISSION_CHANGE) {
122            throw new PermissionDeniedException();
123        }
124
125        if (!in_array($state, array('opened', 'closed', 'rejected'))) {
126            throw new ValidationException('thread', array('state should be opened, closed or rejected'));
127        }
128
129        //nothing to do
130        if ($state == $this->state) {
131            return;
132        }
133
134        if ($state == 'closed' || $state == 'rejected') {
135            $this->model->sqlite->query("UPDATE {$this->get_table_name()} SET state=?, closed_by=?, close_date=? WHERE id=?",
136                $state,
137                $this->model->user_nick,
138                date('c'),
139                $this->id);
140            //reopen the task
141        } else {
142            $this->model->sqlite->query("UPDATE {$this->get_table_name()} SET state=? WHERE id=?", $state, $this->id);
143        }
144
145        $this->state = $state;
146    }
147
148    public function set_private_flag($flag) {
149        $private = '0';
150        if ($flag) {
151            $private = '1';
152        }
153
154        if ($private == $this->private) {
155            return;
156        }
157
158        $this->model->sqlite->query("UPDATE {$this->get_table_name()} SET private=? WHERE id=?", $private, $this->id);
159
160    }
161
162	public function update_last_activity() {
163        $this->last_activity_date = date('c');
164        $this->model->sqlite->query('UPDATE thread SET last_activity_date=? WHERE id=?',
165                                    $this->last_activity_date, $this->id);
166    }
167
168    public function get_participants($filter='') {
169        if ($this->id === NULL) {
170            return array();
171        }
172
173        $sql = 'SELECT * FROM thread_participant WHERE';
174        $possible_flags = array('original_poster', 'coordinator', 'commentator', 'task_assignee', 'subscribent');
175        if ($filter != '') {
176            if (!in_array($filter, $possible_flags)) {
177                throw new \Exception("unknown flag $filter");
178            }
179            $sql .= " $filter=1 AND";
180        }
181        $sql .= ' thread_id=? ORDER BY user_id';
182
183        $r = $this->model->sqlite->query($sql, $this->id);
184        $pars = $this->model->sqlite->res2arr($r);
185        $participants = array();
186        foreach ($pars as $par) {
187            $participants[$par['user_id']] = $par;
188        }
189
190        return $participants;
191    }
192
193    public function get_participant($user_id) {
194        if ($this->id === NULL) {
195            return array();
196        }
197
198        $r = $this->model->sqlite->query('SELECT * FROM thread_participant WHERE thread_id=? AND user_id=?', $this->id, $user_id);
199        $par = $this->model->sqlite->res2row($r);
200        if (!is_array($par)) {
201            return false;
202        }
203
204        return $par;
205    }
206
207    public function is_subscribent($user_id=null) {
208        if ($user_id == null) {
209            $user_id = $this->model->user_nick;
210        }
211        $par = $this->get_participant($user_id);
212        if ($par['subscribent'] == 1) {
213            return true;
214        }
215        return false;
216    }
217
218    public function remove_participant_flags($user_id, $flags) {
219        //thread not saved yet
220        if ($this->id === NULL) {
221            throw new \Exception('cannot remove flags from not saved thread');
222        }
223
224        $possible_flags = array('original_poster', 'coordinator', 'commentator', 'task_assignee', 'subscribent');
225        if (array_intersect($flags, $possible_flags) != $flags) {
226            throw new \Exception('unknown flags');
227        }
228
229        $set = implode(',', array_map(function ($v) { return "$v=0"; }, $flags));
230
231        $sql = "UPDATE thread_participant SET $set WHERE thread_id=? AND user_id=?";
232        $this->model->sqlite->query($sql, $this->id, $user_id);
233
234    }
235
236	public function set_participant_flags($user_id, $flags=array()) {
237        //thread not saved yet
238        if ($this->id === NULL) {
239            throw new \Exception('cannot add flags to not saved thread');
240        }
241
242        //validate user
243        if (!$this->model->userFactory->exists($user_id)) {
244            throw new \Exception("$user_id isn't dokuwiki user");
245        }
246
247        $possible_flags = array('original_poster', 'coordinator', 'commentator', 'task_assignee', 'subscribent');
248        if (array_intersect($flags, $possible_flags) != $flags) {
249            throw new \Exception('unknown flags');
250        }
251
252        $participant = $this->get_participant($user_id);
253        if ($participant == false) {
254            $participant = array_fill_keys($possible_flags, 0);
255
256            $participant['thread_id'] = $this->id;
257            $participant['user_id'] = $user_id;
258            $participant['added_by'] = $this->model->user_nick;
259            $participant['added_date'] = date('c');
260        }
261        $values = array_merge($participant, array_fill_keys($flags, 1));
262
263        $keys = join(',', array_keys($values));
264        $vals = join(',', array_fill(0,count($values),'?'));
265
266        $sql = "REPLACE INTO thread_participant ($keys) VALUES ($vals)";
267        $this->model->sqlite->query($sql, array_values($values));
268	}
269
270
271    public function invite($client) {
272        $this->set_participant_flags($client, array('subscribent'));
273        $this->mail_notify_invite($client);
274    }
275
276    public function get_labels() {
277        //record not saved
278        if ($this->id === NULL) {
279           return array();
280        }
281
282        $labels = array();
283        $r = $this->model->sqlite->query('SELECT * FROM label JOIN thread_label ON label.id = thread_label.label_id
284                                            WHERE thread_label.thread_id=?', $this->id);
285        $arr = $this->model->sqlite->res2arr($r);
286        foreach ($arr as $label) {
287            $labels[$label['id']] = $label;
288        }
289
290        return $labels;
291    }
292
293    public function add_label($label_id) {
294         //issue not saved yet
295        if ($this->id === NULL) {
296            throw new \Exception('cannot add labels to not saved thread. use initial_save() instead');
297        }
298
299        $r = $this->model->sqlite->query('SELECT id FROM label WHERE id=?', $label_id);
300        $label_id = $this->model->sqlite->res2single($r);
301        if (!$label_id) {
302            throw new \Exception("label($label_id) doesn't exist");
303        }
304
305
306        $this->model->sqlite->storeEntry('thread_label',
307                                         array('thread_id' => $this->id,
308                                               'label_id' => $label_id));
309
310    }
311
312    public function remove_label($label_id) {
313        //issue not saved yet
314        if ($this->id === NULL) {
315            throw new \Exception('cannot remove labels from not saved thread. use initial_save() instead');
316        }
317
318        /** @var \PDOStatement $r */
319        $r = $this->model->sqlite->query('DELETE FROM thread_label WHERE thread_id=? AND label_id=?',$this->id, $label_id);
320        if ($r->rowCount() != 1) {
321            throw new \Exception('label was not assigned to this thread');
322        }
323
324    }
325
326    public function get_causes() {
327        $r = $this->model->sqlite->query("SELECT id FROM thread_comment WHERE type LIKE 'cause_%' AND thread_id=?",
328                                         $this->id);
329        $arr = $this->model->sqlite->res2arr($r);
330        $causes = array();
331        foreach ($arr as $cause) {
332            $causes[] = $cause['id'];
333        }
334
335        return $causes;
336    }
337
338    public function can_add_comments() {
339        return in_array($this->state, array('proposal', 'opened', 'done'));
340    }
341
342    public function can_add_causes() {
343        return $this->type == 'issue' && in_array($this->state, array('opened', 'done'));
344    }
345
346    public function can_add_tasks() {
347        return in_array($this->state, array('opened', 'done'));
348    }
349
350    public function can_add_participants() {
351        return in_array($this->state, array('opened', 'done'));
352    }
353
354    public function can_be_closed() {
355        $res = $this->model->sqlite->query("SELECT thread_comment.id FROM thread_comment
356                               LEFT JOIN task ON thread_comment.id = task.thread_comment_id
357                               WHERE thread_comment.thread_id = ? AND
358                                     thread_comment.type LIKE 'cause_%' AND task.id IS NULL", $this->id);
359
360        $causes_without_tasks = $this->model->sqlite->res2row($res) ? true : false;
361        return $this->state == 'done' &&
362            ! $causes_without_tasks;
363
364    }
365
366    public function can_be_rejected() {
367        return $this->state != 'rejected' && $this->task_count == 0;
368    }
369
370    public function can_be_reopened() {
371        return in_array($this->state, array('closed', 'rejected'));
372    }
373
374    public function closing_comment() {
375        $r = $this->model->thread_commentFactory->get_from_thread($this, array(), 'id', true, 1);
376        $thread_comment = $r->fetch();
377
378        return $thread_comment->content_html;
379    }
380
381    //http://data.agaric.com/capture-all-sent-mail-locally-postfix
382    //https://askubuntu.com/questions/192572/how-do-i-read-local-email-in-thunderbird
383    public function mail_notify($replacements=array(), $users=false) {
384        $plain = io_readFile($this->model->action->localFN('thread-notification'));
385        $html = io_readFile($this->model->action->localFN('thread-notification', 'html'));
386
387        $thread_reps = array(
388                                'thread_id' => $this->id,
389                                'thread_link' => $this->model->action->url('thread', 'id', $this->id),
390                                'thread_unsubscribe' =>
391                                    $this->model->action->url('thread', 'id', $this->id, 'action', 'unsubscribe'),
392                                'custom_content' => false,
393                                'action_border_color' => 'transparent',
394                                'action_color' => 'transparent',
395                           );
396
397        //$replacements can override $issue_reps
398        $rep = array_merge($thread_reps, $replacements);
399        //auto title
400        if (!isset($rep['subject'])) {
401            $rep['subject'] =  '#'.$this->id. ' ' .$this->title;
402        }
403        if (!isset($rep['content_html'])) {
404            $rep['content_html'] = $rep['content'];
405        }
406        if (!isset($rep['who_full_name'])) {
407            $rep['who_full_name'] =
408                $this->model->userFactory->get_user_full_name($rep['who']);
409        }
410
411        //format when
412        $rep['when'] =  dformat(strtotime($rep['when']), '%Y-%m-%d %H:%M');
413
414        if ($rep['custom_content'] === false) {
415            $html = str_replace('@CONTENT_HTML@', '
416                <div style="margin: 5px 0;">
417                    <strong>@WHO_FULL_NAME@</strong> <br>
418                    <span style="color: #888">@WHEN@</span>
419                </div>
420                @CONTENT_HTML@
421            ', $html);
422        }
423
424        //we must do it manually becouse Mailer uses htmlspecialchars()
425        $html = str_replace('@CONTENT_HTML@', $rep['content_html'], $html);
426
427        $mailer = new Mailer();
428        $mailer->setBody($plain, $rep, $rep, $html, false);
429
430        if ($users == FALSE) {
431
432            $users = $this->get_participants('subscribent');
433            //don't notify myself
434            unset($users[$this->model->user_nick]);
435        }
436
437        $emails = array_map(function($user) {
438            if (is_array($user)) {
439                $user = $user['user_id'];
440            }
441            return $this->model->userFactory->get_user_email($user);
442        }, $users);
443
444
445        $mailer->to($emails);
446        $mailer->subject($rep['subject']);
447
448        $send = $mailer->send();
449        if ($send === false) {
450            //this may mean empty $emails
451            //throw new Exception("can't send email");
452        }
453    }
454
455    protected function mail_issue_box_reps($replacements=array()) {
456        $replacements['custom_content'] = true;
457
458        $html =  '<h2 style="font-size: 1.2em;">';
459	    $html .=    '<a style="font-size:115%" href="@THREAD_LINK@">#@THREAD_ID@</a> ';
460
461        if ( ! empty($this->type_string)) {
462            $html .= $this->type_string;
463        } else {
464            $html .= '<i style="color: #777"> '.
465                        $this->model->action->getLang('issue_type_no_specified').
466                    '</i>';
467        }
468
469        $html .= ' ('. $this->model->action->getLang('state_' . $this->state ) .') ';
470
471        $html .= '<span style="color: #777; font-weight: normal; font-size: 90%;">';
472        $html .= $this->model->action->getLang('coordinator') . ': ';
473        $html .= '<span style="font-weight: bold;">';
474
475        if ($this->state == 'proposal') {
476            $html .= '<i style="font-weight: normal;">' .
477                $this->model->action->getLang('proposal') .
478                '</i>';
479        } else {
480            $html .= $this->model->userFactory->get_user_full_name($this->coordinator);
481        }
482        $html .= '</span></span></h2>';
483
484        $html .= '<h2 style="font-size: 1.2em;border-bottom: 1px solid @ACTION_BORDER_COLOR@">' . $this->title . '</h2>';
485
486        $html .= $this->content_html;
487
488        $replacements['content_html'] = $html;
489
490
491         switch ($this->priority) {
492            case '0':
493                $replacements['action_color'] = '#F8E8E8';
494                $replacements['action_border_color'] = '#F0AFAD';
495                break;
496            case '1':
497                $replacements['action_color'] = '#ffd';
498                $replacements['action_border_color'] = '#dd9';
499                break;
500            case '2':
501                $replacements['action_color'] = '#EEF6F0';
502                $replacements['action_border_color'] = '#B0D2B6';
503                break;
504            case 'None':
505                $replacements['action_color'] = '#e7f1ff';
506                $replacements['action_border_color'] = '#a3c8ff';
507                break;
508            default:
509                $replacements['action_color'] = '#fff';
510                $replacements['action_border_color'] = '#bbb';
511                break;
512        }
513
514        return $replacements;
515    }
516
517    public function mail_notify_change_state() {
518        $this->mail_notify($this->mail_issue_box_reps(array(
519            'who' => $this->model->user_nick,
520            'action' => $this->model->action->getLang('mail_mail_notify_change_state_action'),
521            //'subject' => $this->model->action->getLang('mail_mail_notify_change_state_subject') . ' #'.$this->id
522        )));
523    }
524
525    public function mail_notify_invite($client) {
526        $this->mail_notify($this->mail_issue_box_reps(array(
527            'who' => $this->model->user_nick,
528            'action' => $this->model->action->getLang('mail_mail_notify_invite_action'),
529            //'subject' => $this->model->action->getLang('mail_mail_notify_invite_subject') . ' #'.$this->id
530        )), array($client));
531    }
532
533    public function mail_inform_coordinator() {
534        $this->mail_notify($this->mail_issue_box_reps(array(
535            'who' => $this->model->user_nick,
536            'action' => $this->model->action->getLang('mail_mail_inform_coordinator_action'),
537            //'subject' => $this->model->action->getLang('mail_mail_inform_coordinator_subject') . ' #'.$this->id
538        )), array($this->coordinator));
539    }
540
541    public function mail_notify_issue_inactive($users=false) {
542        $this->mail_notify($this->mail_issue_box_reps(array(
543            'who' => '',
544            'action' => $this->model->action->getLang('mail_mail_notify_issue_inactive'),
545        )), $users);
546    }
547
548}
549