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 399 ]; 400 $meta = p_read_metadata($ID); 401 foreach ($managedMeta as $metaKey) { 402 if (!array_key_exists($metaKey, $json)) { 403 if (isset($meta['persistent'][$metaKey])) { 404 unset($meta['persistent'][$metaKey]); 405 } 406 } 407 } 408 return p_save_metadata($ID, $meta); 409 } 410 411 private function updateImageStatistics($value, $renderer) 412 { 413 if(is_array($value)){ 414 foreach($value as $subImage){ 415 $this->updateImageStatistics($subImage, $renderer); 416 } 417 } else { 418 $media = MediaLink::createFromRenderMatch($value); 419 $attributes = $media->toCallStackArray(); 420 syntax_plugin_combo_media::updateStatistics($attributes, $renderer); 421 } 422 } 423 424 private function aggregateImageValues(array &$imageValues, $value) 425 { 426 if (is_array($value)) { 427 foreach ($value as $subImageValue) { 428 $this->aggregateImageValues($imageValues,$subImageValue); 429 } 430 } else { 431 $imageValues[] = $value; 432 } 433 } 434 435 436} 437 438