From e2e206573d64ee38ff5c0d00ae8950a584fb3f67 Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 14 Mar 2026 06:52:18 +0100 Subject: [PATCH] feat: folder upload - deel 1 --- webui/backend/data/tasks.db | Bin 114688 -> 122880 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 24973 -> 25811 bytes .../tests/golden/test_ui_smoke_golden.py | 12 +- webui/html/app.js | 129 +++++++++++++++++- 4 files changed, 139 insertions(+), 2 deletions(-) diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 7a8cc1d4ce7b35f68c98dc33b574f0ff7356aa53..32c94d5282de070ab4bc582bc463ee1c9efb0f7a 100644 GIT binary patch delta 5455 zcmcgwTWlOx8J@G3+3R?Mvqr`BHBE|LdNyi8sh_Ql7~vTYgmGJZg{vj1Ukvj1Rj zuy3)~**DnN*{kep?B%7-EibT<=82`(+T|^|6QGjS6byP|5MjP%0v zItO78;nh-)BW`e$!cGDjR561r6uN|ph!*@h;^8=77LcPs&gC8a z+olE$OA?_Wxw2$B_|Fw|;>jtprOL7mRnf4Z=n4Wf43meh$Z@J`@V0A62Z^IeJWW%D zvZ1OtNJ)mKra92XpH$0|In6W=5J%0mMawl^xYYiWzcIN{`ell~O23?l(>D@IqAl^o z#J;8E#UIdxyP{IisOY0%hQk2{G93D$FvG|?LuDHD;k%;!RliLq4DXEVK(`6Y9jnPB!X?ewk$pS_v=UE)^u zEWPaQngz`*^wa5|CVrIu1{tDsF7oxCUZc9f@-Q^oe( zUwdNW!$VCUDLzmfPR1w-|9_x$-s*n97)bH_J()Bfiev6e1bgH_*sHfdPw^m$=CH`LTlY7z5mfmsz-Vq=*bH{z4+Bu*9I5$3qV3`w}Z=-^Z(sSeb813D15K59&VY0Ns3$6<^4{T%-wLLTzKj?&3z zc5GWQ5(w~O_s&q?$4{p|SJ_)slBs&OfDSbfboLdGP_6R?NvdbuR#3_IePL{BdWyt| zaEy|7;XLT_11uXlx`y_5`$3}aok70dufGEZ1K;9()&n2gR_AL*DnKn~=9B$e!>gXn z@gsgaNVcRMbx=lW;$1N?#P^XXTIoV)Tnoyu--Y4gA*yvDE=l!JbGZxm)EZ+#8I3+= z?4KT+{IcH*d4)4pS>fSYFDN^$l1137j3BlkyU7aVs+4n1()}ioWJweJO~@VC7!QAU ztvG8XY6pyeXmATn2mMZ9hQjk;Nzx{=^lHW52M-5U*578V!MnoE`rP&deq~AUq_P`% z^?#$)`vWQ%8pQJ8&%wE9j=>SopowbePTxpvi{FaLpy|aT0J|64PWXC=Dbd%%{$hXY z{F7J(>e;ufFwdsYV=TAj;%A6p)_PluckL07X$)awpTNSF@2iui?=7tLvO>(O2f;v- z!l{B;O=#MP^T+bT;mDX$6wlBL@bwPL~tH%_1LoAwcfE9)bwuZ*Z*0cF;mNjF15yON}) zwd#siK;#F#a?25zDkn-J3QiT_v#h8u_DuO8!usTcXt+3xH5r4bwkW5{=jsor%4<7$ zZ{j@I5wbh4aS~Y7m`zaiz%xMg9no5OE8?E#@l}S(SIS#$;w4T}^!dR_A4#~pK9Y!3 z;o@%G1vGBFiufSgfOThsTGbs5P`h&4<*Totw2>+ytqN6DX(LtBI7KIV2$(Ara|K@! lzQU*raMa&ukdpz{KS}z3%irc`7de95eu-J>eudZd{{k^rzgYkP delta 455 zcmYL^Pe>F|9LL{#^XARId4GP}mLTiax>$#ekaFpe6?KT}7_uOQL81-w%8~&Z^}&mfK;DBZM+u z=NUX6TwR2W4Qug$((nhG@B_ZX7S!PrY{ELMg?l;!2tQ;CNjgk&C~QBPg<0W%HIZ0;eFMfePpf`p6LXEJRea${? zZj(XT)X#KXBH0&|2XL5r@>9`%>sGA0y1+blv-1443g_O8E^#u8j-$6o6nt62NBrIr z?oE&{bV0ki9!-{T8==ND%6sKQerp1oKDm#({LiC!2~THsyP(B^TwC?2~zmvzkPr8n@n#YYW1*zxr1k+#XFj_@U{!)u=Xu5Ar0V s4W#d=tO)r|qKh_y@&=6>_i&~|QdvZ>PajriWidRKJSU^#3%qRn1v>tWvH$=8 diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index fdf1700cd8b732bc7498efeb62b128a1a6045502..84688b6b98401ab6e7d2aa090807e95e71ceee13 100644 GIT binary patch delta 934 zcmaKo+fUO_9DvttK)LJ^!7(B+WrJl$*&>QY7+ahX5+Dk(6hR@Ourp|=?JOO-ApwIg z1_{IC1uuwrNqjOf^#KwejK1iL8F5~sA@LtjNi;raOzcj_5@KQ>&ez}fyR~QL1@Yz; zVg7D173kFGmunR3AOHW&Q?B?x$|n1?;JXsxCM^%hvuR8~BdMx0nN zYXxf>mAc(U1g_}b^C$=X1~y_0eFaY9Z2B~eGX|8bccO2=K}=vjiTyd%_xUtGh5dQu z*Zye&eereSHQfy^!iXjtoa(s_Dh$(%#)}!wsI>Ywva=bR$Z$?&vZlgtDH9_m2T!@2 z;T0wq$5)v=%xesZInU%{US|q0Z!m?JH<_X|iCgHo-;=*U-=-Jq^nw|^_E+RA(0AxX zv^`Lz`X%hGC=w_&nCZJ{y_hmG7W!WLWea1a@9)*8?_Q^)bb~tZKzSNS=!piD2$rF6 zu(ZVuN1-4C${(P{;>BE;(u9}gja&?NF9D7A9~!%%VqpC*|IFS4Nxsmufu zh6!glDh$Y+C{R!siAqpw+*}#omM!{scDyE=?2mGMXwv|m72IKtha=UL%u0h$ZfE5o z5UHXETV4pN}?!h>Ii!hj#KWmr9^>`fq0pgprR4pqFxAvQxarDv3jMvamu1^ zD^!*`y!zUXWM@x`?EOF%&6+NllD3@>J5yxs|3l?nXB(00TUIL%p}AxBt<5j@rO2b% zCjatiiflsW6E-qwJNT?AMYf{V<96igu_(1&pYjMnvG%nRwlhYek(ejn+rg?G{LtZW KeH5Ky%l`rs^Gav{ delta 457 zcmcb7lCk$NBj0CUUM>b8P&V9>dC+|$pMe@vg~VnDwX1BC*V>9q*3=aM)4sYUlmBXp zOpfIe0MVOw>Z-7SC3frdco@MdB8}IvZqB!=V`Q!lWtpsCsm#bS`Gck+ zb1jeuqU)e^J&@i2mu-ZKHbLoTAnjlU)jRpEbp+VbNE z2h+Ww9FqlXl^Hp~y#7!wFg+oZ8%$3O#|M`o)SEDa*Vwy=j7mN!BZ`n z^7tnw*ee3Xrvt?|*vm4`nEXLlfhCkbcqWJ;6e79GwtNs92l<0#2$GeI`Ile z(&o*P{*0TmqhGNzrfd#N_GUE7V_|eTZ{vVhbSfq-?fNlT)aaNSK diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 1e0243f..fc9923e 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -178,7 +178,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function effectiveThemeKey(theme, colorMode)', app_js) self.assertIn("document.documentElement.dataset.theme", app_js) self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js) - self.assertIn('document.getElementById("upload-btn").onclick = openUploadPicker;', app_js) + self.assertIn('document.getElementById("upload-btn").onclick = (event) => {', app_js) + self.assertIn('if (event.altKey) {', app_js) + self.assertIn("openFolderPicker();", app_js) + self.assertIn("openUploadPicker();", app_js) self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js) self.assertIn('async function loadSettings()', app_js) self.assertIn('await loadSettings();', app_js) @@ -200,6 +203,13 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('"/api/settings"', app_js) self.assertIn('function uploadElements()', app_js) self.assertIn('function openUploadPicker()', app_js) + self.assertIn('function ensureFolderUploadPicker()', app_js) + self.assertIn('function openFolderPicker()', app_js) + self.assertIn('function buildFolderUploadPlan(files, targetPath)', app_js) + self.assertIn('function handleFolderSelection(event)', app_js) + self.assertIn('input.setAttribute("webkitdirectory", "")', app_js) + self.assertIn('Folder: ${plan.rootFolderName} (plan only)', app_js) + self.assertIn('Folder upload plan ready:', app_js) self.assertIn('async function handleUploadSelection(event)', app_js) self.assertIn('uploadElements().input.onchange = handleUploadSelection;', app_js) self.assertIn('"/api/files/upload"', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 8357e08..8a438db 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -69,6 +69,14 @@ let uploadState = { cancelled: false, conflictResolver: null, }; +let folderUploadPlanState = { + targetPath: "", + rootFolderName: "", + entries: [], + fileCount: 0, + subfolderCount: 0, +}; +let folderUploadPickerInput = null; let settingsState = { activeTab: "general", logsLoaded: false, @@ -325,6 +333,22 @@ function uploadElements() { }; } +function ensureFolderUploadPicker() { + if (folderUploadPickerInput) { + return folderUploadPickerInput; + } + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.hidden = true; + input.setAttribute("webkitdirectory", ""); + input.setAttribute("directory", ""); + input.onchange = handleFolderSelection; + document.body.append(input); + folderUploadPickerInput = input; + return folderUploadPickerInput; +} + function uploadConflictElements() { return { overlay: document.getElementById("upload-conflict-modal"), @@ -471,6 +495,16 @@ function resetUploadProgress() { setUploadProgressVisible(false); } +function showFolderUploadPlan(plan) { + const elements = uploadElements(); + folderUploadPlanState = plan; + elements.button.disabled = false; + elements.target.textContent = `Upload to: ${plan.targetPath}`; + elements.currentFile.textContent = `Folder: ${plan.rootFolderName} (plan only)`; + elements.count.textContent = `${plan.fileCount} file${plan.fileCount === 1 ? "" : "s"}${plan.subfolderCount > 0 ? ` • ${plan.subfolderCount} subfolder${plan.subfolderCount === 1 ? "" : "s"}` : ""}`; + setUploadProgressVisible(true); +} + function updateUploadProgress() { const elements = uploadElements(); const total = uploadState.files.length; @@ -494,6 +528,22 @@ function openUploadPicker() { elements.input.click(); } +function openFolderPicker() { + if (uploadState.active) { + return; + } + folderUploadPlanState = { + targetPath: activePaneState().currentPath, + rootFolderName: "", + entries: [], + fileCount: 0, + subfolderCount: 0, + }; + const input = ensureFolderUploadPicker(); + input.value = ""; + input.click(); +} + function isUploadConflictOpen() { const overlay = document.getElementById("upload-conflict-modal"); return Boolean(overlay) && !overlay.classList.contains("hidden"); @@ -522,6 +572,77 @@ function promptUploadConflict(fileName, targetPath, message) { }); } +function countPlannedSubfolders(relativePaths) { + const folderPaths = new Set(); + relativePaths.forEach((relativePath) => { + const parts = relativePath.split("/").filter(Boolean); + if (parts.length <= 1) { + return; + } + let current = ""; + for (let index = 0; index < parts.length - 1; index += 1) { + current = current ? `${current}/${parts[index]}` : parts[index]; + folderPaths.add(current); + } + }); + return folderPaths.size; +} + +function buildFolderUploadPlan(files, targetPath) { + if (!files.length) { + return null; + } + const plannedEntries = files.map((file) => { + const webkitRelativePath = String(file.webkitRelativePath || "").replace(/\\/g, "/"); + const parts = webkitRelativePath.split("/").filter(Boolean); + return { + file, + webkitRelativePath, + rootFolderName: parts[0] || "", + relativePath: parts.length > 1 ? parts.slice(1).join("/") : file.name, + }; + }); + const rootFolderName = plannedEntries[0].rootFolderName; + if (!rootFolderName) { + throw new Error("Folder picker did not return a usable folder structure"); + } + if (plannedEntries.some((entry) => entry.rootFolderName !== rootFolderName || !entry.relativePath)) { + throw new Error("Folder picker returned multiple roots or invalid relative paths"); + } + return { + targetPath, + rootFolderName, + entries: plannedEntries.map((entry) => ({ + name: entry.file.name, + size: entry.file.size, + relativePath: entry.relativePath, + })), + fileCount: plannedEntries.length, + subfolderCount: countPlannedSubfolders(plannedEntries.map((entry) => entry.relativePath)), + }; +} + +function handleFolderSelection(event) { + const files = Array.from(event.target.files || []); + event.target.value = ""; + if (files.length === 0) { + return; + } + try { + const targetPath = folderUploadPlanState.targetPath || activePaneState().currentPath; + const plan = buildFolderUploadPlan(files, targetPath); + if (!plan) { + return; + } + showFolderUploadPlan(plan); + setError("actions-error", ""); + setStatus(`Folder upload plan ready: ${plan.rootFolderName} (${plan.fileCount} file${plan.fileCount === 1 ? "" : "s"})`); + } catch (err) { + setUploadProgressVisible(false); + setActionError("Folder upload", err); + } +} + async function handleUploadSelection(event) { const files = Array.from(event.target.files || []); event.target.value = ""; @@ -3006,7 +3127,13 @@ function setupEvents() { setupPaneEvents("right"); document.addEventListener("keydown", handleKeyboardShortcuts); document.getElementById("theme-toggle").onclick = toggleTheme; - document.getElementById("upload-btn").onclick = openUploadPicker; + document.getElementById("upload-btn").onclick = (event) => { + if (event.altKey) { + openFolderPicker(); + return; + } + openUploadPicker(); + }; document.getElementById("settings-btn").onclick = () => openSettings("general"); document.getElementById("view-btn").onclick = openViewer; document.getElementById("edit-btn").onclick = openEditor;