Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
567d2f62e8 | ||
|
|
9be53ba033 | ||
|
|
de925e6fc2 | ||
|
|
bd7ff4d9cd | ||
|
|
6727cc66ac | ||
|
|
f3269877c7 |
34
CHANGELOG.md
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 11/9/2025 (v1.9.1)
|
||||
|
||||
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
|
||||
|
||||
### Highlights v1.9.1
|
||||
|
||||
- 🎨 Per-folder colors with live SVG preview and consistent styling in light/dark modes.
|
||||
- 📄 Folder icons auto-refresh when contents change (no full page reload).
|
||||
- 🧭 Drag-and-drop breadcrumb fallback for folder→folder moves.
|
||||
- 🛠️ Safer upgrade helper script to rsync app files without touching data.
|
||||
|
||||
- feat(colors): add per-folder color customization
|
||||
- New endpoints: GET /api/folder/getFolderColors.php and POST /api/folder/saveFolderColor.php
|
||||
- AuthZ: reuse canRename for “customize folder”, validate hex, and write atomically to metadata/folder_colors.json.
|
||||
- Read endpoint filters map by ACL::canRead before returning to the user.
|
||||
- Frontend: load/apply colors to tree rows; persist on move/rename; API helpers saveFolderColor/getFolderColors.
|
||||
|
||||
- feat(ui): color-picker modal with live SVG folder preview
|
||||
- Shows preview that updates as you pick; supports Save/Reset; protects against accidental toggle clicks.
|
||||
|
||||
- feat(controls): “Color folder” button in Folder Management card
|
||||
- New `.btn-color-folder` with accent palette (#008CB4), hover/active/focus states, dark-mode tuning; event wiring gated by caps.
|
||||
|
||||
- i18n: add strings for color UI (color_folder, choose_color, reset_default, save_color, folder_color_saved, folder_color_cleared).
|
||||
|
||||
- ux(tree): make expansion state more predictable across refreshes
|
||||
- `expandTreePath(path, {force,persist,includeLeaf})` with persistence; keep ancestors expanded; add click-suppression guard.
|
||||
|
||||
- ux(layout): center the folder-actions toolbar; remove left padding hacks; normalize icon sizing.
|
||||
|
||||
- chore(ops): add scripts/manual-sync.sh (safe rsync update path, preserves data dirs and public/.htaccess).
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/9/2025 (v1.9.0)
|
||||
|
||||
release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening
|
||||
|
||||
@@ -29,7 +29,7 @@ New: Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **Pow
|
||||
|
||||
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -445,18 +445,11 @@ Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
|
||||
|
||||
### ONLYOFFICE integration
|
||||
|
||||
FileRise can open office documents using a self-hosted ONLYOFFICE Document Server.
|
||||
|
||||
- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**.
|
||||
- **Licensing:** ONLYOFFICE Document Server (Community Edition) is released under the GNU AGPL v3. Enterprise editions are commercially licensed. When you deploy ONLYOFFICE, you are responsible for complying with the license of the edition you use.
|
||||
– Project page & license: <https://github.com/ONLYOFFICE/DocumentServer> (AGPL-3.0)
|
||||
- **FileRise license unaffected:** FileRise communicates with ONLYOFFICE over standard HTTP and loads `api.js` from the configured Document Server at runtime; FileRise does not redistribute ONLYOFFICE code.
|
||||
- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE.
|
||||
|
||||
#### Security / CSP
|
||||
|
||||
If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src`, `connect-src`). The Admin panel shows a ready-to-copy line for Apache/Nginx.
|
||||
|
||||
### PHP Libraries
|
||||
|
||||
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
|
||||
|
||||
17
public/api/folder/getFolderColors.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
|
||||
|
||||
try {
|
||||
$ctl = new FolderController();
|
||||
$ctl->getFolderColors(); // echoes JSON + status codes
|
||||
} catch (Throwable $e) {
|
||||
error_log('getFolderColors failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'Internal server error']);
|
||||
}
|
||||
17
public/api/folder/saveFolderColor.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
|
||||
|
||||
try {
|
||||
$ctl = new FolderController();
|
||||
$ctl->saveFolderColor(); // validates method + CSRF, does ACL, echoes JSON
|
||||
} catch (Throwable $e) {
|
||||
error_log('saveFolderColor failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'Internal server error']);
|
||||
}
|
||||
@@ -62,6 +62,51 @@ body {
|
||||
@media (max-width: 600px) {
|
||||
.zones-toggle { left: 85px !important; }
|
||||
}
|
||||
|
||||
/* Optional tokens */
|
||||
:root{
|
||||
--filr-accent-500:#008CB4; /* base */
|
||||
--filr-accent-600:#00789A; /* hover */
|
||||
--filr-accent-700:#006882; /* active/border */
|
||||
--filr-accent-ring:rgba(0,140,180,.4);
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.btn-color-folder{
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
background:var(--filr-accent-500);
|
||||
border:1px solid var(--filr-accent-700);
|
||||
color:#fff; /* ensure white text */
|
||||
}
|
||||
.btn-color-folder .material-icons{
|
||||
color:currentColor; /* makes icon white too */
|
||||
}
|
||||
|
||||
.btn-color-folder:hover,
|
||||
.btn-color-folder:focus-visible{
|
||||
background:var(--filr-accent-600);
|
||||
border-color:var(--filr-accent-700);
|
||||
}
|
||||
|
||||
.btn-color-folder:active{
|
||||
background:var(--filr-accent-700);
|
||||
}
|
||||
|
||||
.btn-color-folder:focus-visible{
|
||||
outline:2px solid var(--filr-accent-ring);
|
||||
outline-offset:2px;
|
||||
}
|
||||
|
||||
/* Dark mode: start slightly deeper so it doesn't glow */
|
||||
.dark-mode .btn-color-folder{
|
||||
background:var(--filr-accent-600);
|
||||
border-color:var(--filr-accent-700);
|
||||
color:#fff;
|
||||
}
|
||||
.dark-mode .btn-color-folder:hover,
|
||||
.dark-mode .btn-color-folder:focus-visible{
|
||||
background:var(--filr-accent-700);
|
||||
}
|
||||
/* ===========================================================
|
||||
HEADER & NAVIGATION
|
||||
=========================================================== */
|
||||
@@ -801,14 +846,17 @@ body {
|
||||
}
|
||||
#uploadForm {
|
||||
display: none;
|
||||
}.folder-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.folder-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding-top: 10px;
|
||||
}@media (min-width: 600px) and (max-width: 992px) {
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
white-space: normal;
|
||||
margin: 0; /* no hacks needed */
|
||||
}
|
||||
@media (min-width: 600px) and (max-width: 992px) {
|
||||
.folder-actions {
|
||||
white-space: nowrap;
|
||||
}}
|
||||
@@ -821,10 +869,8 @@ body {
|
||||
}.folder-actions .material-icons {
|
||||
font-size: 24px;
|
||||
vertical-align: -2px;
|
||||
}.folder-actions .btn + .btn {
|
||||
margin-left: 6px;
|
||||
}.folder-actions .btn {
|
||||
padding: 10px 12px;
|
||||
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.1;
|
||||
border-radius: 6px;
|
||||
@@ -834,7 +880,7 @@ body {
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
will-change: transform;
|
||||
}.folder-actions .material-icons {
|
||||
font-size: 24px;
|
||||
font-size: 20px;
|
||||
vertical-align: -2px;
|
||||
transition: transform 120ms ease;
|
||||
}.folder-actions .btn:hover,
|
||||
@@ -1152,6 +1198,7 @@ body {
|
||||
width: 5px;
|
||||
}#folderTreeContainer {
|
||||
display: block;
|
||||
margin-left: 10px;
|
||||
}.folder-option {
|
||||
cursor: pointer;
|
||||
}.folder-option:hover {
|
||||
@@ -1641,6 +1688,7 @@ body {
|
||||
}.custom-folder-card-body {
|
||||
padding-top: 5px !important;
|
||||
padding-right: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
}#addUserModal,
|
||||
#removeUserModal {
|
||||
z-index: 5000 !important;
|
||||
@@ -2073,17 +2121,6 @@ body {
|
||||
|
||||
/* ---------- Crisp colors & strokes for the SVG parts ---------- */
|
||||
|
||||
#folderTreeContainer .folder-icon .folder-front,
|
||||
#folderTreeContainer .folder-icon .folder-back {
|
||||
fill: currentColor;
|
||||
stroke: var(--filr-folder-stroke);
|
||||
stroke-width: 1.1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-icon .folder-front { color: var(--filr-folder-front); }
|
||||
#folderTreeContainer .folder-icon .folder-back { color: var(--filr-folder-back); }
|
||||
|
||||
#folderTreeContainer .folder-icon .paper {
|
||||
fill: var(--filr-paper-fill);
|
||||
@@ -2123,3 +2160,15 @@ body {
|
||||
background: rgba(122,179,255,.24);
|
||||
box-shadow: inset 0 0 0 1px rgba(122,179,255,.45);
|
||||
}
|
||||
|
||||
/* variables will be set inline per .folder-option when user colors a folder */
|
||||
#folderTreeContainer .folder-icon .folder-front,
|
||||
#folderTreeContainer .folder-icon .folder-back {
|
||||
fill: currentColor;
|
||||
stroke: var(--filr-folder-stroke);
|
||||
stroke-width: 1.1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
#folderTreeContainer .folder-icon .folder-front { color: var(--filr-folder-front); }
|
||||
#folderTreeContainer .folder-icon .folder-back { color: var(--filr-folder-back); }
|
||||
|
||||
@@ -252,6 +252,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="colorFolderBtn" class="btn btn-color-folder ml-2" data-i18n-title="color_folder" title="Color folder">
|
||||
<i class="material-icons">palette</i>
|
||||
</button>
|
||||
|
||||
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
|
||||
<i class="material-icons">share</i>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
|
||||
export function handleDeleteSelected(e) {
|
||||
@@ -47,6 +48,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (data.success) {
|
||||
showToast("Selected files deleted successfully!");
|
||||
loadFileList(window.currentFolder);
|
||||
refreshFolderIcon(window.currentFolder);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not delete files"));
|
||||
}
|
||||
@@ -129,6 +131,7 @@ export async function handleCreateFile(e) {
|
||||
if (!js.success) throw new Error(js.error);
|
||||
showToast(t('file_created'));
|
||||
loadFileList(folder);
|
||||
refreshFolderIcon(folder);
|
||||
} catch (err) {
|
||||
showToast(err.message || t('error_creating_file'));
|
||||
} finally {
|
||||
@@ -300,6 +303,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
showToast(t('file_created_successfully'));
|
||||
loadFileList(window.currentFolder);
|
||||
refreshFolderIcon(folder);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast(err.message || t('error_creating_file'));
|
||||
@@ -633,6 +637,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (data.success) {
|
||||
showToast("Selected files copied successfully!", 5000);
|
||||
loadFileList(window.currentFolder);
|
||||
refreshFolderIcon(targetFolder);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not copy files"), 5000);
|
||||
}
|
||||
@@ -685,6 +690,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (data.success) {
|
||||
showToast("Selected files moved successfully!");
|
||||
loadFileList(window.currentFolder);
|
||||
refreshFolderIcon(targetFolder);
|
||||
refreshFolderIcon(window.currentFolder);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not move files"));
|
||||
}
|
||||
|
||||
@@ -74,6 +74,13 @@ function loadFolderTreeState() {
|
||||
function saveFolderTreeState(state) {
|
||||
localStorage.setItem("folderTreeState", JSON.stringify(state));
|
||||
}
|
||||
/* ----------------------
|
||||
Transient UI guards (click suppression)
|
||||
----------------------*/
|
||||
let _suppressToggleUntil = 0;
|
||||
function suppressNextToggle(ms = 300) {
|
||||
_suppressToggleUntil = performance.now() + ms;
|
||||
}
|
||||
|
||||
// Helper for getting the parent folder.
|
||||
export function getParentFolder(folder) {
|
||||
@@ -102,9 +109,11 @@ async function applyFolderCapabilities(folder) {
|
||||
window.currentFolderCaps = caps;
|
||||
|
||||
const isRoot = (folder === 'root');
|
||||
|
||||
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
|
||||
setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder);
|
||||
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
|
||||
setControlEnabled(document.getElementById('colorFolderBtn'), !isRoot && !!caps.canRename);
|
||||
setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete);
|
||||
setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder);
|
||||
}
|
||||
@@ -174,56 +183,71 @@ function breadcrumbDropHandler(e) {
|
||||
e.preventDefault();
|
||||
link.classList.remove("drop-hover");
|
||||
const dropFolder = link.getAttribute("data-folder");
|
||||
|
||||
let dragData;
|
||||
try {
|
||||
dragData = JSON.parse(e.dataTransfer.getData("application/json"));
|
||||
} catch (err) {
|
||||
console.error("Invalid drag data on breadcrumb:", err);
|
||||
return;
|
||||
}
|
||||
/* FOLDER MOVE FALLBACK */
|
||||
} catch (_) { /* noop */ }
|
||||
|
||||
// FOLDER MOVE FALLBACK (folder->folder)
|
||||
if (!dragData) {
|
||||
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) ||
|
||||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
|
||||
if (plain) {
|
||||
const sourceFolder = String(plain).trim();
|
||||
if (sourceFolder && sourceFolder !== "root") {
|
||||
if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) {
|
||||
showToast("Invalid destination.", 4000);
|
||||
return;
|
||||
}
|
||||
fetchWithCsrf("/api/folder/moveFolder.php", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
|
||||
})
|
||||
.then(safeJson)
|
||||
.then(data => {
|
||||
if (data && !data.error) {
|
||||
showToast(`Folder moved to ${dropFolder}!`);
|
||||
if (window.currentFolder && (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) {
|
||||
const base = sourceFolder.split("/").pop();
|
||||
const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
|
||||
window.currentFolder = newPath;
|
||||
}
|
||||
return loadFolderTree().then(() => {
|
||||
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
|
||||
loadFileList(window.currentFolder || "root");
|
||||
});
|
||||
} else {
|
||||
showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error moving folder:", err);
|
||||
showToast("Error moving folder", 5000);
|
||||
});
|
||||
}
|
||||
const plain = (e.dataTransfer && e.dataTransfer.getData("application/x-filerise-folder")) ||
|
||||
(e.dataTransfer && e.dataTransfer.getData("text/plain")) || "";
|
||||
const sourceFolder = String(plain || "").trim();
|
||||
if (!sourceFolder || sourceFolder === "root") return;
|
||||
|
||||
if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) {
|
||||
showToast("Invalid destination.", 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchWithCsrf("/api/folder/moveFolder.php", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
|
||||
})
|
||||
.then(safeJson)
|
||||
.then(data => {
|
||||
if (data && !data.error) {
|
||||
showToast(`Folder moved to ${dropFolder}!`);
|
||||
// Make icons reflect new emptiness without reload
|
||||
refreshFolderIcon(dragData.sourceFolder);
|
||||
refreshFolderIcon(dropFolder);
|
||||
|
||||
if (window.currentFolder &&
|
||||
(window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) {
|
||||
const base = sourceFolder.split("/").pop();
|
||||
const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
|
||||
|
||||
// carry color without await
|
||||
const oldColor = window.folderColorMap[sourceFolder];
|
||||
if (oldColor) {
|
||||
saveFolderColor(newPath, oldColor)
|
||||
.then(() => saveFolderColor(sourceFolder, ''))
|
||||
.catch(() => { });
|
||||
}
|
||||
|
||||
window.currentFolder = newPath;
|
||||
}
|
||||
|
||||
return loadFolderTree().then(() => {
|
||||
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
|
||||
loadFileList(window.currentFolder || "root");
|
||||
});
|
||||
} else {
|
||||
showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error moving folder:", err);
|
||||
showToast("Error moving folder", 5000);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// File(s) drop path (unchanged)
|
||||
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||
if (filesToMove.length === 0) return;
|
||||
|
||||
@@ -242,6 +266,8 @@ function breadcrumbDropHandler(e) {
|
||||
if (data.success) {
|
||||
showToast(`File(s) moved successfully to ${dropFolder}!`);
|
||||
loadFileList(dragData.sourceFolder);
|
||||
refreshFolderIcon(dragData.sourceFolder);
|
||||
refreshFolderIcon(dropFolder);
|
||||
} else {
|
||||
showToast("Error moving files: " + (data.error || "Unknown error"));
|
||||
}
|
||||
@@ -252,6 +278,230 @@ function breadcrumbDropHandler(e) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Folder Colors (state + helpers) ----
|
||||
window.folderColorMap = {}; // { "path": "#RRGGBB", ... }
|
||||
|
||||
async function loadFolderColors() {
|
||||
try {
|
||||
const r = await fetch('/api/folder/getFolderColors.php', { credentials: 'include' });
|
||||
if (!r.ok) return (window.folderColorMap = {});
|
||||
window.folderColorMap = await r.json() || {};
|
||||
} catch { window.folderColorMap = {}; }
|
||||
}
|
||||
|
||||
// tiny color utils
|
||||
function hexToHsl(hex) {
|
||||
hex = hex.replace('#', '');
|
||||
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
|
||||
const r = parseInt(hex.substr(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.substr(2, 2), 16) / 255;
|
||||
const b = parseInt(hex.substr(4, 2), 16) / 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h, s, l = (max + min) / 2;
|
||||
if (max === min) { h = s = 0; }
|
||||
else {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
||||
case g: h = (b - r) / d + 2; break;
|
||||
case b: h = (r - g) / d + 4; break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
return { h: h * 360, s: s * 100, l: l * 100 };
|
||||
}
|
||||
function hslToHex(h, s, l) {
|
||||
h /= 360; s /= 100; l /= 100;
|
||||
const f = n => {
|
||||
const k = (n + h * 12) % 12, a = s * Math.min(l, 1 - l);
|
||||
const c = l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
|
||||
return Math.round(255 * c).toString(16).padStart(2, '0');
|
||||
};
|
||||
return '#' + f(0) + f(8) + f(4);
|
||||
}
|
||||
function lighten(hex, amt = 12) {
|
||||
const { h, s, l } = hexToHsl(hex); return hslToHex(h, s, Math.min(100, l + amt));
|
||||
}
|
||||
function darken(hex, amt = 18) {
|
||||
const { h, s, l } = hexToHsl(hex); return hslToHex(h, s, Math.max(0, l - amt));
|
||||
}
|
||||
|
||||
function applyFolderColorToOption(folder, hex) {
|
||||
// accepts folder like "root" or "A/B"
|
||||
const sel = folder === 'root'
|
||||
? '#rootRow .folder-option'
|
||||
: `.folder-option[data-folder="${CSS.escape(folder)}"]`;
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return;
|
||||
|
||||
if (!hex) {
|
||||
el.style.removeProperty('--filr-folder-front');
|
||||
el.style.removeProperty('--filr-folder-back');
|
||||
el.style.removeProperty('--filr-folder-stroke');
|
||||
return;
|
||||
}
|
||||
|
||||
const front = hex; // main
|
||||
const back = lighten(hex, 14); // body (slightly lighter)
|
||||
const stroke = darken(hex, 22); // outline
|
||||
|
||||
el.style.setProperty('--filr-folder-front', front);
|
||||
el.style.setProperty('--filr-folder-back', back);
|
||||
el.style.setProperty('--filr-folder-stroke', stroke);
|
||||
}
|
||||
|
||||
function applyAllFolderColors(scope = document) {
|
||||
Object.entries(window.folderColorMap || {}).forEach(([folder, hex]) => {
|
||||
applyFolderColorToOption(folder, hex);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveFolderColor(folder, colorHexOrEmpty) {
|
||||
const res = await fetch('/api/folder/saveFolderColor.php', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken },
|
||||
body: JSON.stringify({ folder, color: colorHexOrEmpty })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
// update local map & apply
|
||||
if (data.color) window.folderColorMap[folder] = data.color;
|
||||
else delete window.folderColorMap[folder];
|
||||
applyFolderColorToOption(folder, data.color || '');
|
||||
return data;
|
||||
}
|
||||
|
||||
function openColorFolderModal(folder) {
|
||||
const existing = window.folderColorMap[folder] || '';
|
||||
const defaultHex = existing || '#f6b84e';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'colorFolderModal';
|
||||
modal.className = 'modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="width:460px;max-width:90vw;">
|
||||
<style>
|
||||
/* Scoped styles for the preview only */
|
||||
#colorFolderModal .folder-preview {
|
||||
display:flex; align-items:center; gap:12px;
|
||||
margin-top:12px; padding:10px 12px; border-radius:12px;
|
||||
border:1px solid var(--border-color, #ddd);
|
||||
background: var(--bg, transparent);
|
||||
}
|
||||
body.dark-mode #colorFolderModal .folder-preview {
|
||||
--border-color:#444; --bg: rgba(255,255,255,.02);
|
||||
}
|
||||
#colorFolderModal .folder-preview .folder-icon { width:56px; height:56px; display:inline-block }
|
||||
#colorFolderModal .folder-preview svg { width:56px; height:56px; display:block }
|
||||
/* Use the same variable names you already apply on folder rows */
|
||||
#colorFolderModal .folder-preview .folder-back { fill:var(--filr-folder-back, #f0d084) }
|
||||
#colorFolderModal .folder-preview .folder-front { fill:var(--filr-folder-front, #e2b158); stroke:var(--filr-folder-stroke, #996a1e); stroke-width:.6 }
|
||||
#colorFolderModal .folder-preview .lip-highlight { stroke:rgba(255,255,255,.35); fill:none; stroke-width:.9 }
|
||||
#colorFolderModal .folder-preview .paper { fill:#fff; stroke:#d0d0d0; stroke-width:.6 }
|
||||
#colorFolderModal .folder-preview .paper-fold { fill:#ececec }
|
||||
#colorFolderModal .folder-preview .paper-line { stroke:#c8c8c8; stroke-width:.8 }
|
||||
#colorFolderModal .folder-preview .label { font-weight:600; user-select:none }
|
||||
|
||||
/* High-contrast ghost button just for this modal */
|
||||
#colorFolderModal .btn-ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--ghost-border, #cfcfcf);
|
||||
color: var(--ghost-fg, #222);
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
#colorFolderModal .btn-ghost:hover {
|
||||
background: var(--ghost-hover-bg, #f5f5f5);
|
||||
}
|
||||
#colorFolderModal .btn-ghost:focus-visible {
|
||||
outline: 2px solid #8ab4f8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
body.dark-mode #colorFolderModal .btn-ghost {
|
||||
--ghost-border: #60636b;
|
||||
--ghost-fg: #f0f0f0;
|
||||
--ghost-hover-bg: rgba(255,255,255,.08);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h3 style="margin:0;">${t('color_folder')}: ${escapeHTML(folder)}</h3>
|
||||
<span id="closeColorFolderModal" class="editor-close-btn" role="button" aria-label="Close">×</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="margin-top:10px;">
|
||||
<label for="folderColorInput" style="display:block;margin-bottom:6px;">${t('choose_color')}</label>
|
||||
<input type="color" id="folderColorInput" style="width:100%;padding:6px;" value="${defaultHex}"/>
|
||||
|
||||
<!-- Live preview -->
|
||||
<div class="folder-preview" id="folderColorPreview" aria-label="Preview">
|
||||
<span class="folder-icon" aria-hidden="true">${folderSVG('paper')}</span>
|
||||
<span class="label">${escapeHTML(folder)}</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px;display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button id="resetFolderColorBtn" class="btn btn-ghost">${t('reset_default')}</button>
|
||||
<button id="saveFolderColorBtn" class="btn btn-primary">${t('save_color')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = 'block';
|
||||
|
||||
// --- live preview wiring
|
||||
const previewEl = modal.querySelector('#folderColorPreview');
|
||||
const inputEl = modal.querySelector('#folderColorInput');
|
||||
|
||||
function applyPreview(hex) {
|
||||
if (!hex || typeof hex !== 'string') return;
|
||||
const front = hex;
|
||||
const back = lighten(hex, 14);
|
||||
const stroke = darken(hex, 22);
|
||||
previewEl.style.setProperty('--filr-folder-front', front);
|
||||
previewEl.style.setProperty('--filr-folder-back', back);
|
||||
previewEl.style.setProperty('--filr-folder-stroke', stroke);
|
||||
}
|
||||
applyPreview(defaultHex);
|
||||
inputEl?.addEventListener('input', () => applyPreview(inputEl.value));
|
||||
|
||||
// --- buttons/close
|
||||
document.getElementById('closeColorFolderModal')?.addEventListener('click', (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
suppressNextToggle(300);
|
||||
setTimeout(() => expandTreePath(folder, { force: true }), 0);
|
||||
modal.remove();
|
||||
});
|
||||
|
||||
document.getElementById('resetFolderColorBtn')?.addEventListener('click', async (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
try {
|
||||
await saveFolderColor(folder, ''); // clear
|
||||
showToast(t('folder_color_cleared'));
|
||||
} catch (err) {
|
||||
showToast(err.message || 'Error');
|
||||
} finally {
|
||||
suppressNextToggle(300);
|
||||
setTimeout(() => expandTreePath(folder, { force: true }), 0);
|
||||
modal.remove();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('saveFolderColorBtn')?.addEventListener('click', async (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
try {
|
||||
const hex = String(inputEl.value || '').trim();
|
||||
await saveFolderColor(folder, hex);
|
||||
showToast(t('folder_color_saved'));
|
||||
} finally {
|
||||
suppressNextToggle(300);
|
||||
setTimeout(() => expandTreePath(folder, { force: true }), 0);
|
||||
modal.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
Check Current User's Folder-Only Permission
|
||||
----------------------*/
|
||||
@@ -287,6 +537,20 @@ async function checkUserFolderPermission() {
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate client-side folder "non-empty" caches
|
||||
function invalidateFolderCaches(folder) {
|
||||
if (!folder) return;
|
||||
_folderCountCache.delete(folder);
|
||||
_nonEmptyCache.delete(folder);
|
||||
_inflightCounts.delete(folder);
|
||||
}
|
||||
|
||||
// Public: force a fresh count + icon for a folder row
|
||||
export function refreshFolderIcon(folder) {
|
||||
invalidateFolderCaches(folder);
|
||||
ensureFolderIcon(folder);
|
||||
}
|
||||
|
||||
// ---------------- SVG icons + icon helpers ----------------
|
||||
const _nonEmptyCache = new Map();
|
||||
|
||||
@@ -369,7 +633,9 @@ async function fetchFolderCounts(folder) {
|
||||
if (_folderCountCache.has(folder)) return _folderCountCache.get(folder);
|
||||
if (_inflightCounts.has(folder)) return _inflightCounts.get(folder);
|
||||
|
||||
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}`;
|
||||
// cache-bust query param to avoid any proxy/cdn caching
|
||||
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}&t=${Date.now()}`;
|
||||
|
||||
const p = _runCount(url).then(data => {
|
||||
const result = {
|
||||
folders: Number(data?.folders || 0),
|
||||
@@ -473,15 +739,18 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
||||
return html;
|
||||
}
|
||||
|
||||
// replace your current expandTreePath with this version
|
||||
function expandTreePath(path, opts = {}) {
|
||||
const { force = false } = opts;
|
||||
const { force = false, persist = false, includeLeaf = false } = opts;
|
||||
const state = loadFolderTreeState();
|
||||
const parts = (path || '').split('/').filter(Boolean);
|
||||
let cumulative = '';
|
||||
|
||||
const lastIndex = includeLeaf ? parts.length - 1 : Math.max(0, parts.length - 2);
|
||||
|
||||
parts.forEach((part, i) => {
|
||||
cumulative = i === 0 ? part : `${cumulative}/${part}`;
|
||||
if (i > lastIndex) return; // skip leaf unless asked
|
||||
|
||||
const option = document.querySelector(`.folder-option[data-folder="${CSS.escape(cumulative)}"]`);
|
||||
if (!option) return;
|
||||
|
||||
@@ -489,12 +758,17 @@ function expandTreePath(path, opts = {}) {
|
||||
const nestedUl = li ? li.querySelector(':scope > ul') : null;
|
||||
if (!nestedUl) return;
|
||||
|
||||
// Only expand if caller forces it OR saved state says "block"
|
||||
const shouldExpand = force || state[cumulative] === 'block';
|
||||
nestedUl.classList.toggle('expanded', shouldExpand);
|
||||
nestedUl.classList.toggle('collapsed', !shouldExpand);
|
||||
li.setAttribute('aria-expanded', String(!!shouldExpand));
|
||||
|
||||
if (persist && shouldExpand) {
|
||||
state[cumulative] = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
if (persist) saveFolderTreeState(state);
|
||||
}
|
||||
|
||||
|
||||
@@ -514,58 +788,72 @@ function folderDropHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
||||
|
||||
let dragData = null;
|
||||
try {
|
||||
const jsonStr = event.dataTransfer.getData("application/json") || "";
|
||||
if (jsonStr) dragData = JSON.parse(jsonStr);
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
console.error("Invalid drag data", e);
|
||||
return;
|
||||
}
|
||||
/* FOLDER MOVE FALLBACK */
|
||||
|
||||
// FOLDER MOVE FALLBACK (folder->folder)
|
||||
if (!dragData) {
|
||||
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) ||
|
||||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
|
||||
if (plain) {
|
||||
const sourceFolder = String(plain).trim();
|
||||
if (sourceFolder && sourceFolder !== "root") {
|
||||
if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) {
|
||||
showToast("Invalid destination.", 4000);
|
||||
return;
|
||||
}
|
||||
fetchWithCsrf("/api/folder/moveFolder.php", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
|
||||
})
|
||||
.then(safeJson)
|
||||
.then(data => {
|
||||
if (data && !data.error) {
|
||||
showToast(`Folder moved to ${dropFolder}!`);
|
||||
if (window.currentFolder && (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) {
|
||||
const base = sourceFolder.split("/").pop();
|
||||
const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
|
||||
window.currentFolder = newPath;
|
||||
}
|
||||
return loadFolderTree().then(() => {
|
||||
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
|
||||
loadFileList(window.currentFolder || "root");
|
||||
});
|
||||
} else {
|
||||
showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error moving folder:", err);
|
||||
showToast("Error moving folder", 5000);
|
||||
});
|
||||
}
|
||||
const sourceFolder = String(plain || "").trim();
|
||||
if (!sourceFolder || sourceFolder === "root") return;
|
||||
|
||||
if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) {
|
||||
showToast("Invalid destination.", 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchWithCsrf("/api/folder/moveFolder.php", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
|
||||
})
|
||||
.then(safeJson)
|
||||
.then(data => {
|
||||
if (data && !data.error) {
|
||||
showToast(`Folder moved to ${dropFolder}!`);
|
||||
|
||||
if (window.currentFolder &&
|
||||
(window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) {
|
||||
const base = sourceFolder.split("/").pop();
|
||||
const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
|
||||
|
||||
// carry color without await
|
||||
const oldColor = window.folderColorMap[sourceFolder];
|
||||
if (oldColor) {
|
||||
saveFolderColor(newPath, oldColor)
|
||||
.then(() => saveFolderColor(sourceFolder, ''))
|
||||
.catch(() => { });
|
||||
}
|
||||
|
||||
window.currentFolder = newPath;
|
||||
}
|
||||
|
||||
return loadFolderTree().then(() => {
|
||||
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
|
||||
loadFileList(window.currentFolder || "root");
|
||||
});
|
||||
} else {
|
||||
showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error moving folder:", err);
|
||||
showToast("Error moving folder", 5000);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// File(s) drop path (unchanged)
|
||||
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||
if (filesToMove.length === 0) return;
|
||||
|
||||
@@ -584,6 +872,8 @@ function folderDropHandler(event) {
|
||||
if (data.success) {
|
||||
showToast(`File(s) moved successfully to ${dropFolder}!`);
|
||||
loadFileList(dragData.sourceFolder);
|
||||
refreshFolderIcon(dragData.sourceFolder);
|
||||
refreshFolderIcon(dropFolder);
|
||||
} else {
|
||||
showToast("Error moving files: " + (data.error || "Unknown error"));
|
||||
}
|
||||
@@ -721,6 +1011,11 @@ export async function loadFolderTree(selectedFolder) {
|
||||
}
|
||||
container.innerHTML = html;
|
||||
|
||||
await loadFolderColors();
|
||||
try { applyAllFolderColors(container); } catch (e) {
|
||||
console.warn('applyAllFolderColors failed:', e);
|
||||
}
|
||||
|
||||
const st = loadFolderTreeState();
|
||||
const rootUl = container.querySelector('#rootRow + ul');
|
||||
if (rootUl) {
|
||||
@@ -777,7 +1072,7 @@ export async function loadFolderTree(selectedFolder) {
|
||||
|
||||
// Show ancestors so the current selection is visible, but don't persist
|
||||
if (window.currentFolder && window.currentFolder !== effectiveRoot) {
|
||||
expandTreePath(window.currentFolder, { persist: false, includeLeaf: false });
|
||||
expandTreePath(window.currentFolder, { force: true, persist: true, includeLeaf: false });
|
||||
}
|
||||
|
||||
const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`);
|
||||
@@ -811,45 +1106,48 @@ export async function loadFolderTree(selectedFolder) {
|
||||
});
|
||||
});
|
||||
|
||||
// Root toggle
|
||||
const rootToggle = container.querySelector("#rootRow .folder-toggle");
|
||||
if (rootToggle) {
|
||||
rootToggle.addEventListener("click", function (e) {
|
||||
// --- One delegated toggle handler (robust) ---
|
||||
(function bindToggleDelegation() {
|
||||
const container = document.getElementById('folderTreeContainer');
|
||||
if (!container || container._toggleBound) return;
|
||||
container._toggleBound = true;
|
||||
|
||||
container.addEventListener('click', (e) => {
|
||||
if (performance.now() < _suppressToggleUntil) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const btn = e.target.closest('button.folder-toggle');
|
||||
if (!btn || !container.contains(btn)) return;
|
||||
e.stopPropagation();
|
||||
const nestedUl = container.querySelector("#rootRow + ul");
|
||||
if (!nestedUl) return;
|
||||
|
||||
const state = loadFolderTreeState();
|
||||
const expanded = !(nestedUl.classList.contains("expanded"));
|
||||
nestedUl.classList.toggle("expanded", expanded);
|
||||
nestedUl.classList.toggle("collapsed", !expanded);
|
||||
const folderPath = btn.getAttribute('data-folder');
|
||||
let siblingUl = null;
|
||||
let expandedTarget = null;
|
||||
|
||||
document.getElementById("rootRow").setAttribute("aria-expanded", String(expanded));
|
||||
state[effectiveRoot] = expanded ? "block" : "none";
|
||||
saveFolderTreeState(state);
|
||||
});
|
||||
}
|
||||
|
||||
// Other toggles
|
||||
|
||||
container.querySelectorAll("button.folder-toggle").forEach(toggle => {
|
||||
toggle.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
const li = this.closest('li[role="treeitem"]');
|
||||
const siblingUl = li ? li.querySelector(':scope > ul') : null;
|
||||
const folderPath = this.getAttribute("data-folder");
|
||||
// Root toggle?
|
||||
if (btn.closest('#rootRow')) {
|
||||
siblingUl = container.querySelector('#rootRow + ul');
|
||||
expandedTarget = document.getElementById('rootRow');
|
||||
} else {
|
||||
const li = btn.closest('li[role="treeitem"]');
|
||||
if (!li) return;
|
||||
siblingUl = li.querySelector(':scope > ul');
|
||||
expandedTarget = li;
|
||||
}
|
||||
if (!siblingUl) return;
|
||||
|
||||
const expanded = !siblingUl.classList.contains('expanded');
|
||||
siblingUl.classList.toggle('expanded', expanded);
|
||||
siblingUl.classList.toggle('collapsed', !expanded);
|
||||
if (expandedTarget) expandedTarget.setAttribute('aria-expanded', String(expanded));
|
||||
|
||||
const state = loadFolderTreeState();
|
||||
const expanded = !(siblingUl.classList.contains("expanded"));
|
||||
siblingUl.classList.toggle("expanded", expanded);
|
||||
siblingUl.classList.toggle("collapsed", !expanded);
|
||||
li.setAttribute("aria-expanded", String(expanded));
|
||||
state[folderPath] = expanded ? "block" : "none";
|
||||
state[folderPath] = expanded ? 'block' : 'none';
|
||||
saveFolderTreeState(state);
|
||||
ensureFolderIcon(folderPath);
|
||||
});
|
||||
});
|
||||
}, true);
|
||||
})();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error loading folder tree:", error);
|
||||
@@ -928,6 +1226,16 @@ if (submitRename) {
|
||||
if (data.success) {
|
||||
showToast("Folder renamed successfully!");
|
||||
window.currentFolder = newFolderFull;
|
||||
|
||||
// carry color without await
|
||||
const oldPath = selectedFolder;
|
||||
const oldColor = window.folderColorMap[oldPath];
|
||||
if (oldColor) {
|
||||
saveFolderColor(newFolderFull, oldColor)
|
||||
.then(() => saveFolderColor(oldPath, ''))
|
||||
.catch(() => { });
|
||||
}
|
||||
|
||||
localStorage.setItem("lastOpenedFolder", newFolderFull);
|
||||
loadFolderList(newFolderFull);
|
||||
} else {
|
||||
@@ -1020,6 +1328,8 @@ if (confirmDelete) {
|
||||
if (data.success) {
|
||||
showToast("Folder deleted successfully!");
|
||||
window.currentFolder = getParentFolder(selectedFolder);
|
||||
const parentForIcon = getParentFolder(selectedFolder);
|
||||
refreshFolderIcon(parentForIcon);
|
||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||
loadFolderList(window.currentFolder);
|
||||
} else {
|
||||
@@ -1083,6 +1393,8 @@ if (submitCreate) {
|
||||
.then(data => {
|
||||
if (!data.success) throw new Error(data.error || "Server rejected the request");
|
||||
showToast("Folder created!");
|
||||
const parentForIcon = parent || 'root';
|
||||
refreshFolderIcon(parentForIcon);
|
||||
const full = parent ? `${parent}/${folderInput}` : folderInput;
|
||||
window.currentFolder = full;
|
||||
localStorage.setItem("lastOpenedFolder", full);
|
||||
@@ -1101,6 +1413,45 @@ if (submitCreate) {
|
||||
}
|
||||
|
||||
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
|
||||
async function folderManagerContextMenuHandler(e) {
|
||||
const target = e.target.closest(".folder-option, .breadcrumb-link");
|
||||
if (!target) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const folder = target.getAttribute("data-folder");
|
||||
if (!folder) return;
|
||||
|
||||
window.currentFolder = folder;
|
||||
await applyFolderCapabilities(folder); // <-- await ensures fresh caps
|
||||
|
||||
// Visual selection
|
||||
document.querySelectorAll(".folder-option, .breadcrumb-link")
|
||||
.forEach(el => el.classList.remove("selected"));
|
||||
target.classList.add("selected");
|
||||
|
||||
const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canRename);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: t("create_folder"), action: () => {
|
||||
const modal = document.getElementById("createFolderModal");
|
||||
const input = document.getElementById("newFolderName");
|
||||
if (modal) modal.style.display = "block";
|
||||
if (input) input.focus();
|
||||
}
|
||||
},
|
||||
{ label: t("move_folder"), action: () => openMoveFolderUI(folder) },
|
||||
{ label: t("rename_folder"), action: () => openRenameFolderModal() },
|
||||
...(canColor ? [{ label: t("color_folder"), action: () => openColorFolderModal(folder) }] : []),
|
||||
{ label: t("folder_share"), action: () => openFolderShareModal(folder) },
|
||||
{ label: t("delete_folder"), action: () => openDeleteFolderModal() }
|
||||
];
|
||||
|
||||
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
||||
}
|
||||
|
||||
export function showFolderManagerContextMenu(x, y, menuItems) {
|
||||
let menu = document.getElementById("folderManagerContextMenu");
|
||||
if (!menu) {
|
||||
@@ -1112,6 +1463,7 @@ export function showFolderManagerContextMenu(x, y, menuItems) {
|
||||
menu.style.zIndex = "9999";
|
||||
document.body.appendChild(menu);
|
||||
}
|
||||
|
||||
if (document.body.classList.contains("dark-mode")) {
|
||||
menu.style.backgroundColor = "#2c2c2c";
|
||||
menu.style.border = "1px solid #555";
|
||||
@@ -1121,18 +1473,16 @@ export function showFolderManagerContextMenu(x, y, menuItems) {
|
||||
menu.style.border = "1px solid #ccc";
|
||||
menu.style.color = "#000";
|
||||
}
|
||||
|
||||
menu.innerHTML = "";
|
||||
menuItems.forEach(item => {
|
||||
const menuItem = document.createElement("div");
|
||||
menuItem.textContent = item.label;
|
||||
menuItem.style.padding = "5px 15px";
|
||||
menuItem.style.cursor = "pointer";
|
||||
|
||||
menuItem.addEventListener("mouseover", () => {
|
||||
if (document.body.classList.contains("dark-mode")) {
|
||||
menuItem.style.backgroundColor = "#444";
|
||||
} else {
|
||||
menuItem.style.backgroundColor = "#f0f0f0";
|
||||
}
|
||||
menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0";
|
||||
});
|
||||
menuItem.addEventListener("mouseout", () => {
|
||||
menuItem.style.backgroundColor = "";
|
||||
@@ -1141,75 +1491,26 @@ export function showFolderManagerContextMenu(x, y, menuItems) {
|
||||
item.action();
|
||||
hideFolderManagerContextMenu();
|
||||
});
|
||||
|
||||
menu.appendChild(menuItem);
|
||||
});
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
menu.style.display = "block";
|
||||
}
|
||||
|
||||
export function hideFolderManagerContextMenu() {
|
||||
const menu = document.getElementById("folderManagerContextMenu");
|
||||
if (menu) {
|
||||
menu.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function folderManagerContextMenuHandler(e) {
|
||||
const target = e.target.closest(".folder-option, .breadcrumb-link");
|
||||
if (!target) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const folder = target.getAttribute("data-folder");
|
||||
if (!folder) return;
|
||||
window.currentFolder = folder;
|
||||
applyFolderCapabilities(window.currentFolder);
|
||||
|
||||
// Visual selection
|
||||
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
|
||||
target.classList.add("selected");
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: t("create_folder"),
|
||||
action: () => {
|
||||
const modal = document.getElementById("createFolderModal");
|
||||
const input = document.getElementById("newFolderName");
|
||||
if (modal) modal.style.display = "block";
|
||||
if (input) input.focus();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t("move_folder"),
|
||||
action: () => { openMoveFolderUI(folder); }
|
||||
},
|
||||
{
|
||||
label: t("rename_folder"),
|
||||
action: () => { openRenameFolderModal(); }
|
||||
},
|
||||
{
|
||||
label: t("folder_share"),
|
||||
action: () => { openFolderShareModal(folder); }
|
||||
},
|
||||
{
|
||||
label: t("delete_folder"),
|
||||
action: () => { openDeleteFolderModal(); }
|
||||
}
|
||||
];
|
||||
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
||||
if (menu) menu.style.display = "none";
|
||||
}
|
||||
|
||||
// Delegate contextmenu so it works with dynamically re-rendered breadcrumbs
|
||||
function bindFolderManagerContextMenu() {
|
||||
const tree = document.getElementById("folderTreeContainer");
|
||||
if (tree) {
|
||||
// remove old bound handler if present
|
||||
if (tree._ctxHandler) {
|
||||
tree.removeEventListener("contextmenu", tree._ctxHandler, false);
|
||||
}
|
||||
tree._ctxHandler = function (e) {
|
||||
if (tree._ctxHandler) tree.removeEventListener("contextmenu", tree._ctxHandler, false);
|
||||
tree._ctxHandler = (e) => {
|
||||
const onOption = e.target.closest(".folder-option");
|
||||
if (!onOption) return;
|
||||
folderManagerContextMenuHandler(e);
|
||||
@@ -1219,10 +1520,8 @@ function bindFolderManagerContextMenu() {
|
||||
|
||||
const title = document.getElementById("fileListTitle");
|
||||
if (title) {
|
||||
if (title._ctxHandler) {
|
||||
title.removeEventListener("contextmenu", title._ctxHandler, false);
|
||||
}
|
||||
title._ctxHandler = function (e) {
|
||||
if (title._ctxHandler) title.removeEventListener("contextmenu", title._ctxHandler, false);
|
||||
title._ctxHandler = (e) => {
|
||||
const onCrumb = e.target.closest(".breadcrumb-link");
|
||||
if (!onCrumb) return;
|
||||
folderManagerContextMenuHandler(e);
|
||||
@@ -1266,6 +1565,22 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const colorFolderBtn = document.getElementById("colorFolderBtn");
|
||||
if (colorFolderBtn) {
|
||||
colorFolderBtn.addEventListener("click", () => {
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
if (!selectedFolder || selectedFolder === "root") {
|
||||
showToast(t('please_select_valid_folder') || "Please select a valid folder.");
|
||||
return;
|
||||
}
|
||||
openColorFolderModal(selectedFolder);
|
||||
});
|
||||
} else {
|
||||
console.warn("colorFolderBtn element not found in the DOM.");
|
||||
}
|
||||
});
|
||||
|
||||
// Initial context menu delegation bind
|
||||
bindFolderManagerContextMenu();
|
||||
|
||||
@@ -1309,6 +1624,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
await loadFolderTree();
|
||||
const base = source.split('/').pop();
|
||||
const newPath = (destination === 'root' ? '' : destination + '/') + base;
|
||||
const oldColor = window.folderColorMap[source];
|
||||
if (oldColor) {
|
||||
try {
|
||||
await saveFolderColor(newPath, oldColor);
|
||||
await saveFolderColor(source, '');
|
||||
} catch (_) { }
|
||||
}
|
||||
window.currentFolder = newPath;
|
||||
loadFileList(window.currentFolder || 'root');
|
||||
} else {
|
||||
|
||||
@@ -312,7 +312,13 @@ const translations = {
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"watched": "Watched",
|
||||
"reset_progress": "Reset Progress"
|
||||
"reset_progress": "Reset Progress",
|
||||
"color_folder": "Color folder",
|
||||
"choose_color": "Choose a color",
|
||||
"reset_default": "Reset",
|
||||
"save_color": "Save",
|
||||
"folder_color_saved": "Folder color saved.",
|
||||
"folder_color_cleared": "Folder color reset."
|
||||
},
|
||||
es: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { loadFolderTree, refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
|
||||
function showConfirm(message, onConfirm) {
|
||||
@@ -89,6 +89,7 @@ export function setupTrashRestoreDelete() {
|
||||
toggleVisibility("restoreFilesModal", false);
|
||||
loadFileList(window.currentFolder);
|
||||
loadFolderTree(window.currentFolder);
|
||||
refreshFolderIcon(window.currentFolder);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error restoring files:", err);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
|
||||
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
|
||||
/* -----------------------------------------------------
|
||||
@@ -588,7 +589,7 @@ async function initResumableUpload() {
|
||||
if (removeBtn) removeBtn.style.display = "none";
|
||||
setTimeout(() => li.remove(), 5000);
|
||||
}
|
||||
|
||||
refreshFolderIcon(window.currentFolder);
|
||||
loadFileList(window.currentFolder);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v1.9.0';
|
||||
window.APP_VERSION = 'v1.9.1';
|
||||
|
||||
|
Before Width: | Height: | Size: 618 KiB After Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 687 KiB After Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
BIN
resources/filerise-v1.9.0.gif
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
resources/light-color-folder.png
Normal file
|
After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 788 KiB After Width: | Height: | Size: 754 KiB |
|
Before Width: | Height: | Size: 500 KiB After Width: | Height: | Size: 541 KiB |
54
scripts/manual-sync.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# === Update FileRise to v1.9.1 (safe rsync) ===
|
||||
# shellcheck disable=SC2155 # we intentionally assign 'stamp' with command substitution
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
VER="v1.9.1"
|
||||
ASSET="FileRise-${VER}.zip" # If the asset name is different, set it exactly (e.g. FileRise-v1.9.0.zip)
|
||||
WEBROOT="/var/www"
|
||||
TMP="/tmp/filerise-update"
|
||||
|
||||
# 0) (optional) quick backup of critical bits
|
||||
stamp="$(date +%F-%H%M)"
|
||||
mkdir -p /root/backups
|
||||
tar -C "$WEBROOT" -czf "/root/backups/filerise-$stamp.tgz" \
|
||||
public/.htaccess config users uploads metadata || true
|
||||
echo "Backup saved to /root/backups/filerise-$stamp.tgz"
|
||||
|
||||
# 1) Fetch the release zip
|
||||
rm -rf "$TMP"
|
||||
mkdir -p "$TMP"
|
||||
curl -fsSL "https://github.com/error311/FileRise/releases/download/${VER}/${ASSET}" -o "$TMP/$ASSET"
|
||||
|
||||
# 2) Unzip to a staging dir
|
||||
unzip -q "$TMP/$ASSET" -d "$TMP"
|
||||
STAGE_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'FileRise*' ! -path "$TMP" | head -n1 || true)"
|
||||
[ -n "${STAGE_DIR:-}" ] || STAGE_DIR="$TMP"
|
||||
|
||||
# 3) Sync code into /var/www
|
||||
# - keep public/.htaccess
|
||||
# - keep data dirs and current config.php
|
||||
rsync -a --delete \
|
||||
--exclude='public/.htaccess' \
|
||||
--exclude='uploads/***' \
|
||||
--exclude='users/***' \
|
||||
--exclude='metadata/***' \
|
||||
--exclude='config/config.php' \
|
||||
--exclude='.github/***' \
|
||||
--exclude='docker-compose.yml' \
|
||||
"$STAGE_DIR"/ "$WEBROOT"/
|
||||
|
||||
# 4) Ownership (Ubuntu/Debian w/ Apache)
|
||||
chown -R www-data:www-data "$WEBROOT"
|
||||
|
||||
# 5) (optional) Composer autoload optimization if composer is available
|
||||
if command -v composer >/dev/null 2>&1; then
|
||||
cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
|
||||
composer install --no-dev --optimize-autoloader
|
||||
fi
|
||||
|
||||
# 6) Reload Apache (don’t fail the whole script if reload isn’t available)
|
||||
systemctl reload apache2 2>/dev/null || true
|
||||
|
||||
echo "✅ FileRise updated to ${VER} (code). Data and public/.htaccess preserved."
|
||||
390
src/lib/ACL.php
@@ -10,23 +10,38 @@ class ACL
|
||||
private static $path = null;
|
||||
|
||||
private const BUCKETS = [
|
||||
'owners','read','write','share','read_own',
|
||||
'create','upload','edit','rename','copy','move','delete','extract',
|
||||
'share_file','share_folder'
|
||||
'owners',
|
||||
'read',
|
||||
'write',
|
||||
'share',
|
||||
'read_own',
|
||||
'create',
|
||||
'upload',
|
||||
'edit',
|
||||
'rename',
|
||||
'copy',
|
||||
'move',
|
||||
'delete',
|
||||
'extract',
|
||||
'share_file',
|
||||
'share_folder'
|
||||
];
|
||||
|
||||
private static function path(): string {
|
||||
private static function path(): string
|
||||
{
|
||||
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||
return self::$path;
|
||||
}
|
||||
|
||||
public static function normalizeFolder(string $f): string {
|
||||
public static function normalizeFolder(string $f): string
|
||||
{
|
||||
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
|
||||
if ($f === '' || $f === 'root') return 'root';
|
||||
return $f;
|
||||
}
|
||||
|
||||
public static function purgeUser(string $user): bool {
|
||||
public static function purgeUser(string $user): bool
|
||||
{
|
||||
$user = (string)$user;
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$changed = false;
|
||||
@@ -41,49 +56,107 @@ class ACL
|
||||
return $changed ? self::save($acl) : true;
|
||||
}
|
||||
public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
if (self::hasGrant($user, $folder, 'owners')) return true;
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
if (self::hasGrant($user, $folder, 'owners')) return true;
|
||||
|
||||
$folder = trim($folder, "/\\ ");
|
||||
if ($folder === '' || $folder === 'root') return false;
|
||||
$folder = trim($folder, "/\\ ");
|
||||
if ($folder === '' || $folder === 'root') return false;
|
||||
|
||||
$parts = explode('/', $folder);
|
||||
while (count($parts) > 1) {
|
||||
array_pop($parts);
|
||||
$parent = implode('/', $parts);
|
||||
if (self::hasGrant($user, $parent, 'owners')) return true;
|
||||
$parts = explode('/', $folder);
|
||||
while (count($parts) > 1) {
|
||||
array_pop($parts);
|
||||
$parent = implode('/', $parts);
|
||||
if (self::hasGrant($user, $parent, 'owners')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function migrateSubtree(string $source, string $target): array
|
||||
{
|
||||
// PHP <8 polyfill
|
||||
if (!function_exists('str_starts_with')) {
|
||||
function str_starts_with(string $h, string $n): bool
|
||||
{
|
||||
return $n === '' || strncmp($h, $n, strlen($n)) === 0;
|
||||
}
|
||||
}
|
||||
|
||||
$src = self::normalizeFolder($source);
|
||||
$dst = self::normalizeFolder($target);
|
||||
if ($src === 'root') return ['changed' => false, 'moved' => 0];
|
||||
|
||||
$file = self::path(); // e.g. META_DIR/folder_acl.json
|
||||
$raw = @file_get_contents($file);
|
||||
$map = is_string($raw) ? json_decode($raw, true) : [];
|
||||
if (!is_array($map)) $map = [];
|
||||
|
||||
$prefix = $src;
|
||||
$needle = $src . '/';
|
||||
|
||||
$new = $map;
|
||||
$changed = false;
|
||||
$moved = 0;
|
||||
|
||||
foreach ($map as $key => $entry) {
|
||||
$isMatch = ($key === $prefix) || str_starts_with($key . '/', $needle);
|
||||
if (!$isMatch) continue;
|
||||
|
||||
unset($new[$key]);
|
||||
|
||||
$suffix = substr($key, strlen($prefix)); // '' or '/sub/...'
|
||||
$newKey = ($dst === 'root') ? ltrim($suffix, '/\\') : rtrim($dst, '/\\') . $suffix;
|
||||
|
||||
// keep only known buckets (defensive)
|
||||
if (is_array($entry)) {
|
||||
$clean = [];
|
||||
foreach (self::BUCKETS as $b) if (array_key_exists($b, $entry)) $clean[$b] = $entry[$b];
|
||||
$entry = $clean ?: $entry;
|
||||
}
|
||||
|
||||
// overwrite any existing entry at destination path (safer than union)
|
||||
$new[$newKey] = $entry;
|
||||
$changed = true;
|
||||
$moved++;
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
@file_put_contents($file, json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
@chmod($file, 0664);
|
||||
self::$cache = $new; // keep in-process cache fresh if you use it
|
||||
}
|
||||
|
||||
return ['changed' => $changed, 'moved' => $moved];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
|
||||
public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
{
|
||||
$old = self::normalizeFolder($oldFolder);
|
||||
$new = self::normalizeFolder($newFolder);
|
||||
if ($old === '' || $old === 'root') return; // nothing to re-key for root
|
||||
public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
{
|
||||
$old = self::normalizeFolder($oldFolder);
|
||||
$new = self::normalizeFolder($newFolder);
|
||||
if ($old === '' || $old === 'root') return; // nothing to re-key for root
|
||||
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
|
||||
|
||||
$rebased = [];
|
||||
foreach ($acl['folders'] as $k => $rec) {
|
||||
if ($k === $old || strpos($k, $old . '/') === 0) {
|
||||
$suffix = substr($k, strlen($old));
|
||||
$suffix = ltrim((string)$suffix, '/');
|
||||
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
|
||||
$rebased[$newKey] = $rec;
|
||||
} else {
|
||||
$rebased[$k] = $rec;
|
||||
$rebased = [];
|
||||
foreach ($acl['folders'] as $k => $rec) {
|
||||
if ($k === $old || strpos($k, $old . '/') === 0) {
|
||||
$suffix = substr($k, strlen($old));
|
||||
$suffix = ltrim((string)$suffix, '/');
|
||||
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
|
||||
$rebased[$newKey] = $rec;
|
||||
} else {
|
||||
$rebased[$k] = $rec;
|
||||
}
|
||||
}
|
||||
$acl['folders'] = $rebased;
|
||||
self::save($acl);
|
||||
}
|
||||
$acl['folders'] = $rebased;
|
||||
self::save($acl);
|
||||
}
|
||||
|
||||
private static function loadFresh(): array {
|
||||
private static function loadFresh(): array
|
||||
{
|
||||
$path = self::path();
|
||||
if (!is_file($path)) {
|
||||
@mkdir(dirname($path), 0755, true);
|
||||
@@ -94,7 +167,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
'read' => ['admin'],
|
||||
'write' => ['admin'],
|
||||
'share' => ['admin'],
|
||||
'read_own'=> [],
|
||||
'read_own' => [],
|
||||
'create' => [],
|
||||
'upload' => [],
|
||||
'edit' => [],
|
||||
@@ -130,12 +203,21 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
|
||||
$healed = false;
|
||||
foreach ($data['folders'] as $folder => &$rec) {
|
||||
if (!is_array($rec)) { $rec = []; $healed = true; }
|
||||
if (!is_array($rec)) {
|
||||
$rec = [];
|
||||
$healed = true;
|
||||
}
|
||||
foreach (self::BUCKETS as $k) {
|
||||
$v = $rec[$k] ?? [];
|
||||
if (!is_array($v)) { $v = []; $healed = true; }
|
||||
if (!is_array($v)) {
|
||||
$v = [];
|
||||
$healed = true;
|
||||
}
|
||||
$v = array_values(array_unique(array_map('strval', $v)));
|
||||
if (($rec[$k] ?? null) !== $v) { $rec[$k] = $v; $healed = true; }
|
||||
if (($rec[$k] ?? null) !== $v) {
|
||||
$rec[$k] = $v;
|
||||
$healed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($rec);
|
||||
@@ -145,19 +227,22 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
return $data;
|
||||
}
|
||||
|
||||
private static function save(array $acl): bool {
|
||||
private static function save(array $acl): bool
|
||||
{
|
||||
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
|
||||
if ($ok) self::$cache = $acl;
|
||||
return $ok;
|
||||
}
|
||||
|
||||
private static function listFor(string $folder, string $key): array {
|
||||
private static function listFor(string $folder, string $key): array
|
||||
{
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$f = $acl['folders'][$folder] ?? null;
|
||||
return is_array($f[$key] ?? null) ? $f[$key] : [];
|
||||
}
|
||||
|
||||
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void {
|
||||
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
if (!isset($acl['folders'][$folder])) {
|
||||
@@ -182,19 +267,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
}
|
||||
}
|
||||
|
||||
public static function isAdmin(array $perms = []): bool {
|
||||
public static function isAdmin(array $perms = []): bool
|
||||
{
|
||||
if (!empty($_SESSION['isAdmin'])) return true;
|
||||
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
|
||||
if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
|
||||
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
|
||||
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
|
||||
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) {
|
||||
if (
|
||||
defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
|
||||
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function hasGrant(string $user, string $folder, string $cap): bool {
|
||||
public static function hasGrant(string $user, string $folder, string $cap): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$capKey = ($cap === 'owner') ? 'owners' : $cap;
|
||||
$arr = self::listFor($folder, $capKey);
|
||||
@@ -202,35 +291,41 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function isOwner(string $user, array $perms, string $folder): bool {
|
||||
public static function isOwner(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners');
|
||||
}
|
||||
|
||||
public static function canManage(string $user, array $perms, string $folder): bool {
|
||||
public static function canManage(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
return self::isOwner($user, $perms, $folder);
|
||||
}
|
||||
|
||||
public static function canRead(string $user, array $perms, string $folder): bool {
|
||||
public static function canRead(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'read');
|
||||
}
|
||||
|
||||
public static function canReadOwn(string $user, array $perms, string $folder): bool {
|
||||
public static function canReadOwn(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
if (self::canRead($user, $perms, $folder)) return true;
|
||||
return self::hasGrant($user, $folder, 'read_own');
|
||||
}
|
||||
|
||||
public static function canWrite(string $user, array $perms, string $folder): bool {
|
||||
public static function canWrite(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canShare(string $user, array $perms, string $folder): bool {
|
||||
public static function canShare(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
@@ -238,7 +333,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
}
|
||||
|
||||
// Legacy-only explicit (to avoid breaking existing callers)
|
||||
public static function explicit(string $folder): array {
|
||||
public static function explicit(string $folder): array
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$rec = $acl['folders'][$folder] ?? [];
|
||||
@@ -257,7 +353,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
}
|
||||
|
||||
// New: full explicit including granular
|
||||
public static function explicitAll(string $folder): array {
|
||||
public static function explicitAll(string $folder): array
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$rec = $acl['folders'][$folder] ?? [];
|
||||
@@ -285,7 +382,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
];
|
||||
}
|
||||
|
||||
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool {
|
||||
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$existing = $acl['folders'][$folder] ?? ['read_own' => []];
|
||||
@@ -314,19 +412,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
return self::save($acl);
|
||||
}
|
||||
|
||||
public static function applyUserGrantsAtomic(string $user, array $grants): array {
|
||||
public static function applyUserGrantsAtomic(string $user, array $grants): array
|
||||
{
|
||||
$user = (string)$user;
|
||||
$path = self::path();
|
||||
|
||||
$fh = @fopen($path, 'c+');
|
||||
if (!$fh) throw new RuntimeException('Cannot open ACL storage');
|
||||
if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); }
|
||||
if (!flock($fh, LOCK_EX)) {
|
||||
fclose($fh);
|
||||
throw new RuntimeException('Cannot lock ACL storage');
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = stream_get_contents($fh);
|
||||
if ($raw === false) $raw = '';
|
||||
$acl = json_decode($raw, true);
|
||||
if (!is_array($acl)) $acl = ['folders'=>[], 'groups'=>[]];
|
||||
if (!is_array($acl)) $acl = ['folders' => [], 'groups' => []];
|
||||
if (!isset($acl['folders']) || !is_array($acl['folders'])) $acl['folders'] = [];
|
||||
if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = [];
|
||||
|
||||
@@ -335,7 +437,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
foreach ($grants as $folder => $caps) {
|
||||
$ff = self::normalizeFolder((string)$folder);
|
||||
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = [];
|
||||
$rec =& $acl['folders'][$ff];
|
||||
$rec = &$acl['folders'][$ff];
|
||||
|
||||
foreach (self::BUCKETS as $k) {
|
||||
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
|
||||
@@ -365,10 +467,16 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
|
||||
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
|
||||
|
||||
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true; }
|
||||
if ($m) {
|
||||
$v = true;
|
||||
$w = true;
|
||||
$u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true;
|
||||
}
|
||||
if ($u && !$v && !$vo) $vo = true;
|
||||
//if ($s && !$v) $v = true;
|
||||
if ($w) { $c = $u = $ed = $rn = $cp = $dl = $ex = true; }
|
||||
if ($w) {
|
||||
$c = $u = $ed = $rn = $cp = $dl = $ex = true;
|
||||
}
|
||||
|
||||
if ($m) $rec['owners'][] = $user;
|
||||
if ($v) $rec['read'][] = $user;
|
||||
@@ -385,7 +493,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
if ($dl) $rec['delete'][] = $user;
|
||||
if ($ex) $rec['extract'][] = $user;
|
||||
if ($sf) $rec['share_file'][] = $user;
|
||||
if ($sfo)$rec['share_folder'][] = $user;
|
||||
if ($sfo) $rec['share_folder'][] = $user;
|
||||
|
||||
foreach (self::BUCKETS as $k) {
|
||||
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
|
||||
@@ -409,90 +517,102 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
}
|
||||
}
|
||||
|
||||
// --- Granular write family -----------------------------------------------
|
||||
// --- Granular write family -----------------------------------------------
|
||||
|
||||
public static function canCreate(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'create')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canCreate(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'create')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canCreateFolder(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
// Only owners/managers can create subfolders under $folder
|
||||
return self::hasGrant($user, $folder, 'owners');
|
||||
}
|
||||
public static function canCreateFolder(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
// Only owners/managers can create subfolders under $folder
|
||||
return self::hasGrant($user, $folder, 'owners');
|
||||
}
|
||||
|
||||
public static function canUpload(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'upload')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canUpload(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'upload')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canEdit(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'edit')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canEdit(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'edit')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canRename(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'rename')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canRename(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'rename')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canCopy(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'copy')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canCopy(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'copy')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canMove(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||
}
|
||||
public static function canMove(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||
}
|
||||
|
||||
public static function canMoveFolder(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||
}
|
||||
public static function canMoveFolder(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||
}
|
||||
|
||||
public static function canDelete(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'delete')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canDelete(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'delete')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canExtract(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'extract')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canExtract(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'extract')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
/** Sharing: files use share, folders require share + full-view. */
|
||||
public static function canShareFile(string $user, array $perms, string $folder): bool {
|
||||
public static function canShareFile(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||
}
|
||||
public static function canShareFolder(string $user, array $perms, string $folder): bool {
|
||||
public static function canShareFolder(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||
|
||||
90
src/models/FolderMeta.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
// src/models/FolderMeta.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PROJECT_ROOT . '/config/config.php';
|
||||
require_once __DIR__ . '/../../src/lib/ACL.php';
|
||||
|
||||
class FolderMeta
|
||||
{
|
||||
private static function path(): string {
|
||||
return rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_colors.json';
|
||||
}
|
||||
|
||||
public static function normalizeFolder(string $folder): string {
|
||||
$f = trim(str_replace('\\','/',$folder), "/ \t\r\n");
|
||||
return ($f === '' || $f === 'root') ? 'root' : $f;
|
||||
}
|
||||
|
||||
/** Normalize hex (accepts #RGB or #RRGGBB, returns #RRGGBB) */
|
||||
public static function normalizeHex(?string $hex): ?string {
|
||||
if ($hex === null || $hex === '') return null;
|
||||
if (!preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $hex)) {
|
||||
throw new \InvalidArgumentException('Invalid color hex');
|
||||
}
|
||||
if (strlen($hex) === 4) {
|
||||
$hex = '#' . $hex[1].$hex[1] . $hex[2].$hex[2] . $hex[3].$hex[3];
|
||||
}
|
||||
return strtoupper($hex);
|
||||
}
|
||||
|
||||
/** Read full map from disk */
|
||||
public static function getMap(): array {
|
||||
$file = self::path();
|
||||
$raw = @file_get_contents($file);
|
||||
$map = is_string($raw) ? json_decode($raw, true) : [];
|
||||
return is_array($map) ? $map : [];
|
||||
}
|
||||
|
||||
/** Write full map to disk (atomic-ish) */
|
||||
private static function writeMap(array $map): void {
|
||||
$file = self::path();
|
||||
$dir = dirname($file);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||
$tmp = $file . '.tmp';
|
||||
@file_put_contents($tmp, json_encode($map, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
@rename($tmp, $file);
|
||||
@chmod($file, 0664);
|
||||
}
|
||||
|
||||
/** Set or clear a color for one folder */
|
||||
public static function setColor(string $folder, ?string $hex): array {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$hex = self::normalizeHex($hex);
|
||||
$map = self::getMap();
|
||||
|
||||
if ($hex === null) unset($map[$folder]);
|
||||
else $map[$folder] = $hex;
|
||||
|
||||
self::writeMap($map);
|
||||
return ['folder'=>$folder, 'color'=>$map[$folder] ?? null];
|
||||
}
|
||||
|
||||
/** Migrate color entries for a whole subtree (used by move/rename) */
|
||||
public static function migrateSubtree(string $source, string $target): array {
|
||||
$src = self::normalizeFolder($source);
|
||||
$dst = self::normalizeFolder($target);
|
||||
if ($src === 'root') return ['changed'=>false, 'moved'=>0];
|
||||
|
||||
$map = self::getMap();
|
||||
if (!$map) return ['changed'=>false, 'moved'=>0];
|
||||
|
||||
$new = $map;
|
||||
$moved = 0;
|
||||
|
||||
foreach ($map as $key => $hex) {
|
||||
$isSelf = ($key === $src);
|
||||
$isSub = str_starts_with($key.'/', $src.'/');
|
||||
if (!$isSelf && !$isSub) continue;
|
||||
|
||||
unset($new[$key]);
|
||||
$suffix = substr($key, strlen($src)); // '' or '/child/...'
|
||||
$newKey = $dst === 'root' ? ltrim($suffix,'/') : rtrim($dst,'/') . $suffix;
|
||||
$new[$newKey] = $hex;
|
||||
$moved++;
|
||||
}
|
||||
|
||||
if ($moved) self::writeMap($new);
|
||||
return ['changed'=> (bool)$moved, 'moved'=> $moved];
|
||||
}
|
||||
}
|
||||