root = realpath((string)($root ?: cfg('docsets/path'))) ?: (string)($root ?: cfg('docsets/path')); } public function listDocsets(): array { if(!is_dir($this->root)) return array(); $docsets = array(); foreach(scandir($this->root) ?: array() as $entry) { if($entry === '.' || $entry === '..') continue; if(!str_ends_with($entry, '.docset')) continue; try { $docsets[] = $this->readDocset($entry); } catch(Throwable $e) { Log::text('docsets', 'Skipping '.$entry.': '.$e->getMessage()); } } usort($docsets, fn($a, $b) => strcasecmp($a['name'], $b['name'])); return $docsets; } public function getDocset(string $id): ?array { $id = $this->cleanId($id); if($id === '') return null; $path = $this->root.'/'.$id.'.docset'; if(!is_dir($path)) return null; return $this->readDocset($id.'.docset'); } public function search(string $query, string $docsetId = '', int $limit = 80): array { $query = trim($query); if($query === '') return array(); $docsets = $docsetId !== '' ? array_filter(array($this->getDocset($docsetId))) : $this->listDocsets(); $all = array(); foreach($docsets as $docset) { $all = array_merge($all, $this->searchOne($docset, $query, $limit)); } usort($all, function($a, $b) use ($query) { $qa = strtolower($query); $an = strtolower($a['name']); $bn = strtolower($b['name']); $scoreA = $an === $qa ? 0 : (str_starts_with($an, $qa) ? 1 : 2); $scoreB = $bn === $qa ? 0 : (str_starts_with($bn, $qa) ? 1 : 2); return $scoreA <=> $scoreB ?: strcasecmp($a['name'], $b['name']); }); return array_slice($all, 0, $limit); } private function searchOne(array $docset, string $query, int $limit): array { if(!class_exists('SQLite3')) throw new RuntimeException('PHP SQLite3 extension is not installed.'); $db = new SQLite3($docset['index_path'], SQLITE3_OPEN_READONLY); try { if($this->tableExists($db, 'searchIndex')) $rows = $this->searchDashIndex($db, $query, $limit); elseif($this->tableExists($db, 'ZTOKEN')) $rows = $this->searchCoreDataIndex($db, $query, $limit); else $rows = array(); return array_map(function($row) use ($docset) { $row['path'] = $this->cleanIndexedPath((string)$row['path']); $row['docset'] = $docset['id']; $row['docset_name'] = $docset['name']; $row['href'] = URL::Link('view', array('docset' => $docset['id'], 'path' => $row['path'])); return $row; }, array_filter($rows, fn($row) => !empty($row['path']))); } finally { $db->close(); } } private function searchDashIndex(SQLite3 $db, string $query, int $limit): array { $sql = 'select name, type, path from searchIndex where name like :prefix escape "\\" order by length(name), lower(name) limit :limit'; $stmt = $db->prepare($sql); $stmt->bindValue(':prefix', $this->escapeLike($query).'%', SQLITE3_TEXT); $stmt->bindValue(':limit', max(1, min($limit, 200)), SQLITE3_INTEGER); $rows = $this->fetchRows($stmt); if(count($rows) < $limit) { $sql = 'select name, type, path from searchIndex where name like :contains escape "\\" and name not like :prefix escape "\\" order by lower(name) limit :limit'; $stmt = $db->prepare($sql); $stmt->bindValue(':contains', '%'.$this->escapeLike($query).'%', SQLITE3_TEXT); $stmt->bindValue(':prefix', $this->escapeLike($query).'%', SQLITE3_TEXT); $stmt->bindValue(':limit', max(1, min($limit - count($rows), 200)), SQLITE3_INTEGER); $rows = array_merge($rows, $this->fetchRows($stmt)); } return $rows; } private function searchCoreDataIndex(SQLite3 $db, string $query, int $limit): array { $select = 'select t.ZTOKENNAME as name, coalesce(tt.ZTYPENAME, "") as type, fp.ZPATH as path, coalesce(m.ZANCHOR, "") as anchor from ZTOKEN t left join ZTOKENTYPE tt on tt.Z_PK = t.ZTOKENTYPE left join ZTOKENMETAINFORMATION m on m.ZTOKEN = t.Z_PK left join ZFILEPATH fp on fp.Z_PK = m.ZFILE'; $sql = $select.' where t.ZTOKENNAME like :prefix escape "\\" and fp.ZPATH is not null order by length(t.ZTOKENNAME), lower(t.ZTOKENNAME) limit :limit'; $stmt = $db->prepare($sql); $stmt->bindValue(':prefix', $this->escapeLike($query).'%', SQLITE3_TEXT); $stmt->bindValue(':limit', max(1, min($limit, 200)), SQLITE3_INTEGER); $rows = $this->fetchRows($stmt); if(count($rows) < $limit) { $sql = $select.' where t.ZTOKENNAME like :contains escape "\\" and t.ZTOKENNAME not like :prefix escape "\\" and fp.ZPATH is not null order by lower(t.ZTOKENNAME) limit :limit'; $stmt = $db->prepare($sql); $stmt->bindValue(':contains', '%'.$this->escapeLike($query).'%', SQLITE3_TEXT); $stmt->bindValue(':prefix', $this->escapeLike($query).'%', SQLITE3_TEXT); $stmt->bindValue(':limit', max(1, min($limit - count($rows), 200)), SQLITE3_INTEGER); $rows = array_merge($rows, $this->fetchRows($stmt)); } foreach($rows as &$row) { if(!empty($row['anchor'])) $row['path'] .= '#'.rawurldecode((string)$row['anchor']); } return $rows; } private function fetchRows(SQLite3Stmt $stmt): array { $result = $stmt->execute(); $rows = array(); if($result) { while($row = $result->fetchArray(SQLITE3_ASSOC)) $rows[] = $row; $result->finalize(); } $stmt->close(); return $rows; } public function resolveDocumentPath(string $docsetId, string $relativePath): ?array { $docset = $this->getDocset($docsetId); if(!$docset) return null; $relativePath = ltrim(str_replace('\\', '/', $relativePath), '/'); $anchorParts = explode('#', $relativePath, 2); $queryParts = explode('?', $anchorParts[0], 2); $filePath = $queryParts[0]; $anchor = $anchorParts[1] ?? ''; $real = realpath($docset['documents_path'].'/'.$filePath); $documentsRoot = realpath($docset['documents_path']); if(!$real || !$documentsRoot || !$this->isWithin($real, $documentsRoot) || !is_file($real)) return null; return array('docset' => $docset, 'file' => $real, 'relative' => $filePath, 'anchor' => $anchor); } public function assetUrl(string $docsetId, string $relativePath): string { return URL::Link('asset', array('docset' => $docsetId, 'path' => ltrim($relativePath, '/'))); } private function readDocset(string $entry): array { $id = substr($entry, 0, -strlen('.docset')); $base = $this->root.'/'.$entry; $resources = $base.'/Contents/Resources'; $index = $resources.'/docSet.dsidx'; $documents = $resources.'/Documents'; if(!is_file($index)) throw new RuntimeException('missing docSet.dsidx'); if(!is_dir($documents)) throw new RuntimeException('missing Documents directory'); $plist = $this->readInfoPlist($base.'/Contents/Info.plist'); $count = $this->countEntries($index); return array( 'id' => $id, 'name' => $plist['CFBundleName'] ?? $id, 'family' => $plist['DocSetPlatformFamily'] ?? '', 'index_file' => $plist['dashIndexFilePath'] ?? 'index.html', 'path' => $base, 'index_path' => $index, 'documents_path' => $documents, 'entry_count' => $count, ); } private function countEntries(string $index): int { if(!class_exists('SQLite3')) return 0; $db = new SQLite3($index, SQLITE3_OPEN_READONLY); try { if($this->tableExists($db, 'searchIndex')) return (int)$db->querySingle('select count(*) from searchIndex'); if($this->tableExists($db, 'ZTOKEN')) return (int)$db->querySingle('select count(*) from ZTOKEN'); return 0; } catch(Throwable $e) { return 0; } finally { $db->close(); } } private function readInfoPlist(string $path): array { if(!is_file($path)) return array(); $xml = @simplexml_load_file($path); if(!$xml || !isset($xml->dict)) return array(); $dict = array(); $key = null; foreach($xml->dict->children() as $node) { if($node->getName() === 'key') { $key = (string)$node; continue; } if($key !== null) { $dict[$key] = (string)$node; $key = null; } } return $dict; } private function tableExists(SQLite3 $db, string $table): bool { $stmt = $db->prepare('select 1 from sqlite_master where type = "table" and name = :name limit 1'); $stmt->bindValue(':name', $table, SQLITE3_TEXT); $result = $stmt->execute(); $exists = $result ? (bool)$result->fetchArray(SQLITE3_NUM) : false; if($result) $result->finalize(); $stmt->close(); return $exists; } private function cleanIndexedPath(string $path): string { $path = trim($path); while(str_starts_with($path, '<')) { $end = strpos($path, '>'); if($end === false) break; $path = substr($path, $end + 1); } return $path; } private function cleanId(string $id): string { $id = trim($id); $out = ''; foreach(str_split($id) as $char) { if(ctype_alnum($char) || $char === '-' || $char === '_' || $char === '.' || $char === '+') $out .= $char; } return trim($out, '.'); } private function escapeLike(string $value): string { $value = str_replace('\\', '\\\\', $value); $value = str_replace('%', '\\%', $value); return str_replace('_', '\\_', $value); } private function isWithin(string $path, string $root): bool { $root = rtrim($root, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; return str_starts_with($path, $root) || $path === rtrim($root, DIRECTORY_SEPARATOR); } }