xref: /plugin/combo/ComboStrap/PageId.php (revision 82a60d039cd81033dc8147c27f0a50716b7a5301)
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