Completed
Push — master ( 6ae034...0bac1c )
by Jeff
04:52
created

Agenda::processData()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 20
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 20
rs 9.2
cc 4
eloc 11
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) {
1 ignored issue
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
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) {
1 ignored issue
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
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