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