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