xref: /dokuwiki/inc/Search/LegacyIndexer.php (revision 6e39b4e379a661a3abd765df49fa679d2119741c)
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 true|string true on success, error message on failure
66     *
67     * @deprecated 2026-04-07 use {@see Indexer::addPage()} with try/catch instead
68     */
69    public function addPage(string $page, bool $force = false): bool|string
70    {
71        DebugHelper::dbgDeprecatedFunction(Indexer::class . '::addPage()');
72        try {
73            $this->indexer->addPage($page, $force);
74            return true;
75        } catch (SearchException $e) {
76            return $e->getMessage();
77        }
78    }
79
80    /**
81     * @return true|string true on success, error message on failure
82     *
83     * @deprecated 2026-04-07 use {@see Indexer::deletePage()} with try/catch instead
84     */
85    public function deletePage(string $page, bool $force = false): bool|string
86    {
87        DebugHelper::dbgDeprecatedFunction(Indexer::class . '::deletePage()');
88        try {
89            $this->indexer->deletePage($page, $force);
90            return true;
91        } catch (SearchException $e) {
92            return $e->getMessage();
93        }
94    }
95
96    /**
97     * @return true|string true on success, error message on failure
98     *
99     * @deprecated 2026-04-07 use {@see Indexer::renamePage()} with try/catch instead
100     */
101    public function renamePage(string $oldpage, string $newpage): bool|string
102    {
103        DebugHelper::dbgDeprecatedFunction(Indexer::class . '::renamePage()');
104        try {
105            $this->indexer->renamePage($oldpage, $newpage);
106            return true;
107        } catch (SearchException $e) {
108            return $e->getMessage();
109        }
110    }
111
112    /**
113     * @return true|string true on success, error message on failure
114     *
115     * @deprecated 2026-04-07 use {@see Indexer::clear()} with try/catch instead
116     */
117    public function clear(): bool|string
118    {
119        DebugHelper::dbgDeprecatedFunction(Indexer::class . '::clear()');
120        try {
121            $this->indexer->clear();
122            return true;
123        } catch (SearchException $e) {
124            return $e->getMessage();
125        }
126    }
127
128    /**
129     * Find pages containing a metadata value
130     *
131     * @param string $key metadata key name
132     * @param string|string[] $value search term(s)
133     * @param callable|null $func ignored, kept for backward compatibility
134     * @return array
135     *
136     * @deprecated 2026-04-07 use MetadataSearch::lookupKey() instead
137     */
138    public function lookupKey($key, &$value, $func = null)
139    {
140        DebugHelper::dbgDeprecatedFunction(MetadataSearch::class . '::lookupKey()');
141        return (new MetadataSearch())->lookupKey($key, $value);
142    }
143
144    /**
145     * Add metadata values for a page
146     *
147     * @param string $page page name
148     * @param string $key metadata key name
149     * @param string|string[]|null $value value(s) to add
150     * @return bool
151     *
152     * @deprecated 2026-04-07 use Collection classes directly instead
153     */
154    public function addMetaKeys($page, $key, $value = null)
155    {
156        DebugHelper::dbgDeprecatedFunction('Collection classes');
157        try {
158            if ($key === 'title') {
159                $collection = new PageTitleCollection();
160            } else {
161                $collection = new PageMetaCollection($key);
162            }
163            $values = is_array($value) ? $value : ($value !== null && $value !== '' ? [$value] : []);
164            $collection->lock()->addEntity($page, $values)->unlock();
165            $this->indexer->updateMetadataRegistry([$key]);
166            return true;
167        } catch (SearchException) {
168            return false;
169        }
170    }
171
172    /**
173     * Rename a metadata value in the index
174     *
175     * @param string $key metadata key name
176     * @param string $oldvalue old value
177     * @param string $newvalue new value
178     * @return bool
179     *
180     * @deprecated 2026-04-07 use Collection classes directly instead
181     */
182    public function renameMetaValue($key, $oldvalue, $newvalue)
183    {
184        DebugHelper::dbgDeprecatedFunction('Collection classes');
185        try {
186            $collection = new PageMetaCollection($key);
187            $collection->lock();
188
189            $tokenIndex = $collection->getTokenIndex();
190
191            // find old value — search() is read-only, won't create entries
192            $matches = $tokenIndex->search('/^' . preg_quote($oldvalue, '/') . '$/');
193            if ($matches === []) {
194                $collection->unlock();
195                return true;
196            }
197            $oldid = array_key_first($matches);
198
199            // check if new value already exists (read-only lookup)
200            $newMatches = $tokenIndex->search('/^' . preg_quote($newvalue, '/') . '$/');
201
202            if ($newMatches !== []) {
203                // both values exist — merge frequency data from old to new
204                $newid = array_key_first($newMatches);
205                $freqIndex = $collection->getFrequencyIndex();
206                $reverseIndex = $collection->getReverseIndex();
207                $oldFreqLine = $freqIndex->retrieveRow($oldid);
208
209                if ($oldFreqLine !== '') {
210                    $newFreqLine = $freqIndex->retrieveRow($newid);
211                    foreach (TupleOps::parseTuples($oldFreqLine) as $entityId => $count) {
212                        $newFreqLine = TupleOps::updateTuple($newFreqLine, $entityId, $count);
213
214                        // update reverse index: remove old token, add new
215                        $reverseRow = $reverseIndex->retrieveRow((int)$entityId);
216                        $keyline = explode(':', $reverseRow);
217                        $keyline = array_diff($keyline, [(string)$oldid]);
218                        if (!in_array((string)$newid, $keyline)) {
219                            $keyline[] = $newid;
220                        }
221                        $reverseIndex->changeRow(
222                            (int)$entityId,
223                            implode(':', array_filter($keyline, fn($v) => $v !== ''))
224                        );
225                    }
226                    $freqIndex->changeRow($oldid, '');
227                    $freqIndex->changeRow($newid, $newFreqLine);
228                }
229            } else {
230                // new value doesn't exist — simple rename
231                $tokenIndex->changeRow($oldid, $newvalue);
232            }
233
234            $collection->unlock();
235            return true;
236        } catch (SearchException) {
237            return false;
238        }
239    }
240
241    /**
242     * Get the page ID for a page name
243     *
244     * @param string $page page name
245     * @return int|false
246     *
247     * @deprecated 2026-04-07 use FileIndex directly instead
248     */
249    public function getPID($page)
250    {
251        DebugHelper::dbgDeprecatedFunction(FileIndex::class);
252        try {
253            return (new FileIndex('page', '', true))->accessCachedValue($page);
254        } catch (SearchException) {
255            return false;
256        }
257    }
258
259    /**
260     * Find tokens in the fulltext index
261     *
262     * @param array $tokens list of words to search for
263     * @return array list of pages found [word => [page => count, ...]]
264     *
265     * @deprecated 2026-04-07 use CollectionSearch on PageFulltextCollection instead
266     */
267    public function lookup($tokens)
268    {
269        DebugHelper::dbgDeprecatedFunction(CollectionSearch::class);
270        $collection = new PageFulltextCollection();
271        $search = new CollectionSearch($collection);
272        $termMap = [];
273        foreach ($tokens as $token) {
274            if (!Tokenizer::isValidSearchTerm($token)) continue;
275            $term = $search->addTerm($token);
276            $termMap[$token] = $term;
277        }
278
279        if ($termMap === []) return [];
280        $search->execute();
281
282        $result = [];
283        foreach ($termMap as $word => $term) {
284            $freqs = $term->getEntityFrequencies();
285            // filter to only existing pages
286            $filtered = array_filter($freqs, fn($page) => page_exists($page, '', false), ARRAY_FILTER_USE_KEY);
287            $result[$word] = $filtered;
288        }
289        return $result;
290    }
291}
292