1<?php /** @noinspection SpellCheckingInspection */
2
3
4namespace ComboStrap;
5
6
7use action_plugin_combo_linkmove;
8use ComboStrap\Meta\Api\Metadata;
9use ComboStrap\Meta\Api\MetadataText;
10use ComboStrap\Meta\Store\MetadataDbStore;
11use ComboStrap\Meta\Store\MetadataDokuWikiStore;
12use Hidehalo\Nanoid\Client;
13
14
15class PageId extends MetadataText
16{
17
18    public const PROPERTY_NAME = "page_id";
19
20    /**
21     * No separator, no uppercase to be consistent on the whole url
22     */
23    public const PAGE_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
24
25    /**
26     * Length to get the same probability than uuid v4. Too much ?
27     */
28    public const PAGE_ID_LENGTH = 21;
29    /**
30     *
31     * The page id abbreviation is used in the url to make them unique.
32     *
33     * A website is not git but an abbreviation of 7
34     * is enough for a website.
35     *
36     * 7 is also the initial length of the git has abbreviation
37     *
38     * It gives a probability of collision of 1 percent
39     * for 24 pages creation by day over a period of 100 year
40     * (You need to create 876k pages).
41     *  with the 36 alphabet
42     * Furthermore, we test on creation the uniqueness on the 7 page id abbreviation
43     *
44     * more ... https://datacadamia.com/crypto/hash/collision
45     */
46    public const PAGE_ID_ABBREV_LENGTH = 7;
47    public const PAGE_ID_ABBR_ATTRIBUTE = "page_id_abbr";
48
49    public static function createForPage(ResourceCombo $resource): PageId
50    {
51        return (new PageId())
52            ->setResource($resource);
53    }
54
55    public static function getAbbreviated(string $pageId)
56    {
57        return substr($pageId, 0, PageId::PAGE_ID_ABBREV_LENGTH);
58    }
59
60    /**
61     * Generate and store
62     * Store the page id on the file system
63     */
64    public static function generateAndStorePageId(MarkupPath $markupPath): string
65    {
66        $pageId = self::generateUniquePageId();
67        MetadataDokuWikiStore::getOrCreateFromResource($markupPath)
68            ->setFromPersistentName(PageId::getPersistentName(), $pageId);
69        return $pageId;
70    }
71
72
73    /**
74     *
75     *
76     * @param string|null $value
77     * @return MetadataText
78     * @throws ExceptionCompile
79     */
80    public function setValue($value): Metadata
81    {
82        return $this->setValueWithOrWithoutForce($value);
83    }
84
85    /**
86     * Page Id cannot be null when build
87     *
88     * Check how to handle a move id to avoid creating an id for a page that is moving with the
89     * move plugin {@link \action_plugin_combo_linkmove::handle_rename_after()}
90     *
91     * @param $value
92     * @return Metadata
93     */
94    public function setFromStoreValueWithoutException($value): Metadata
95    {
96
97        if ($value !== null) {
98            return parent::setFromStoreValueWithoutException($value);
99        }
100
101
102        $resource = $this->getResource();
103        if (!($resource instanceof MarkupPath)) {
104            LogUtility::msg("Page Id is for now only for the page, this is not a page but {$this->getResource()->getType()}");
105            return $this;
106        }
107
108        // null for non-existing page
109        if (!FileSystems::exists($resource->getPathObject())) {
110            return parent::setFromStoreValueWithoutException($value);
111        }
112
113
114        /**
115         * If the store is not the file system store
116         * check that it does not exist already on the file system
117         * and save it
118         */
119        $readStore = $this->getReadStore();
120        if (!($readStore instanceof MetadataDokuWikiStore)) {
121            $metadataFileSystemStore = MetadataDokuWikiStore::getOrCreateFromResource($resource);
122            $value = $metadataFileSystemStore->getFromName(self::getPersistentName());
123            if ($value !== null) {
124                return parent::setFromStoreValueWithoutException($value);
125            }
126        }
127
128        // The page Id can be into the frontmatter
129        // if the instructions are old, render them to parse the frontmatter
130        // frontmatter is the first element that is processed during a run
131        try {
132            $frontmatter = MetadataFrontmatterStore::createFromPage($resource);
133            $value = $frontmatter->getFromName(self::getPersistentName());
134            if ($value !== null) {
135                return parent::setFromStoreValueWithoutException($value);
136            }
137        } catch (ExceptionCompile $e) {
138            LogUtility::msg("Error while reading the frontmatter");
139            return $this;
140        }
141
142        // datastore
143        if (!($readStore instanceof MetadataDbStore)) {
144            try {
145                $dbStore = MetadataDbStore::getOrCreateFromResource($resource);
146                $value = $dbStore->getFromName(self::getPersistentName());
147                if ($value !== null && $value !== "") {
148
149                    $pathDbValue = $dbStore->getFromName(PagePath::getPersistentName());
150
151                    /**
152                     * If the page in the database does not exist,
153                     * We think that the page was moved from the file system
154                     * and we return the page id
155                     */
156                    $pageDbValue = MarkupPath::createPageFromAbsoluteId($pathDbValue);
157                    if (!FileSystems::exists($pageDbValue->getPathObject())) {
158                        return parent::setFromStoreValueWithoutException($value);
159                    }
160
161                    /**
162                     * The page path in the database exists
163                     * If they are the same, we return the page id
164                     * (because due to duplicate in canonical, the row returned may be from another resource)
165                     */
166                    $resourcePath = $resource->getPathObject()->toAbsoluteId();
167                    if ($pathDbValue === $resourcePath) {
168                        return parent::setFromStoreValueWithoutException($value);
169                    }
170                }
171            } catch (ExceptionNotExists|ExceptionSqliteNotAvailable $e) {
172                // no page id or not in the store or whatever
173            }
174
175        }
176
177        // null ?
178        return parent::setFromStoreValueWithoutException($value);
179
180    }
181
182
183    static public function getTab(): string
184    {
185        return MetaManagerForm::TAB_INTEGRATION_VALUE;
186    }
187
188    static public function getDescription(): string
189    {
190        return "An unique identifier for the page";
191    }
192
193    static public function getLabel(): string
194    {
195        return "Page Id";
196    }
197
198    static public function getName(): string
199    {
200        return self::PROPERTY_NAME;
201    }
202
203    static public function getPersistenceType(): string
204    {
205        return Metadata::PERSISTENT_METADATA;
206    }
207
208    static public function isMutable(): bool
209    {
210        return false;
211    }
212
213    /**
214     * @return string|null
215     */
216    public function getDefaultValue(): ?string
217    {
218        return null;
219    }
220
221
222    /**
223     * For, there is no real replication between website.
224     *
225     * Therefore, the source of truth is the value in the {@link syntax_plugin_combo_frontmatter}
226     * Therefore, the page id generation should happen after the rendering of the page
227     * at the database level
228     *
229     * Return a page id collision free
230     * for the page already {@link DatabasePageRow::replicatePage() replicated}
231     *
232     * https://zelark.github.io/nano-id-cc/
233     *
234     * 1000 id / hour = ~35 years needed, in order to have a 1% probability of at least one collision.
235     *
236     * We don't rely on a sequence because
237     *    - the database may be refreshed
238     *    - sqlite does have only auto-increment support
239     * https://www.sqlite.org/autoinc.html
240     *
241     * @return string
242     */
243    static function generateUniquePageId(): string
244    {
245        /**
246         * Collision detection happens just after the use of this function on the
247         * creation of the {@link DatabasePageRow::getDatabaseRowFromPage() databasePage object}
248         *
249         */
250        $nanoIdClient = new Client();
251        $pageId = ($nanoIdClient)->formattedId(self::PAGE_ID_ALPHABET, self::PAGE_ID_LENGTH);
252        /**
253         * The real id is the abbreviated one
254         * Test if there is not yet a page with this value
255         */
256        while (
257        DatabasePageRow::createFromPageIdAbbr(self::getAbbreviated($pageId))->exists()
258        ) {
259            $pageId = ($nanoIdClient)->formattedId(self::PAGE_ID_ALPHABET, self::PAGE_ID_LENGTH);
260        }
261
262        return $pageId;
263    }
264
265    /**
266     * Overwrite the page id even if it exists already
267     * It should not be possible - used for now in case of conflict in page move
268     * @throws ExceptionCompile
269     */
270    public function setValueForce(?string $value): PageId
271    {
272        return $this->setValueWithOrWithoutForce($value, true);
273    }
274
275
276    /**
277     *
278     * @param bool $force - It should not be possible - used for now in case of conflict in page move
279     * @throws ExceptionCompile
280     */
281    private function setValueWithOrWithoutForce(?string $value, bool $force = false): PageId
282    {
283        if ($value === null) {
284            throw new ExceptionCompile("A page id can not be set with a null value (Page: {$this->getResource()})", $this->getCanonical());
285        }
286        if (!is_string($value) || !preg_match("/[" . self::PAGE_ID_ALPHABET . "]/", $value)) {
287            throw new ExceptionCompile("The page id value to set ($value) is not an alphanumeric string (Page: {$this->getResource()})", $this->getCanonical());
288        }
289        try {
290            $actualId = $this->getValue();
291        } catch (ExceptionNotFound $e) {
292            $actualId = null;
293        }
294
295        if ($force !== true) {
296            if ($actualId !== null && $actualId !== $value) {
297                throw new ExceptionCompile("The page id cannot be changed, the page ({$this->getResource()}) has already an id ($actualId}) that has not the same value ($value})", $this->getCanonical());
298            }
299            if ($actualId !== null) {
300                throw new ExceptionCompile("The page id cannot be changed, the page ({$this->getResource()}) has already an id ($actualId})", $this->getCanonical());
301            }
302        } else {
303
304            /**
305             * This should never happened (exception in test/dev)
306             * Unfortunately, it does not happen in test
307             * but in real life
308             */
309            if (!(action_plugin_combo_linkmove::isMoveOperation())) {
310                LogUtility::internalError("Forcing of the page id should not happen in dev/test", $this->getCanonical());
311            }
312        }
313        return parent::setValue($value);
314    }
315
316    /**
317     * @throws ExceptionBadArgument
318     *
319     */
320    public function sendToWriteStore(): Metadata
321    {
322        /**
323         * If the data was built with one store
324         * and send to another store
325         * We prevent the overwriting of a page id
326         */
327        $actualStoreValue = $this->getReadStore()->get($this);
328        try {
329            $value = $this->getValue();
330        } catch (ExceptionNotFound $e) {
331            throw new ExceptionBadArgument("No value to store");
332        }
333        if ($actualStoreValue !== null && $actualStoreValue !== $value) {
334            throw new ExceptionBadArgument("The page id can not be modified once generated. The value in the store is $actualStoreValue while the new value is $value");
335        }
336        parent::sendToWriteStore();
337        return $this;
338
339    }
340
341
342    public function getValueFromStore()
343    {
344        return $this->getReadStore()->get($this);
345    }
346
347    /**
348     * @throws ExceptionNotFound
349     */
350    public function getValue(): string
351    {
352
353        return parent::getValue();
354
355    }
356
357
358    static public function isOnForm(): bool
359    {
360        return true;
361    }
362}
363