Completed
Push — master ( 8a2992...29f7fa )
by Jeff
03:36
created

Agenda::parseEvents()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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