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