V1
This commit is contained in:
296
src/PageLink.php
Normal file
296
src/PageLink.php
Normal file
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\plugin\luxtools;
|
||||
|
||||
class PageLink
|
||||
{
|
||||
public const META_KEY = 'pagelink';
|
||||
public const CACHE_FILE = 'pagelink_cache.json';
|
||||
|
||||
/** @var string */
|
||||
protected $pathConfig;
|
||||
|
||||
/** @var int */
|
||||
protected $maxDepth;
|
||||
|
||||
/** @var array|null */
|
||||
protected $cache = null;
|
||||
|
||||
/** @var bool */
|
||||
protected $cacheDirty = false;
|
||||
|
||||
public function __construct(string $pathConfig, int $maxDepth)
|
||||
{
|
||||
$this->pathConfig = $pathConfig;
|
||||
$this->maxDepth = max(0, $maxDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a v4 UUID (lowercase).
|
||||
*/
|
||||
public static function createUuidV4(): string
|
||||
{
|
||||
$bytes = random_bytes(16);
|
||||
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
||||
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
||||
$hex = bin2hex($bytes);
|
||||
return sprintf(
|
||||
'%s-%s-%s-%s-%s',
|
||||
substr($hex, 0, 8),
|
||||
substr($hex, 8, 4),
|
||||
substr($hex, 12, 4),
|
||||
substr($hex, 16, 4),
|
||||
substr($hex, 20, 12)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and validate a UUID v4 string.
|
||||
*
|
||||
* @param string $uuid
|
||||
* @return string|null
|
||||
*/
|
||||
public static function normalizeUuid(string $uuid): ?string
|
||||
{
|
||||
$uuid = strtolower(trim($uuid));
|
||||
if ($uuid === '') return null;
|
||||
|
||||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uuid)) {
|
||||
return null;
|
||||
}
|
||||
return $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the page's pagelink UUID from metadata (if valid).
|
||||
*/
|
||||
public function getPageUuid(string $pageId): ?string
|
||||
{
|
||||
if ($pageId === '') return null;
|
||||
if (!function_exists('p_get_metadata')) return null;
|
||||
|
||||
$value = p_get_metadata($pageId, self::META_KEY, METADATA_DONT_RENDER);
|
||||
if (!is_string($value)) return null;
|
||||
return self::normalizeUuid($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a pagelink UUID in page metadata.
|
||||
*/
|
||||
public function setPageUuid(string $pageId, string $uuid): bool
|
||||
{
|
||||
if ($pageId === '') return false;
|
||||
if (!function_exists('p_set_metadata')) return false;
|
||||
$uuid = self::normalizeUuid($uuid);
|
||||
if ($uuid === null) return false;
|
||||
|
||||
return (bool)p_set_metadata($pageId, [self::META_KEY => $uuid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pagelink UUID to a linked folder (if any).
|
||||
*
|
||||
* @param string $uuid
|
||||
* @return array{folder: string|null, multiple: bool}
|
||||
*/
|
||||
public function resolveUuid(string $uuid): array
|
||||
{
|
||||
$uuid = self::normalizeUuid($uuid);
|
||||
if ($uuid === null) {
|
||||
return ['folder' => null, 'multiple' => false];
|
||||
}
|
||||
|
||||
$cache = $this->loadCache();
|
||||
if (isset($cache[$uuid]) && is_string($cache[$uuid]) && $cache[$uuid] !== '') {
|
||||
$cachedPath = $cache[$uuid];
|
||||
if ($this->isValidLink($cachedPath, $uuid)) {
|
||||
return ['folder' => $cachedPath, 'multiple' => false];
|
||||
}
|
||||
|
||||
unset($cache[$uuid]);
|
||||
$this->cacheDirty = true;
|
||||
}
|
||||
|
||||
$matches = $this->scanRootsForUuid($uuid, 2);
|
||||
if ($matches !== []) {
|
||||
$cache[$uuid] = $matches[0];
|
||||
$this->cacheDirty = true;
|
||||
}
|
||||
|
||||
if ($this->cacheDirty) {
|
||||
$this->writeCache($cache);
|
||||
}
|
||||
|
||||
return [
|
||||
'folder' => $matches[0] ?? null,
|
||||
'multiple' => count($matches) > 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the cache file into memory.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function loadCache(): array
|
||||
{
|
||||
if ($this->cache !== null) return $this->cache;
|
||||
|
||||
$this->cache = [];
|
||||
$file = $this->getCacheFile();
|
||||
if (!is_file($file) || !is_readable($file)) return $this->cache;
|
||||
|
||||
$raw = @file_get_contents($file);
|
||||
if (!is_string($raw) || $raw === '') return $this->cache;
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) return $this->cache;
|
||||
|
||||
$this->cache = $decoded;
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write cache to disk atomically.
|
||||
*/
|
||||
protected function writeCache(array $cache): void
|
||||
{
|
||||
$file = $this->getCacheFile();
|
||||
$dir = dirname($file);
|
||||
if (function_exists('io_mkdir_p')) {
|
||||
io_mkdir_p($dir);
|
||||
} elseif (!@is_dir($dir)) {
|
||||
@mkdir($dir, 0777, true);
|
||||
}
|
||||
|
||||
$tmp = $file . '.tmp.' . getmypid();
|
||||
$data = json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($data === false) return;
|
||||
|
||||
@file_put_contents($tmp, $data, LOCK_EX);
|
||||
if (!@rename($tmp, $file)) {
|
||||
@copy($tmp, $file);
|
||||
@unlink($tmp);
|
||||
}
|
||||
|
||||
$this->cache = $cache;
|
||||
$this->cacheDirty = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache file path for pagelink mappings.
|
||||
*/
|
||||
protected function getCacheFile(): string
|
||||
{
|
||||
global $conf;
|
||||
$cacheDir = rtrim((string)$conf['cachedir'], '/');
|
||||
return $cacheDir . '/luxtools/' . self::CACHE_FILE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the cached path still points to a valid .pagelink file.
|
||||
*/
|
||||
protected function isValidLink(string $folder, string $uuid): bool
|
||||
{
|
||||
if ($folder === '') return false;
|
||||
if (!is_dir($folder)) return false;
|
||||
if (is_link($folder)) return false;
|
||||
|
||||
$file = rtrim($folder, '/\\') . '/.pagelink';
|
||||
if (!is_file($file) || is_link($file) || !is_readable($file)) return false;
|
||||
|
||||
$content = @file_get_contents($file);
|
||||
if (!is_string($content)) return false;
|
||||
|
||||
$content = self::normalizeUuid($content);
|
||||
return $content !== null && $content === $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan configured roots for matching .pagelink files.
|
||||
*
|
||||
* @param string $uuid
|
||||
* @param int $limit Maximum number of matches to collect.
|
||||
* @return string[]
|
||||
*/
|
||||
protected function scanRootsForUuid(string $uuid, int $limit = 2): array
|
||||
{
|
||||
$roots = $this->getConfiguredRoots();
|
||||
if ($roots === []) return [];
|
||||
|
||||
$matches = [];
|
||||
foreach ($roots as $root) {
|
||||
$this->scanDirectory($root, 0, $uuid, $limit, $matches);
|
||||
if (count($matches) >= $limit) break;
|
||||
}
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan a directory for .pagelink files.
|
||||
*
|
||||
* @param string $dir
|
||||
* @param int $depth
|
||||
* @param string $uuid
|
||||
* @param int $limit
|
||||
* @param array $matches
|
||||
*/
|
||||
protected function scanDirectory(string $dir, int $depth, string $uuid, int $limit, array &$matches): void
|
||||
{
|
||||
if ($dir === '' || count($matches) >= $limit) return;
|
||||
if (!is_dir($dir) || is_link($dir)) return;
|
||||
if (!is_readable($dir)) return;
|
||||
if ($depth > $this->maxDepth) return;
|
||||
|
||||
$file = rtrim($dir, '/\\') . '/.pagelink';
|
||||
if (is_file($file) && !is_link($file) && is_readable($file)) {
|
||||
$content = @file_get_contents($file);
|
||||
if (is_string($content)) {
|
||||
$content = self::normalizeUuid($content);
|
||||
if ($content !== null && $content === $uuid) {
|
||||
$matches[] = rtrim($dir, '/\\');
|
||||
if (count($matches) >= $limit) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($depth >= $this->maxDepth) return;
|
||||
|
||||
$handle = @opendir($dir);
|
||||
if ($handle === false) return;
|
||||
|
||||
$base = rtrim($dir, '/\\');
|
||||
while (($entry = readdir($handle)) !== false) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
if ($entry === '.pagelink') continue;
|
||||
|
||||
$path = $base . '/' . $entry;
|
||||
if (!is_dir($path) || is_link($path)) continue;
|
||||
|
||||
$this->scanDirectory($path, $depth + 1, $uuid, $limit, $matches);
|
||||
if (count($matches) >= $limit) break;
|
||||
}
|
||||
|
||||
closedir($handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve configured root paths (excluding aliases).
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
protected function getConfiguredRoots(): array
|
||||
{
|
||||
$pathConfig = trim($this->pathConfig);
|
||||
if ($pathConfig === '') return [];
|
||||
|
||||
$helper = new Path($pathConfig);
|
||||
$paths = $helper->getPaths();
|
||||
$roots = [];
|
||||
foreach ($paths as $key => $info) {
|
||||
if (!isset($info['root']) || $key !== $info['root']) continue;
|
||||
$roots[] = $info['root'];
|
||||
}
|
||||
return $roots;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user