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 /** 112 * Due to duplicate in canonical, the row returned may be from another resource 113 */ 114 $pathDbValue = $dbStore->getFromPersistentName(PagePath::getPersistentName()); 115 $resourcePath = $resource->getPath()->toString(); 116 if ($pathDbValue === $resourcePath) { 117 return parent::buildFromStoreValue($value); 118 } 119 } 120 } 121 122 // Value is still null, not in the the frontmatter, not in the database 123 // generate and store 124 $actualValue = self::generateUniquePageId(); 125 parent::buildFromStoreValue($actualValue); 126 try { 127 // Store the page id on the file system 128 MetadataDokuWikiStore::getOrCreateFromResource($resource) 129 ->set($this); 130 /** 131 * Create the row in the database (to allow permanent url redirection {@link PageUrlType}) 132 */ 133 (new DatabasePageRow()) 134 ->setPage($resource) 135 ->upsertAttributes([PageId::getPersistentName() => $actualValue]); 136 } catch (ExceptionCombo $e) { 137 LogUtility::msg("Unable to store the page id generated. Message:" . $e->getMessage()); 138 } 139 140 return $this; 141 142 } 143 144 145 public function getTab(): string 146 { 147 return MetaManagerForm::TAB_INTEGRATION_VALUE; 148 } 149 150 public function getDescription(): string 151 { 152 return "An unique identifier for the page"; 153 } 154 155 public function getLabel(): string 156 { 157 return "Page Id"; 158 } 159 160 static public function getName(): string 161 { 162 return self::PROPERTY_NAME; 163 } 164 165 public function getPersistenceType(): string 166 { 167 return Metadata::PERSISTENT_METADATA; 168 } 169 170 public function getMutable(): bool 171 { 172 return false; 173 } 174 175 /** 176 * @return string|null 177 */ 178 public function getDefaultValue(): ?string 179 { 180 return null; 181 } 182 183 public function getCanonical(): string 184 { 185 return $this->getName(); 186 } 187 188 189 /** 190 * For, there is no real replication between website. 191 * 192 * Therefore, the source of truth is the value in the {@link syntax_plugin_combo_frontmatter} 193 * Therefore, the page id generation should happen after the rendering of the page 194 * at the database level 195 * 196 * Return a page id collision free 197 * for the page already {@link DatabasePageRow::replicatePage() replicated} 198 * 199 * https://zelark.github.io/nano-id-cc/ 200 * 201 * 1000 id / hour = ~35 years needed, in order to have a 1% probability of at least one collision. 202 * 203 * We don't rely on a sequence because 204 * - the database may be refreshed 205 * - sqlite does have only auto-increment support 206 * https://www.sqlite.org/autoinc.html 207 * 208 * @return string 209 */ 210 static function generateUniquePageId(): string 211 { 212 /** 213 * Collision detection happens just after the use of this function on the 214 * creation of the {@link DatabasePageRow::getDatabaseRowFromPage() databasePage object} 215 * 216 */ 217 $nanoIdClient = new Client(); 218 $pageId = ($nanoIdClient)->formattedId(self::PAGE_ID_ALPHABET, self::PAGE_ID_LENGTH); 219 while (DatabasePageRow::createFromPageId($pageId)->exists()) { 220 $pageId = ($nanoIdClient)->formattedId(self::PAGE_ID_ALPHABET, self::PAGE_ID_LENGTH); 221 } 222 return $pageId; 223 } 224 225 /** 226 * Overwrite the page id even if it exists already 227 * It should not be possible - used for now in case of conflict in page move 228 * @throws ExceptionCombo 229 */ 230 public function setValueForce(?string $value): PageId 231 { 232 return $this->setValueWithOrWithoutForce($value, true); 233 } 234 235 236 /** 237 * 238 * @param bool $force - It should not be possible - used for now in case of conflict in page move 239 * @throws ExceptionCombo 240 */ 241 private function setValueWithOrWithoutForce(?string $value, bool $force = false): PageId 242 { 243 if ($value === null) { 244 throw new ExceptionCombo("A page id can not be set with a null value (Page: {$this->getResource()})", $this->getCanonical()); 245 } 246 if (!is_string($value) || !preg_match("/[" . self::PAGE_ID_ALPHABET . "]/", $value)) { 247 throw new ExceptionCombo("The page id value to set ($value) is not an alphanumeric string (Page: {$this->getResource()})", $this->getCanonical()); 248 } 249 $actualId = $this->getValue(); 250 251 if ($force !== true) { 252 if ($actualId !== null && $actualId !== $value) { 253 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()); 254 } 255 if ($actualId !== null) { 256 throw new ExceptionCombo("The page id cannot be changed, the page ({$this->getResource()}) has already an id ($actualId})", $this->getCanonical()); 257 } 258 } else { 259 if (PluginUtility::isDevOrTest()) { 260 // this should never happened (exception in test/dev) 261 throw new ExceptionComboRuntime("Forcing of the page id should not happen in dev/test", $this->getCanonical()); 262 } 263 } 264 return parent::setValue($value); 265 } 266 267 public function sendToWriteStore(): Metadata 268 { 269 /** 270 * If the data was built with one store 271 * and send to another store 272 * We prevent the overwriting of a page id 273 */ 274 $actualStoreValue = $this->getReadStore()->get($this); 275 $value = $this->getValue(); 276 if ($actualStoreValue !== null && $actualStoreValue !== $value) { 277 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"); 278 } 279 parent::sendToWriteStore(); 280 return $this; 281 282 } 283 284 285 public function getValueFromStore() 286 { 287 return $this->getReadStore()->get($this); 288 } 289 290 291} 292