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