Private
Public Access
299 lines
9.2 KiB
PHP
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);
|
|
}
|
|
}
|