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