Files
docset-viewer/lib/docset_repository.php
T
2026-06-21 20:29:30 +00:00

234 lines
6.5 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
{
$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 array_map(function($row) use ($docset) {
$row['docset'] = $docset['id'];
$row['docset_name'] = $docset['name'];
$row['href'] = URL::Link('view', array('docset' => $docset['id'], 'path' => $row['path']));
return $row;
}, $rows);
}
finally
{
$db->close();
}
}
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
{
$result = $db->querySingle('select count(*) from searchIndex');
return (int)$result;
}
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 cleanId(string $id): string
{
$id = trim($id);
$out = '';
foreach(str_split($id) as $char)
{
if(ctype_alnum($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);
}
}