1<?php
2
3
4namespace ComboStrap;
5
6
7use syntax_plugin_combo_frontmatter;
8
9class MetadataFrontmatterStore extends MetadataSingleArrayStore
10{
11
12    const NAME = "frontmatter";
13    const CANONICAL = self::NAME;
14
15    /**
16     * @var bool Do we have a frontmatter on the page
17     */
18    private $isPresent = false;
19    /**
20     * @var string
21     */
22    private $contentWithoutFrontMatter;
23
24    /**
25     * @throws ExceptionCombo
26     */
27    private function syncData()
28    {
29
30        /**
31         * @var Page $resourceCombo
32         */
33        $resourceCombo = $this->getResource();
34
35        /**
36         * Resource Id special
37         */
38        $guidObject = $resourceCombo->getUidObject();
39        if (
40            !$this->hasProperty($guidObject::getPersistentName())
41            &&
42            $guidObject->getValue() !== null
43        ) {
44            $this->setFromPersistentName($guidObject::getPersistentName(), $guidObject->getValue());
45        }
46
47        /**
48         * Read store
49         */
50        $dokuwikiStore = MetadataDokuWikiStore::getOrCreateFromResource($resourceCombo);
51        $metaFilePath = $dokuwikiStore->getMetaFilePath();
52        if ($metaFilePath !== null) {
53            $metaModifiedTime = FileSystems::getModifiedTime($metaFilePath);
54            $pageModifiedTime = FileSystems::getModifiedTime($resourceCombo->getPath());
55            $diff = $pageModifiedTime->diff($metaModifiedTime);
56            if ($diff === false) {
57                throw new ExceptionCombo("Unable to calculate the diff between the page and metadata file");
58            }
59            $secondDiff = intval($diff->format('%s'));
60            if ($secondDiff > 0) {
61                $resourceCombo->renderMetadataAndFlush();
62            }
63        }
64        /**
65         * Update the mutable data
66         * (ie delete insert)
67         */
68        foreach (Metadata::MUTABLE_METADATA as $metaKey) {
69            $metadata = Metadata::getForName($metaKey);
70            if ($metadata === null) {
71                $msg = "The metadata $metaKey should be defined";
72                if (PluginUtility::isDevOrTest()) {
73                    throw new ExceptionCombo($msg);
74                } else {
75                    LogUtility::msg($msg);
76                }
77            }
78            $metadata
79                ->setResource($resourceCombo)
80                ->setReadStore($dokuwikiStore)
81                ->setWriteStore($this);
82
83            $sourceValue = $this->get($metadata);
84            $targetValue = $metadata->getValue();
85            $defaultValue = $metadata->getDefaultValue();
86            /**
87             * Strict because otherwise the comparison `false = null` is true
88             */
89            $targetValueShouldBeStore = !in_array($targetValue, [$defaultValue, null], true);
90            if ($targetValueShouldBeStore) {
91                if ($sourceValue !== $targetValue) {
92                    $this->set($metadata);
93                }
94            } else {
95                if ($sourceValue !== null) {
96                    $this->remove($metadata);
97                }
98            }
99        }
100    }
101
102    /**
103     * Update the frontmatter with the managed metadata
104     * Used after a submit from the form
105     * @return Message
106     */
107    public function sync(): Message
108    {
109
110        /**
111         * Default update value for the frontmatter
112         */
113        $updateFrontMatter = PluginUtility::getConfValue(syntax_plugin_combo_frontmatter::CONF_ENABLE_FRONT_MATTER_ON_SUBMIT, syntax_plugin_combo_frontmatter::CONF_ENABLE_FRONT_MATTER_ON_SUBMIT_DEFAULT);
114
115
116        if ($this->isPresent()) {
117            $updateFrontMatter = 1;
118        }
119
120
121        if ($updateFrontMatter === 0) {
122            return Message::createInfoMessage("The frontmatter is not enabled")
123                ->setStatus(syntax_plugin_combo_frontmatter::UPDATE_EXIT_CODE_NOT_ENABLED);
124        }
125
126        try {
127            $this->syncData();
128        } catch (ExceptionCombo $e) {
129            return Message::createInfoMessage($e->getMessage())
130                ->setStatus(syntax_plugin_combo_frontmatter::UPDATE_EXIT_CODE_ERROR);
131        }
132
133
134        /**
135         * Same ?
136         */
137        if (!$this->hasStateChanged()) {
138            return Message::createInfoMessage("The frontmatter are the same (no update)")
139                ->setStatus(syntax_plugin_combo_frontmatter::UPDATE_EXIT_CODE_NOT_CHANGED);
140        }
141
142        $this->persist();
143
144        return Message::createInfoMessage("The frontmatter was changed")
145            ->setStatus(syntax_plugin_combo_frontmatter::UPDATE_EXIT_CODE_DONE);
146
147    }
148
149
150    public function isPresent(): bool
151    {
152        return $this->isPresent;
153    }
154
155    /**
156     * MetadataFrontmatterStore constructor.
157     * @param ResourceCombo $page
158     * @param array|null $data
159     */
160    public function __construct(ResourceCombo $page, array $data = null)
161    {
162        parent::__construct($page, $data);
163    }
164
165    /**
166     * @param $match
167     * @return array|null - null if decodage problem, empty array if no json or an associative array
168     * @deprecated used {@link MetadataFrontmatterStore::loadAsString()} instead
169     */
170    public static function frontMatterMatchToAssociativeArray($match): ?array
171    {
172        $jsonString = self::stripFrontmatterTag($match);
173
174        // Empty front matter
175        if (trim($jsonString) == "") {
176            return [];
177        }
178
179        // Otherwise you get an object ie $arrayFormat-> syntax
180        $arrayFormat = true;
181        return json_decode($jsonString, $arrayFormat);
182    }
183
184    public static function stripFrontmatterTag($match)
185    {
186        // strip
187        //   from start `---json` + eol = 8
188        //   from end   `---` + eol = 4
189        return substr($match, 7, -3);
190    }
191
192
193    public static function getOrCreateFromResource(ResourceCombo $resourceCombo): MetadataStore
194    {
195        return new MetadataFrontmatterStore($resourceCombo, null);
196    }
197
198    public static function createFromArray(ResourceCombo $page, array $jsonArray): MetadataFrontmatterStore
199    {
200        return new MetadataFrontmatterStore($page, $jsonArray);
201    }
202
203    /**
204     * @throws ExceptionCombo
205     */
206    public static function createFromFrontmatterString($page, $frontmatter = null): MetadataFrontmatterStore
207    {
208        if ($frontmatter === null) {
209            return new MetadataFrontmatterStore($page, []);
210        }
211        $jsonArray = self::frontMatterMatchToAssociativeArray($frontmatter);
212        if ($jsonArray === null) {
213            throw new ExceptionCombo("The frontmatter is not valid");
214        }
215        return new MetadataFrontmatterStore($page, $jsonArray);
216    }
217
218    /**
219     * @throws ExceptionCombo
220     */
221    public static function createFromPage(Page $page): MetadataFrontmatterStore
222    {
223        $content = FileSystems::getContent($page->getPath());
224        $frontMatterStartTag = syntax_plugin_combo_frontmatter::START_TAG;
225        if (strpos($content, $frontMatterStartTag) === 0) {
226
227            /**
228             * Extract the actual values
229             */
230            $pattern = syntax_plugin_combo_frontmatter::PATTERN;
231            $split = preg_split("/($pattern)/ms", $content, 2, PREG_SPLIT_DELIM_CAPTURE);
232
233            /**
234             * The split normally returns an array
235             * where the first element is empty followed by the frontmatter
236             */
237            $emptyString = array_shift($split);
238            if (!empty($emptyString)) {
239                throw new ExceptionCombo("The frontmatter is not the first element");
240            }
241
242            $frontMatterMatch = array_shift($split);
243            /**
244             * Building the document again
245             */
246            $contentWithoutFrontMatter = "";
247            while (($element = array_shift($split)) != null) {
248                $contentWithoutFrontMatter .= $element;
249            }
250
251            return MetadataFrontmatterStore::createFromFrontmatterString($page, $frontMatterMatch)
252                ->setIsPresent(true)
253                ->setContentWithoutFrontMatter($contentWithoutFrontMatter);
254
255        }
256        return (new MetadataFrontmatterStore($page))
257            ->setIsPresent(false)
258            ->setContentWithoutFrontMatter($content);
259
260    }
261
262
263    public function __toString()
264    {
265        return self::NAME;
266    }
267
268
269    public function getJsonString(): string
270    {
271
272        $jsonArray = $this->getData();
273        ksort($jsonArray);
274        return self::toFrontmatterJsonString($jsonArray);
275
276    }
277
278    /**
279     * This formatting make the object on one line for a list of object
280     * making the frontmatter compacter (one line, one meta)
281     * @param $jsonArray
282     * @return string
283     */
284    public static function toFrontmatterJsonString($jsonArray): string
285    {
286
287        if (sizeof($jsonArray) === 0) {
288            return "{}";
289        }
290        $jsonString = "";
291        self::jsonFlatRecursiveEncoding($jsonArray, $jsonString);
292
293        /**
294         * Double Guard (frontmatter should be quick enough)
295         * to support this overhead
296         */
297        $decoding = json_decode($jsonString);
298        if ($decoding === null) {
299            throw new ExceptionComboRuntime("The generated frontmatter json is no a valid json");
300        }
301        return $jsonString;
302    }
303
304    private static function jsonFlatRecursiveEncoding(array $jsonProperty, &$jsonString, $level = 0, $endOfFieldCharacter = DOKU_LF, $type = Json::TYPE_OBJECT, $parentType = Json::TYPE_OBJECT)
305    {
306        /**
307         * Open the root object
308         */
309        if ($type === Json::TYPE_OBJECT) {
310            $jsonString .= "{";
311        } else {
312            $jsonString .= "[";
313        }
314
315        /**
316         * Level indentation
317         */
318        $levelSpaceIndentation = str_repeat(" ", ($level + 1) * Json::TAB_SPACES_COUNTER);
319
320        /**
321         * Loop
322         */
323        $elementCounter = 0;
324        foreach ($jsonProperty as $key => $value) {
325
326            $elementCounter++;
327
328            /**
329             * Close the previous property
330             */
331            $isFirstProperty = $elementCounter === 1;
332            if ($isFirstProperty && $parentType !== Json::PARENT_TYPE_ARRAY) {
333                // go the line if this is not a list of object
334                $jsonString .= DOKU_LF;
335            }
336            if (!$isFirstProperty) {
337                $jsonString .= ",$endOfFieldCharacter";
338            }
339            if ($endOfFieldCharacter === DOKU_LF) {
340                $tab = $levelSpaceIndentation;
341            } else {
342                $tab = " ";
343            }
344            $jsonString .= $tab;
345
346            /**
347             * Recurse
348             */
349            $jsonEncodedKey = json_encode($key);
350            if (is_array($value)) {
351                $childLevel = $level + 1;
352                if (is_numeric($key)) {
353                    /**
354                     * List of object
355                     */
356                    $childType = Json::TYPE_OBJECT;
357                    $childEndOField = "";
358                } else {
359                    /**
360                     * Array
361                     */
362                    $jsonString .= "$jsonEncodedKey: ";
363                    $childType = Json::TYPE_OBJECT;
364                    if ($value[0] !== null) {
365                        $childType = Json::PARENT_TYPE_ARRAY;
366                    }
367                    $childEndOField = $endOfFieldCharacter;
368                }
369                self::jsonFlatRecursiveEncoding($value, $jsonString, $childLevel, $childEndOField, $childType, $type);
370
371            } else {
372                /**
373                 * Single property
374                 */
375                $jsonEncodedValue = json_encode($value);
376                $jsonString .= "$jsonEncodedKey: $jsonEncodedValue";
377
378            }
379
380        }
381
382        /**
383         * Close the object or array
384         */
385        $closingLevelSpaceIndentation = str_repeat(" ", $level * Json::TAB_SPACES_COUNTER);
386        if ($type === Json::TYPE_OBJECT) {
387            if ($parentType !== Json::PARENT_TYPE_ARRAY) {
388                $jsonString .= DOKU_LF . $closingLevelSpaceIndentation;
389            } else {
390                $jsonString .= " ";
391            }
392            $jsonString .= "}";
393        } else {
394            /**
395             * The array is not going one level back
396             */
397            $jsonString .= DOKU_LF . $closingLevelSpaceIndentation . "]";
398        }
399    }
400
401
402    public function toFrontmatterString(): string
403    {
404        $frontmatterStartTag = syntax_plugin_combo_frontmatter::START_TAG;
405        $frontmatterEndTag = syntax_plugin_combo_frontmatter::END_TAG;
406        $jsonArray = $this->getData();
407        ksort($jsonArray);
408        $jsonEncode = self::toFrontmatterJsonString($jsonArray);
409
410        return <<<EOF
411$frontmatterStartTag
412$jsonEncode
413$frontmatterEndTag
414EOF;
415
416
417    }
418
419    private function setIsPresent(bool $bool): MetadataFrontmatterStore
420    {
421        $this->isPresent = $bool;
422        return $this;
423    }
424
425    public function persist()
426    {
427        if ($this->contentWithoutFrontMatter === null) {
428            LogUtility::msg("The content without frontmatter should have been set. Did you you use the createFromPage constructor");
429            return $this;
430        }
431        $targetFrontMatterJsonString = $this->toFrontmatterString();
432
433        /**
434         * EOL for the first frontmatter
435         */
436        $sep = "";
437        if (strlen($this->contentWithoutFrontMatter) > 0) {
438            $firstChar = $this->contentWithoutFrontMatter[0];
439            if (!in_array($firstChar, ["\n", "\r"])) {
440                $sep = "\n";
441            }
442        }
443
444        /**
445         * Build the new document
446         */
447        $newPageContent = <<<EOF
448$targetFrontMatterJsonString$sep$this->contentWithoutFrontMatter
449EOF;
450        $resourceCombo = $this->getResource();
451        if ($resourceCombo instanceof Page) {
452            $resourceCombo->upsertContent($newPageContent, "Metadata frontmatter store upsert");
453        }
454        return $this;
455    }
456
457    private function setContentWithoutFrontMatter(string $contentWithoutFrontMatter): MetadataFrontmatterStore
458    {
459        $this->contentWithoutFrontMatter = $contentWithoutFrontMatter;
460        return $this;
461    }
462
463
464}
465