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