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