Completed
Push — master ( 3a0c9c...9f512c )
by Jeff
08:43
created

Agenda::finalize()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 8
rs 9.4285
cc 1
eloc 4
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
use yii\helpers\Url;
9
10
/**
11
 * This is the model class for Agenda content type.
12
 */
13
class Agenda extends ContentType
14
{
15
    const BASE_CACHE_TIME = 7200; // 2 hours
16
    const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
17
    const HOUR_MIN = 8;
18
    const HOUR_MAX = 18;
19
20
    public $html = '<div class="agenda">%data%</div>';
21
    public $css = <<<'EO1'
22
%field% .agenda { width: 100%; height: 100%; text-align: center; background-color: white; }
23
%field% .agenda-header { font-weight: bold; }
24
%field% .agenda-contents { width: 100%; height: calc(100% - 1.3em); display: table; table-layout: fixed; border-collapse: collapse; }
25
%field% .agenda-time { display: table-cell; width: 2.2em;  border: solid 1px black; }
26
%field% .agenda-time-header { }
27
%field% .agenda-time-contents { width: 100%; height: calc(100% - 1.3em); display: table; position: relative; }
28
%field% .agenda-time-h { position: absolute; border-top: solid 1px black; width: 100%; }
29
%field% .agenda-time-m { position: absolute; border-top: dotted 1px black; right: 0; }
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; }
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 $preview = '@web/images/agenda.preview.jpg';
42
43
    private static $translit;
44
    private $color = [];
45
    private $opts;
46
47
    /**
48
     * {@inheritdoc}
49
     */
50
    public function __construct($config = [])
51
    {
52
        parent::__construct($config);
53
        $this->name = Yii::t('app', 'Agenda');
54
        $this->description = Yii::t('app', 'Display an agenda from an ICal feed.');
55
    }
56
57
    /**
58
     * {@inheritdoc}
59
     */
60
    public function processData($data)
61
    {
62
        $agenda = self::fromCache($data);
63
        if (!$agenda) {
64
            $agenda = $this->genAgenda($data);
65
            if ($agenda) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $agenda of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
66
                self::toCache($data, $agenda);
67
            }
68
        }
69
70
        return $agenda;
71
    }
72
73
    /**
74
     * Read .ical data and parse to day-based array.
75
     *
76
     * @param string $data ical raw data
77
     *
78
     * @return array events
79
     */
80
    public function parseIcal($data)
81
    {
82
        // Init ICal parser
83
        $ical = new ICal();
84
        $ical->initString($data);
85
86
        // Retrieve event for this week only
87
        $events = $ical->eventsFromRange(self::DAYS[0].' this week', self::DAYS[count(self::DAYS) - 1].' this week 23:59');
88
89
        if (!is_array($events) || !count($events)) {
90
            return null;
91
        }
92
93
        // Use own timezone to display
94
        $tz = new \DateTimeZone(ini_get('date.timezone'));
95
        // Always transliterate text contents
96
        self::$translit = \Transliterator::create('Latin-ASCII');
97
        if (!self::$translit) {
98
            return null;
99
        }
100
101
        // Base agenda format info
102
        $format = [
103
            'minHour' => self::HOUR_MIN,
104
            'maxHour' => self::HOUR_MAX,
105
            'days' => [],
106
        ];
107
108
        $parsedEvents = [];
109
110
        foreach ($events as $e) {
111
            // Convert timezones
112
            $start = (new \DateTime($e->dtstart))->setTimeZone($tz);
113
            $end = (new \DateTime($e->dtend))->setTimeZone($tz);
114
115
            // Event info
116
            $b = [
117
                'dow' => $start->format('w') - 1,
118
                'start' => $start->format('G') + ($start->format('i') / 60.0),
119
                'startStr' => $start->format('G:i'),
120
                'end' => $end->format('G') + ($end->format('i') / 60.0),
121
                'endStr' => $end->format('G:i'),
122
                'name' => self::filter($e->summary, 'name'),
123
                'locations' => self::arrayFilter(explode(',', $e->location), 'location'),
124
                'desc' => self::arrayFilter(explode(PHP_EOL, $e->description), 'description'),
125
            ];
126
            $b['duration'] = $b['end'] - $b['start'];
127
128
            // Adjust agenda format based on events
129
            if ($b['start'] < $format['minHour']) {
130
                $format['minHour'] = $b['start'];
131
            }
132
            if ($b['end'] > $format['maxHour']) {
133
                $format['maxHour'] = $b['end'];
134
            }
135
136
            // Only add days with events
137
            if (!array_key_exists($b['dow'], $parsedEvents)) {
138
                $parsedEvents[$b['dow']] = [];
139
                $format['days'][$b['dow']] = $start->format('d/m');
140
            }
141
142
            $parsedEvents[$b['dow']][] = $b;
143
        }
144
145
        $format['dayLen'] = $format['maxHour'] - $format['minHour'];
146
147
        return ['info' => $format, 'events' => $parsedEvents];
148
    }
149
150
    /**
151
     * Use agenda events data to build blocks for rendering.
152
     *
153
     * @param array $agenda
154
     *
155
     * @return array blocks
156
     */
157
    public function blockize($agenda)
158
    {
159
        $scanOffset = 0.1;
160
161
        $blocks = [];
162
163
        foreach ($agenda['events'] as $day => $events) {
164
            // Sort by desc first line
165
            usort($events, function ($a, $b) {
166
                return strcmp($a['desc'][0], $b['desc'][0]);
167
            });
168
169
            // Scan each 0.1h for overlapping events
170
            for ($i = $agenda['info']['minHour']; $i <= $agenda['info']['maxHour']; $i += $scanOffset) {
171
                // $overlap is every overlapping event
172
                $overlap = [];
173
                foreach ($events as $k => $e) {
174
                    if ($e['start'] < $i && $i < $e['end']) {
175
                        $overlap[] = $k;
176
                    }
177
                }
178
179
                // $overlaps is maximum concurrent overlappings
180
                // Used to fix block width
181
                $overlaps = count($overlap);
182
183
                foreach ($events as $k => $e) {
184
                    if ($e['start'] < $i && $i < $e['end']) {
185
                        if (!array_key_exists('overlaps', $e)) {
186
                            $e['overlaps'] = $overlaps;
187
                            $e['overlap'] = $overlap;
188
                        } else {
189
                            if ($overlaps >= $e['overlaps']) {
190
                                $e['overlaps'] = $overlaps;
191
                            }
192
                            // Merge overlap to always get full range of overlapping events
193
                            // Used to calculate block position
194
                            $e['overlap'] = array_unique(array_merge($e['overlap'], $overlap));
195
                        }
196
197
                        $events[$k] = $e;
198
                    }
199
                }
200
            }
201
202
            foreach ($events as $k => $e) {
203
                if ($e['overlaps'] < 2) {
204
                    // No overlap, easy mode
205
                    $e['position'] = 0;
206
                    $events[$k] = $e;
207
                    continue;
208
                }
209
210
                if (array_key_exists('position', $e)) {
211
                    // Position already set, don't touch
212
                    continue;
213
                }
214
215
                // Find available spots for this event
216
                $spots = range(0, $e['overlaps'] - 1);
217
                for ($i = 0; $i < count($e['overlap']); ++$i) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
218
                    $overlaped = $events[$e['overlap'][$i]];
219
                    if (array_key_exists('position', $overlaped)) {
220
                        unset($spots[$overlaped['position']]);
221
                    }
222
                }
223
224
                // Take first one
225
                $e['position'] = array_shift($spots);
226
227
                $events[$k] = $e;
228
            }
229
230
            $blocks[$day] = $events;
231
        }
232
233
        return $blocks;
234
    }
235
236
    /**
237
     * Scan agenda events for distinct locations, count them and sort desc.
238
     *
239
     * @param array $agenda
240
     *
241
     * @return array locations
242
     */
243
    private static function locations($agenda)
244
    {
245
        $locations = [];
246
        foreach ($agenda['events'] as $events) {
247
            foreach ($events as $e) {
248
                if (!array_key_exists('locations', $e)) {
249
                    continue;
250
                }
251
252
                foreach ($e['locations'] as $l) {
253
                    if (!array_key_exists($l, $locations)) {
254
                        $locations[$l] = 0;
255
                    }
256
                    ++$locations[$l];
257
                }
258
            }
259
        }
260
261
        arsort($locations);
262
263
        return $locations;
264
    }
265
266
    /**
267
     * Scan agenda events for distinct descriptions, count them (with overlap weight) and sort desc.
268
     *
269
     * @param array $agenda
270
     *
271
     * @return array descriptions
272
     */
273
    private static function descriptions($agenda)
274
    {
275
        $descriptions = [];
276
        foreach ($agenda['events'] as $events) {
277
            foreach ($events as $e) {
278
                if (!array_key_exists('desc', $e)) {
279
                    continue;
280
                }
281
282
                foreach ($e['desc'] as $d) {
283
                    if (!array_key_exists($d, $descriptions)) {
284
                        $descriptions[$d] = 0;
285
                    }
286
                    $descriptions[$d] += 1 / ($e['overlaps'] * 2);
287
                    break;
288
                }
289
            }
290
        }
291
292
        arsort($descriptions);
293
294
        return $descriptions;
295
    }
296
297
    /**
298
     * Scan agenda events for overlaps.
299
     *
300
     * @param array $agenda
301
     *
302
     * @return bool has overlaps
303
     */
304
    private static function hasOverlaps($agenda)
305
    {
306
        foreach ($agenda['events'] as $events) {
307
            foreach ($events as $e) {
308
                if ($e['overlaps'] > 1) {
309
                    return true;
310
                }
311
            }
312
        }
313
314
        return false;
315
    }
316
317
    /**
318
     * Scan agenda and guess best title based on locations and descriptions.
319
     *
320
     * @param array $agenda
321
     *
322
     * @return string title
323
     */
324
    private static function genTitle($agenda)
325
    {
326
        if (!self::hasOverlaps($agenda)) {
327
            $locations = self::locations($agenda);
328
            if ($locations < 3) {
329
                reset($locations);
330
331
                return key($locations);
332
            }
333
        }
334
335
        $descriptions = self::descriptions($agenda);
336
        reset($descriptions);
337
338
        return key($descriptions);
339
    }
340
341
    /**
342
     * Last processing before render
343
     * Generate title.
344
     *
345
     * @param array $agenda
346
     *
347
     * @return array agenga info
348
     */
349
    public function finalize($agenda)
350
    {
351
        $info = $agenda['info'];
352
353
        $info['title'] = self::genTitle($agenda);
354
355
        return $info;
356
    }
357
358
    /**
359
     * Render agenda events block to HTML.
360
     *
361
     * @param array $agenda
362
     *
363
     * @return string HTML result
364
     */
365
    public function render($agenda)
366
    {
367
        $h = '<div class="agenda-header">'.$agenda['info']['title'].'</div><div class="agenda-contents">';
368
369
        $timeTraces = 0.25;
370
        $h .= '<div class="agenda-time"><div class="agenda-time-header">&nbsp;</div><div class="agenda-time-contents">';
371
        for ($i = floor($agenda['info']['minHour']); $i < ceil($agenda['info']['maxHour']); $i += $timeTraces) {
372
            if (fmod($i, 1) == 0) {
373
                $h .= '<div class="agenda-time-h" style="top: '.((($i - $agenda['info']['minHour']) / $agenda['info']['dayLen']) * 100).'%;">'.$i.'h</div>';
374
            } else {
375
                $h .= '<div class="agenda-time-m" style="top: '.((($i - $agenda['info']['minHour']) / $agenda['info']['dayLen']) * 100).'%; width: '.(fmod($i, 0.5) == 0 ? 40 : 20).'%;"></div>';
376
            }
377
        }
378
        $h .= '</div></div>';
379
380
        foreach ($agenda['events'] as $day => $events) {
381
            $h .= '<div class="agenda-day" id="day-'.$day.'">'.
382
                '<div class="agenda-day-header">'.\Yii::t('app', self::DAYS[$day]).' '.$agenda['info']['days'][$day].'</div>'.
383
                '<div class="agenda-day-contents">';
384
385
            foreach ($events as $e) {
386
                $style = [
387
                    'top' => ((($e['start'] - $agenda['info']['minHour']) / $agenda['info']['dayLen']) * 100).'%',
388
                    'bottom' => ((($agenda['info']['maxHour'] - $e['end']) / $agenda['info']['dayLen']) * 100).'%',
389
                    'left' => ($e['position'] / $e['overlaps'] * 100).'%',
390
                    'right' => ((1 - ($e['position'] + 1) / $e['overlaps']) * 100).'%',
391
                    'background-color' => $this->getColor($e['desc'][0]),
392
                ];
393
                $styleStr = implode('; ', array_map(function ($k, $v) {
394
                    return $k.':'.$v;
395
                }, array_keys($style), $style));
396
397
                $content = [];
398 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...
399
                    $content[] = '<span class="agenda-event-desc">'.$e['desc'][0].'</span>';
400
                }
401
                foreach ($e['locations'] as $l) {
402
                    $content[] = ' <span class="agenda-event-location">'.$l.'</span>';
403
                }
404
                if ($e['name']) {
405
                    $content[] = '<span class="agenda-event-name">'.$e['name'].'</span>';
406
                }
407 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...
408
                    $content[] = '<br />'.$e['startStr'].' - '.$e['endStr'];
409
                }
410
411
                $h .= '<div class="agenda-event" style="'.$styleStr.'">'.implode('', $content).'</div>';
412
            }
413
414
            $h .= '</div></div>';
415
        }
416
417
        $h .= '</div>';
418
419
        return $h;
420
    }
421
422
    /**
423
     * Generate agenda HTML from .ical url.
424
     *
425
     * @param string $url ical url
426
     *
427
     * @return string HTML agenda
428
     */
429
    public function genAgenda($url)
430
    {
431
        $this->opts = \Yii::$app->params['agenda'];
432
433
        $content = self::downloadContent($url);
434
435
        $agenda = $this->parseIcal($content);
436
        if (!$agenda) {
437
            return null;
438
        }
439
440
        $agenda['events'] = $this->blockize($agenda);
441
442
        $agenda['info'] = $this->finalize($agenda);
443
444
        return $this->render($agenda);
445
    }
446
447
    /**
448
     * Apply self::filter() to each array member.
449
     *
450
     * @param array  $arr  input
451
     * @param string $type array type
452
     *
453
     * @return array filtered output
454
     */
455
    private static function arrayFilter(array $arr, $type)
456
    {
457
        $res = [];
458
        foreach ($arr as $v) {
459
            $res[] = self::filter($v, $type);
460
        }
461
462
        return array_values(array_filter($res));
463
    }
464
465
    /**
466
     * Filter string from feed.
467
     *
468
     * @param string $str  input string
469
     * @param string $type string type
470
     *
471
     * @return string filtered string
472
     */
473
    private static function filter($str, $type)
474
    {
475
        $str = html_entity_decode($str);
476
477
        if (self::$translit) {
478
            $str = self::$translit->transliterate($str);
479
        }
480
481
        $str = preg_replace([
482
            '/\s{2,}/',
483
            '/\s*\\\,\s*/',
484
            '/\s*\([^\)]*\)/',
485
        ], [
486
            ' ',
487
            ', ',
488
            '',
489
        ], trim($str));
490
491
        switch ($type) {
492
            case 'name':
493
                return preg_replace([
494
                    '/^\d+\s*-/',
495
                    '/^\d+\s+/',
496
                ], [
497
                    '',
498
                    '',
499
                ], $str);
500
            case 'location':
501
                return preg_replace([
502
                    '/(\d) (\d{3}).*/',
503
                ], [
504
                    '\\1-\\2',
505
                ], $str);
506
            case 'description':
507
                return preg_replace([
508
                    '/(modif).*/',
509
                ], [
510
                    '',
511
                ], $str);
512
            default:
513
                return $str;
514
        }
515
    }
516
517
    /**
518
     * Generate color based on string
519
     * Using MD5 to always get the same color for a given string.
520
     *
521
     * @param string $str
522
     *
523
     * @return string color hexcode
524
     */
525
    private function getColor($str)
526
    {
527
        if (array_key_exists($str, $this->color)) {
528
            return $this->color[$str];
529
        }
530
531
        // %140 + 95 make colors brighter
532
        $hash = md5($str);
533
        $this->color[$str] = sprintf(
534
            '#%X%X%X',
535
            hexdec(substr($hash, 0, 2)) % 140 + 95,
536
            hexdec(substr($hash, 2, 2)) % 140 + 95,
537
            hexdec(substr($hash, 4, 2)) % 140 + 95
538
        );
539
540
        return $this->color[$str];
541
    }
542
}
543