feat/perf: large-file handling, faster file list, richer CodeMirror modes (fixes #48)
This commit is contained in:
21
CHANGELOG.md
21
CHANGELOG.md
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 10/6/2025 v1.3.15
|
||||
|
||||
feat/perf: large-file handling, faster file list, richer CodeMirror modes (fixes #48)
|
||||
|
||||
- fileEditor.js: block ≥10 MB; plain-text fallback >5 MB; lighter CM settings for big files.
|
||||
- fileListView.js: latest-call-wins; compute editable via ext + sizeBytes (no blink).
|
||||
- FileModel.php: add sizeBytes; cap inline content to ≤5 MB (INDEX_TEXT_BYTES_MAX).
|
||||
- HTML: load extra CM modes: htmlmixed, php, clike, python, yaml, markdown, shell, sql, vb, ruby, perl, properties, nginx.
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/5/2025 v1.3.14
|
||||
|
||||
fix(admin): OIDC optional by default; validate only when enabled (fixes #44)
|
||||
@@ -9,6 +20,8 @@ fix(admin): OIDC optional by default; validate only when enabled (fixes #44)
|
||||
- AdminController default loginOptions sets disableOIDCLogin=true; CSRF via header or body
|
||||
- Normalize file perms to 0664 after write
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/4/2025 v1.3.13
|
||||
|
||||
fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash
|
||||
@@ -27,6 +40,8 @@ chore(scanner): skip profile_pics subtree during scans
|
||||
- Avoids indexing internal avatar images (folder already hidden in UI)
|
||||
- Reduces scan noise and metadata churn; keeps firmware/other content indexed
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/4/2025 v1.3.12
|
||||
|
||||
Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
|
||||
@@ -35,6 +50,8 @@ Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
|
||||
- Added CHOWN_ON_START env to control recursive chown (default true; turn off after first run)
|
||||
- SCAN_ON_START unchanged, with non-root fallback
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/4/2025 v1.3.11
|
||||
|
||||
Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect
|
||||
@@ -42,6 +59,8 @@ Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect
|
||||
- Remove no-op sed of SHARE_URL from start.sh (env already used)
|
||||
- Build default share link with correct scheme (http/https, proxy-aware)
|
||||
|
||||
---
|
||||
|
||||
## Changes 10/4/2025 v1.3.10
|
||||
|
||||
Fix: index externally added files on startup; harden start.sh (#46)
|
||||
@@ -54,6 +73,8 @@ Fix: index externally added files on startup; harden start.sh (#46)
|
||||
|
||||
No behavior change unless SCAN_ON_START=true.
|
||||
|
||||
---
|
||||
|
||||
## Changes 5/27/2025 v1.3.9
|
||||
|
||||
- Support for mounting CIFS (SMB) network shares via Docker volumes
|
||||
|
||||
@@ -3,7 +3,7 @@ import { loadAdminConfigFunc } from './auth.js';
|
||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
|
||||
import { sendRequest } from './networkUtils.js';
|
||||
|
||||
const version = "v1.3.13";
|
||||
const version = "v1.3.15";
|
||||
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
||||
|
||||
// ————— Inject updated styles —————
|
||||
|
||||
@@ -3,20 +3,143 @@ import { escapeHTML, showToast } from './domUtils.js';
|
||||
import { loadFileList } from './fileListView.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
// thresholds for editor behavior
|
||||
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
|
||||
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
|
||||
|
||||
// Lazy-load CodeMirror modes on demand
|
||||
const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
|
||||
const MODE_URL = {
|
||||
// core you've likely already loaded:
|
||||
"xml": "mode/xml/xml.min.js",
|
||||
"css": "mode/css/css.min.js",
|
||||
"javascript": "mode/javascript/javascript.min.js",
|
||||
|
||||
// extras you may want on-demand:
|
||||
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js",
|
||||
"application/x-httpd-php": "mode/php/php.min.js",
|
||||
"php": "mode/php/php.min.js",
|
||||
"markdown": "mode/markdown/markdown.min.js",
|
||||
"python": "mode/python/python.min.js",
|
||||
"sql": "mode/sql/sql.min.js",
|
||||
"shell": "mode/shell/shell.min.js",
|
||||
"yaml": "mode/yaml/yaml.min.js",
|
||||
"properties": "mode/properties/properties.min.js",
|
||||
"text/x-csrc": "mode/clike/clike.min.js",
|
||||
"text/x-c++src": "mode/clike/clike.min.js",
|
||||
"text/x-java": "mode/clike/clike.min.js",
|
||||
"text/x-csharp": "mode/clike/clike.min.js",
|
||||
"text/x-kotlin": "mode/clike/clike.min.js"
|
||||
};
|
||||
|
||||
function loadScriptOnce(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const key = `cm:${url}`;
|
||||
let s = document.querySelector(`script[data-key="${key}"]`);
|
||||
if (s) {
|
||||
if (s.dataset.loaded === "1") return resolve();
|
||||
s.addEventListener("load", () => resolve());
|
||||
s.addEventListener("error", reject);
|
||||
return;
|
||||
}
|
||||
s = document.createElement("script");
|
||||
s.src = url;
|
||||
s.defer = true;
|
||||
s.dataset.key = key;
|
||||
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
|
||||
s.addEventListener("error", reject);
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureModeLoaded(modeOption) {
|
||||
if (!window.CodeMirror) return; // CM core must be present
|
||||
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
|
||||
if (!name) return;
|
||||
// Already registered?
|
||||
if ((CodeMirror.modes && CodeMirror.modes[name]) || (CodeMirror.mimeModes && CodeMirror.mimeModes[name])) {
|
||||
return;
|
||||
}
|
||||
const url = MODE_URL[name];
|
||||
if (!url) return; // unknown -> fallback to text/plain
|
||||
// Dependencies (htmlmixed needs xml/css/js; php highlighting with HTML also benefits from htmlmixed)
|
||||
if (name === "htmlmixed") {
|
||||
await Promise.all([
|
||||
ensureModeLoaded("xml"),
|
||||
ensureModeLoaded("css"),
|
||||
ensureModeLoaded("javascript")
|
||||
]);
|
||||
}
|
||||
if (name === "application/x-httpd-php") {
|
||||
await ensureModeLoaded("htmlmixed");
|
||||
}
|
||||
await loadScriptOnce(CM_CDN + url);
|
||||
}
|
||||
|
||||
function getModeForFile(fileName) {
|
||||
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
||||
|
||||
switch (ext) {
|
||||
case "css":
|
||||
return "css";
|
||||
case "json":
|
||||
return { name: "javascript", json: true };
|
||||
case "js":
|
||||
return "javascript";
|
||||
// markup
|
||||
case "html":
|
||||
case "htm":
|
||||
return "text/html";
|
||||
return "text/html"; // ensureModeLoaded will map to htmlmixed
|
||||
case "xml":
|
||||
return "xml";
|
||||
case "md":
|
||||
case "markdown":
|
||||
return "markdown";
|
||||
case "yml":
|
||||
case "yaml":
|
||||
return "yaml";
|
||||
|
||||
// styles & scripts
|
||||
case "css":
|
||||
return "css";
|
||||
case "js":
|
||||
return "javascript";
|
||||
case "json":
|
||||
return { name: "javascript", json: true };
|
||||
|
||||
// server / langs
|
||||
case "php":
|
||||
return "application/x-httpd-php";
|
||||
case "py":
|
||||
return "python";
|
||||
case "sql":
|
||||
return "sql";
|
||||
case "sh":
|
||||
case "bash":
|
||||
case "zsh":
|
||||
case "bat":
|
||||
return "shell";
|
||||
|
||||
// config-y files
|
||||
case "ini":
|
||||
case "conf":
|
||||
case "config":
|
||||
case "properties":
|
||||
return "properties";
|
||||
|
||||
// C-family / JVM
|
||||
case "c":
|
||||
case "h":
|
||||
return "text/x-csrc";
|
||||
case "cpp":
|
||||
case "cxx":
|
||||
case "hpp":
|
||||
case "hh":
|
||||
case "hxx":
|
||||
return "text/x-c++src";
|
||||
case "java":
|
||||
return "text/x-java";
|
||||
case "cs":
|
||||
return "text/x-csharp";
|
||||
case "kt":
|
||||
case "kts":
|
||||
return "text/x-kotlin";
|
||||
|
||||
default:
|
||||
return "text/plain";
|
||||
}
|
||||
@@ -47,6 +170,7 @@ export function editFile(fileName, folder) {
|
||||
if (existingEditor) {
|
||||
existingEditor.remove();
|
||||
}
|
||||
|
||||
const folderUsed = folder || window.currentFolder || "root";
|
||||
const folderPath = folderUsed === "root"
|
||||
? "uploads/"
|
||||
@@ -55,26 +179,40 @@ export function editFile(fileName, folder) {
|
||||
|
||||
fetch(fileUrl, { method: "HEAD" })
|
||||
.then(response => {
|
||||
const contentLength = response.headers.get("Content-Length");
|
||||
if (contentLength !== null && parseInt(contentLength) > 10485760) {
|
||||
const lenHeader =
|
||||
response.headers.get("content-length") ??
|
||||
response.headers.get("Content-Length");
|
||||
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||
|
||||
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
|
||||
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
||||
throw new Error("File too large.");
|
||||
}
|
||||
return fetch(fileUrl);
|
||||
return response;
|
||||
})
|
||||
.then(() => fetch(fileUrl))
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP error! Status: " + response.status);
|
||||
}
|
||||
return response.text();
|
||||
const lenHeader =
|
||||
response.headers.get("content-length") ??
|
||||
response.headers.get("Content-Length");
|
||||
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||
return Promise.all([response.text(), sizeBytes]);
|
||||
})
|
||||
.then(content => {
|
||||
.then(([content, sizeBytes]) => {
|
||||
const forcePlainText =
|
||||
sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD;
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "editorContainer";
|
||||
modal.classList.add("modal", "editor-modal");
|
||||
modal.innerHTML = `
|
||||
<div class="editor-header">
|
||||
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}</h3>
|
||||
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}${
|
||||
forcePlainText ? " <span style='font-size:.8em;opacity:.7'>(plain text mode)</span>" : ""
|
||||
}</h3>
|
||||
<div class="editor-controls">
|
||||
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
|
||||
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
|
||||
@@ -90,61 +228,74 @@ export function editFile(fileName, folder) {
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = "block";
|
||||
|
||||
const mode = getModeForFile(fileName);
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
const theme = isDarkMode ? "material-darker" : "default";
|
||||
|
||||
const editor = CodeMirror.fromTextArea(document.getElementById("fileEditor"), {
|
||||
lineNumbers: true,
|
||||
// choose mode + lighter settings for large files
|
||||
const mode = forcePlainText ? "text/plain" : getModeForFile(fileName);
|
||||
const cmOptions = {
|
||||
lineNumbers: !forcePlainText,
|
||||
mode: mode,
|
||||
theme: theme,
|
||||
viewportMargin: Infinity
|
||||
});
|
||||
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||
lineWrapping: false,
|
||||
};
|
||||
|
||||
window.currentEditor = editor;
|
||||
// ✅ LOAD MODE FIRST, THEN INSTANTIATE CODEMIRROR
|
||||
ensureModeLoaded(mode).finally(() => {
|
||||
const editor = CodeMirror.fromTextArea(
|
||||
document.getElementById("fileEditor"),
|
||||
cmOptions
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
adjustEditorSize();
|
||||
}, 50);
|
||||
window.currentEditor = editor;
|
||||
|
||||
observeModalResize(modal);
|
||||
setTimeout(() => {
|
||||
adjustEditorSize();
|
||||
}, 50);
|
||||
|
||||
let currentFontSize = 14;
|
||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
observeModalResize(modal);
|
||||
|
||||
document.getElementById("closeEditorX").addEventListener("click", function () {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
document.getElementById("decreaseFont").addEventListener("click", function () {
|
||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||
let currentFontSize = 14;
|
||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
|
||||
document.getElementById("closeEditorX").addEventListener("click", function () {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
document.getElementById("decreaseFont").addEventListener("click", function () {
|
||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
});
|
||||
|
||||
document.getElementById("increaseFont").addEventListener("click", function () {
|
||||
currentFontSize = Math.min(32, currentFontSize + 2);
|
||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
});
|
||||
|
||||
document.getElementById("saveBtn").addEventListener("click", function () {
|
||||
saveFile(fileName, folderUsed);
|
||||
});
|
||||
|
||||
document.getElementById("closeBtn").addEventListener("click", function () {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
function updateEditorTheme() {
|
||||
const isDark = document.body.classList.contains("dark-mode");
|
||||
editor.setOption("theme", isDark ? "material-darker" : "default");
|
||||
}
|
||||
const toggle = document.getElementById("darkModeToggle");
|
||||
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
||||
});
|
||||
|
||||
document.getElementById("increaseFont").addEventListener("click", function () {
|
||||
currentFontSize = Math.min(32, currentFontSize + 2);
|
||||
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
});
|
||||
|
||||
document.getElementById("saveBtn").addEventListener("click", function () {
|
||||
saveFile(fileName, folderUsed);
|
||||
});
|
||||
|
||||
document.getElementById("closeBtn").addEventListener("click", function () {
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
function updateEditorTheme() {
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
editor.setOption("theme", isDarkMode ? "material-darker" : "default");
|
||||
}
|
||||
|
||||
document.getElementById("darkModeToggle").addEventListener("click", updateEditorTheme);
|
||||
})
|
||||
.catch(error => console.error("Error loading file:", error));
|
||||
.catch(error => {
|
||||
if (error && error.name === "AbortError") return;
|
||||
console.error("Error loading file:", error);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
export let fileData = [];
|
||||
export let sortOrder = { column: "uploaded", ascending: true };
|
||||
|
||||
// Hide "Edit" for files >10 MiB
|
||||
const MAX_EDIT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
// Latest-response-wins guard (prevents double render/flicker if loadFileList gets called twice)
|
||||
let __fileListReqSeq = 0;
|
||||
|
||||
window.itemsPerPage = parseInt(
|
||||
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10',
|
||||
10
|
||||
@@ -202,51 +208,39 @@ window.toggleRowSelection = toggleRowSelection;
|
||||
window.updateRowHighlight = updateRowHighlight;
|
||||
|
||||
export async function loadFileList(folderParam) {
|
||||
const reqId = ++__fileListReqSeq; // latest call wins
|
||||
const folder = folderParam || "root";
|
||||
const fileListContainer = document.getElementById("fileList");
|
||||
const actionsContainer = document.getElementById("fileListActions");
|
||||
|
||||
// 1) show loader
|
||||
// 1) show loader (only this request is allowed to render)
|
||||
fileListContainer.style.visibility = "hidden";
|
||||
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
|
||||
|
||||
try {
|
||||
// 2) fetch files + folders in parallel
|
||||
const [filesRes, foldersRes] = await Promise.all([
|
||||
fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`),
|
||||
fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`)
|
||||
]);
|
||||
// Kick off both in parallel, but we'll render as soon as FILES are ready
|
||||
const filesPromise = fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`);
|
||||
const foldersPromise = fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`);
|
||||
|
||||
// ----- FILES FIRST -----
|
||||
const filesRes = await filesPromise;
|
||||
|
||||
if (filesRes.status === 401) {
|
||||
window.location.href = "/api/auth/logout.php";
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const data = await filesRes.json();
|
||||
const folderRaw = await foldersRes.json();
|
||||
|
||||
// --- build ONLY the *direct* children of current folder ---
|
||||
let subfolders = [];
|
||||
const hidden = new Set(["profile_pics", "trash"]);
|
||||
if (Array.isArray(folderRaw)) {
|
||||
const allPaths = folderRaw.map(item => item.folder ?? item);
|
||||
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
|
||||
subfolders = allPaths
|
||||
.filter(p => {
|
||||
if (folder === "root") {
|
||||
return p.indexOf("/") === -1;
|
||||
}
|
||||
if (!p.startsWith(folder + "/")) return false;
|
||||
return p.split("/").length === depth;
|
||||
})
|
||||
.map(p => ({ name: p.split("/").pop(), full: p }));
|
||||
}
|
||||
subfolders = subfolders.filter(sf => !hidden.has(sf.name));
|
||||
// If another loadFileList ran after this one, bail before touching the DOM
|
||||
if (reqId !== __fileListReqSeq) return [];
|
||||
|
||||
// 3) clear loader
|
||||
// 3) clear loader (still only if this request is the latest)
|
||||
fileListContainer.innerHTML = "";
|
||||
|
||||
// 4) handle “no files” case
|
||||
if (!data.files || Object.keys(data.files).length === 0) {
|
||||
if (reqId !== __fileListReqSeq) return [];
|
||||
fileListContainer.textContent = t("no_files_found");
|
||||
|
||||
// hide summary + slider
|
||||
@@ -255,36 +249,12 @@ export async function loadFileList(folderParam) {
|
||||
const sliderContainer = document.getElementById("viewSliderContainer");
|
||||
if (sliderContainer) sliderContainer.style.display = "none";
|
||||
|
||||
// show/hide folder strip *even when there are no files*
|
||||
let strip = document.getElementById("folderStripContainer");
|
||||
if (!strip) {
|
||||
strip = document.createElement("div");
|
||||
strip.id = "folderStripContainer";
|
||||
strip.className = "folder-strip-container";
|
||||
actionsContainer.parentNode.insertBefore(strip, fileListContainer);
|
||||
}
|
||||
if (window.showFoldersInList && subfolders.length) {
|
||||
strip.innerHTML = subfolders.map(sf => `
|
||||
<div class="folder-item" data-folder="${sf.full}">
|
||||
<i class="material-icons">folder</i>
|
||||
<div class="folder-name">${escapeHTML(sf.name)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
strip.style.display = "flex";
|
||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
updateBreadcrumbTitle(dest);
|
||||
loadFileList(dest);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
strip.style.display = "none";
|
||||
}
|
||||
// hide folder strip for now; we’ll re-show it after folders load (below)
|
||||
const strip = document.getElementById("folderStripContainer");
|
||||
if (strip) strip.style.display = "none";
|
||||
|
||||
updateFileActionButtons();
|
||||
fileListContainer.style.visibility = "visible";
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -295,14 +265,49 @@ export async function loadFileList(folderParam) {
|
||||
return meta;
|
||||
});
|
||||
}
|
||||
|
||||
data.files = data.files.map(f => {
|
||||
f.fullName = (f.path || f.name).trim().toLowerCase();
|
||||
f.editable = canEditFile(f.name);
|
||||
|
||||
// Prefer numeric size if your API provides it; otherwise parse the "1.2 MB" string
|
||||
let bytes = Number.isFinite(f.sizeBytes)
|
||||
? f.sizeBytes
|
||||
: parseSizeToBytes(String(f.size || ""));
|
||||
|
||||
if (!Number.isFinite(bytes)) bytes = Infinity;
|
||||
|
||||
// extension policy + size policy
|
||||
f.editable = canEditFile(f.name) && (bytes <= MAX_EDIT_BYTES);
|
||||
|
||||
f.folder = folder;
|
||||
return f;
|
||||
});
|
||||
fileData = data.files;
|
||||
|
||||
// Decide editability BEFORE render to avoid any post-render “blink”
|
||||
data.files = data.files.map(f => {
|
||||
f.fullName = (f.path || f.name).trim().toLowerCase();
|
||||
|
||||
// extension policy
|
||||
const extOk = canEditFile(f.name);
|
||||
|
||||
// prefer numeric byte size if API provides it; otherwise parse "12.3 MB" strings
|
||||
let bytes = Infinity;
|
||||
if (Number.isFinite(f.sizeBytes)) {
|
||||
bytes = f.sizeBytes;
|
||||
} else if (f.size != null && String(f.size).trim() !== "") {
|
||||
bytes = parseSizeToBytes(String(f.size));
|
||||
}
|
||||
|
||||
f.editable = extOk && (bytes <= MAX_EDIT_BYTES);
|
||||
f.folder = folder;
|
||||
return f;
|
||||
});
|
||||
fileData = data.files;
|
||||
|
||||
// If stale, stop before any DOM updates
|
||||
if (reqId !== __fileListReqSeq) return [];
|
||||
|
||||
// 6) inject summary + slider
|
||||
if (actionsContainer) {
|
||||
// a) summary
|
||||
@@ -342,19 +347,19 @@ export async function loadFileList(folderParam) {
|
||||
);
|
||||
|
||||
sliderContainer.innerHTML = `
|
||||
<label for="galleryColumnsSlider" style="margin-right:8px;line-height:1;">
|
||||
${t("columns")}:
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
id="galleryColumnsSlider"
|
||||
min="1"
|
||||
max="${maxCols}"
|
||||
value="${currentCols}"
|
||||
style="vertical-align:middle;"
|
||||
>
|
||||
<span id="galleryColumnsValue" style="margin-left:6px;line-height:1;">${currentCols}</span>
|
||||
`;
|
||||
<label for="galleryColumnsSlider" style="margin-right:8px;line-height:1;">
|
||||
${t("columns")}:
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
id="galleryColumnsSlider"
|
||||
min="1"
|
||||
max="${maxCols}"
|
||||
value="${currentCols}"
|
||||
style="vertical-align:middle;"
|
||||
>
|
||||
<span id="galleryColumnsValue" style="margin-left:6px;line-height:1;">${currentCols}</span>
|
||||
`;
|
||||
const gallerySlider = document.getElementById("galleryColumnsSlider");
|
||||
const galleryValue = document.getElementById("galleryColumnsValue");
|
||||
gallerySlider.oninput = e => {
|
||||
@@ -367,12 +372,12 @@ export async function loadFileList(folderParam) {
|
||||
} else {
|
||||
const currentHeight = parseInt(localStorage.getItem("rowHeight") || "48", 10);
|
||||
sliderContainer.innerHTML = `
|
||||
<label for="rowHeightSlider" style="margin-right:8px;line-height:1;">
|
||||
${t("row_height")}:
|
||||
</label>
|
||||
<input type="range" id="rowHeightSlider" min="30" max="60" value="${currentHeight}" style="vertical-align:middle;">
|
||||
<span id="rowHeightValue" style="margin-left:6px;line-height:1;">${currentHeight}px</span>
|
||||
`;
|
||||
<label for="rowHeightSlider" style="margin-right:8px;line-height:1;">
|
||||
${t("row_height")}:
|
||||
</label>
|
||||
<input type="range" id="rowHeightSlider" min="30" max="60" value="${currentHeight}" style="vertical-align:middle;">
|
||||
<span id="rowHeightValue" style="margin-left:6px;line-height:1;">${currentHeight}px</span>
|
||||
`;
|
||||
const rowSlider = document.getElementById("rowHeightSlider");
|
||||
const rowValue = document.getElementById("rowHeightValue");
|
||||
rowSlider.oninput = e => {
|
||||
@@ -384,93 +389,121 @@ export async function loadFileList(folderParam) {
|
||||
}
|
||||
}
|
||||
|
||||
// 7) inject folder strip below actions, above file list
|
||||
let strip = document.getElementById("folderStripContainer");
|
||||
if (!strip) {
|
||||
strip = document.createElement("div");
|
||||
strip.id = "folderStripContainer";
|
||||
strip.className = "folder-strip-container";
|
||||
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
|
||||
}
|
||||
// 7) render files (only if still latest)
|
||||
if (reqId !== __fileListReqSeq) return [];
|
||||
|
||||
if (window.showFoldersInList && subfolders.length) {
|
||||
strip.innerHTML = subfolders.map(sf => `
|
||||
<div class="folder-item" data-folder="${sf.full}" draggable="true">
|
||||
<i class="material-icons">folder</i>
|
||||
<div class="folder-name">${escapeHTML(sf.name)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
strip.style.display = "flex";
|
||||
|
||||
// wire up each folder‐tile
|
||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||
// 1) click to navigate
|
||||
el.addEventListener("click", () => {
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
updateBreadcrumbTitle(dest);
|
||||
document.querySelectorAll(".folder-option.selected").forEach(o => o.classList.remove("selected"));
|
||||
document.querySelector(`.folder-option[data-folder="${dest}"]`)?.classList.add("selected");
|
||||
loadFileList(dest);
|
||||
});
|
||||
|
||||
// 2) drag & drop
|
||||
el.addEventListener("dragover", folderDragOverHandler);
|
||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||
el.addEventListener("drop", folderDropHandler);
|
||||
|
||||
// 3) right-click context menu
|
||||
el.addEventListener("contextmenu", e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
|
||||
// highlight the strip tile
|
||||
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
|
||||
el.classList.add("selected");
|
||||
|
||||
// reuse folderManager menu
|
||||
const menuItems = [
|
||||
{
|
||||
label: t("create_folder"),
|
||||
action: () => document.getElementById("createFolderModal").style.display = "block"
|
||||
},
|
||||
{
|
||||
label: t("rename_folder"),
|
||||
action: () => openRenameFolderModal()
|
||||
},
|
||||
{
|
||||
label: t("folder_share"),
|
||||
action: () => openFolderShareModal(dest)
|
||||
},
|
||||
{
|
||||
label: t("delete_folder"),
|
||||
action: () => openDeleteFolderModal()
|
||||
}
|
||||
];
|
||||
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
||||
});
|
||||
});
|
||||
|
||||
// one global click to hide any open context menu
|
||||
document.addEventListener("click", hideFolderManagerContextMenu);
|
||||
|
||||
} else {
|
||||
strip.style.display = "none";
|
||||
}
|
||||
|
||||
// 8) render files
|
||||
if (window.viewMode === "gallery") {
|
||||
renderGalleryView(folder);
|
||||
} else {
|
||||
renderFileTable(folder);
|
||||
}
|
||||
|
||||
updateFileActionButtons();
|
||||
fileListContainer.style.visibility = "visible";
|
||||
|
||||
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
|
||||
try {
|
||||
const foldersRes = await foldersPromise;
|
||||
const folderRaw = await foldersRes.json();
|
||||
if (reqId !== __fileListReqSeq) return data.files;
|
||||
|
||||
// --- build ONLY the *direct* children of current folder ---
|
||||
let subfolders = [];
|
||||
const hidden = new Set(["profile_pics", "trash"]);
|
||||
if (Array.isArray(folderRaw)) {
|
||||
const allPaths = folderRaw.map(item => item.folder ?? item);
|
||||
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
|
||||
subfolders = allPaths
|
||||
.filter(p => {
|
||||
if (folder === "root") return p.indexOf("/") === -1;
|
||||
if (!p.startsWith(folder + "/")) return false;
|
||||
return p.split("/").length === depth;
|
||||
})
|
||||
.map(p => ({ name: p.split("/").pop(), full: p }));
|
||||
}
|
||||
subfolders = subfolders.filter(sf => !hidden.has(sf.name));
|
||||
|
||||
// inject folder strip below actions, above file list
|
||||
let strip = document.getElementById("folderStripContainer");
|
||||
if (!strip) {
|
||||
strip = document.createElement("div");
|
||||
strip.id = "folderStripContainer";
|
||||
strip.className = "folder-strip-container";
|
||||
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
|
||||
}
|
||||
|
||||
if (window.showFoldersInList && subfolders.length) {
|
||||
strip.innerHTML = subfolders.map(sf => `
|
||||
<div class="folder-item" data-folder="${sf.full}" draggable="true">
|
||||
<i class="material-icons">folder</i>
|
||||
<div class="folder-name">${escapeHTML(sf.name)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
strip.style.display = "flex";
|
||||
|
||||
// wire up each folder‐tile
|
||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||
// 1) click to navigate
|
||||
el.addEventListener("click", () => {
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
updateBreadcrumbTitle(dest);
|
||||
document.querySelectorAll(".folder-option.selected").forEach(o => o.classList.remove("selected"));
|
||||
document.querySelector(`.folder-option[data-folder="${dest}"]`)?.classList.add("selected");
|
||||
loadFileList(dest);
|
||||
});
|
||||
|
||||
// 2) drag & drop
|
||||
el.addEventListener("dragover", folderDragOverHandler);
|
||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||
el.addEventListener("drop", folderDropHandler);
|
||||
|
||||
// 3) right-click context menu
|
||||
el.addEventListener("contextmenu", e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const dest = el.dataset.folder;
|
||||
window.currentFolder = dest;
|
||||
localStorage.setItem("lastOpenedFolder", dest);
|
||||
|
||||
// highlight the strip tile
|
||||
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
|
||||
el.classList.add("selected");
|
||||
|
||||
// reuse folderManager menu
|
||||
const menuItems = [
|
||||
{
|
||||
label: t("create_folder"),
|
||||
action: () => document.getElementById("createFolderModal").style.display = "block"
|
||||
},
|
||||
{
|
||||
label: t("rename_folder"),
|
||||
action: () => openRenameFolderModal()
|
||||
},
|
||||
{
|
||||
label: t("folder_share"),
|
||||
action: () => openFolderShareModal(dest)
|
||||
},
|
||||
{
|
||||
label: t("delete_folder"),
|
||||
action: () => openDeleteFolderModal()
|
||||
}
|
||||
];
|
||||
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
||||
});
|
||||
});
|
||||
|
||||
// one global click to hide any open context menu
|
||||
document.addEventListener("click", hideFolderManagerContextMenu);
|
||||
|
||||
} else {
|
||||
strip.style.display = "none";
|
||||
}
|
||||
} catch {
|
||||
// ignore folder errors; rows already rendered
|
||||
}
|
||||
|
||||
return data.files;
|
||||
|
||||
} catch (err) {
|
||||
@@ -480,7 +513,10 @@ export async function loadFileList(folderParam) {
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
fileListContainer.style.visibility = "visible";
|
||||
// Only the latest call should restore visibility
|
||||
if (reqId === __fileListReqSeq) {
|
||||
fileListContainer.style.visibility = "visible";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,12 +1173,64 @@ function parseCustomDate(dateStr) {
|
||||
}
|
||||
|
||||
export function canEditFile(fileName) {
|
||||
if (!fileName || typeof fileName !== "string") return false;
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
if (dot < 0) return false;
|
||||
|
||||
const ext = fileName.slice(dot + 1).toLowerCase();
|
||||
|
||||
// Text/code-only. Intentionally exclude php/phtml/phar/etc.
|
||||
const allowedExtensions = [
|
||||
"txt", "html", "htm", "css", "js", "json", "xml",
|
||||
"md", "py", "ini", "csv", "log", "conf", "config", "bat",
|
||||
"rtf", "doc", "docx"
|
||||
// Plain text & docs (text)
|
||||
"txt", "text", "md", "markdown", "rst",
|
||||
|
||||
// Web
|
||||
"html", "htm", "xhtml", "shtml",
|
||||
"css", "scss", "sass", "less",
|
||||
|
||||
// JS/TS
|
||||
"js", "mjs", "cjs", "jsx",
|
||||
"ts", "tsx",
|
||||
|
||||
// Data & config formats
|
||||
"json", "jsonc", "ndjson",
|
||||
"yml", "yaml", "toml", "xml", "plist",
|
||||
"ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc",
|
||||
"env", "dotenv",
|
||||
"csv", "tsv", "tab",
|
||||
"log",
|
||||
|
||||
// Shell / scripts
|
||||
"sh", "bash", "zsh", "ksh", "fish",
|
||||
"bat", "cmd",
|
||||
"ps1", "psm1", "psd1",
|
||||
|
||||
// Languages
|
||||
"py", "pyw", // Python
|
||||
"rb", // Ruby
|
||||
"pl", "pm", // Perl
|
||||
"go", // Go
|
||||
"rs", // Rust
|
||||
"java", // Java
|
||||
"kt", "kts", // Kotlin
|
||||
"scala", "sc", // Scala
|
||||
"groovy", "gradle", // Groovy/Gradle
|
||||
"c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx", // C/C++
|
||||
"m", "mm", // Obj-C / Obj-C++
|
||||
"swift", // Swift
|
||||
"cs", "fs", "fsx", // C#, F#
|
||||
"dart",
|
||||
"lua",
|
||||
"r", "rmd",
|
||||
|
||||
// SQL
|
||||
"sql",
|
||||
|
||||
// Front-end SFC/templates
|
||||
"vue", "svelte",
|
||||
"twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade"
|
||||
];
|
||||
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
|
||||
|
||||
return allowedExtensions.includes(ext);
|
||||
}
|
||||
|
||||
|
||||
@@ -1167,19 +1167,24 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
||||
* @return array Returns an associative array with keys "files" and "globalTags".
|
||||
*/
|
||||
public static function getFileList(string $folder): array {
|
||||
// --- caps for safe inlining ---
|
||||
if (!defined('LISTING_CONTENT_BYTES_MAX')) define('LISTING_CONTENT_BYTES_MAX', 8192); // 8 KB snippet
|
||||
if (!defined('INDEX_TEXT_BYTES_MAX')) define('INDEX_TEXT_BYTES_MAX', 5 * 1024 * 1024); // only sample files ≤ 5 MB
|
||||
|
||||
$folder = trim($folder) ?: 'root';
|
||||
|
||||
// Determine the target directory.
|
||||
if (strtolower($folder) !== 'root') {
|
||||
$directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
|
||||
} else {
|
||||
$directory = UPLOAD_DIR;
|
||||
}
|
||||
|
||||
|
||||
// Validate folder.
|
||||
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
return ["error" => "Invalid folder name."];
|
||||
}
|
||||
|
||||
|
||||
// Helper: Build the metadata file path.
|
||||
$getMetadataFilePath = function(string $folder): string {
|
||||
if (strtolower($folder) === 'root' || trim($folder) === '') {
|
||||
@@ -1188,23 +1193,26 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
||||
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
|
||||
};
|
||||
$metadataFile = $getMetadataFilePath($folder);
|
||||
$metadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
|
||||
|
||||
$metadata = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : [];
|
||||
|
||||
if (!is_dir($directory)) {
|
||||
return ["error" => "Directory not found."];
|
||||
}
|
||||
|
||||
|
||||
$allFiles = array_values(array_diff(scandir($directory), array('.', '..')));
|
||||
$fileList = [];
|
||||
|
||||
|
||||
// Define a safe file name pattern.
|
||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||
|
||||
|
||||
// Prepare finfo (if available) for MIME sniffing.
|
||||
$finfo = function_exists('finfo_open') ? @finfo_open(FILEINFO_MIME_TYPE) : false;
|
||||
|
||||
foreach ($allFiles as $file) {
|
||||
if (substr($file, 0, 1) === '.') {
|
||||
continue; // Skip hidden files.
|
||||
if ($file === '' || $file[0] === '.') {
|
||||
continue; // Skip hidden/invalid entries.
|
||||
}
|
||||
|
||||
|
||||
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
||||
if (!is_file($filePath)) {
|
||||
continue; // Only process files.
|
||||
@@ -1212,13 +1220,17 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
||||
if (!preg_match($safeFileNamePattern, $file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileDateModified = filemtime($filePath) ? date(DATE_TIME_FORMAT, filemtime($filePath)) : "Unknown";
|
||||
|
||||
// Meta
|
||||
$mtime = @filemtime($filePath);
|
||||
$fileDateModified = $mtime ? date(DATE_TIME_FORMAT, $mtime) : "Unknown";
|
||||
$metaKey = $file;
|
||||
$fileUploadedDate = isset($metadata[$metaKey]["uploaded"]) ? $metadata[$metaKey]["uploaded"] : "Unknown";
|
||||
$fileUploader = isset($metadata[$metaKey]["uploader"]) ? $metadata[$metaKey]["uploader"] : "Unknown";
|
||||
|
||||
$fileSizeBytes = filesize($filePath);
|
||||
|
||||
// Size
|
||||
$fileSizeBytes = @filesize($filePath);
|
||||
if (!is_int($fileSizeBytes)) $fileSizeBytes = 0;
|
||||
if ($fileSizeBytes >= 1073741824) {
|
||||
$fileSizeFormatted = sprintf("%.1f GB", $fileSizeBytes / 1073741824);
|
||||
} elseif ($fileSizeBytes >= 1048576) {
|
||||
@@ -1228,29 +1240,65 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
|
||||
} else {
|
||||
$fileSizeFormatted = sprintf("%s bytes", number_format($fileSizeBytes));
|
||||
}
|
||||
|
||||
$fileEntry = [
|
||||
'name' => $file,
|
||||
'modified' => $fileDateModified,
|
||||
'uploaded' => $fileUploadedDate,
|
||||
'size' => $fileSizeFormatted,
|
||||
'uploader' => $fileUploader,
|
||||
'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : []
|
||||
];
|
||||
|
||||
// Optionally include file content for text-based files.
|
||||
if (preg_match('/\.(txt|html|htm|md|js|css|json|xml|php|py|ini|conf|log)$/i', $file)) {
|
||||
$content = file_get_contents($filePath);
|
||||
$fileEntry['content'] = $content;
|
||||
|
||||
// MIME + text detection (fallback to extension)
|
||||
$mime = 'application/octet-stream';
|
||||
if ($finfo) {
|
||||
$det = @finfo_file($finfo, $filePath);
|
||||
if (is_string($det) && $det !== '') $mime = $det;
|
||||
}
|
||||
|
||||
$isTextByMime = (strpos((string)$mime, 'text/') === 0) || $mime === 'application/json' || $mime === 'application/xml';
|
||||
$isTextByExt = (bool)preg_match('/\.(txt|md|csv|json|xml|html?|css|js|log|ini|conf|config|yml|yaml|php|py|rb|sh|bat|ps1|ts|tsx|c|cpp|h|hpp|java|go|rs)$/i', $file);
|
||||
$isText = $isTextByMime || $isTextByExt;
|
||||
|
||||
// Build entry
|
||||
$fileEntry = [
|
||||
'name' => $file,
|
||||
'modified' => $fileDateModified,
|
||||
'uploaded' => $fileUploadedDate,
|
||||
'size' => $fileSizeFormatted,
|
||||
'sizeBytes' => $fileSizeBytes, // ← numeric size for frontend logic
|
||||
'uploader' => $fileUploader,
|
||||
'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : [],
|
||||
'mime' => $mime,
|
||||
];
|
||||
|
||||
// Small, safe snippet for text files only (never full content)
|
||||
$fileEntry['content'] = '';
|
||||
$fileEntry['contentTruncated'] = false;
|
||||
|
||||
if ($isText && $fileSizeBytes > 0) {
|
||||
if ($fileSizeBytes <= INDEX_TEXT_BYTES_MAX) {
|
||||
$fh = @fopen($filePath, 'rb');
|
||||
if ($fh) {
|
||||
$snippet = @fread($fh, LISTING_CONTENT_BYTES_MAX);
|
||||
@fclose($fh);
|
||||
if ($snippet !== false) {
|
||||
// ensure UTF-8 for JSON
|
||||
if (function_exists('mb_check_encoding') && !mb_check_encoding($snippet, 'UTF-8')) {
|
||||
if (function_exists('mb_convert_encoding')) {
|
||||
$snippet = @mb_convert_encoding($snippet, 'UTF-8', 'UTF-8, ISO-8859-1, Windows-1252');
|
||||
}
|
||||
}
|
||||
$fileEntry['content'] = $snippet;
|
||||
$fileEntry['contentTruncated'] = ($fileSizeBytes > LISTING_CONTENT_BYTES_MAX);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// too large to sample: mark truncated so UI/search knows
|
||||
$fileEntry['contentTruncated'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$fileList[] = $fileEntry;
|
||||
}
|
||||
|
||||
|
||||
if ($finfo) { @finfo_close($finfo); }
|
||||
|
||||
// Load global tags.
|
||||
$globalTagsFile = META_DIR . "createdTags.json";
|
||||
$globalTags = file_exists($globalTagsFile) ? json_decode(file_get_contents($globalTagsFile), true) : [];
|
||||
|
||||
$globalTags = file_exists($globalTagsFile) ? (json_decode(file_get_contents($globalTagsFile), true) ?: []) : [];
|
||||
|
||||
return ["files" => $fileList, "globalTags" => $globalTags];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user