1<?php
2
3namespace dokuwiki\plugin\structgantt\meta;
4
5use dokuwiki\plugin\struct\meta\Aggregation;
6use dokuwiki\plugin\struct\meta\SearchConfig;
7use dokuwiki\plugin\struct\meta\StructException;
8use dokuwiki\plugin\struct\meta\Value;
9use dokuwiki\plugin\struct\types\Color;
10use dokuwiki\plugin\struct\types\Date;
11use dokuwiki\plugin\struct\types\DateTime;
12
13class Gantt extends Aggregation
14{
15
16    /** @var int column number containing the start date */
17    protected $colrefStart = -1;
18
19    /** @var int column number containing the end date */
20    protected $colrefEnd = -1;
21
22    /** @var int column number containing the color */
23    protected $colrefColor = -1;
24
25    /** @var int column number containing the label */
26    protected $labelRef = -1;
27
28    /** @var int column number containing the title */
29    protected $titleRef = -1;
30
31    /** @var  string first date */
32    protected $minDate;
33
34    /** @var  string last date */
35    protected $maxDate;
36
37    /** @var  \DateTime[] all the days */
38    protected $days;
39
40    /** @var  int number of days */
41    protected $daynum;
42
43    /** @var  bool do not show saturday and sunday */
44    protected $skipWeekends;
45
46    /** @var string[] interval formats used */
47    protected $interval = [
48        'header' => 'j', // shown in header
49        'period' => 'P1D', // smallest shown interval
50        'next' => '+1 day', // one more interval
51        'short' => 'q', // interval label
52        'long' => 'Y-m-d', // interval long label
53        'comp' => 'Y-m-d', // sortable interval
54    ];
55
56    /** @inheritdoc */
57    public function __construct($id, $mode, \Doku_Renderer $renderer, SearchConfig $searchConfig)
58    {
59        parent::__construct($id, $mode, $renderer, $searchConfig);
60        if ($this->mode !== 'xhtml') {
61            return;
62        }
63
64        $conf = $searchConfig->getConf();
65        $this->skipWeekends = $conf['skipweekends'] ?? false;
66        $this->initColumnRefs();
67        if ($this->resultCount) {
68            $this->initMinMax();
69        }
70    }
71
72    /**
73     * Figure out which columns will be used for dates and color
74     *
75     * The first date column is the start, the second is the end
76     *
77     * @todo suport Lookups pointing to dates and colors
78     * @todo handle multi columns
79     */
80    protected function initColumnRefs()
81    {
82        $ref = 0;
83        foreach ($this->columns as $column) {
84            if (
85                is_a($column->getType(), Date::class) ||
86                is_a($column->getType(), DateTime::class)
87            ) {
88                if ($this->colrefStart == -1) {
89                    $this->colrefStart = $ref;
90                } else {
91                    $this->colrefEnd = $ref;
92                }
93            } elseif (is_a($column->getType(), Color::class)) {
94                $this->colrefColor = $ref;
95            } else {
96                if ($this->labelRef == -1) {
97                    $this->labelRef = $ref;
98                } else {
99                    if ($this->titleRef == -1) {
100                        $this->titleRef = $ref;
101                    }
102                }
103            }
104            $ref++;
105        }
106
107        if ($this->colrefStart === -1 || $this->colrefEnd === -1) {
108            throw new StructException('Not enough Date columns selected');
109        }
110
111        if ($this->labelRef === -1) {
112            throw new StructException('No label column found');
113        }
114
115        if ($this->titleRef === -1) {
116            $this->titleRef = $this->labelRef;
117        }
118    }
119
120    /**
121     * Figure out the minimum and maximum dates and number of days inbetween
122     *
123     * @throws StructException when the range is not at least two days
124     */
125    protected function initMinMax()
126    {
127        $min = PHP_INT_MAX;
128        $max = 0;
129
130        /** @var Value[] $row */
131        foreach ($this->result as $row) {
132            $start = $row[$this->colrefStart]->getCompareValue();
133            $start = explode(' ', $start); // cut off time
134            $start = array_shift($start);
135            if ($start && $start < $min) $min = $start;
136            if ($start && $start > $max) $max = $start;
137
138            $end = $row[$this->colrefEnd]->getCompareValue();
139            $end = explode(' ', $end); // cut off time
140            $end = array_shift($end);
141            if ($end && $end < $min) $min = $end;
142            if ($end && $end > $max) $max = $end;
143        }
144
145        $daynum = (new \DateTime($min))->diff(new \DateTime($max))->days;
146        if ($daynum <= 1) {
147            throw new StructException('Not enough variation in dates to create a range');
148        }
149
150        // define the resolution
151        if ($daynum < 14) {
152            $this->interval = [
153                'header' => 'j', // days
154                'period' => 'P1D',
155                'next' => '+1 day',
156                'short' => '\\\\q',
157                'long' => 'Y-m-d',
158                'comp' => 'Y-m-d'
159            ];
160        } elseif ($daynum < 52) {
161            $this->interval = [
162                'header' => '\wW', // week numbers
163                'period' => 'P1D',
164                'next' => '+1 day',
165                'short' => '\\\\q',
166                'long' => 'Y-m-d',
167                'comp' => 'Y-m-d',
168            ];
169        } elseif ($daynum < 360) {
170            $this->interval = [
171                'header' => 'F', // months
172                'period' => 'P1D',
173                'next' => '+1 day',
174                'short' => '\\\\q',
175                'long' => 'Y-m-d',
176                'comp' => 'Y-m-d',
177            ];
178        } elseif ($daynum < 600) {
179            $this->interval = [
180                'header' => 'M \'y', // months and year
181                'period' => 'P1W', // weeks
182                'next' => '+1 week',
183                'short' => '\wW',
184                'long' => '\wW o',
185                'comp' => 'o-W',
186            ];
187            $this->skipWeekends = false;
188        } else {
189            $this->interval = [
190                'header' => '\\\\Q \'y', // quarter and year
191                'period' => 'P1M', // months
192                'next' => '+1 month',
193                'short' => 'M',
194                'long' => 'F Y',
195                'comp' => 'Y-m',
196            ];
197            $this->skipWeekends = false;
198        }
199
200        $this->minDate = $min;
201        $this->maxDate = $max;
202        $this->days = $this->listDays($min, $max);
203        $this->daynum = $daynum;
204    }
205
206    /** @inheritdoc */
207    public function getScopeClasses()
208    {
209        $classes = parent::getScopeClasses();
210        $classes[] = 'table';
211        return $classes;
212    }
213
214
215    /** @inheritdoc */
216    public function render($showNotFound = false)
217    {
218        if ($this->mode !== 'xhtml') {
219            return;
220        }
221
222        if ($this->resultCount) {
223            $this->renderer->doc .= '<table>';
224            $this->renderer->doc .= '<thead>';
225            $this->renderHeaders();
226            $this->renderer->doc .= '</thead>';
227            $this->renderer->doc .= '<tbody>';
228            foreach ($this->result as $row) {
229                $this->renderRow($row);
230            }
231            $this->renderer->doc .= '</tbody>';
232            $this->renderer->doc .= '<tfoot>';
233            $this->renderDayRow();
234            $this->renderer->doc .= '</tfoot>';
235            $this->renderer->doc .= '</table>';
236        } elseif ($showNotFound) {
237            global $lang;
238            $this->renderer->cdata($lang['nothingfound']);
239        }
240    }
241
242    /**
243     * Get the color to use in this row
244     *
245     * @param Value[] $row
246     * @return string
247     */
248    protected function getColorStyle($row)
249    {
250        if ($this->colrefColor === -1) return '';
251        $color = $row[$this->colrefColor]->getValue();
252        $conf = $row[$this->colrefColor]->getColumn()->getType()->getConfig();
253        if ($color == $conf['default']) return '';
254        return 'style="background-color:' . $color . '"';
255    }
256
257    /**
258     * Render the headers
259     *
260     * Automatically decides on the scale
261     */
262    protected function renderHeaders()
263    {
264        $headers = $this->makeHeaders($this->minDate, $this->maxDate);
265
266        $this->renderer->doc .= '<tr>';
267        $this->renderer->doc .= '<th></th>';
268        foreach ($headers as $name => $days) {
269            $this->renderer->doc .= '<th colspan="' . $days . '">' . $name . '</th>';
270        }
271        $this->renderer->doc .= '</tr>';
272        $this->renderDayRow();
273    }
274
275    /**
276     * Render a row for the days and the today pointer
277     */
278    protected function renderDayRow()
279    {
280        $today = new \DateTime();
281        $this->renderer->doc .= '<tr class="days">';
282        $this->renderer->doc .= '<th></th>';
283        foreach ($this->days as $day) {
284            if ($day->format($this->interval['long']) == $today->format($this->interval['long'])) {
285                $class = 'today';
286            } else {
287                $class = '';
288            }
289            $text = $this->intervalFormat($day, 'short');
290            $title = $this->intervalFormat($day, 'long');
291            $this->renderer->doc .= '<td title="' . $title . '" class="' . $class . '">' . $text . '</td>';
292        }
293        $this->renderer->doc .= '</tr>';
294    }
295
296    /**
297     * Render one row in the  diagram
298     *
299     * @param Value[] $row
300     */
301    protected function renderRow($row)
302    {
303        $start = $row[$this->colrefStart]->getCompareValue();
304        $end = $row[$this->colrefEnd]->getCompareValue();
305
306        if ($start && $end) {
307            $r1 = $this->listDays($this->minDate, $start);
308            $r2 = $this->listDays($start, $end);
309            $r3 = $this->listDays($end, $this->maxDate);
310
311            while ($r1 && ($this->intervalFormat(end($r1), 'comp') >= $this->intervalFormat($r2[0], 'comp'))) {
312                array_pop($r1);
313            }
314            while ($r3 && ($this->intervalFormat($r3[0], 'comp') <= $this->intervalFormat(end($r2), 'comp'))) {
315                array_shift($r3);
316            }
317        } else {
318            $r1 = $this->days;
319            $r2 = 0;
320            $r3 = 0;
321        }
322
323        // header
324        $this->renderer->doc .= '<tr>';
325        $this->renderer->doc .= '<th>';
326        $row[$this->labelRef]->render($this->renderer, $this->mode);
327        $this->renderer->doc .= '</th>';
328
329        // period before the task
330        foreach ($r1 as $day) {
331            $this->renderer->doc .= '<td title="' . $this->intervalFormat($day, 'long') . '"></td>';
332        }
333
334        // the task itself
335        if ($r2) {
336            $style = $this->getColorStyle($row);
337            $this->renderer->doc .= '<td colspan="' . count($r2) . '" class="task" ' . $style . '>';
338            $row[$this->titleRef]->render($this->renderer, $this->mode);
339
340            $this->renderer->doc .= '<dl class="flyout">';
341            foreach ($row as $value) {
342                $this->renderer->doc .= '<dd>';
343                $value->render($this->renderer, $this->mode);
344                $this->renderer->doc .= '<dd>';
345
346            }
347            $this->renderer->doc .= '</dl>';
348
349            $this->renderer->doc .= '</td>';
350        }
351
352        // period after the task
353        foreach ($r3 as $day) {
354            $this->renderer->doc .= '<td title="' . $this->intervalFormat($day, 'long') . '"></td>';
355        }
356
357        $this->renderer->doc .= '</tr>';
358    }
359
360    /**
361     * Returns the interval units in the given period
362     *
363     * @fixme currently it's still called days, but may actually use weeks or months
364     * @link based on http://stackoverflow.com/a/31046319/172068
365     * @param string $start as YYYY-MM-DD
366     * @param string $end as YYYY-MM-DD
367     * @return \DateTime[]
368     */
369    protected function listDays($start, $end)
370    {
371        if ($start > $end) list($start, $end) = array($end, $start);
372        $days = array();
373
374        $period = new \DatePeriod(
375            new \DateTime($start),
376            new \DateInterval($this->interval['period']),
377            (new \DateTime($end))->modify($this->interval['next']) // Include End Date (flag is only available in PHP8)
378        );
379
380        /** @var \DateTime $date */
381        foreach ($period as $date) {
382            if ($this->skipWeekends && (int)$date->format('N') >= 6) {
383                continue;
384            } else {
385                $days[] = $date;
386            }
387        }
388
389        return $days;
390    }
391
392    /**
393     * Returns the headers
394     *
395     * @param string $start as YYYY-MM-DD
396     * @param string $end as YYYY-MM-DD
397     * @return array
398     */
399    protected function makeHeaders($start, $end)
400    {
401        if ($start > $end) list($start, $end) = array($end, $start);
402        $headers = array();
403
404        $period = new \DatePeriod(
405            new \DateTime($start),
406            new \DateInterval($this->interval['period']),
407            (new \DateTime($end))->modify($this->interval['next']) // Include End Date (flag is only available in PHP8)
408        );
409
410        /** @var \DateTime $date */
411        foreach ($period as $date) {
412            if ($this->skipWeekends && (int)$date->format('N') >= 6) {
413                continue;
414            } else {
415                $ident = $this->intervalFormat($date, 'header');
416                if (!isset($headers[$ident])) {
417                    $headers[$ident] = 1;
418                } else {
419                    $headers[$ident]++;
420                }
421            }
422        }
423
424        return $headers;
425    }
426
427    /**
428     * Wrapper around DateTime->format() to implement our own placeholders
429     *
430     * @param \DateTime $date
431     * @param string $formatname
432     * @return string
433     */
434    protected function intervalFormat(\DateTime $date, $formatname)
435    {
436        $format = $this->interval[$formatname];
437        $label = $date->format($format);
438        return str_replace(
439            [
440                '\Q', // quarter of the year
441                '\q', // first letter of the day
442            ],
443            [
444                'Q' . ceil($date->format('n') / 3),
445                substr($date->format('l'), 0, 1),
446            ],
447            $label
448        );
449    }
450}
451