Files
docset-viewer/lib/docset_repository.php
2026-06-21 21:09:04 +00:00

299 lines
9.2 KiB
PHP

<?php
class DocsetRepository
{
private string $root;
public function __construct(?string $root = null)
{
$this->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);
}
}