1<?php /** @noinspection SpellCheckingInspection */ 2 3 4namespace ComboStrap; 5 6 7use Hidehalo\Nanoid\Client; 8use RuntimeException; 9 10class PageId extends MetadataText 11{ 12 13 public const PROPERTY_NAME = "page_id"; 14 15 /** 16 * No separator, no uppercase to be consistent on the whole url 17 */ 18 public const PAGE_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; 19 20 /** 21 * Length to get the same probability than uuid v4. Too much ? 22 */ 23 public const PAGE_ID_LENGTH = 21; 24 public const PAGE_ID_ABBREV_LENGTH = 7; 25 public const PAGE_ID_ABBR_ATTRIBUTE = "page_id_abbr"; 26 27 public static function createForPage(ResourceCombo $resource): PageId 28 { 29 return (new PageId()) 30 ->setResource($resource); 31 } 32 33 34 /** 35 * 36 * 37 * @param string|null $value 38 * @return MetadataText 39 * @throws ExceptionCombo 40 */ 41 public function setValue($value): Metadata 42 { 43 return $this->setValueWithOrWithoutForce($value); 44 } 45 46 /** 47 * Page Id cannot be null when build 48 * 49 * Check how to handle a move id to avoid creating an id for a page that is moving with the 50 * move plugin {@link \action_plugin_combo_linkmove::handle_rename_after()} 51 * 52 * @param $value 53 * @return Metadata 54 */ 55 public function buildFromStoreValue($value): Metadata 56 { 57 58 if ($value !== null) { 59 return parent::buildFromStoreValue($value); 60 } 61 62 63 $resource = $this->getResource(); 64 if (!($resource instanceof Page)) { 65 LogUtility::msg("Page Id is for now only for the page, this is not a page but {$this->getResource()->getType()}"); 66 return $this; 67 } 68 69 // null for non-existing page 70 if (!FileSystems::exists($resource->getPath())) { 71 if (PluginUtility::isDevOrTest()) { 72 LogUtility::msg("You can't ask a `page id`, the page ({$this->getResource()}) does not exist", LogUtility::LVL_MSG_INFO, $this->getCanonical()); 73 } 74 return parent::buildFromStoreValue($value); 75 } 76 77 78 /** 79 * If the store is not the file system store 80 * check that it does not exist already on the file system 81 * and save it 82 */ 83 $readStore = $this->getReadStore(); 84 if (!($readStore instanceof MetadataDokuWikiStore)) { 85 $metadataFileSystemStore = MetadataDokuWikiStore::getOrCreateFromResource($resource); 86 $value = $metadataFileSystemStore->getFromPersistentName(self::getPersistentName()); 87 if ($value !== null) { 88 return parent::buildFromStoreValue($value); 89 } 90 } 91 92 // The page Id can be into the frontmatter 93 // if the instructions are old, render them to parse the frontmatter 94 // frontmatter is the first element that is processed during a run 95 try { 96 $frontmatter = MetadataFrontmatterStore::createFromPage($resource); 97 $value = $frontmatter->getFromPersistentName(self::getPersistentName()); 98 if ($value !== null) { 99 return parent::buildFromStoreValue($value); 100 } 101 } catch (ExceptionCombo $e) { 102 LogUtility::msg("Error while reading the frontmatter"); 103 return $this; 104 } 105 106 // datastore 107 if (!($readStore instanceof MetadataDbStore)) { 108 $dbStore = MetadataDbStore::getOrCreateFromResource($resource); 109 $value = $dbStore->getFromPersistentName(self::getPersistentName()); 110 if ($value !== null) { 111 return parent::buildFromStoreValue($value); 112 } 113 } 114 115 // Value is still null, not in the the frontmatter, not in the database 116 // generate and store 117 $actualValue = self::generateUniquePageId(); 118 parent::buildFromStoreValue($actualValue); 119 try { 120 // Store the page id on the file system 121 MetadataDokuWikiStore::getOrCreateFromResource($resource) 122 ->set($this); 123 /** 124 * Create the row in the database (to allow permanent url redirection {@link PageUrlType}) 125 */ 126 (new DatabasePageRow()) 127 ->setPage($resource) 128 ->upsertAttributes([PageId::getPersistentName() => $actualValue]); 129 } catch (ExceptionCombo $e) { 130 LogUtility::msg("Unable to store the page id generated. Message:" . $e->getMessage()); 131 } 132 133 return $this; 134 135 } 136 137 138 public function getTab(): string 139 { 140 return MetaManagerForm::TAB_INTEGRATION_VALUE; 141 } 142 143 public function getDescription(): string 144 { 145 return "An unique identifier for the page"; 146 } 147 148 public function getLabel(): string 149 { 150 return "Page Id"; 151 } 152 153 static public function getName(): string 154 { 155 return self::PROPERTY_NAME; 156 } 157 158 public function getPersistenceType(): string 159 { 160 return Metadata::PERSISTENT_METADATA; 161 } 162 163 public function getMutable(): bool 164 { 165 return false; 166 } 167 168 /** 169 * @return string|null 170 */ 171 public function getDefaultValue(): ?string 172 { 173 return null; 174 } 175 176 public function getCanonical(): string 177 { 178 return $this->getName(); 179 } 180 181 182 /** 183 * For, there is no real replication between website. 184 * 185 * Therefore, the source of truth is the value in the {@link syntax_plugin_combo_frontmatter} 186 * Therefore, the page id generation should happen after the rendering of the page 187 * at the database level 188 * 189 * Return a page id collision free 190 * for the page already {@link DatabasePageRow::replicatePage() replicated} 191 * 192 * https://zelark.github.io/nano-id-cc/ 193 * 194 * 1000 id / hour = ~35 years needed, in order to have a 1% probability of at least one collision. 195 * 196 * We don't rely on a sequence because 197 * - the database may be refreshed 198 * - sqlite does have only auto-increment support 199 * https://www.sqlite.org/autoinc.html 200 * 201 * @return string 202 */ 203 static function generateUniquePageId(): string 204 { 205 /** 206 * Collision detection happens just after the use of this function on the 207 * creation of the {@link DatabasePageRow::getDatabaseRowFromPage() databasePage object} 208 * 209 */ 210 $nanoIdClient = new Client(); 211 $pageId = ($nanoIdClient)->formattedId(self::PAGE_ID_ALPHABET, self::PAGE_ID_LENGTH); 212 while (DatabasePageRow::createFromPageId($pageId)->exists()) { 213 $pageId = ($nanoIdClient)->formattedId(self::PAGE_ID_ALPHABET, self::PAGE_ID_LENGTH); 214 } 215 return $pageId; 216 } 217 218 /** 219 * Overwrite the page id even if it exists already 220 * It should not be possible - used for now in case of conflict in page move 221 * @throws ExceptionCombo 222 */ 223 public function setValueForce(?string $value): PageId 224 { 225 return $this->setValueWithOrWithoutForce($value, true); 226 } 227 228 229 /** 230 * 231 * @param bool $force - It should not be possible - used for now in case of conflict in page move 232 * @throws ExceptionCombo 233 */ 234 private function setValueWithOrWithoutForce(?string $value, bool $force = false): PageId 235 { 236 if ($value === null) { 237 throw new ExceptionCombo("A page id can not be set with a null value (Page: {$this->getResource()})", $this->getCanonical()); 238 } 239 if (!is_string($value) || !preg_match("/[" . self::PAGE_ID_ALPHABET . "]/", $value)) { 240 throw new ExceptionCombo("The page id value to set ($value) is not an alphanumeric string (Page: {$this->getResource()})", $this->getCanonical()); 241 } 242 $actualId = $this->getValue(); 243 244 if ($force !== true) { 245 if ($actualId !== null && $actualId !== $value) { 246 throw new ExceptionCombo("The page id cannot be changed, the page ({$this->getResource()}) has already an id ($actualId}) that has not the same value ($value})", $this->getCanonical()); 247 } 248 if ($actualId !== null) { 249 throw new ExceptionCombo("The page id cannot be changed, the page ({$this->getResource()}) has already an id ($actualId})", $this->getCanonical()); 250 } 251 } else { 252 if (PluginUtility::isDevOrTest()) { 253 // this should never happened (exception in test/dev) 254 throw new ExceptionComboRuntime("Forcing of the page id should not happen in dev/test", $this->getCanonical()); 255 } 256 } 257 return parent::setValue($value); 258 } 259 260 public function sendToWriteStore(): Metadata 261 { 262 /** 263 * If the data was built with one store 264 * and send to another store 265 * We prevent the overwriting of a page id 266 */ 267 $actualStoreValue = $this->getReadStore()->get($this); 268 $value = $this->getValue(); 269 if ($actualStoreValue !== null && $actualStoreValue !== $value) { 270 throw new ExceptionComboRuntime("The page id can not be modified once generated. The value in the store is $actualStoreValue while the new value is $value"); 271 } 272 parent::sendToWriteStore(); 273 return $this; 274 275 } 276 277 278 public function getValueFromStore() 279 { 280 return $this->getReadStore()->get($this); 281 } 282 283 284} 285