xref: /dokuwiki/inc/Search/LegacyIndexer.php (revision 2cda016644e923dbda996c52bedee2113ba6d653)
1<?php
2
3namespace dokuwiki\Search;
4
5use dokuwiki\Debug\DebugHelper;
6use dokuwiki\Search\Collection\CollectionSearch;
7use dokuwiki\Search\Collection\PageFulltextCollection;
8use dokuwiki\Search\Collection\PageMetaCollection;
9use dokuwiki\Search\Collection\PageTitleCollection;
10use dokuwiki\Search\Exception\SearchException;
11use dokuwiki\Search\Index\FileIndex;
12use dokuwiki\Search\Index\TupleOps;
13
14/**
15 * Backward-compatible wrapper around {@see Indexer}
16 *
17 * The refactored {@see Indexer} reports failures by throwing
18 * {@see SearchException} subclasses. Plugins written against the legacy
19 * Doku_Indexer API expect the four mutating methods (addPage, deletePage,
20 * renamePage, clear) to return `true` on success or a string error message
21 * on failure. This class wraps an {@see Indexer} instance and restores that
22 * contract for those four methods. It also hosts the legacy helpers
23 * (lookupKey, getPages, addMetaKeys, renameMetaValue, getPID, lookup) that
24 * used to live on Indexer itself.
25 *
26 * It is returned by the deprecated {@see ::idx_get_indexer()} helper, which
27 * is the entry point most plugins use to obtain an indexer instance. New
28 * code should instantiate {@see Indexer} directly and handle
29 * {@see SearchException} via try/catch.
30 *
31 * Composition (not inheritance) is used because PHP does not allow
32 * overriding a `void` return type with `bool|string`.
33 *
34 * @deprecated 2026-04-07 use {@see Indexer} directly with try/catch
35 *
36 * @method string|int getVersion()
37 * @method string[] getAllPages(bool $existsFilter = false)
38 * @method string[] getPages(?string $key = null)
39 * @method bool needsIndexing(string $page, bool $force = false)
40 * @method void checkIntegrity()
41 * @method bool isIndexEmpty()
42 */
43class LegacyIndexer
44{
45    protected Indexer $indexer;
46
47    public function __construct(?Indexer $indexer = null)
48    {
49        $this->indexer = $indexer ?? new Indexer();
50    }
51
52    /**
53     * Forward any other call (getVersion, getAllPages, getPages, needsIndexing,
54     * checkIntegrity, isIndexEmpty, ...) to the wrapped indexer.
55     *
56     * @deprecated 2026-04-07 call the same method on {@see Indexer} directly
57     */
58    public function __call(string $name, array $args): mixed
59    {
60        DebugHelper::dbgDeprecatedFunction(Indexer::class . '::' . $name . '()');
61        return $this->indexer->$name(...$args);
62    }
63
64    /**
65     * @return bool|string true if work was done, false if there was nothing to do,
66     *                     error message string on failure
67     *
68     * @deprecated 2026-04-07 use {@see Indexer::addPage()} with try/catch instead
69     */
70    public function addPage(string $page, bool $force = false): bool|string
71    {
72        DebugHelper::dbgDeprecatedFunction(Indexer::class . '::addPage()');
73        try {
74            return $this->indexer->addPage($page, $force);
75        } catch (SearchException $e) {
76            return $e->getMessage();
77        }
78    }
79
80    /**
81     * @return bool|string true if work was done, false if there was nothing to do,
82     *                     error message string on failure
83     *
84     * @deprecated 2026-04-07 use {@see Indexer::deletePage()} with try/catch instead
85     */
86    public function deletePage(string $page, bool $force = false): bool|string
87    {
88        DebugHelper::dbgDeprecatedFunction(Indexer::class . '::deletePage()');
89        try {
90            return $this->indexer->deletePage($page, $force);
91        } catch (SearchException $e) {
92            return $e->getMessage();
93        }
94    }
95
96    /**
97     * @return bool|string true if work was done, false if there was nothing to do,
98     *                     error message string on failure
99     *
100     * @deprecated 2026-04-07 use {@see Indexer::renamePage()} with try/catch instead
101     */
102    public function renamePage(string $oldpage, string $newpage): bool|string
103    {
104        DebugHelper::dbgDeprecatedFunction(Indexer::class . '::renamePage()');
105        try {
106            $result = $this->indexer->renamePage($oldpage, $newpage);
107            // a false result for differing names means the old page was not in the
108            // index; restore the legacy error message that callers expect here
109            if ($result === false && $oldpage !== $newpage) {
110                return 'page is not in index';
111            }
112            return $result;
113        } catch (SearchException $e) {
114            return $e->getMessage();
115        }
116    }
117
118    /**
119     * @return true|string true on success, error message on failure
120     *
121     * @deprecated 2026-04-07 use {@see Indexer::clear()} with try/catch instead
122     */
123    public function clear(): bool|string
124    {
125        DebugHelper::dbgDeprecatedFunction(Indexer::class . '::clear()');
126        try {
127            $this->indexer->clear();
128            return true;
129        } catch (SearchException $e) {
130            return $e->getMessage();
131        }
132    }
133
134    /**
135     * Find pages containing a metadata value
136     *
137     * @param string $key metadata key name
138     * @param string|string[] $value search term(s)
139     * @param callable|null $func ignored, kept for backward compatibility
140     * @return array
141     *
142     * @deprecated 2026-04-07 use MetadataSearch::lookupKey() instead
143     */
144    public function lookupKey($key, &$value, $func = null)
145    {
146        DebugHelper::dbgDeprecatedFunction(MetadataSearch::class . '::lookupKey()');
147        return (new MetadataSearch())->lookupKey($key, $value);
148    }
149
150    /**
151     * Add metadata values for a page
152     *
153     * @param string $page page name
154     * @param string $key metadata key name
155     * @param string|string[]|null $value value(s) to add
156     * @return bool
157     *
158     * @deprecated 2026-04-07 use Collection classes directly instead
159     */
160    public function addMetaKeys($page, $key, $value = null)
161    {
162        DebugHelper::dbgDeprecatedFunction('Collection classes');
163        try {
164            if ($key === 'title') {
165                $collection = new PageTitleCollection();
166            } else {
167                $collection = new PageMetaCollection($key);
168            }
169            $values = is_array($value) ? $value : ($value !== null && $value !== '' ? [$value] : []);
170            $collection->lock()->addEntity($page, $values)->unlock();
171            $this->indexer->updateMetadataRegistry([$key]);
172            return true;
173        } catch (SearchException) {
174            return false;
175        }
176    }
177
178    /**
179     * Rename a metadata value in the index
180     *
181     * @param string $key metadata key name
182     * @param string $oldvalue old value
183     * @param string $newvalue new value
184     * @return bool
185     *
186     * @deprecated 2026-04-07 use Collection classes directly instead
187     */
188    public function renameMetaValue($key, $oldvalue, $newvalue)
189    {
190        DebugHelper::dbgDeprecatedFunction('Collection classes');
191        try {
192            $collection = new PageMetaCollection($key);
193            $collection->lock();
194
195            $tokenIndex = $collection->getTokenIndex();
196
197            // find old value — search() is read-only, won't create entries
198            $matches = $tokenIndex->search('/^' . preg_quote($oldvalue, '/') . '$/');
199            if ($matches === []) {
200                $collection->unlock();
201                return true;
202            }
203            $oldid = array_key_first($matches);
204
205            // check if new value already exists (read-only lookup)
206            $newMatches = $tokenIndex->search('/^' . preg_quote($newvalue, '/') . '$/');
207
208            if ($newMatches !== []) {
209                // both values exist — merge frequency data from old to new
210                $newid = array_key_first($newMatches);
211                $freqIndex = $collection->getFrequencyIndex();
212                $reverseIndex = $collection->getReverseIndex();
213                $oldFreqLine = $freqIndex->retrieveRow($oldid);
214
215                if ($oldFreqLine !== '') {
216                    $newFreqLine = $freqIndex->retrieveRow($newid);
217                    foreach (TupleOps::parseTuples($oldFreqLine) as $entityId => $count) {
218                        $newFreqLine = TupleOps::updateTuple($newFreqLine, $entityId, $count);
219
220                        // update reverse index: remove old token, add new
221                        $reverseRow = $reverseIndex->retrieveRow((int)$entityId);
222                        $keyline = explode(':', $reverseRow);
223                        $keyline = array_diff($keyline, [(string)$oldid]);
224                        if (!in_array((string)$newid, $keyline)) {
225                            $keyline[] = $newid;
226                        }
227                        $reverseIndex->changeRow(
228                            (int)$entityId,
229                            implode(':', array_filter($keyline, fn($v) => $v !== ''))
230                        );
231                    }
232                    $freqIndex->changeRow($oldid, '');
233                    $freqIndex->changeRow($newid, $newFreqLine);
234                }
235            } else {
236                // new value doesn't exist — simple rename
237                $tokenIndex->changeRow($oldid, $newvalue);
238            }
239
240            $collection->unlock();
241            return true;
242        } catch (SearchException) {
243            return false;
244        }
245    }
246
247    /**
248     * Get the page ID for a page name
249     *
250     * @param string $page page name
251     * @return int|false
252     *
253     * @deprecated 2026-04-07 use FileIndex directly instead
254     */
255    public function getPID($page)
256    {
257        DebugHelper::dbgDeprecatedFunction(FileIndex::class);
258        try {
259            return (new FileIndex('page', '', true))->accessCachedValue($page);
260        } catch (SearchException) {
261            return false;
262        }
263    }
264
265    /**
266     * Find tokens in the fulltext index
267     *
268     * @param array $tokens list of words to search for
269     * @return array list of pages found [word => [page => count, ...]]
270     *
271     * @deprecated 2026-04-07 use CollectionSearch on PageFulltextCollection instead
272     */
273    public function lookup($tokens)
274    {
275        DebugHelper::dbgDeprecatedFunction(CollectionSearch::class);
276        $collection = new PageFulltextCollection();
277        $search = new CollectionSearch($collection);
278        $termMap = [];
279        foreach ($tokens as $token) {
280            if (!Tokenizer::isValidSearchTerm($token)) continue;
281            $term = $search->addTerm($token);
282            $termMap[$token] = $term;
283        }
284
285        if ($termMap === []) return [];
286        $search->execute();
287
288        $result = [];
289        foreach ($termMap as $word => $term) {
290            $freqs = $term->getEntityFrequencies();
291            // filter to only existing pages
292            $filtered = array_filter($freqs, fn($page) => page_exists($page, '', false), ARRAY_FILTER_USE_KEY);
293            $result[$word] = $filtered;
294        }
295        return $result;
296    }
297}
298