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