diff --git a/Output.php b/Output.php
index 6ce7cde..3f9351d 100644
--- a/Output.php
+++ b/Output.php
@@ -54,23 +54,74 @@ class Output
return;
}
+ $thumbW = 150;
+ $thumbH = 150;
+
+ $placeholderUrl = DOKU_BASE . 'lib/images/blank.gif';
+ $syntax = plugin_load('syntax', 'luxtools');
+ $placeholderId = $syntax ? trim((string)$syntax->getConf('thumb_placeholder')) : '';
+ if ($placeholderId !== '' && function_exists('ml')) {
+ // ml() builds a fetch.php URL for a MediaManager item
+ $placeholderUrl = ml($placeholderId, ['w' => $thumbW, 'h' => $thumbH], true, '&');
+ }
+
/** @var \Doku_Renderer_xhtml $renderer */
$renderer = $this->renderer;
$renderer->doc .= '
';
}
+ /**
+ * Append query parameters to a URL.
+ *
+ * Preserves existing query and fragment and uses RFC3986 encoding.
+ *
+ * @param string $url
+ * @param array $params
+ * @return string
+ */
+ protected function withQueryParams(string $url, array $params): string
+ {
+ if ($params === []) return $url;
+
+ $fragment = '';
+ $hashPos = strpos($url, '#');
+ if ($hashPos !== false) {
+ $fragment = substr($url, $hashPos);
+ $url = substr($url, 0, $hashPos);
+ }
+
+ $glue = (strpos($url, '?') === false) ? '?' : '&';
+ return $url . $glue . http_build_query($params, '', '&', PHP_QUERY_RFC3986) . $fragment;
+ }
+
/**
* Renders the files as a table, including details if configured that way.
*
diff --git a/_test/SyntaxTest.php b/_test/SyntaxTest.php
index 2481aed..4c387d3 100644
--- a/_test/SyntaxTest.php
+++ b/_test/SyntaxTest.php
@@ -239,6 +239,9 @@ class plugin_luxtools_test extends DokuWikiTest
$this->structureCheck($doc, $structure);
$this->assertStringContainsString('exampleimage.png', $xhtml);
+ $this->assertStringContainsString('data-thumb-src=', $xhtml);
+ $this->assertStringContainsString('width="150"', $xhtml);
+ $this->assertStringContainsString('height="150"', $xhtml);
}
/**
diff --git a/conf/default.php b/conf/default.php
index feb1322..826614c 100644
--- a/conf/default.php
+++ b/conf/default.php
@@ -9,6 +9,9 @@ $conf['allow_in_comments'] = 0;
$conf['defaults'] = '';
$conf['extensions'] = '';
+// MediaManager ID for gallery thumbnail placeholder
+$conf['thumb_placeholder'] = ':wiki:thumb-placeholder.png';
+
// Local opener service used by {{open>...}}.
$conf['open_service_url'] = 'http://127.0.0.1:8765';
$conf['open_service_token'] = '';
diff --git a/conf/metadata.php b/conf/metadata.php
index ec3812c..479d907 100644
--- a/conf/metadata.php
+++ b/conf/metadata.php
@@ -12,5 +12,7 @@ $meta['allow_in_comments'] = array('onoff');
$meta['defaults'] = array('string');
$meta['extensions'] = array('string');
+$meta['thumb_placeholder'] = array('string');
+
$meta['open_service_url'] = array('string');
$meta['open_service_token'] = array('string');
diff --git a/file.php b/file.php
index 04b3089..f680c67 100644
--- a/file.php
+++ b/file.php
@@ -17,6 +17,172 @@ if (!$syntax) die('plugin disabled?');
$pathUtil = new Path($syntax->getConf('paths'));
$path = $INPUT->str('root') . $INPUT->str('file');
+/**
+ * Send a file to the client with basic caching headers.
+ *
+ * @param string $path
+ * @param string $mime
+ * @param bool $download
+ * @param string|null $downloadName
+ * @param string|null $etag
+ * @param int|null $mtime
+ * @param int|null $maxAge
+ * @return void
+ */
+function luxtools_sendfile($path, $mime, $download = false, $downloadName = null, $etag = null, $mtime = null, $maxAge = null)
+{
+ header('Content-Type: ' . $mime);
+ header('X-Content-Type-Options: nosniff');
+
+ if ($download) {
+ $downloadName = $downloadName ?: basename($path);
+ header('Content-Disposition: attachment; filename="' . $downloadName . '"');
+ }
+
+ if ($etag !== null) {
+ header('ETag: "' . $etag . '"');
+ }
+ if ($mtime !== null) {
+ header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
+ }
+
+ if ($maxAge !== null) {
+ header('Cache-Control: public, max-age=' . (int)$maxAge . ', immutable');
+ }
+
+ // Conditional request handling
+ if ($etag !== null && isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
+ if (trim($_SERVER['HTTP_IF_NONE_MATCH']) === '"' . $etag . '"') {
+ http_status(304);
+ exit;
+ }
+ }
+ if ($mtime !== null && isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
+ $ims = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);
+ if ($ims !== false && $ims >= $mtime) {
+ http_status(304);
+ exit;
+ }
+ }
+
+ http_sendfile($path);
+ readfile($path);
+ exit;
+}
+
+/**
+ * Create a thumbnail file using GD.
+ *
+ * @param string $src
+ * @param string $dst
+ * @param int $maxW
+ * @param int $maxH
+ * @param string $dstFormat 'jpg' or 'png'
+ * @param int $quality JPEG quality 0-100
+ * @return bool
+ */
+function luxtools_create_thumb_gd($src, $dst, $maxW, $maxH, $dstFormat, $quality)
+{
+ if (!function_exists('imagecreatetruecolor')) return false;
+ if (!is_readable($src)) return false;
+
+ $info = @getimagesize($src);
+ if (!is_array($info) || empty($info[0]) || empty($info[1]) || empty($info['mime'])) return false;
+
+ $srcW = (int)$info[0];
+ $srcH = (int)$info[1];
+ $srcMime = (string)$info['mime'];
+ if ($srcW <= 0 || $srcH <= 0) return false;
+
+ $maxW = max(1, (int)$maxW);
+ $maxH = max(1, (int)$maxH);
+
+ $scale = min($maxW / $srcW, $maxH / $srcH, 1);
+ $dstW = max(1, (int)floor($srcW * $scale));
+ $dstH = max(1, (int)floor($srcH * $scale));
+
+ switch ($srcMime) {
+ case 'image/jpeg':
+ if (!function_exists('imagecreatefromjpeg')) return false;
+ $srcImg = @imagecreatefromjpeg($src);
+ break;
+ case 'image/png':
+ if (!function_exists('imagecreatefrompng')) return false;
+ $srcImg = @imagecreatefrompng($src);
+ break;
+ case 'image/gif':
+ if (!function_exists('imagecreatefromgif')) return false;
+ $srcImg = @imagecreatefromgif($src);
+ break;
+ case 'image/webp':
+ if (!function_exists('imagecreatefromwebp')) return false;
+ $srcImg = @imagecreatefromwebp($src);
+ break;
+ default:
+ return false;
+ }
+
+ if (!$srcImg) return false;
+
+ $dstImg = imagecreatetruecolor($dstW, $dstH);
+ if (!$dstImg) {
+ imagedestroy($srcImg);
+ return false;
+ }
+
+ // Preserve transparency for formats that support it
+ if ($dstFormat === 'png') {
+ imagealphablending($dstImg, false);
+ imagesavealpha($dstImg, true);
+ $transparent = imagecolorallocatealpha($dstImg, 0, 0, 0, 127);
+ imagefilledrectangle($dstImg, 0, 0, $dstW, $dstH, $transparent);
+ }
+
+ $ok = imagecopyresampled($dstImg, $srcImg, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH);
+ imagedestroy($srcImg);
+
+ if (!$ok) {
+ imagedestroy($dstImg);
+ return false;
+ }
+
+ // Write to a temporary file then rename atomically
+ $tmp = $dst . '.tmp.' . getmypid();
+ @io_mkdir_p(dirname($dst));
+
+ $written = false;
+ if ($dstFormat === 'png') {
+ if (!function_exists('imagepng')) {
+ $written = false;
+ } else {
+ // compression: 0 (none) .. 9 (max). Use a reasonable default.
+ $written = @imagepng($dstImg, $tmp, 6);
+ }
+ } else {
+ if (!function_exists('imagejpeg')) {
+ $written = false;
+ } else {
+ $q = max(0, min(100, (int)$quality));
+ $written = @imagejpeg($dstImg, $tmp, $q);
+ }
+ }
+ imagedestroy($dstImg);
+
+ if (!$written || !is_file($tmp)) {
+ @unlink($tmp);
+ return false;
+ }
+
+ // Best-effort atomic move
+ if (!@rename($tmp, $dst)) {
+ // fallback copy
+ $ok = @copy($tmp, $dst);
+ @unlink($tmp);
+ if (!$ok) return false;
+ }
+ return true;
+}
+
try {
$pathInfo = $pathUtil->getPathInfo($path, false);
if ($pathUtil::isWikiControlled($pathInfo['path'])) {
@@ -30,13 +196,47 @@ try {
exit;
}
[$ext, $mime, $download] = mimetype($pathInfo['path'], false);
- $basename = basename($pathInfo['path']);
- header('Content-Type: ' . $mime);
- if ($download) {
- header('Content-Disposition: attachment; filename="' . $basename . '"');
+
+ // Optional thumbnail mode: ?thumb=1&w=150&h=150
+ $thumb = (int)$INPUT->int('thumb');
+ $w = (int)$INPUT->int('w');
+ $h = (int)$INPUT->int('h');
+ $q = (int)$INPUT->int('q');
+ if ($q <= 0) $q = 80;
+
+ $isImage = is_string($mime) && str_starts_with($mime, 'image/');
+ $wantThumb = $thumb === 1 && $isImage && ($w > 0 || $h > 0);
+
+ if ($wantThumb) {
+ if ($w <= 0) $w = $h;
+ if ($h <= 0) $h = $w;
+
+ global $conf;
+ $srcMtime = @filemtime($pathInfo['path']) ?: time();
+
+ // Decide output format (prefer PNG when transparency is likely)
+ $dstFormat = ($mime === 'image/png' || $mime === 'image/gif') ? 'png' : 'jpg';
+ $dstMime = ($dstFormat === 'png') ? 'image/png' : 'image/jpeg';
+ $hash = sha1($pathInfo['path'] . '|' . $srcMtime . '|w=' . $w . '|h=' . $h . '|q=' . $q . '|f=' . $dstFormat);
+ $sub = substr($hash, 0, 2);
+ $cacheDir = rtrim($conf['cachedir'], '/');
+ $thumbPath = $cacheDir . '/luxtools/thumbs/' . $sub . '/' . $hash . '.' . $dstFormat;
+
+ if (!is_file($thumbPath)) {
+ $ok = luxtools_create_thumb_gd($pathInfo['path'], $thumbPath, $w, $h, $dstFormat, $q);
+ if (!$ok || !is_file($thumbPath)) {
+ // Fallback: serve original if we cannot thumbnail
+ luxtools_sendfile($pathInfo['path'], $mime, $download, basename($pathInfo['path']), null, $srcMtime, 3600);
+ }
+ }
+
+ // Cached thumbs are immutable because filename includes mtime
+ luxtools_sendfile($thumbPath, $dstMime, false, null, $hash, @filemtime($thumbPath) ?: $srcMtime, 31536000);
}
- http_sendfile($pathInfo['path']);
- readfile($pathInfo['path']);
+
+ // Default: serve original file
+ $basename = basename($pathInfo['path']);
+ luxtools_sendfile($pathInfo['path'], $mime, $download, $basename, null, @filemtime($pathInfo['path']) ?: null, 3600);
} catch (Exception $e) {
header('Content-Type: text/plain');
http_status(403);
diff --git a/lang/de/settings.php b/lang/de/settings.php
index 5629d41..c05e006 100644
--- a/lang/de/settings.php
+++ b/lang/de/settings.php
@@ -2,5 +2,7 @@
$lang['allow_in_comments'] = 'Files-Syntax in Kommentaren erlauben.';
+$lang['thumb_placeholder'] = 'MediaManager-ID für den Platzhalter der Galerie-Thumbnails';
+
$lang['open_service_url'] = 'URL des lokalen Öffner-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).';
$lang['open_service_token'] = 'Token für den lokalen Öffner-Dienst (X-Filetools-Token).';
diff --git a/lang/en/settings.php b/lang/en/settings.php
index 3c261be..52398d7 100644
--- a/lang/en/settings.php
+++ b/lang/en/settings.php
@@ -4,5 +4,7 @@ $lang['allow_in_comments'] = 'Whether to allow the files syntax to be used in co
$lang['defaults'] = 'Default options. Use the same syntax as in inline configuration';
$lang['extensions'] = 'Comma-separated list of allowed file extensions to list';
+$lang['thumb_placeholder'] = 'MediaManager ID for the gallery thumbnail placeholder';
+
$lang['open_service_url'] = 'Local opener service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).';
$lang['open_service_token'] = 'Token sent to the local opener service (X-Filetools-Token).';
diff --git a/lang/nl/settings.php b/lang/nl/settings.php
index cda7a0a..480c08d 100644
--- a/lang/nl/settings.php
+++ b/lang/nl/settings.php
@@ -8,5 +8,7 @@ $lang['allow_in_comments'] = 'Of de files syntax toegestaan is voor gebruik in c
$lang['defaults'] = 'Default options. Gebruik dezelfde syntax als de inline configuratie.';
$lang['extensions'] = 'Komma-gescheiden lijst van toegestane bestandsextensies voor de lijst.';
+$lang['thumb_placeholder'] = 'MediaManager-ID voor de placeholder van galerij thumbnails';
+
$lang['open_service_url'] = 'Lokale opener service-URL voor de {{open>...}} knop (bijv. http://127.0.0.1:8765).';
$lang['open_service_token'] = 'Token dat naar de lokale opener service wordt gestuurd (X-Filetools-Token).';
diff --git a/script.js b/script.js
index 6d9310d..ebafb4c 100644
--- a/script.js
+++ b/script.js
@@ -3,6 +3,46 @@
(function () {
'use strict';
+ function initGalleryThumbs() {
+ var imgs = document.querySelectorAll('div.filetools-gallery img[data-thumb-src]');
+ if (!imgs || !imgs.length) return;
+
+ function loadThumb(img) {
+ var src = img.getAttribute('data-thumb-src') || '';
+ if (!src) return;
+ if (img.getAttribute('data-thumb-loading') === '1') return;
+ img.setAttribute('data-thumb-loading', '1');
+
+ var pre = new window.Image();
+ pre.onload = function () {
+ img.src = src;
+ img.removeAttribute('data-thumb-src');
+ img.removeAttribute('data-thumb-loading');
+ };
+ pre.onerror = function () {
+ img.removeAttribute('data-thumb-loading');
+ };
+ pre.src = src;
+ }
+
+ if ('IntersectionObserver' in window) {
+ var io = new window.IntersectionObserver(function (entries) {
+ entries.forEach(function (entry) {
+ if (!entry.isIntersecting) return;
+ loadThumb(entry.target);
+ io.unobserve(entry.target);
+ });
+ }, { rootMargin: '200px' });
+
+ imgs.forEach(function (img) { io.observe(img); });
+ } else {
+ // Fallback: load soon after initial render
+ window.setTimeout(function () {
+ imgs.forEach(loadThumb);
+ }, 0);
+ }
+ }
+
function getServiceUrl(el) {
var url = el.getAttribute('data-service-url') || '';
url = (url || '').trim();
@@ -120,4 +160,5 @@
}
document.addEventListener('click', onClick, false);
+ document.addEventListener('DOMContentLoaded', initGalleryThumbs, false);
})();