Agenda::processData()   A
last analyzed

Complexity

Conditions 4
Paths 5

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 20
rs 9.6
c 0
b 0
f 0
cc 4
nc 5
nop 1
1
<?php
2
3
namespace app\models\types;
4
5
use Yii;
6
use ICal\ICal;
7
use app\models\ContentType;
8
9
/**
10
 * This is the model class for Agenda content type.
11
 */
12
class Agenda extends ContentType
13
{
14
    const BASE_CACHE_TIME = 7200; // 2 hours
15
    const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
16
    const HOUR_MIN = 8;
17
    const HOUR_MAX = 18;
18
19
    public $html = '<div class="agenda">%data%</div>';
20
    public $css = <<<'EO1'
21
%field% .agenda { width: 100%; height: 100%; text-align: center; background-color: white; color: black; font-size: 1.1em; }
22
%field% .agenda-header { font-weight: bold; }
23
%field% .agenda-contents { width: 100%; height: calc(100% - 1.3em); display: table; table-layout: fixed; border-collapse: collapse; }
24
%field% .agenda-time { display: table-cell; width: 2.2em;  border: solid 1px black; }
25
%field% .agenda-time-header { }
26
%field% .agenda-time-contents { width: 100%; height: calc(100% - 1.3em); display: table; position: relative; }
27
%field% .agenda-time-h { position: absolute; border-top: solid 1px black; width: 100%; }
28
%field% .agenda-time-m { position: absolute; border-top: dotted 1px black; right: 0; }
29
%field% .agenda-time-trace { position: absolute; border-top: dotted 1px gray; width: 100%; }
30
%field% .agenda-day { display: table-cell; border: solid 1px black; }
31
%field% .agenda-day-header { border-bottom: solid 1px black; }
32
%field% .agenda-day-contents { width: 100%; height: calc(100% - 1.3em); display: table; position: relative; }
33
%field% .agenda-event { position: absolute; overflow: hidden; border-bottom: solid 1px black; z-index: 10; }
34
%field% .agenda-event-desc { font-weight: bold; font-size: 1.1em; }
35
%field% .agenda-event-location { font-size: 1.1em; white-space: nowrap; }
36
%field% .agenda-event-name { word-break: break-all; display: block; }
37
EO1;
38
    public $input = 'url';
39
    public $output = 'raw';
40
    public $usable = true;
41
    public $exemple = '@web/images/agenda.preview.jpg';
42
    public $canPreview = true;
43
44
    private static $translit;
45
    private $color = [];
46
    private $overlapScanOffset = 0.1;
47
    private $tz;
48
49
    /**
50
     * {@inheritdoc}
51
     */
52
    public function __construct($config = [])
53
    {
54
        parent::__construct($config);
55
        $this->name = Yii::t('app', 'Agenda');
56
        $this->description = Yii::t('app', 'Display an agenda from an ICal feed.');
57
    }
58
59
    /**
60
     * {@inheritdoc}
61
     */
62
    public function processData($data)
63
    {
64
        $content = self::fromCache($data);
65
        if ($content === false) {
66
            if (!$data) {
67
                return null;
68
            }
69
70
            $content = self::downloadContent($data);
71
            self::toCache($data, $content);
72
        }
73
74
        if (!$content) {
75
            return null;
76
        }
77
78
        $agenda = $this->genAgenda($content);
79
80
        return $agenda;
81
    }
82
83
    /**
84
     * Extract data from ical event.
85
     *
86
     * @param array $e ical event
87
     *
88
     * @return array parsed event
89
     */
90
    private function parseEvent($e)
91
    {
92
        // Convert timezones
93
        $start = (new \DateTime($e->dtstart))->setTimeZone($this->tz);
94
        $end = (new \DateTime($e->dtend))->setTimeZone($this->tz);
95
96
        // Event info
97
        $event = [
98
            'dow' => $start->format('w') - 1,
99
            'dayMonth' => $start->format('d/m'),
100
            'start' => $start->format('G') + ($start->format('i') / 60.0),
101
            'startStr' => $start->format('G:i'),
102
            'end' => $end->format('G') + ($end->format('i') / 60.0),
103
            'endStr' => $end->format('G:i'),
104
            'name' => self::filter($e->summary, 'name'),
105
            'locations' => self::arrayFilter(explode(',', $e->location), 'location'),
106
            'desc' => self::arrayFilter(explode(PHP_EOL, $e->description), 'description'),
107
        ];
108
        $event['duration'] = $event['end'] - $event['start'];
109
110
        return $event;
111
    }
112
113
    /**
114
     * Read .ical data and parse to day-based array.
115
     *
116
     * @param string $data ical raw data
117
     *
118
     * @return array agenda
119
     */
120
    public function parseIcal($data)
121
    {
122
        // Init ICal parser
123
        $ical = new ICal();
124
        $ical->initString($data);
125
126
        // Retrieve event for this week only
127
        $events = $ical->eventsFromRange(self::DAYS[0].' this week', self::DAYS[count(self::DAYS) - 1].' this week 23:59');
128
129
        if (!is_array($events) || !count($events)) {
130
            return null;
131
        }
132
133
        // Use own timezone to display
134
        $this->tz = new \DateTimeZone(ini_get('date.timezone'));
135
        // Always transliterate text contents
136
        self::$translit = \Transliterator::create('Latin-ASCII');
137
        if (!self::$translit) {
138
            return null;
139
        }
140
141
        // Base agenda format info
142
        $info = [
143
            'minHour' => self::HOUR_MIN,
144
            'maxHour' => self::HOUR_MAX,
145
            'days' => [],
146
        ];
147
148
        $parsedEvents = [];
149
150
        foreach ($events as $event) {
151
            $e = $this->parseEvent($event);
152
153
            // Adjust agenda format based on events
154
            if ($e['start'] < $info['minHour']) {
155
                $info['minHour'] = $e['start'];
156
            }
157
            if ($e['end'] > $info['maxHour']) {
158
                $info['maxHour'] = $e['end'];
159
            }
160
161
            // Only add days with events
162
            if (!array_key_exists($e['dow'], $parsedEvents)) {
163
                $parsedEvents[$e['dow']] = [];
164
                $info['days'][$e['dow']] = $e['dayMonth'];
165
            }
166
167
            $parsedEvents[$e['dow']][] = $e;
168
        }
169
170
        return ['info' => $info, 'events' => $parsedEvents];
171
    }
172
173
    /**
174
     * Loop through day events and tag with overlaps.
175
     *
176
     * @param array $events
177
     * @param int   $from   start hour
178
     * @param int   $to     end hour
179
     *
180
     * @return array tagged events
181
     */
182
    private function tagOverlaps($events, $from, $to)
183
    {
184
        // Scan each 0.1h for overlapping events
185
        for ($i = $from; $i <= $to; $i += $this->overlapScanOffset) {
186
            // $overlap is every overlapping event
187
            $overlap = $this->scanOverlap($events, $i);
188
189
            // $overlaps is maximum concurrent overlappings
190
            // Used to fix block width
191
            $overlaps = count($overlap);
192
193
            foreach ($events as $k => $e) {
194
                if ($e['start'] < $i && $i < $e['end']) {
195
                    if (!array_key_exists('overlaps', $e)) {
196
                        $e['overlaps'] = $overlaps;
197
                        $e['overlap'] = $overlap;
198
                    } else {
199
                        if ($overlaps >= $e['overlaps']) {
200
                            $e['overlaps'] = $overlaps;
201
                        }
202
                        // Merge overlap to always get full range of overlapping events
203
                        // Used to calculate block position
204
                        $e['overlap'] = array_unique(array_merge($e['overlap'], $overlap));
205
                    }
206
207
                    $events[$k] = $e;
208
                }
209
            }
210
        }
211
212
        // Second pass, fixing non-contiguous overlaps
213
        for ($i = $from; $i <= $to; $i += $this->overlapScanOffset) {
214
            // Find maximum overlap for a given line
215
            $maxOverlaps = 0;
216
            foreach ($events as $k => $e) {
217
                if ($e['start'] < $i && $i < $e['end']) {
218
                    if ($e['overlaps'] > $maxOverlaps) {
219
                        $maxOverlaps = $e['overlaps'];
220
                    }
221
                }
222
            }
223
224
            // Apply it to all events on this line
225
            foreach ($events as $k => $e) {
226
                if ($e['start'] < $i && $i < $e['end']) {
227 View Code Duplication
                    if ($e['overlaps'] < $maxOverlaps) {
228
                        $e['overlaps'] = $maxOverlaps;
229
                        $events[$k] = $e;
230
                    }
231
                }
232
            }
233
        }
234
235
        return $events;
236
    }
237
238
    /**
239
     * Scan for overlap at precise time.
240
     *
241
     * @param array $events
242
     * @param int   $at     scan hour
243
     *
244
     * @return array overlap
245
     */
246
    private function scanOverlap($events, $at)
247
    {
248
        $overlap = [];
249
        foreach ($events as $k => $e) {
250
            if ($e['start'] < $at && $at < $e['end']) {
251
                $overlap[] = $k;
252
            }
253
        }
254
255
        return $overlap;
256
    }
257
258
    /**
259
     * Loop through day events and scan for open position.
260
     *
261
     * @param array $events
262
     *
263
     * @return array positionned blocks
264
     */
265
    private function positionBlocks($events)
266
    {
267
        foreach ($events as $k => $e) {
268 View Code Duplication
            if ($e['overlaps'] < 2) {
269
                // No overlap, easy mode
270
                $e['position'] = 0;
271
                $events[$k] = $e;
272
                continue;
273
            }
274
275
            if (array_key_exists('position', $e)) {
276
                // Position already set, don't touch
277
                continue;
278
            }
279
280
            // Find available spots for this event
281
            $spots = range(0, $e['overlaps'] - 1);
282
            $overlapCount = count($e['overlap']);
283
            for ($i = 0; $i < $overlapCount; ++$i) {
284
                $overlaped = $events[$e['overlap'][$i]];
285
                if (array_key_exists('position', $overlaped)) {
286
                    unset($spots[$overlaped['position']]);
287
                }
288
            }
289
290
            // Take first one
291
            $e['position'] = array_shift($spots);
292
293
            $events[$k] = $e;
294
        }
295
296
        return $events;
297
    }
298
299
    /**
300
     * Use agenda events data to build blocks for rendering.
301
     *
302
     * @param array $agenda
303
     *
304
     * @return array blocks
305
     */
306
    public function blockize($agenda)
307
    {
308
        $blocks = [];
309
310
        foreach ($agenda['events'] as $day => $events) {
311
            // Sort by desc first line
312
            usort($events, function ($a, $b) {
313
                return strcmp($a['desc'][0], $b['desc'][0]);
314
            });
315
316
            $events = $this->tagOverlaps($events, $agenda['info']['minHour'], $agenda['info']['maxHour']);
317
318
            $blocks[$day] = $this->positionBlocks($events);
319
        }
320
321
        return $blocks;
322
    }
323
324
    /**
325
     * Render agenda left column with hours.
326
     *
327
     * @param array $agenda
328
     *
329
     * @return string HTML column
330
     */
331
    private function renderHoursColumn($agenda)
332
    {
333
        $hourColumnIntervals = 0.25;
334
        $min = $agenda['info']['minHour'];
335
        $max = $agenda['info']['maxHour'];
336
        $len = $max - $min;
337
338
        $h = '<div class="agenda-time"><div class="agenda-time-header">&nbsp;</div><div class="agenda-time-contents">';
339
        for ($i = floor($min); $i < ceil($max); $i += $hourColumnIntervals) {
340
            if (fmod($i, 1) == 0) {
341
                $h .= '<div class="agenda-time-h" style="top: '.((($i - $min) / $len) * 100).'%;">'.$i.'h</div>';
342
            } else {
343
                $width = fmod($i, 0.5) == 0 ? 40 : 20;
344
                $h .= '<div class="agenda-time-m" style="top: '.((($i - $min) / $len) * 100).'%; width: '.$width.'%;"></div>';
345
            }
346
        }
347
        $h .= '</div></div>';
348
349
        return $h;
350
    }
351
352
    /**
353
     * Render agenda events blocks columns.
354
     *
355
     * @param array $agenda
356
     *
357
     * @return string HTML blocks columns
358
     */
359
    private function renderEvents($agenda)
360
    {
361
        $hourIntervals = 1;
362
        $min = $agenda['info']['minHour'];
363
        $max = $agenda['info']['maxHour'];
364
        $len = $max - $min;
365
366
        $h = '';
367
        foreach ($agenda['events'] as $day => $events) {
368
            // Draw day header
369
            $h .= '<div class="agenda-day" id="day-'.$day.'">'.
370
                '<div class="agenda-day-header">'.\Yii::t('app', self::DAYS[$day]).' '.$agenda['info']['days'][$day].'</div>'.
371
                '<div class="agenda-day-contents">';
372
373
            // Draw events
374
            foreach ($events as $e) {
375
                $style = [
376
                    'top' => ((($e['start'] - $min) / $len) * 100).'%',
377
                    'bottom' => ((($max - $e['end']) / $len) * 100).'%',
378
                    'left' => ($e['position'] / $e['overlaps'] * 100).'%',
379
                    'right' => ((1 - ($e['position'] + 1) / $e['overlaps']) * 100).'%',
380
                    'background-color' => $this->getColor($e['desc'][0]),
381
                ];
382
                $styleStr = implode('; ', array_map(function ($k, $v) {
383
                    return $k.':'.$v;
384
                }, array_keys($style), $style));
385
386
                $content = [];
387
                $content[] = '<div class="agenda-event-header">';
388
                if (count($e['desc']) && $e['desc'][0]) {
389
                    $content[] = '<span class="agenda-event-desc">'.$e['desc'][0].'</span>';
390
                }
391
                foreach ($e['locations'] as $l) {
392
                    $content[] = ' <span class="agenda-event-location">'.$l.'</span>';
393
                }
394
                $content[] = '</div>';
395
                if ($e['name']) {
396
                    $content[] = '<div class="agenda-event-name">'.$e['name'].'</div>';
397
                }
398
                $content[] = '<div class="agenda-event-time">';
399
                $content[] = '<span class="agenda-event-time-start">'.$e['startStr'].'</span>';
400
                $content[] = ' - ';
401
                $content[] = '<span class="agenda-event-time-end">'.$e['endStr'].'</span>';
402
                $content[] = '</div>';
403
404
                $h .= '<div class="agenda-event" style="'.$styleStr.'">'.implode('', $content).'</div>';
405
            }
406
407
            // Draw background hour traces
408
            for ($i = floor($min) + 1; $i < ceil($max); $i += $hourIntervals) {
409
                $h .= '<div class="agenda-time-trace" style="top: '.((($i - $min) / $len) * 100).'%;"></div>';
410
            }
411
412
            $h .= '</div></div>';
413
        }
414
415
        return $h;
416
    }
417
418
    /**
419
     * Render agenda to HTML.
420
     *
421
     * @param array $agenda
422
     *
423
     * @return string HTML result
424
     */
425
    public function render($agenda)
426
    {
427
        $h = '<div class="agenda-header">%name%</div><div class="agenda-contents">';
428
429
        $h .= $this->renderHoursColumn($agenda);
430
431
        $h .= $this->renderEvents($agenda);
432
433
        $h .= '</div>';
434
435
        return $h;
436
    }
437
438
    /**
439
     * Generate agenda HTML from .ical raw data.
440
     *
441
     * @param string $content ical raw data
442
     *
443
     * @return string|null HTML agenda
444
     */
445
    public function genAgenda($content)
446
    {
447
        $agenda = $this->parseIcal($content);
448
        if (!$agenda) {
449
            return null;
450
        }
451
452
        $agenda['events'] = $this->blockize($agenda);
453
454
        return $this->render($agenda);
455
    }
456
457
    /**
458
     * Apply self::filter() to each array member.
459
     *
460
     * @param array  $arr  input
461
     * @param string $type array type
462
     *
463
     * @return array filtered output
464
     */
465
    private static function arrayFilter(array $arr, $type)
466
    {
467
        $res = [];
468
        foreach ($arr as $v) {
469
            $res[] = self::filter($v, $type);
470
        }
471
472
        return array_values(array_filter($res));
473
    }
474
475
    /**
476
     * Filter string from feed.
477
     *
478
     * @param string $str  input string
479
     * @param string $type string type
480
     *
481
     * @return string filtered string
482
     */
483
    private static function filter($str, $type)
484
    {
485
        $str = html_entity_decode($str);
486
487
        if (self::$translit) {
488
            $str = self::$translit->transliterate($str);
489
        }
490
491
        $str = preg_replace([
492
            '/\s{2,}/',
493
            '/\s*\\\,\s*/',
494
            '/\s*\([^\)]*\)/',
495
        ], [
496
            ' ',
497
            ', ',
498
            '',
499
        ], trim($str));
500
501
        switch ($type) {
502
            case 'name':
503
                return preg_replace([
504
                    '/^\d+\s*-/',
505
                    '/^\d+\s+/',
506
                ], [
507
                    '',
508
                    '',
509
                ], $str);
510
            case 'location':
511
                return preg_replace([
512
                    '/(\d) (\d{3}).*/',
513
                ], [
514
                    '\\1-\\2',
515
                ], $str);
516
            case 'description':
517
                return preg_replace([
518
                    '/(modif).*/',
519
                ], [
520
                    '',
521
                ], $str);
522
            default:
523
                return $str;
524
        }
525
    }
526
527
    /**
528
     * Generate color based on string
529
     * Using MD5 to always get the same color for a given string.
530
     *
531
     * @param string $str
532
     *
533
     * @return string color hexcode
534
     */
535
    private function getColor($str)
536
    {
537
        if (array_key_exists($str, $this->color)) {
538
            return $this->color[$str];
539
        }
540
541
        // %140 + 95 make colors brighter
542
        $hash = md5($str);
543
        $this->color[$str] = sprintf(
544
            '#%X%X%X',
545
            hexdec(substr($hash, 0, 2)) % 140 + 95,
546
            hexdec(substr($hash, 2, 2)) % 140 + 95,
547
            hexdec(substr($hash, 4, 2)) % 140 + 95
548
        );
549
550
        return $this->color[$str];
551
    }
552
}
553