1<?php
2/**
3 * Modernized Sphinx Search API Client
4 *
5 * Updated for PHP 8.x compatibility
6 * @license GPL 2
7 */
8
9/// known searchd commands
10const SEARCHD_COMMAND_SEARCH   = 0;
11const SEARCHD_COMMAND_EXCERPT  = 1;
12const SEARCHD_COMMAND_UPDATE   = 2;
13const SEARCHD_COMMAND_KEYWORDS = 3;
14const SEARCHD_COMMAND_PERSIST  = 4;
15const SEARCHD_COMMAND_STATUS   = 5;
16const SEARCHD_COMMAND_QUERY    = 6;
17
18/// current client-side command implementation versions
19const VER_COMMAND_SEARCH   = 0x116;
20const VER_COMMAND_EXCERPT  = 0x100;
21const VER_COMMAND_UPDATE   = 0x102;
22const VER_COMMAND_KEYWORDS = 0x100;
23const VER_COMMAND_STATUS   = 0x100;
24const VER_COMMAND_QUERY    = 0x100;
25
26/// known searchd status codes
27const SEARCHD_OK      = 0;
28const SEARCHD_ERROR   = 1;
29const SEARCHD_RETRY   = 2;
30const SEARCHD_WARNING = 3;
31
32/// known match modes
33const SPH_MATCH_ALL       = 0;
34const SPH_MATCH_ANY       = 1;
35const SPH_MATCH_PHRASE    = 2;
36const SPH_MATCH_BOOLEAN   = 3;
37const SPH_MATCH_EXTENDED  = 4;
38const SPH_MATCH_FULLSCAN  = 5;
39const SPH_MATCH_EXTENDED2 = 6;
40
41/// known ranking modes
42const SPH_RANK_PROXIMITY_BM25 = 0;
43const SPH_RANK_BM25           = 1;
44const SPH_RANK_NONE           = 2;
45const SPH_RANK_WORDCOUNT      = 3;
46const SPH_RANK_PROXIMITY      = 4;
47const SPH_RANK_MATCHANY       = 5;
48const SPH_RANK_FIELDMASK      = 6;
49
50/// known sort modes
51const SPH_SORT_RELEVANCE     = 0;
52const SPH_SORT_ATTR_DESC     = 1;
53const SPH_SORT_ATTR_ASC      = 2;
54const SPH_SORT_TIME_SEGMENTS = 3;
55const SPH_SORT_EXTENDED      = 4;
56const SPH_SORT_EXPR          = 5;
57
58/// known filter types
59const SPH_FILTER_VALUES     = 0;
60const SPH_FILTER_RANGE      = 1;
61const SPH_FILTER_FLOATRANGE = 2;
62
63/// known attribute types
64const SPH_ATTR_INTEGER   = 1;
65const SPH_ATTR_TIMESTAMP = 2;
66const SPH_ATTR_ORDINAL   = 3;
67const SPH_ATTR_BOOL      = 4;
68const SPH_ATTR_FLOAT     = 5;
69const SPH_ATTR_BIGINT    = 6;
70const SPH_ATTR_MULTI     = 0x40000000;
71
72/// known grouping functions
73const SPH_GROUPBY_DAY      = 0;
74const SPH_GROUPBY_WEEK     = 1;
75const SPH_GROUPBY_MONTH    = 2;
76const SPH_GROUPBY_YEAR     = 3;
77const SPH_GROUPBY_ATTR     = 4;
78const SPH_GROUPBY_ATTRPAIR = 5;
79
80/**
81 * 64-bit integer packing helpers
82 */
83function sphPackI64($v): string {
84    $v = (string)$v;
85    if (PHP_INT_SIZE >= 8) {
86        $v = (int)$v;
87        return pack("NN", $v >> 32, $v & 0xFFFFFFFF);
88    }
89    if (function_exists("bcmul")) {
90        if (bccomp($v, "0") == -1) $v = bcadd("18446744073709551616", $v);
91        $h = bcdiv($v, "4294967296", 0);
92        $l = bcmod($v, "4294967296");
93        return pack("NN", (float)$h, (float)$l);
94    }
95    return pack("NN", 0, (int)$v); // Fallback for simple ints
96}
97
98function sphPackU64($v): string {
99    return sphPackI64($v);
100}
101
102function sphUnpackU64($v) {
103    $data = unpack("Nhi/Nlo", $v);
104    $hi = $data['hi'];
105    $lo = $data['lo'];
106
107    if (PHP_INT_SIZE >= 8) {
108        if ($hi < 0) $hi += (1 << 32);
109        if ($lo < 0) $lo += (1 << 32);
110        return ($hi << 32) + $lo;
111    }
112
113    $hi = sprintf("%u", $hi);
114    $lo = sprintf("%u", $lo);
115    if (function_exists("bcmul")) {
116        return bcadd($lo, bcmul($hi, "4294967296"));
117    }
118    return $lo;
119}
120
121function sphUnpackI64($v) {
122    return sphUnpackU64($v);
123}
124
125function sphFixUint($value) {
126    if (PHP_INT_SIZE >= 8) {
127        if ($value < 0) $value += (1 << 32);
128        return $value;
129    }
130    return sprintf("%u", $value);
131}
132
133class SphinxClient {
134    public $_host = "localhost";
135    public $_port = 9312;
136    public $_offset = 0;
137    public $_limit = 20;
138    public $_mode = SPH_MATCH_ALL;
139    public $_weights = [];
140    public $_sort = SPH_SORT_RELEVANCE;
141    public $_sortby = "";
142    public $_min_id = 0;
143    public $_max_id = 0;
144    public $_filters = [];
145    public $_groupby = "";
146    public $_groupfunc = SPH_GROUPBY_DAY;
147    public $_groupsort = "@group desc";
148    public $_groupdistinct = "";
149    public $_maxmatches = 1000;
150    public $_cutoff = 0;
151    public $_retrycount = 0;
152    public $_retrydelay = 0;
153    public $_anchor = [];
154    public $_indexweights = [];
155    public $_ranker = SPH_RANK_PROXIMITY_BM25;
156    public $_maxquerytime = 0;
157    public $_fieldweights = [];
158    public $_overrides = [];
159    public $_select = "*";
160
161    public $_error = "";
162    public $_warning = "";
163    public $_connerror = false;
164
165    public $_reqs = [];
166    public $_mbenc = "";
167    public $_arrayresult = false;
168    public $_timeout = 0;
169
170    protected $_socket = false;
171    protected $_path = false;
172
173    public function __construct() {
174        // Initialized via property defaults
175    }
176
177    public function __destruct() {
178        if ($this->_socket !== false) {
179            fclose($this->_socket);
180        }
181    }
182
183    public function GetLastError(): string {
184        return $this->_error;
185    }
186
187    public function GetLastWarning(): string {
188        return $this->_warning;
189    }
190
191    public function IsConnectError(): bool {
192        return $this->_connerror;
193    }
194
195    public function SetServer(string $host, int $port = 0): void {
196        if ($host !== '' && $host[0] === '/') {
197            $this->_path = 'unix://' . $host;
198            return;
199        }
200        if (str_starts_with($host, "unix://")) {
201            $this->_path = $host;
202            return;
203        }
204
205        $this->_host = $host;
206        $this->_port = $port;
207        $this->_path = '';
208    }
209
210    public function SetConnectTimeout($timeout): void {
211        $this->_timeout = (float)$timeout;
212    }
213
214    protected function _Send($handle, $data, $length): bool {
215        if (feof($handle) || fwrite($handle, $data, $length) !== $length) {
216            $this->_error = 'connection unexpectedly closed (timed out?)';
217            $this->_connerror = true;
218            return false;
219        }
220        return true;
221    }
222
223    protected function _MBPush(): void {
224        $this->_mbenc = "";
225        // mbstring.func_overload is removed in PHP 8.0, but we check for legacy
226        if (function_exists('mb_internal_encoding') && (ini_get("mbstring.func_overload") & 2)) {
227            $this->_mbenc = mb_internal_encoding();
228            mb_internal_encoding("latin1");
229        }
230    }
231
232    protected function _MBPop(): void {
233        if ($this->_mbenc !== "" && function_exists('mb_internal_encoding')) {
234            mb_internal_encoding($this->_mbenc);
235        }
236    }
237
238    protected function _Connect() {
239        if ($this->_socket !== false) {
240            if (!feof($this->_socket)) return $this->_socket;
241            $this->_socket = false;
242        }
243
244        $errno = 0;
245        $errstr = "";
246        $this->_connerror = false;
247
248        $host = $this->_path ?: $this->_host;
249        $port = $this->_path ? 0 : $this->_port;
250
251        $fp = @fsockopen($host, $port, $errno, $errstr, $this->_timeout > 0 ? $this->_timeout : 5);
252
253        if (!$fp) {
254            $location = $this->_path ?: "{$this->_host}:{$this->_port}";
255            $this->_error = "connection to $location failed (errno=$errno, msg=" . trim($errstr) . ")";
256            $this->_connerror = true;
257            return false;
258        }
259
260        if (!$this->_Send($fp, pack("N", 1), 4)) {
261            fclose($fp);
262            $this->_error = "failed to send client protocol version";
263            return false;
264        }
265
266        $vData = fread($fp, 4);
267        if (strlen($vData) < 4) {
268             fclose($fp);
269             $this->_error = "failed to read searchd version";
270             return false;
271        }
272        $v = unpack("N", $vData)[1];
273        if ($v < 1) {
274            fclose($fp);
275            $this->_error = "expected searchd protocol version 1+, got version '$v'";
276            return false;
277        }
278
279        return $fp;
280    }
281
282    protected function _GetResponse($fp, $client_ver) {
283        $response = "";
284        $header = fread($fp, 8);
285        if (strlen($header) == 8) {
286            $data = unpack("nstatus/nver/Nlen", $header);
287            $status = $data['status'];
288            $ver = $data['ver'];
289            $len = $data['len'];
290            $left = $len;
291            while ($left > 0 && !feof($fp)) {
292                $chunk = fread($fp, min($left, 8192));
293                if ($chunk) {
294                    $response .= $chunk;
295                    $left -= strlen($chunk);
296                }
297            }
298        } else {
299            $status = SEARCHD_ERROR; $ver = 0; $len = 0;
300        }
301
302        if ($this->_socket === false) fclose($fp);
303
304        $read = strlen($response);
305        if (!$response && $len > 0) {
306            $this->_error = "received zero-sized searchd response";
307            return false;
308        }
309
310        if ($status == SEARCHD_WARNING) {
311            $wlen = unpack("N", substr($response, 0, 4))[1];
312            $this->_warning = substr($response, 4, $wlen);
313            return substr($response, 4 + $wlen);
314        }
315        if ($status == SEARCHD_ERROR || $status == SEARCHD_RETRY) {
316            $this->_error = "searchd error: " . substr($response, 4);
317            return false;
318        }
319        if ($status != SEARCHD_OK) {
320            $this->_error = "unknown status code '$status'";
321            return false;
322        }
323
324        if ($ver < $client_ver) {
325            $this->_warning = sprintf("searchd command v.%d.%d older than client, some options might not work", $ver >> 8, $ver & 0xff);
326        }
327
328        return $response;
329    }
330
331    public function SetLimits(int $offset, int $limit, int $max = 0, int $cutoff = 0): void {
332        $this->_offset = $offset;
333        $this->_limit = $limit;
334        if ($max > 0) $this->_maxmatches = $max;
335        if ($cutoff > 0) $this->_cutoff = $cutoff;
336    }
337
338    public function SetMaxQueryTime(int $max): void {
339        $this->_maxquerytime = max(0, $max);
340    }
341
342    public function SetMatchMode(int $mode): void {
343        $this->_mode = $mode;
344    }
345
346    public function SetRankingMode(int $ranker): void {
347        $this->_ranker = $ranker;
348    }
349
350    public function SetSortMode(int $mode, string $sortby = ""): void {
351        $this->_sort = $mode;
352        $this->_sortby = $sortby;
353    }
354
355    public function SetFieldWeights(array $weights): void {
356        $this->_fieldweights = $weights;
357    }
358
359    public function SetIndexWeights(array $weights): void {
360        $this->_indexweights = $weights;
361    }
362
363    public function SetIDRange($min, $max): void {
364        $this->_min_id = $min;
365        $this->_max_id = $max;
366    }
367
368    public function SetFilter(string $attribute, array $values, bool $exclude = false): void {
369        if (!empty($values)) {
370            $this->_filters[] = ["type" => SPH_FILTER_VALUES, "attr" => $attribute, "exclude" => $exclude, "values" => $values];
371        }
372    }
373
374    public function SetFilterRange(string $attribute, $min, $max, bool $exclude = false): void {
375        $this->_filters[] = ["type" => SPH_FILTER_RANGE, "attr" => $attribute, "exclude" => $exclude, "min" => $min, "max" => $max];
376    }
377
378    public function SetFilterFloatRange(string $attribute, float $min, float $max, bool $exclude = false): void {
379        $this->_filters[] = ["type" => SPH_FILTER_FLOATRANGE, "attr" => $attribute, "exclude" => $exclude, "min" => $min, "max" => $max];
380    }
381
382    public function SetGeoAnchor(string $attrlat, string $attrlong, float $lat, float $long): void {
383        $this->_anchor = ["attrlat" => $attrlat, "attrlong" => $attrlong, "lat" => $lat, "long" => $long];
384    }
385
386    public function SetGroupBy(string $attribute, int $func, string $groupsort = "@group desc"): void {
387        $this->_groupby = $attribute;
388        $this->_groupfunc = $func;
389        $this->_groupsort = $groupsort;
390    }
391
392    public function SetGroupDistinct(string $attribute): void {
393        $this->_groupdistinct = $attribute;
394    }
395
396    public function SetRetries(int $count, int $delay = 0): void {
397        $this->_retrycount = $count;
398        $this->_retrydelay = $delay;
399    }
400
401    public function SetArrayResult(bool $arrayresult): void {
402        $this->_arrayresult = $arrayresult;
403    }
404
405    public function SetOverride(string $attrname, int $attrtype, array $values): void {
406        $this->_overrides[$attrname] = ["attr" => $attrname, "type" => $attrtype, "values" => $values];
407    }
408
409    public function SetSelect(string $select): void {
410        $this->_select = $select;
411    }
412
413    public function ResetFilters(): void {
414        $this->_filters = [];
415        $this->_anchor = [];
416    }
417
418    public function ResetGroupBy(): void {
419        $this->_groupby = "";
420        $this->_groupfunc = SPH_GROUPBY_DAY;
421        $this->_groupsort = "@group desc";
422        $this->_groupdistinct = "";
423    }
424
425    public function ResetOverrides(): void {
426        $this->_overrides = [];
427    }
428
429    public function Query(string $query, string $index = "*", string $comment = "") {
430        $this->_reqs = [];
431        $this->AddQuery($query, $index, $comment);
432        $results = $this->RunQueries();
433        $this->_reqs = [];
434
435        if (!is_array($results) || !isset($results[0])) return false;
436
437        $this->_error = $results[0]["error"];
438        $this->_warning = $results[0]["warning"];
439        return ($results[0]["status"] == SEARCHD_ERROR) ? false : $results[0];
440    }
441
442    protected function _PackFloat($f): string {
443        return pack("N", unpack("L", pack("f", (float)$f))[1]);
444    }
445
446    public function AddQuery(string $query, string $index = "*", string $comment = ""): int {
447        $this->_MBPush();
448
449        $req = pack("NNNNN", $this->_offset, $this->_limit, $this->_mode, $this->_ranker, $this->_sort);
450        $req .= pack("N", strlen($this->_sortby)) . $this->_sortby;
451        $req .= pack("N", strlen($query)) . $query;
452        $req .= pack("N", count($this->_weights));
453        foreach ($this->_weights as $weight) $req .= pack("N", (int)$weight);
454        $req .= pack("N", strlen($index)) . $index;
455        $req .= pack("N", 1);
456        $req .= sphPackU64($this->_min_id) . sphPackU64($this->_max_id);
457
458        $req .= pack("N", count($this->_filters));
459        foreach ($this->_filters as $filter) {
460            $req .= pack("N", strlen($filter["attr"])) . $filter["attr"];
461            $req .= pack("N", $filter["type"]);
462            switch ($filter["type"]) {
463                case SPH_FILTER_VALUES:
464                    $req .= pack("N", count($filter["values"]));
465                    foreach ($filter["values"] as $value) $req .= sphPackI64($value);
466                    break;
467                case SPH_FILTER_RANGE:
468                    $req .= sphPackI64($filter["min"]) . sphPackI64($filter["max"]);
469                    break;
470                case SPH_FILTER_FLOATRANGE:
471                    $req .= $this->_PackFloat($filter["min"]) . $this->_PackFloat($filter["max"]);
472                    break;
473            }
474            $req .= pack("N", (int)$filter["exclude"]);
475        }
476
477        $req .= pack("NN", $this->_groupfunc, strlen($this->_groupby)) . $this->_groupby;
478        $req .= pack("N", $this->_maxmatches);
479        $req .= pack("N", strlen($this->_groupsort)) . $this->_groupsort;
480        $req .= pack("NNN", $this->_cutoff, $this->_retrycount, $this->_retrydelay);
481        $req .= pack("N", strlen($this->_groupdistinct)) . $this->_groupdistinct;
482
483        if (empty($this->_anchor)) {
484            $req .= pack("N", 0);
485        } else {
486            $req .= pack("N", 1);
487            $req .= pack("N", strlen($this->_anchor["attrlat"])) . $this->_anchor["attrlat"];
488            $req .= pack("N", strlen($this->_anchor["attrlong"])) . $this->_anchor["attrlong"];
489            $req .= $this->_PackFloat($this->_anchor["lat"]) . $this->_PackFloat($this->_anchor["long"]);
490        }
491
492        $req .= pack("N", count($this->_indexweights));
493        foreach ($this->_indexweights as $idx => $weight) $req .= pack("N", strlen($idx)) . $idx . pack("N", (int)$weight);
494
495        $req .= pack("N", $this->_maxquerytime);
496
497        $req .= pack("N", count($this->_fieldweights));
498        foreach ($this->_fieldweights as $field => $weight) $req .= pack("N", strlen($field)) . $field . pack("N", (int)$weight);
499
500        $req .= pack("N", strlen($comment)) . $comment;
501
502        $req .= pack("N", count($this->_overrides));
503        foreach ($this->_overrides as $entry) {
504            $req .= pack("N", strlen($entry["attr"])) . $entry["attr"];
505            $req .= pack("NN", $entry["type"], count($entry["values"]));
506            foreach ($entry["values"] as $id => $val) {
507                $req .= sphPackU64($id);
508                switch ($entry["type"]) {
509                    case SPH_ATTR_FLOAT:  $req .= $this->_PackFloat($val); break;
510                    case SPH_ATTR_BIGINT: $req .= sphPackI64($val); break;
511                    default:              $req .= pack("N", (int)$val); break;
512                }
513            }
514        }
515
516        $req .= pack("N", strlen($this->_select)) . $this->_select;
517
518        $this->_MBPop();
519        $this->_reqs[] = $req;
520        return count($this->_reqs) - 1;
521    }
522
523    public function RunQueries() {
524        if (empty($this->_reqs)) {
525            $this->_error = "no queries defined, issue AddQuery() first";
526            return false;
527        }
528
529        $this->_MBPush();
530        if (!($fp = $this->_Connect())) {
531            $this->_MBPop();
532            return false;
533        }
534
535        $nreqs = count($this->_reqs);
536        $req = implode("", $this->_reqs);
537        $len = 4 + strlen($req);
538        $req = pack("nnNN", SEARCHD_COMMAND_SEARCH, VER_COMMAND_SEARCH, $len, $nreqs) . $req;
539
540        if (!$this->_Send($fp, $req, $len + 8) || !($response = $this->_GetResponse($fp, VER_COMMAND_SEARCH))) {
541            $this->_MBPop();
542            return false;
543        }
544
545        $this->_reqs = [];
546        $res = $this->_ParseSearchResponse($response, $nreqs);
547        $this->_MBPop();
548        return $res;
549    }
550
551    protected function _ParseSearchResponse($response, $nreqs): array {
552        $p = 0;
553        $max = strlen($response);
554        $results = [];
555
556        for ($ires = 0; $ires < $nreqs && $p < $max; $ires++) {
557            $result = ["error" => "", "warning" => ""];
558            $status = unpack("N", substr($response, $p, 4))[1]; $p += 4;
559            $result["status"] = $status;
560
561            if ($status != SEARCHD_OK) {
562                $len = unpack("N", substr($response, $p, 4))[1]; $p += 4;
563                $message = substr($response, $p, $len); $p += $len;
564                if ($status == SEARCHD_WARNING) $result["warning"] = $message;
565                else { $result["error"] = $message; $results[] = $result; continue; }
566            }
567
568            $fields = [];
569            $nfields = unpack("N", substr($response, $p, 4))[1]; $p += 4;
570            while ($nfields-- > 0 && $p < $max) {
571                $len = unpack("N", substr($response, $p, 4))[1]; $p += 4;
572                $fields[] = substr($response, $p, $len); $p += $len;
573            }
574            $result["fields"] = $fields;
575
576            $attrs = [];
577            $nattrs = unpack("N", substr($response, $p, 4))[1]; $p += 4;
578            while ($nattrs-- > 0 && $p < $max) {
579                $len = unpack("N", substr($response, $p, 4))[1]; $p += 4;
580                $attr = substr($response, $p, $len); $p += $len;
581                $type = unpack("N", substr($response, $p, 4))[1]; $p += 4;
582                $attrs[$attr] = $type;
583            }
584            $result["attrs"] = $attrs;
585
586            $count = unpack("N", substr($response, $p, 4))[1]; $p += 4;
587            $id64 = unpack("N", substr($response, $p, 4))[1]; $p += 4;
588
589            $result["matches"] = [];
590            while ($count-- > 0 && $p < $max) {
591                if ($id64) {
592                    $doc = sphUnpackU64(substr($response, $p, 8)); $p += 8;
593                    $weight = unpack("N", substr($response, $p, 4))[1]; $p += 4;
594                } else {
595                    $matchData = unpack("Ndoc/Nweight", substr($response, $p, 8)); $p += 8;
596                    $doc = sphFixUint($matchData['doc']);
597                    $weight = $matchData['weight'];
598                }
599
600                $attrvals = [];
601                foreach ($attrs as $attr => $type) {
602                    if ($type == SPH_ATTR_BIGINT) {
603                        $attrvals[$attr] = sphUnpackI64(substr($response, $p, 8)); $p += 8;
604                    } elseif ($type == SPH_ATTR_FLOAT) {
605                        $uval = unpack("N", substr($response, $p, 4))[1]; $p += 4;
606                        $attrvals[$attr] = unpack("f", pack("L", $uval))[1];
607                    } else {
608                        $val = unpack("N", substr($response, $p, 4))[1]; $p += 4;
609                        if ($type & SPH_ATTR_MULTI) {
610                            $vals = [];
611                            $nvals = $val;
612                            while ($nvals-- > 0 && $p < $max) {
613                                $vals[] = sphFixUint(unpack("N", substr($response, $p, 4))[1]);
614                                $p += 4;
615                            }
616                            $attrvals[$attr] = $vals;
617                        } else {
618                            $attrvals[$attr] = sphFixUint($val);
619                        }
620                    }
621                }
622
623                if ($this->_arrayresult) $result["matches"][] = ["id" => $doc, "weight" => $weight, "attrs" => $attrvals];
624                else $result["matches"][$doc] = ["weight" => $weight, "attrs" => $attrvals];
625            }
626
627            $stats = unpack("Ntotal/Nfound/Nmsecs/Nwords", substr($response, $p, 16)); $p += 16;
628            $result["total"] = sprintf("%u", $stats['total']);
629            $result["total_found"] = sprintf("%u", $stats['found']);
630            $result["time"] = sprintf("%.3f", $stats['msecs'] / 1000);
631
632            $nwords = $stats['words'];
633            while ($nwords-- > 0 && $p < $max) {
634                $len = unpack("N", substr($response, $p, 4))[1]; $p += 4;
635                $word = substr($response, $p, $len); $p += $len;
636                $wstats = unpack("Ndocs/Nhits", substr($response, $p, 8)); $p += 8;
637                $result["words"][$word] = ["docs" => sprintf("%u", $wstats['docs']), "hits" => sprintf("%u", $wstats['hits'])];
638            }
639            $results[] = $result;
640        }
641        return $results;
642    }
643
644    public function BuildExcerpts(array $docs, string $index, string $words, array $opts = []) {
645        $this->_MBPush();
646        if (!($fp = $this->_Connect())) { $this->_MBPop(); return false; }
647
648        $opts += [
649            "before_match" => "<b>", "after_match" => "</b>", "chunk_separator" => " ... ",
650            "limit" => 256, "around" => 5, "exact_phrase" => false, "single_passage" => false,
651            "use_boundaries" => false, "weight_order" => false
652        ];
653
654        $flags = 1;
655        if ($opts["exact_phrase"])   $flags |= 2;
656        if ($opts["single_passage"]) $flags |= 4;
657        if ($opts["use_boundaries"]) $flags |= 8;
658        if ($opts["weight_order"])   $flags |= 16;
659
660        $req = pack("NN", 0, $flags);
661        $req .= pack("N", strlen($index)) . $index;
662        $req .= pack("N", strlen($words)) . $words;
663        $req .= pack("N", strlen($opts["before_match"])) . $opts["before_match"];
664        $req .= pack("N", strlen($opts["after_match"])) . $opts["after_match"];
665        $req .= pack("N", strlen($opts["chunk_separator"])) . $opts["chunk_separator"];
666        $req .= pack("N", (int)$opts["limit"]);
667        $req .= pack("N", (int)$opts["around"]);
668        $req .= pack("N", count($docs));
669        foreach ($docs as $doc) $req .= pack("N", strlen($doc)) . $doc;
670
671        $len = strlen($req);
672        $req = pack("nnN", SEARCHD_COMMAND_EXCERPT, VER_COMMAND_EXCERPT, $len) . $req;
673        if (!$this->_Send($fp, $req, $len + 8) || !($response = $this->_GetResponse($fp, VER_COMMAND_EXCERPT))) {
674            $this->_MBPop(); return false;
675        }
676
677        $pos = 0; $res = [];
678        foreach ($docs as $unused) {
679            $len = unpack("N", substr($response, $pos, 4))[1]; $pos += 4;
680            $res[] = substr($response, $pos, $len); $pos += $len;
681        }
682        $this->_MBPop();
683        return $res;
684    }
685
686    public function BuildKeywords(string $query, string $index, bool $hits) {
687        $this->_MBPush();
688        if (!($fp = $this->_Connect())) { $this->_MBPop(); return false; }
689
690        $req = pack("N", strlen($query)) . $query;
691        $req .= pack("N", strlen($index)) . $index;
692        $req .= pack("N", (int)$hits);
693
694        $len = strlen($req);
695        $req = pack("nnN", SEARCHD_COMMAND_KEYWORDS, VER_COMMAND_KEYWORDS, $len) . $req;
696        if (!$this->_Send($fp, $req, $len + 8) || !($response = $this->_GetResponse($fp, VER_COMMAND_KEYWORDS))) {
697            $this->_MBPop(); return false;
698        }
699
700        $pos = 0; $res = [];
701        $nwords = unpack("N", substr($response, $pos, 4))[1]; $pos += 4;
702        for ($i = 0; $i < $nwords; $i++) {
703            $len = unpack("N", substr($response, $pos, 4))[1]; $pos += 4;
704            $tokenized = substr($response, $pos, $len); $pos += $len;
705            $len = unpack("N", substr($response, $pos, 4))[1]; $pos += 4;
706            $normalized = substr($response, $pos, $len); $pos += $len;
707            $entry = ["tokenized" => $tokenized, "normalized" => $normalized];
708            if ($hits) {
709                $wstats = unpack("Ndocs/Nhits", substr($response, $pos, 8)); $pos += 8;
710                $entry["docs"] = $wstats['docs']; $entry["hits"] = $wstats['hits'];
711            }
712            $res[] = $entry;
713        }
714        $this->_MBPop();
715        return $res;
716    }
717
718    public function EscapeString(string $string): string {
719        $chars = ['\\', '(', ')', '|', '-', '!', '@', '~', '"', '&', '/', '^', '$', '='];
720        $replacements = array_map(fn($c) => '\\' . $c, $chars);
721        return str_replace($chars, $replacements, $string);
722    }
723
724    public function UpdateAttributes(string $index, array $attrs, array $values, bool $mva = false): int {
725        $req = pack("N", strlen($index)) . $index;
726        $req .= pack("N", count($attrs));
727        foreach ($attrs as $attr) {
728            $req .= pack("N", strlen($attr)) . $attr;
729            $req .= pack("N", (int)$mva);
730        }
731        $req .= pack("N", count($values));
732        foreach ($values as $id => $entry) {
733            $req .= sphPackU64($id);
734            foreach ($entry as $v) {
735                $req .= pack("N", $mva ? count($v) : (int)$v);
736                if ($mva) foreach ($v as $vv) $req .= pack("N", (int)$vv);
737            }
738        }
739
740        if (!($fp = $this->_Connect())) return -1;
741        $len = strlen($req);
742        $req = pack("nnN", SEARCHD_COMMAND_UPDATE, VER_COMMAND_UPDATE, $len) . $req;
743        if (!$this->_Send($fp, $req, $len + 8) || !($response = $this->_GetResponse($fp, VER_COMMAND_UPDATE))) return -1;
744
745        return unpack("N", substr($response, 0, 4))[1];
746    }
747
748    public function Status() {
749        $this->_MBPush();
750        if (!($fp = $this->_Connect())) { $this->_MBPop(); return false; }
751        $req = pack("nnNN", SEARCHD_COMMAND_STATUS, VER_COMMAND_STATUS, 4, 1);
752        if (!$this->_Send($fp, $req, 12) || !($response = $this->_GetResponse($fp, VER_COMMAND_STATUS))) {
753            $this->_MBPop(); return false;
754        }
755
756        $p = 4;
757        $stats = unpack("Nrows/Ncols", substr($response, $p, 8)); $p += 8;
758        $rows = $stats['rows']; $cols = $stats['cols'];
759        $res = [];
760        for ($i = 0; $i < $rows; $i++) {
761            for ($j = 0; $j < $cols; $j++) {
762                $len = unpack("N", substr($response, $p, 4))[1]; $p += 4;
763                $res[$i][] = substr($response, $p, $len); $p += $len;
764            }
765        }
766        $this->_MBPop();
767        return $res;
768    }
769}
770