1<?php /** @noinspection SpellCheckingInspection */ 2 3 4namespace ComboStrap; 5 6 7use action_plugin_combo_linkmove; 8use ComboStrap\Meta\Api\Metadata; 9use ComboStrap\Meta\Api\MetadataText; 10use ComboStrap\Meta\Store\MetadataDbStore; 11use ComboStrap\Meta\Store\MetadataDokuWikiStore; 12use Hidehalo\Nanoid\Client; 13 14 15class PageId extends MetadataText 16{ 17 18 public const PROPERTY_NAME = "page_id"; 19 20 /** 21 * No separator, no uppercase to be consistent on the whole url 22 */ 23 public const PAGE_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; 24 25 /** 26 * Length to get the same probability than uuid v4. Too much ? 27 */ 28 public const PAGE_ID_LENGTH = 21; 29 /** 30 * 31 * The page id abbreviation is used in the url to make them unique. 32 * 33 * A website is not git but an abbreviation of 7 34 * is enough for a website. 35 * 36 * 7 is also the initial length of the git has abbreviation 37 * 38 * It gives a probability of collision of 1 percent 39 * for 24 pages creation by day over a period of 100 year 40 * (You need to create 876k pages). 41 * with the 36 alphabet 42 * Furthermore, we test on creation the uniqueness on the 7 page id abbreviation 43 * 44 * more ... https://datacadamia.com/crypto/hash/collision 45 */ 46 public const PAGE_ID_ABBREV_LENGTH = 7; 47 public const PAGE_ID_ABBR_ATTRIBUTE = "page_id_abbr"; 48 49 public static function createForPage(ResourceCombo $resource): PageId 50 { 51 return (new PageId()) 52 ->setResource($resource); 53 } 54 55 public static function getAbbreviated(string $pageId) 56 { 57 return substr($pageId, 0, PageId::PAGE_ID_ABBREV_LENGTH); 58 } 59 60 /** 61 * Generate and store 62 * Store the page id on the file system 63 */ 64 public static function generateAndStorePageId(MarkupPath $markupPath): string 65 { 66 $pageId = self::generateUniquePageId(); 67 MetadataDokuWikiStore::getOrCreateFromResource($markupPath) 68 ->setFromPersistentName(PageId::getPersistentName(), $pageId); 69 return $pageId; 70 } 71 72 73 /** 74 * 75 * 76 * @param string|null $value 77 * @return MetadataText 78 * @throws ExceptionCompile 79 */ 80 public function setValue($value): Metadata 81 { 82 return $this->setValueWithOrWithoutForce($value); 83 } 84 85 /** 86 * Page Id cannot be null when build 87 * 88 * Check how to handle a move id to avoid creating an id for a page that is moving with the 89 * move plugin {@link \action_plugin_combo_linkmove::handle_rename_after()} 90 * 91 * @param $value 92 * @return Metadata 93 */ 94 public function setFromStoreValueWithoutException($value): Metadata 95 { 96 97 if ($value !== null) { 98 return parent::setFromStoreValueWithoutException($value); 99 } 100 101 102 $resource = $this->getResource(); 103 if (!($resource instanceof MarkupPath)) { 104 LogUtility::msg("Page Id is for now only for the page, this is not a page but {$this->getResource()->getType()}"); 105 return $this; 106 } 107 108 // null for non-existing page 109 if (!FileSystems::exists($resource->getPathObject())) { 110 return parent::setFromStoreValueWithoutException($value); 111 } 112 113 114 /** 115 * If the store is not the file system store 116 * check that it does not exist already on the file system 117 * and save it 118 */ 119 $readStore = $this->getReadStore(); 120 if (!($readStore instanceof MetadataDokuWikiStore)) { 121 $metadataFileSystemStore = MetadataDokuWikiStore::getOrCreateFromResource($resource); 122 $value = $metadataFileSystemStore->getFromName(self::getPersistentName()); 123 if ($value !== null) { 124 return parent::setFromStoreValueWithoutException($value); 125 } 126 } 127 128 // The page Id can be into the frontmatter 129 // if the instructions are old, render them to parse the frontmatter 130 // frontmatter is the first element that is processed during a run 131 try { 132 $frontmatter = MetadataFrontmatterStore::createFromPage($resource); 133 $value = $frontmatter->getFromName(self::getPersistentName()); 134 if ($value !== null) { 135 return parent::setFromStoreValueWithoutException($value); 136 } 137 } catch (ExceptionCompile $e) { 138 LogUtility::msg("Error while reading the frontmatter"); 139 return $this; 140 } 141 142 // datastore 143 if (!($readStore instanceof MetadataDbStore)) { 144 try { 145 $dbStore = MetadataDbStore::getOrCreateFromResource($resource); 146 $value = $dbStore->getFromName(self::getPersistentName()); 147 if ($value !== null && $value !== "") { 148 149 $pathDbValue = $dbStore->getFromName(PagePath::getPersistentName()); 150 151 /** 152 * If the page in the database does not exist, 153 * We think that the page was moved from the file system 154 * and we return the page id 155 */ 156 $pageDbValue = MarkupPath::createPageFromAbsoluteId($pathDbValue); 157 if (!FileSystems::exists($pageDbValue->getPathObject())) { 158 return parent::setFromStoreValueWithoutException($value); 159 } 160 161 /** 162 * The page path in the database exists 163 * If they are the same, we return the page id 164 * (because due to duplicate in canonical, the row returned may be from another resource) 165 */ 166 $resourcePath = $resource->getPathObject()->toAbsoluteId(); 167 if ($pathDbValue === $resourcePath) { 168 return parent::setFromStoreValueWithoutException($value); 169 } 170 } 171 } catch (ExceptionNotExists|ExceptionSqliteNotAvailable $e) { 172 // no page id or not in the store or whatever 173 } 174 175 } 176 177 // null ? 178 return parent::setFromStoreValueWithoutException($value); 179 180 } 181 182 183 static public function getTab(): string 184 { 185 return MetaManagerForm::TAB_INTEGRATION_VALUE; 186 } 187 188 static public function getDescription(): string 189 { 190 return "An unique identifier for the page"; 191 } 192 193 static public function getLabel(): string 194 { 195 return "Page Id"; 196 } 197 198 static public function getName(): string 199 { 200 return self::PROPERTY_NAME; 201 } 202 203 static public function getPersistenceType(): string 204 { 205 return Metadata::PERSISTENT_METADATA; 206 } 207 208 static public function isMutable(): bool 209 { 210 return false; 211 } 212 213 /** 214 * @return string|null 215 */ 216 public function getDefaultValue(): ?string 217 { 218 return null; 219 } 220 221 222 /** 223 * For, there is no real replication between website. 224 * 225 * Therefore, the source of truth is the value in the {@link syntax_plugin_combo_frontmatter} 226 * Therefore, the page id generation should happen after the rendering of the page 227 * at the database level 228 * 229 * Return a page id collision free 230 * for the page already {@link DatabasePageRow::replicatePage() replicated} 231 * 232 * https://zelark.github.io/nano-id-cc/ 233 * 234 * 1000 id / hour = ~35 years needed, in order to have a 1% probability of at least one collision. 235 * 236 * We don't rely on a sequence because 237 * - the database may be refreshed 238 * - sqlite does have only auto-increment support 239 * https://www.sqlite.org/autoinc.html 240 * 241 * @return string 242 */ 243 static function generateUniquePageId(): string 244 { 245 /** 246 * Collision detection happens just after the use of this function on the 247 * creation of the {@link DatabasePageRow::getDatabaseRowFromPage() databasePage object} 248 * 249 */ 250 $nanoIdClient = new Client(); 251 $pageId = ($nanoIdClient)->formattedId(self::PAGE_ID_ALPHABET, self::PAGE_ID_LENGTH); 252 /** 253 * The real id is the abbreviated one 254 * Test if there is not yet a page with this value 255 */ 256 while ( 257 DatabasePageRow::createFromPageIdAbbr(self::getAbbreviated($pageId))->exists() 258 ) { 259 $pageId = ($nanoIdClient)->formattedId(self::PAGE_ID_ALPHABET, self::PAGE_ID_LENGTH); 260 } 261 262 return $pageId; 263 } 264 265 /** 266 * Overwrite the page id even if it exists already 267 * It should not be possible - used for now in case of conflict in page move 268 * @throws ExceptionCompile 269 */ 270 public function setValueForce(?string $value): PageId 271 { 272 return $this->setValueWithOrWithoutForce($value, true); 273 } 274 275 276 /** 277 * 278 * @param bool $force - It should not be possible - used for now in case of conflict in page move 279 * @throws ExceptionCompile 280 */ 281 private function setValueWithOrWithoutForce(?string $value, bool $force = false): PageId 282 { 283 if ($value === null) { 284 throw new ExceptionCompile("A page id can not be set with a null value (Page: {$this->getResource()})", $this->getCanonical()); 285 } 286 if (!is_string($value) || !preg_match("/[" . self::PAGE_ID_ALPHABET . "]/", $value)) { 287 throw new ExceptionCompile("The page id value to set ($value) is not an alphanumeric string (Page: {$this->getResource()})", $this->getCanonical()); 288 } 289 try { 290 $actualId = $this->getValue(); 291 } catch (ExceptionNotFound $e) { 292 $actualId = null; 293 } 294 295 if ($force !== true) { 296 if ($actualId !== null && $actualId !== $value) { 297 throw new ExceptionCompile("The page id cannot be changed, the page ({$this->getResource()}) has already an id ($actualId}) that has not the same value ($value})", $this->getCanonical()); 298 } 299 if ($actualId !== null) { 300 throw new ExceptionCompile("The page id cannot be changed, the page ({$this->getResource()}) has already an id ($actualId})", $this->getCanonical()); 301 } 302 } else { 303 304 /** 305 * This should never happened (exception in test/dev) 306 * Unfortunately, it does not happen in test 307 * but in real life 308 */ 309 if (!(action_plugin_combo_linkmove::isMoveOperation())) { 310 LogUtility::internalError("Forcing of the page id should not happen in dev/test", $this->getCanonical()); 311 } 312 } 313 return parent::setValue($value); 314 } 315 316 /** 317 * @throws ExceptionBadArgument 318 * 319 */ 320 public function sendToWriteStore(): Metadata 321 { 322 /** 323 * If the data was built with one store 324 * and send to another store 325 * We prevent the overwriting of a page id 326 */ 327 $actualStoreValue = $this->getReadStore()->get($this); 328 try { 329 $value = $this->getValue(); 330 } catch (ExceptionNotFound $e) { 331 throw new ExceptionBadArgument("No value to store"); 332 } 333 if ($actualStoreValue !== null && $actualStoreValue !== $value) { 334 throw new ExceptionBadArgument("The page id can not be modified once generated. The value in the store is $actualStoreValue while the new value is $value"); 335 } 336 parent::sendToWriteStore(); 337 return $this; 338 339 } 340 341 342 public function getValueFromStore() 343 { 344 return $this->getReadStore()->get($this); 345 } 346 347 /** 348 * @throws ExceptionNotFound 349 */ 350 public function getValue(): string 351 { 352 353 return parent::getValue(); 354 355 } 356 357 358 static public function isOnForm(): bool 359 { 360 return true; 361 } 362} 363