1<?php
2
3/**
4 * yearbox Plugin: provides a year calendar, with links to a new page for each day
5 *
6 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
7 * @author     Symon Bent: <symonbent [at] gmail [dot] com>
8 */
9
10declare(strict_types=1);
11
12use dokuwiki\Extension\SyntaxPlugin;
13use dokuwiki\Logger;
14use dokuwiki\plugin\yearbox\services\pageNameStrategies\PageNameStrategy;
15
16/**
17 * All DokuWiki plugins to extend the parser/rendering mechanism
18 * need to inherit from this class
19 */
20class syntax_plugin_yearbox extends SyntaxPlugin
21{
22
23    /**
24     * What kind of syntax is this?
25     */
26    public function getType()
27    {
28        return 'substition';
29    }
30
31    public function getPType()
32    {
33        return 'block';
34    }
35
36    /**
37     * What modes are allowed within this mode?
38     */
39    public function getAllowedTypes()
40    {
41        return ['substition', 'protected', 'disabled', 'formatting'];
42    }
43
44    /**
45     * What position in the sort order?
46     */
47    public function getSort()
48    {
49        return 125;
50    }
51
52    /**
53     * Connect pattern to lexer
54     */
55    public function connectTo($mode)
56    {
57        $this->Lexer->addSpecialPattern('{{yearbox>.*?}}', $mode, 'plugin_yearbox');
58    }
59
60    /**
61     * Handle the match
62     * E.g.: {{yearbox>year=2010;name=journal;size=12;ns=diary}}
63     *
64     */
65    public function handle($match, $state, $pos, Doku_Handler $handler)
66    {
67        global $INFO;
68        $opt = [];
69
70        // default options
71        $opt['ns'] = $INFO['namespace'] ?? '';   // this namespace
72        $opt['size'] = 12;                 // 12px font size
73        $opt['name'] = 'day';              // a boring default page name
74        $opt['year'] = date('Y');          // this year
75        $opt['recent'] = false;            // special 1-2 row 'recent pages' view...
76        $opt['months'] = [];               // months to be displayed (csv list), e.g. 1,2,3,4... 1=Sun
77        $opt['weekdays'] = [];             // weekdays which should have links (csv links)... 1=Jan
78        $opt['align'] = '';                // default is centred
79
80        $optionsString = substr($match, 10, -2);
81        $args = explode(';', $optionsString);
82        foreach ($args as $arg) {
83            [$key, $value] = explode('=', $arg);
84            switch ($key) {
85                case 'year':
86                    $opt['year'] = $value;
87                    break;
88                case 'name':
89                    $opt['name'] = $value;
90                    break;
91                case 'fontsize':
92                case 'size':
93                    $opt['size'] = $value;
94                    break;
95                case 'ns':
96                    $opt['ns'] = (strpos($value, ':') === false) ? ':' . $value : $value;
97                    break;
98                case 'recent':
99                    $opt['recent'] = ((int)$value > 0) ? (int)$value : 0;
100                    break;
101                case 'months':
102                    $opt['months'] = explode(',', $value);
103                    break;
104                case 'weekdays':
105                    $opt['weekdays'] = explode(',', $value);
106                    break;
107                case 'align':
108                    if (in_array($value, ['left', 'right'])) {
109                        $opt['align'] = $value;
110                    }
111                    break;
112                default:
113                    if ( class_exists(Logger::class)) {
114                        Logger::getInstance(Logger::LOG_DEBUG)->log(
115                            "Unknown key: '$key' in '$match'"
116                        );
117                    } else {
118                        // TODO: remove after the next DokuWiki release
119                        dbglog("Yearbox Plugin: Unknown key '$key' in '$match'");
120                    }
121            }
122        }
123        return $opt;
124    }
125
126    /**
127     * Create output
128     */
129    public function render($mode, Doku_Renderer $renderer, $opt)
130    {
131        if ($mode == 'xhtml') {
132            $renderer->doc .= $this->buildCalendar($opt);
133            return true;
134        }
135        return false;
136    }
137
138
139    /**
140     * Builds a complete HTML calendar of the year given
141     * Provides a link to a page for each day of the year, with a popup abstract of page content
142     *
143     * $opt = array(
144     *
145     * @param string $year     build calendar for one year (2011), or range of years (2011,2013)
146     * @param string $name     prefix for new page name, e.g diary, journal, day
147     * @param int    $size     font size to use
148     * @param string $ns       root namespace for new page names
149     * @param int    $recent   previous days that must be visible
150     * @param array  $months   which months are visible (1-12), 1=Jan, 2=Feb, etc
151     * @param array  $weekdays which weekdays should have links (1-7), 1=Sun, 2=Mon, etc...
152     *                         }
153     *
154     * @return string   Complete marked up calendar table
155     */
156    private function buildCalendar($opt)
157    {
158        $day_names = $this->getLang('yearbox_days');
159        $cal = '';
160
161        [$years, $first_weekday, $table_cols, $today] = $this->defineCalendar($opt);
162        end($years);
163        $last_year = key($years);
164
165        // initial CSS
166        $font_css = ($opt['size'] != 0) ? ' style="font-size:' . $opt['size'] . 'px;"' : '';
167        if ($opt['align'] == 'left') {
168            $align = ' class=left';
169        } elseif ($opt['align'] == 'right') {
170            $align = ' class=right';
171        } else {
172            $align = '';
173        }
174        $cal .= '<div class="yearbox"' . $font_css . '><table' . $align . '><tbody>';
175
176        foreach ($years as $year_num => $year) {
177            // display the year and day-of-week header
178            $cal .= '<tr class="yr-header">';
179            for ($col = 0; $col < $table_cols; $col++) {
180                $weekday_num = ($col + $first_weekday) % 7;       // current day of week as a number
181                if ($col == 0) {
182                    $cal .= '<th class="plain">' . $year_num . '</th>';
183                }
184                $h = $day_names[$weekday_num];
185                $cal .= '<th>' . $h . '</th>';
186            }
187            $cal .= '</tr>';
188
189            foreach ($year as $mth_num => $month) {
190                $cal .= $this->getMonthHTML(
191                    $month,
192                    $mth_num,
193                    $opt,
194                    $year_num,
195                    $table_cols,
196                    $first_weekday,
197                    $today
198                );
199            }
200            // separator between years in a range
201            if ($year_num != $last_year) {
202                $cal .= '<tr class="blank"><td></td></tr>';
203            }
204        }
205
206        $cal .= '</tbody></table></div><div class="clearer"></div>';
207        return $cal;
208    }
209
210    /**
211     * Get the HTML for one table-row, representing one month
212     *
213     * @param $month
214     * @param $mth_num
215     * @param $opt
216     * @param $year_num
217     * @param $table_cols
218     * @param $first_weekday
219     * @param $today
220     *
221     * @return string
222     */
223    protected function getMonthHTML(
224        $month,
225        $mth_num,
226        $opt,
227        $year_num,
228        $table_cols,
229        $first_weekday,
230        $today
231    ) {
232        $cal = '<tr>';
233        // insert month name into first column of row
234        $cal .= $this->getMonthNameHTML($mth_num);
235        $cur_day = 0;
236        for ($col = 0; $col < $table_cols; $col++) {
237            $weekday_num = ($col + $first_weekday) % 7;       // current day of week as a number
238
239            // current day is only valid if within the month's days, and at the correct starting day
240            if (($cur_day > 0 && $cur_day < $month['len']) || ($col < 7 && $weekday_num == $month['start'])) {
241                $cur_day++;
242                $cal .= $this->getDayHTML($cur_day, $mth_num, $today, $year_num, $weekday_num, $opt);
243            } else {
244                $cur_day = 0;
245                $cal .= $this->getEmptyCellHTML();
246            }
247        }
248        $cal .= '</tr>';
249
250        return $cal;
251    }
252
253    /**
254     * @param int   $cur_day     Day of the month
255     * @param int   $mth_num     Month 1..12
256     * @param int   $today       ts today midnight FIXME
257     * @param int   $year_num    year as YYYY
258     * @param int   $weekday_num day of the week 0..6 (0=sunday, 6=saturday)
259     * @param array $opt         config from handler
260     *
261     * @return string
262     */
263    public function getDayHTML($cur_day, $mth_num, $today, $year_num, $weekday_num, $opt)
264    {
265        if (!$this->isWeekdayToBePrinted($weekday_num, $opt)) {
266            return $this->getEmptyCellHTML();
267        }
268
269        global $conf;
270        $is_weekend = $weekday_num === 0 || $weekday_num === 6;
271        $day_css = ($is_weekend) ? ' class="wkend"' : '';
272        $day_fmt = sprintf("%02d", $cur_day);
273        $month_fmt = sprintf("%02d", $mth_num);
274        $pagenameService = PageNameStrategy::getPagenameStategy($this->getConf('namestructure'));
275        $id = $pagenameService->getPageId($opt['ns'], $year_num, $month_fmt, $day_fmt, $opt['name']);
276        $current = mktime(0, 0, 0, $mth_num, $cur_day, $year_num);
277        if ($current == $today) {
278            $day_css = ' class="today"';
279        }
280
281        $link = $this->getDayLinkHTML($id, $day_fmt, $conf[ 'userewrite' ]);
282        return '<td' . $day_css . '>' . $link . '</td>';
283    }
284
285    /**
286     * Determine if the given weekday should be printed or be an empty cell
287     *
288     * @param $weekday_num
289     * @param $opt
290     *
291     * @return bool
292     */
293    protected function isWeekdayToBePrinted($weekday_num, $opt)
294    {
295        if (empty($opt['weekdays'])) {
296            return true;
297        }
298        return in_array($weekday_num, $opt['weekdays']);
299    }
300
301    /**
302     * Get the HTML for a header cell with the month name
303     *
304     * @param $mth_num
305     *
306     * @return string
307     */
308    protected function getMonthNameHTML($mth_num)
309    {
310        $month_names = [
311            $this->getLang('yearbox_months_jan'),
312            $this->getLang('yearbox_months_feb'),
313            $this->getLang('yearbox_months_mar'),
314            $this->getLang('yearbox_months_apr'),
315            $this->getLang('yearbox_months_may'),
316            $this->getLang('yearbox_months_jun'),
317            $this->getLang('yearbox_months_jul'),
318            $this->getLang('yearbox_months_aug'),
319            $this->getLang('yearbox_months_sep'),
320            $this->getLang('yearbox_months_oct'),
321            $this->getLang('yearbox_months_nov'),
322            $this->getLang('yearbox_months_dec'),
323        ];
324        $alt_css = ($mth_num % 2 == 0) ? ' class="alt"' : '';
325        return '<th' . $alt_css . '>' . $month_names[$mth_num - 1] . '</th>';
326    }
327
328    /**
329     * Get the HTML for an empty cell
330     *
331     * @return string
332     */
333    protected function getEmptyCellHTML()
334    {
335        return '<td class="blank">&nbsp;&nbsp;&nbsp;</td>';
336    }
337
338
339    /**
340     * establish list of valid months and days, ready for building the visible calendar
341     *
342     * @param array $opt users options
343     */
344    private function defineCalendar($opt)
345    {
346        $years = [];
347
348        $table_cols = 0;
349        $first_weekday = 6;
350
351        $year_range = explode(',', $opt['year']);
352        $today = mktime(0, 0, 0, (int)date('m'), (int)date('d'), (int)date('Y'));
353
354        // work out the date range first
355        if ($opt['recent'] > 0) {
356            // recent days (matching at least no. of recent days given; shows complete months only)
357            $mth_last = (int)date('n');
358            $yr_last = (int)date('Y');
359            $prev_date = $today - ($opt['recent'] * 12 * 60 * 60);
360            $mth_first = (int)date('n', $prev_date);
361            $yr_first = (int)date('Y', $prev_date);
362            $mth_last += ($yr_last - $yr_first) * 12;
363        } elseif (count($year_range) == 2) {
364            // if user provides two years: first -> last (inclusive)
365            $mth_first = 1;
366            [$yr_first, $yr_last] = $year_range;
367            $mth_last = 12 + ($yr_last - $yr_first) * 12;
368        } else {
369            // plain old one year calender
370            $mth_first = 1;
371            $mth_last = 12;
372            $yr_first = $yr_last = $opt['year'];
373        }
374        $show_all_mths = empty($opt['months']);
375
376        // first get start day for each month, and length of month,
377        // exact no. of columns needed, and the starting day of week
378        for ($mth = $mth_first; $mth <= $mth_last; $mth++) {
379            $mth_num = ($mth - 1) % 12 + 1; // real month number (1-12)
380
381            // only consider displayed months when calculating column size
382            if ($show_all_mths || in_array($mth_num, $opt['months'])) {
383                $year = $yr_first + floor(($mth - 1) / 12); // allow for year overlaps
384                $start = date('w', mktime(0, 0, 0, $mth_num, 1, (int)$year));
385                $len = date('j', mktime(0, 0, 0, $mth_num + 1, 0, (int)$year));
386
387                // save the first weekday (0-6; 0=Sun) and length (days) of this month
388                $years[$year][$mth_num] = ['start' => $start, 'len' => $len];
389
390                // max number of table columns needed (not including col for months!)
391                $table_cols = ($table_cols < ($start + $len)) ? $start + $len : $table_cols;
392
393                // find the lowest day of week (i.e. Sun = 0, Mon = 1, etc...)
394                // this determines which day of week to begin column headers with
395                $first_weekday = ($first_weekday > $start) ? $start : $first_weekday;
396            }
397        }
398        // final total columns needed in HTML table
399        $table_cols -= $first_weekday;
400
401        return [$years, $first_weekday, $table_cols, $today];
402    }
403
404    private function wikilinkPreviewPopup($id, $name)
405    {
406        // swap normal link title (popup) for a more useful preview
407        $link = html_wikilink($id, $name);
408        $meta = p_get_metadata($id, false, true);
409        $abstract = $meta['description']['abstract'] . '… ' . "\nEdited: " . date('Y-M-d', $meta['date']['modified']);
410        $preview = htmlentities($abstract, ENT_QUOTES, 'UTF-8');
411        $link = preg_replace('/title=\".+?\"/', 'title="' . $preview . '"', $link, 1);
412        return $link;
413    }
414
415    /**
416     * @param string $id
417     * @param string $day_fmt
418     * @param        $userewrite
419     *
420     * @return string|string[]|null
421     */
422    private function getDayLinkHTML(string $id, string $day_fmt, $userewrite)
423    {
424        if (page_exists($id)) {
425            return $this->wikilinkPreviewPopup($id, $day_fmt);
426        }
427
428        $link = html_wikilink($id, $day_fmt);
429        // skip the "do you want to create this page" bit
430        $sym = ($userewrite) ? '?' : '&amp;';
431        return preg_replace('/\" class/', $sym . 'do=edit" class', $link, 1);
432    }
433}
434