xref: /plugin/combo/syntax/frontmatter.php (revision 81104ba86c4efa020d24580e0e76c6d90ec5e062)
1<?php
2/**
3 * Front Matter implementation to add metadata
4 *
5 *
6 * that enhance the metadata dokuwiki system
7 * https://www.dokuwiki.org/metadata
8 * that use the Dublin Core Standard
9 * http://dublincore.org/
10 * by adding the front matter markup specification
11 * https://gerardnico.com/markup/front-matter
12 *
13 * Inspiration
14 * https://github.com/dokufreaks/plugin-meta/blob/master/syntax.php
15 * https://www.dokuwiki.org/plugin:semantic
16 *
17 * See also structured plugin
18 * https://www.dokuwiki.org/plugin:data
19 * https://www.dokuwiki.org/plugin:struct
20 *
21 */
22
23use ComboStrap\Analytics;
24use ComboStrap\CacheManager;
25use ComboStrap\Iso8601Date;
26use ComboStrap\LogUtility;
27use ComboStrap\MediaLink;
28use ComboStrap\Page;
29use ComboStrap\PluginUtility;
30use ComboStrap\Publication;
31
32require_once(__DIR__ . '/../ComboStrap/PluginUtility.php');
33
34
35/**
36 * All DokuWiki plugins to extend the parser/rendering mechanism
37 * need to inherit from this class
38 *
39 * For a list of meta, see also https://ghost.org/docs/publishing/#api-data
40 */
41class syntax_plugin_combo_frontmatter extends DokuWiki_Syntax_Plugin
42{
43    const PARSING_STATE_EMPTY = "empty";
44    const PARSING_STATE_ERROR = "error";
45    const PARSING_STATE_SUCCESSFUL = "successful";
46    const STATUS = "status";
47    const CANONICAL = "frontmatter";
48    const CONF_ENABLE_SECTION_EDITING = 'enableFrontMatterSectionEditing';
49
50    /**
51     * Used in the move plugin
52     * !!! The two last word of the plugin class !!!
53     */
54    const COMPONENT = 'combo_' . self::CANONICAL;
55    const START_TAG = '---json';
56    const END_TAG = '---';
57    const METADATA_IMAGE_CANONICAL = "metadata:image";
58
59    /**
60     * @param $match
61     * @return array|mixed - null if decodage problem, empty array if no json or an associative array
62     */
63    public static function FrontMatterMatchToAssociativeArray($match)
64    {
65        // strip
66        //   from start `---json` + eol = 8
67        //   from end   `---` + eol = 4
68        $jsonString = substr($match, 7, -3);
69
70        // Empty front matter
71        if (trim($jsonString) == "") {
72            self::deleteKnownMetaThatAreNoMorePresent();
73            return [];
74        }
75
76        // Otherwise you get an object ie $arrayFormat-> syntax
77        $arrayFormat = true;
78        return json_decode($jsonString, $arrayFormat);
79    }
80
81    /**
82     * Syntax Type.
83     *
84     * Needs to return one of the mode types defined in $PARSER_MODES in parser.php
85     * @see https://www.dokuwiki.org/devel:syntax_plugins#syntax_types
86     *
87     * Return an array of one or more of the mode types {@link $PARSER_MODES} in Parser.php
88     *
89     * baseonly - run only in the base
90     */
91    function getType()
92    {
93        return 'baseonly';
94    }
95
96    public function getPType()
97    {
98        /**
99         * This element create a section
100         * element that is a div
101         * that should not be in paragraph
102         *
103         * We make it a block
104         */
105        return "block";
106    }
107
108
109    /**
110     * @see Doku_Parser_Mode::getSort()
111     * Higher number than the teaser-columns
112     * because the mode with the lowest sort number will win out
113     */
114    function getSort()
115    {
116        return 99;
117    }
118
119    /**
120     * Create a pattern that will called this plugin
121     *
122     * @param string $mode
123     * @see Doku_Parser_Mode::connectTo()
124     */
125    function connectTo($mode)
126    {
127        if ($mode == "base") {
128            // only from the top
129            $this->Lexer->addSpecialPattern(self::START_TAG . '.*?' . self::END_TAG, $mode, PluginUtility::getModeFromTag($this->getPluginComponent()));
130        }
131    }
132
133    /**
134     *
135     * The handle function goal is to parse the matched syntax through the pattern function
136     * and to return the result for use in the renderer
137     * This result is always cached until the page is modified.
138     * @param string $match
139     * @param int $state
140     * @param int $pos
141     * @param Doku_Handler $handler
142     * @return array|bool
143     * @see DokuWiki_Syntax_Plugin::handle()
144     *
145     */
146    function handle($match, $state, $pos, Doku_Handler $handler)
147    {
148
149        if ($state == DOKU_LEXER_SPECIAL) {
150
151
152            $jsonArray = self::FrontMatterMatchToAssociativeArray($match);
153
154
155            $result = [];
156            // Decodage problem
157            if ($jsonArray == null) {
158
159                $result[self::STATUS] = self::PARSING_STATE_ERROR;
160                $result[PluginUtility::PAYLOAD] = $match;
161
162            } else {
163
164                if (sizeof($jsonArray) === 0) {
165                    return array(self::STATUS => self::PARSING_STATE_EMPTY);
166                }
167
168                $result[self::STATUS] = self::PARSING_STATE_SUCCESSFUL;
169                /**
170                 * Published is an alias for date published
171                 */
172                if (isset($jsonArray[Publication::OLD_META_KEY])) {
173                    $jsonArray[Publication::DATE_PUBLISHED] = $jsonArray[Publication::OLD_META_KEY];
174                    unset($jsonArray[Publication::OLD_META_KEY]);
175                }
176                /**
177                 * Add the time part if not present
178                 */
179                if (isset($jsonArray[Publication::DATE_PUBLISHED])) {
180                    $dateAsString = $jsonArray[Publication::DATE_PUBLISHED];
181                    $dateObject = Iso8601Date::create($dateAsString);
182                    if (!$dateObject->isValidDateEntry()) {
183                        LogUtility::msg("The published date ($dateAsString) is not a valid date supported.", LogUtility::LVL_MSG_ERROR, Iso8601Date::CANONICAL);
184                        unset($jsonArray[Publication::DATE_PUBLISHED]);
185                    } else {
186                        $jsonArray[Publication::DATE_PUBLISHED] = "$dateObject";
187                    }
188                }
189
190                if (isset($jsonArray[Analytics::DATE_START])) {
191                    $dateAsString = $jsonArray[Analytics::DATE_START];
192                    $dateObject = Iso8601Date::create($dateAsString);
193                    if (!$dateObject->isValidDateEntry()) {
194                        LogUtility::msg("The start date ($dateAsString) is not a valid date supported.", LogUtility::LVL_MSG_ERROR, Iso8601Date::CANONICAL);
195                        unset($jsonArray[Analytics::DATE_START]);
196                    } else {
197                        $jsonArray[Analytics::DATE_START] = "$dateObject";
198                    }
199                }
200
201                if (isset($jsonArray[Analytics::DATE_END])) {
202                    $dateAsString = $jsonArray[Analytics::DATE_END];
203                    $dateObject = Iso8601Date::create($dateAsString);
204                    if (!$dateObject->isValidDateEntry()) {
205                        LogUtility::msg("The end date ($dateAsString) is not a valid date supported.", LogUtility::LVL_MSG_ERROR, Iso8601Date::CANONICAL);
206                        unset($jsonArray[Analytics::DATE_END]);
207                    } else {
208                        $jsonArray[Analytics::DATE_END] = "$dateObject";
209                    }
210                }
211
212                $result[PluginUtility::ATTRIBUTES] = $jsonArray;
213            }
214
215            /**
216             * End position is the length of the match + 1 for the newline
217             */
218            $newLine = 1;
219            $endPosition = $pos + strlen($match) + $newLine;
220            $result[PluginUtility::POSITION] = [$pos, $endPosition];
221
222            return $result;
223        }
224
225        return array();
226    }
227
228    /**
229     * Render the output
230     * @param string $format
231     * @param Doku_Renderer $renderer
232     * @param array $data - what the function handle() return'ed
233     * @return boolean - rendered correctly? (however, returned value is not used at the moment)
234     * @see DokuWiki_Syntax_Plugin::render()
235     *
236     *
237     */
238    function render($format, Doku_Renderer $renderer, $data)
239    {
240
241        switch ($format) {
242            case 'xhtml':
243                global $ID;
244                /** @var Doku_Renderer_xhtml $renderer */
245
246                $state = $data[self::STATUS];
247                if ($state == self::PARSING_STATE_ERROR) {
248                    $json = $data[PluginUtility::PAYLOAD];
249                    LogUtility::msg("Front Matter: The json object for the page ($ID) is not valid. See the errors it by clicking on <a href=\"https://jsonformatter.curiousconcept.com/?data=" . urlencode($json) . "\">this link</a>.", LogUtility::LVL_MSG_ERROR);
250                }
251
252                /**
253                 * Section
254                 */
255                list($startPosition, $endPosition) = $data[PluginUtility::POSITION];
256                if (PluginUtility::getConfValue(self::CONF_ENABLE_SECTION_EDITING, 1)) {
257                    $position = $startPosition;
258                    $name = self::CANONICAL;
259                    PluginUtility::startSection($renderer, $position, $name);
260                    $renderer->finishSectionEdit($endPosition);
261                }
262                break;
263            case renderer_plugin_combo_analytics::RENDERER_FORMAT:
264
265                if ($data[self::STATUS] != self::PARSING_STATE_SUCCESSFUL) {
266                    return false;
267                }
268
269                $notModifiableMeta = [
270                    Analytics::PATH,
271                    Analytics::DATE_CREATED,
272                    Analytics::DATE_MODIFIED
273                ];
274
275                /** @var renderer_plugin_combo_analytics $renderer */
276                $jsonArray = $data[PluginUtility::ATTRIBUTES];
277                foreach ($jsonArray as $key => $value) {
278                    if (!in_array($key, $notModifiableMeta)) {
279
280                        $renderer->setMeta($key, $value);
281                        if ($key === Page::IMAGE_META_PROPERTY) {
282                            $this->updateImageStatistics($value, $renderer);
283                        }
284
285                    } else {
286                        LogUtility::msg("The metadata ($key) cannot be set.", LogUtility::LVL_MSG_ERROR, self::CANONICAL);
287                    }
288                }
289                break;
290
291            case "metadata":
292
293                /** @var Doku_Renderer_metadata $renderer */
294                if ($data[self::STATUS] != self::PARSING_STATE_SUCCESSFUL) {
295                    return false;
296                }
297
298                global $ID;
299                $jsonArray = $data[PluginUtility::ATTRIBUTES];
300
301
302                $notModifiableMeta = [
303                    "date",
304                    "user",
305                    "last_change",
306                    "creator",
307                    "contributor"
308                ];
309
310                foreach ($jsonArray as $key => $value) {
311
312                    $lowerCaseKey = trim(strtolower($key));
313
314                    // Not modifiable metadata
315                    if (in_array($lowerCaseKey, $notModifiableMeta)) {
316                        LogUtility::msg("Front Matter: The metadata ($lowerCaseKey) is a protected metadata and cannot be modified", LogUtility::LVL_MSG_WARNING);
317                        continue;
318                    }
319
320                    switch ($lowerCaseKey) {
321
322                        case Page::DESCRIPTION_PROPERTY:
323                            /**
324                             * Overwrite also the actual description
325                             */
326                            p_set_metadata($ID, array(Page::DESCRIPTION_PROPERTY => array(
327                                "abstract" => $value,
328                                "origin" => syntax_plugin_combo_frontmatter::CANONICAL
329                            )));
330                            /**
331                             * Continue because
332                             * the description value was already stored
333                             * We don't want to override it
334                             * And continue 2 because continue == break in a switch
335                             */
336                            continue 2;
337
338
339                        // Canonical should be lowercase
340                        case Page::CANONICAL_PROPERTY:
341                            $value = strtolower($value);
342                            break;
343
344                        case Page::IMAGE_META_PROPERTY:
345
346                            $imageValues = [];
347                            $this->aggregateImageValues($imageValues, $value);
348                            foreach ($imageValues as $imageValue) {
349                                $media = MediaLink::createFromRenderMatch($imageValue);
350                                $attributes = $media->toCallStackArray();
351                                syntax_plugin_combo_media::registerImageMeta($attributes, $renderer);
352                            }
353                            break;
354
355                    }
356                    // Set the value persistently
357                    p_set_metadata($ID, array($lowerCaseKey => $value));
358
359                }
360
361                $this->deleteKnownMetaThatAreNoMorePresent($jsonArray);
362
363                break;
364
365        }
366        return true;
367    }
368
369    /**
370     *
371     * @param array $json - The Json
372     * Delete the controlled meta that are no more present if they exists
373     * @return bool
374     */
375    static public
376    function deleteKnownMetaThatAreNoMorePresent(array $json = array())
377    {
378        global $ID;
379
380        /**
381         * The managed meta with the exception of
382         * the {@link action_plugin_combo_metadescription::DESCRIPTION_META_KEY description}
383         * because it's already managed by dokuwiki in description['abstract']
384         */
385        $managedMeta = [
386            Page::CANONICAL_PROPERTY,
387            Page::TYPE_META_PROPERTY,
388            Page::IMAGE_META_PROPERTY,
389            Page::COUNTRY_META_PROPERTY,
390            Page::LANG_META_PROPERTY,
391            Analytics::TITLE,
392            syntax_plugin_combo_disqus::META_DISQUS_IDENTIFIER,
393            Publication::OLD_META_KEY,
394            Publication::DATE_PUBLISHED,
395            Analytics::NAME,
396            CacheManager::DATE_CACHE_EXPIRATION_META_KEY,
397            action_plugin_combo_metagoogle::JSON_LD_META_PROPERTY,
398            Page::LAYOUT_PROPERTY
399
400        ];
401        $meta = p_read_metadata($ID);
402        foreach ($managedMeta as $metaKey) {
403            if (!array_key_exists($metaKey, $json)) {
404                if (isset($meta['persistent'][$metaKey])) {
405                    unset($meta['persistent'][$metaKey]);
406                }
407            }
408        }
409        return p_save_metadata($ID, $meta);
410    }
411
412    private function updateImageStatistics($value, $renderer)
413    {
414        if(is_array($value)){
415            foreach($value as $subImage){
416                $this->updateImageStatistics($subImage, $renderer);
417            }
418        } else {
419            $media = MediaLink::createFromRenderMatch($value);
420            $attributes = $media->toCallStackArray();
421            syntax_plugin_combo_media::updateStatistics($attributes, $renderer);
422        }
423    }
424
425    private function aggregateImageValues(array &$imageValues, $value)
426    {
427        if (is_array($value)) {
428            foreach ($value as $subImageValue) {
429                $this->aggregateImageValues($imageValues,$subImageValue);
430            }
431        } else {
432            $imageValues[] = $value;
433        }
434    }
435
436
437}
438
439