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