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