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