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