diff --git a/build-plugin.sh b/build-plugin.sh index 2924131..60228e6 100755 --- a/build-plugin.sh +++ b/build-plugin.sh @@ -2,7 +2,7 @@ set -eu PLUGIN="u-navigator" -VERSION="2026.06.23.r030" +VERSION="2026.06.23.r031" PKG_DIR="packages" WORK_DIR=".plugin-build" PKG_NAME="${PLUGIN}-${VERSION}.tgz" diff --git a/packages/u-navigator-2026.06.23.r030.tgz b/packages/u-navigator-2026.06.23.r030.tgz deleted file mode 100644 index 5bbc173..0000000 Binary files a/packages/u-navigator-2026.06.23.r030.tgz and /dev/null differ diff --git a/packages/u-navigator-2026.06.23.r031.tgz b/packages/u-navigator-2026.06.23.r031.tgz new file mode 100644 index 0000000..341071b Binary files /dev/null and b/packages/u-navigator-2026.06.23.r031.tgz differ diff --git a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/preview.php b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/preview.php index 193affd..1e8e10a 100644 --- a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/preview.php +++ b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/preview.php @@ -1,16 +1,17 @@ UNAV_PREVIEW_MAX_BYTES) { - unav_error(413, 'Preview file is larger than 1 MB'); + +$mime = unav_preview_mime($extension, $type); +if (($_GET['raw'] ?? '') === '1') { + header('Content-Type: ' . $mime); + header('Content-Length: ' . $size); + header('X-Content-Type-Options: nosniff'); + readfile($resolved['path']); + exit; } -$content = file_get_contents($resolved['path']); -if ($content === false) { - unav_error(500, 'Could not read file'); -} -if (strpos($content, "\0") !== false) { - unav_error(415, 'Preview is not available for binary files'); -} - -unav_json([ +$payload = [ 'path' => $resolved['path'], - 'name' => basename($resolved['path']), + 'name' => $name, 'size' => $size, 'extension' => $extension, - 'content' => $content, -]); + 'type' => $type, + 'mime' => $mime, + 'rawUrl' => 'preview.php?raw=1&path=' . rawurlencode($resolved['path']), +]; + +if (in_array($type, ['text', 'markdown', 'csv'], true)) { + if ($size > UNAV_TEXT_PREVIEW_MAX_BYTES) { + unav_error(413, 'Preview file is larger than 1 MB'); + } + $content = file_get_contents($resolved['path']); + if ($content === false) { + unav_error(500, 'Could not read file'); + } + if (strpos($content, "\0") !== false) { + unav_error(415, 'Preview is not available for binary files'); + } + $payload['content'] = $content; +} + +unav_json($payload); + +function unav_preview_type(string $extension, string $name): ?string { + $base = strtolower($name); + if (in_array($base, ['dockerfile', 'makefile'], true)) { + return 'text'; + } + $text = ['txt', 'log', 'rd', 'rst', 'yaml', 'yml', 'json', 'xml', 'ini', 'cfg', 'conf', 'env', 'toml', 'sql', 'nfo', 'srt', 'sub', 'vtt', 'm3u', 'm3u8', 'pls', 'sh', 'bash', 'zsh', 'php', 'js', 'ts', 'tsx', 'jsx', 'css', 'html', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'bat', 'ps1', 'properties', 'service', 'timer', 'mount', 'rules', 'cron', 'plg', 'page']; + if (in_array($extension, $text, true)) { + return 'text'; + } + if ($extension === 'md') { + return 'markdown'; + } + if (in_array($extension, ['csv', 'tsv'], true)) { + return 'csv'; + } + if (in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'], true)) { + return 'image'; + } + if ($extension === 'pdf') { + return 'pdf'; + } + if (in_array($extension, ['mp3', 'flac', 'm4a', 'ogg', 'wav'], true)) { + return 'audio'; + } + if (in_array($extension, ['mp4', 'mkv', 'webm', 'mov'], true)) { + return 'video'; + } + return null; +} + +function unav_preview_mime(string $extension, string $type): string { + $map = [ + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'svg' => 'image/svg+xml', + 'pdf' => 'application/pdf', + 'mp3' => 'audio/mpeg', + 'flac' => 'audio/flac', + 'm4a' => 'audio/mp4', + 'ogg' => 'audio/ogg', + 'wav' => 'audio/wav', + 'mp4' => 'video/mp4', + 'mkv' => 'video/x-matroska', + 'webm' => 'video/webm', + 'mov' => 'video/quicktime', + 'csv' => 'text/csv; charset=utf-8', + 'tsv' => 'text/tab-separated-values; charset=utf-8', + 'md' => 'text/markdown; charset=utf-8', + ]; + return $map[$extension] ?? ($type === 'text' ? 'text/plain; charset=utf-8' : 'application/octet-stream'); +} diff --git a/server/public/app.js b/server/public/app.js index 534beec..cde2149 100644 --- a/server/public/app.js +++ b/server/public/app.js @@ -40,7 +40,8 @@ const icons = { symlink: 'Link', other: 'Item' }; -const previewExtensions = new Set(['txt', 'log', 'md', 'rd', 'rst', 'yaml', 'yml', 'json', 'xml', 'csv', 'ini', 'cfg', 'conf', 'sh', 'php', 'js', 'css', 'html']); +const previewExtensions = new Set(['txt', 'log', 'md', 'rd', 'rst', 'yaml', 'yml', 'json', 'xml', 'csv', 'tsv', 'ini', 'cfg', 'conf', 'env', 'toml', 'sql', 'nfo', 'srt', 'sub', 'vtt', 'm3u', 'm3u8', 'pls', 'sh', 'bash', 'zsh', 'php', 'js', 'ts', 'tsx', 'jsx', 'css', 'html', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'bat', 'ps1', 'properties', 'service', 'timer', 'mount', 'rules', 'cron', 'plg', 'page', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'mp3', 'flac', 'm4a', 'ogg', 'wav', 'mp4', 'mkv', 'webm', 'mov']); +const previewFilenames = new Set(['dockerfile', 'makefile']); document.querySelector('#newExplorerButton').addEventListener('click', () => openExplorer()); document.querySelector('#newPropertiesButton').addEventListener('click', () => openProperties()); @@ -429,10 +430,32 @@ function renderPreview(body, win) { ${escapeHtml(entry?.name || data.name || 'Vorschau')} ${escapeHtml(formatBytes(data.size || entry?.size || 0))} -
${escapeHtml(data.content || '')}
+ ${renderPreviewContent(data)} `; } +function renderPreviewContent(data) { + if (data.type === 'markdown') { + return `
${renderMarkdown(data.content || '')}
`; + } + if (data.type === 'csv') { + return renderCsvPreview(data.content || '', data.extension === 'tsv' ? '\t' : ','); + } + if (data.type === 'image') { + return `
${escapeAttr(data.name || 'Vorschau')}
`; + } + if (data.type === 'pdf') { + return ``; + } + if (data.type === 'audio') { + return `
`; + } + if (data.type === 'video') { + return `
`; + } + return `
${escapeHtml(data.content || '')}
`; +} + async function loadPreview(win, entry) { win.data.loading = true; win.data.error = null; @@ -442,6 +465,9 @@ async function loadPreview(win, entry) { win.data.entry = entry; win.data.name = result.name; win.data.size = result.size; + win.data.type = result.type; + win.data.extension = result.extension; + win.data.rawUrl = result.rawUrl; win.data.content = result.content; } catch (error) { win.data.error = error.message; @@ -451,6 +477,99 @@ async function loadPreview(win, entry) { } } +function renderMarkdown(content) { + const blocks = []; + let listItems = []; + const flushList = () => { + if (listItems.length) { + blocks.push(``); + listItems = []; + } + }; + + for (const line of String(content).split(/\r?\n/)) { + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + if (heading) { + flushList(); + const level = heading[1].length; + blocks.push(`${renderInlineMarkdown(heading[2])}`); + continue; + } + const list = /^\s*[-*]\s+(.*)$/.exec(line); + if (list) { + listItems.push(list[1]); + continue; + } + flushList(); + if (!line.trim()) continue; + blocks.push(`

${renderInlineMarkdown(line)}

`); + } + flushList(); + return blocks.join(''); +} + +function renderInlineMarkdown(value) { + return escapeHtml(value) + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1'); +} + +function renderCsvPreview(content, delimiter) { + const rows = parseDelimitedRows(content, delimiter).slice(0, 80); + if (!rows.length) { + return '

Keine Tabellenzeilen.

'; + } + return ` +
+ + + ${rows.map((row) => `${row.map((cell) => ``).join('')}`).join('')} + +
${escapeHtml(cell)}
+
+ `; +} + +function parseDelimitedRows(content, delimiter) { + const rows = []; + let row = []; + let cell = ''; + let quoted = false; + const text = String(content || ''); + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + if (char === '"') { + if (quoted && text[index + 1] === '"') { + cell += '"'; + index += 1; + } else { + quoted = !quoted; + } + continue; + } + if (!quoted && char === delimiter) { + row.push(cell); + cell = ''; + continue; + } + if (!quoted && (char === '\n' || char === '\r')) { + if (char === '\r' && text[index + 1] === '\n') index += 1; + row.push(cell); + rows.push(row); + row = []; + cell = ''; + continue; + } + cell += char; + } + if (cell || row.length) { + row.push(cell); + rows.push(row); + } + return rows; +} + function renderQueue(body) { body.innerHTML = queueMarkup(); bindQueueActions(body); @@ -1421,7 +1540,9 @@ function extensionOf(name) { } function isPreviewable(entry) { - return entry?.type === 'file' && previewExtensions.has(extensionOf(entry.name || entry.path)); + if (entry?.type !== 'file') return false; + const name = String(entry.name || entry.path || '').toLowerCase(); + return previewFilenames.has(name) || previewExtensions.has(extensionOf(name)); } function joinPath(parent, name) { diff --git a/server/public/styles.css b/server/public/styles.css index a871671..dc26a00 100644 --- a/server/public/styles.css +++ b/server/public/styles.css @@ -731,6 +731,82 @@ white-space: pre; } +.u-nav .preview-rendered, +.u-nav .preview-table-wrap, +.u-nav .preview-media, +.u-nav .preview-frame { + min-height: 0; +} + +.u-nav .preview-rendered { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + overflow: auto; + padding: 12px; +} + +.u-nav .preview-rendered h1, +.u-nav .preview-rendered h2, +.u-nav .preview-rendered h3, +.u-nav .preview-rendered p, +.u-nav .preview-rendered ul { + margin-top: 0; +} + +.u-nav .preview-rendered code { + background: var(--surface-2); + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + padding: 1px 4px; +} + +.u-nav .preview-table-wrap { + border: 1px solid var(--line); + border-radius: 6px; + overflow: auto; +} + +.u-nav .preview-table { + border-collapse: collapse; + min-width: 100%; +} + +.u-nav .preview-table td { + border-bottom: 1px solid var(--line); + border-right: 1px solid var(--line); + padding: 6px 8px; + white-space: nowrap; +} + +.u-nav .preview-media { + align-items: center; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 6px; + display: flex; + justify-content: center; + overflow: auto; + padding: 10px; +} + +.u-nav .preview-media img, +.u-nav .preview-media video { + max-height: 100%; + max-width: 100%; +} + +.u-nav .preview-media audio { + width: min(100%, 560px); +} + +.u-nav .preview-frame { + border: 1px solid var(--line); + border-radius: 6px; + height: 100%; + width: 100%; +} + @media (max-width: 720px) { .u-nav .topbar { align-items: flex-start; diff --git a/u-navigator.plg b/u-navigator.plg index 895f04c..ab2a009 100644 --- a/u-navigator.plg +++ b/u-navigator.plg @@ -1,7 +1,7 @@ - + @@ -24,7 +24,7 @@ https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package; -64307317988b7af11b76fce0993e76e6 +ec6c71ed634dea8f56f0349815a89967