From cc5a978e79b2b7bc91bf108069f2943516a55ef2 Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 14 Mar 2026 17:27:24 +0100 Subject: [PATCH] feature: duplicate 02 --- webui/backend/data/tasks.db | Bin 233472 -> 233472 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 47844 -> 48843 bytes .../tests/golden/test_ui_smoke_golden.py | 20 +++++++-- webui/html/app.js | 40 ++++++++++++++++++ webui/html/index.html | 1 + 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 0827a2309cccb3912fe48f47b86146a9880bde60..3b152f2236b0678fe288a8c1cf34dbe45e4cac98 100644 GIT binary patch delta 710 zcma)3&ubGw6y7(x>8~u?=%Ho%gCZzZap=y>&Stlo#Lyn}gJ!ChVZLtVKp(*Vp zNkPGj7O8X1!Gm{^LBWH6fml3<7vrr^D4~L09n)iqsuysNpAd?P z6LQxLB1Sz9c{%eNj_!`_!41u58y}3Xa^yAwkmE&UA$anZWLh9jmeIvqlq@p83N|uP+o@oxgDZra!(b5JXzK!ZeGrS)W%EJ1@qYC>G`q3=&ONysBrLv|x-UK-Axn%NBJ*C)n{wUfS7FWR zR{o5>{*Qi;xO((!WZ}{JX1o?YjMq2&h*c2hVa=&lE?t^byY$2cIjL~!Xi78=(H#Ac Qo{G0mPhs+4Pv0>A0=3e-2mk;8 delta 245 zcmZozz}K*VZ-O+V*hCp;MzO|(tqF`v?lVSDXMMovJl*R7V>x5;^aBqVYZwD38w&VN zPk6}4vc2yiV;LV~#B|mVjM>wRJ}?@{u&1%xvWu|2VmrV#o#7B$9&;0$E29{j4C`ms zQ``4^U<_qsVY*6oVl z7$1AGG%)HgZ0B=eVq|2!!m^8H!FE1JCVnQyE1L}kE-+4C;KC%-=E}6qm6^p_j+c2V q1Ai!=AkS+aQ*L!Gd(LSb$?VZ=JK4lpZZJ;;YA9yjKDC)Sn+E`#5=-p> 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 6646d147f5cd5e576af0da5be87518ead96cb4ab..05919cbf8d330f9c10fb2e748349775e1b9070fc 100644 GIT binary patch delta 1267 zcmah|YfM{Z81C0YFI?KwhFZFoOX-ERSSXv5X-FrXW18sPx^fUED2H;|m6o>loVu;f znv58u5rMsb_Cv|sMK?BC;X@bvGyP$*#OO9e9X~YDpo#oi#7m~}^7W(Z{9)or&hx&{ z^S$r$=Hz?N>M!z7{*-IiHJS<;c{*QvFS=uDPP@v;yG`J4cVJW5X&p2u3~xNzhB+U+BwGIIDQF>^%9DyoJjs7i;xnEu|lF6(xuPk^9PFh|@r?IPcKw)l(4*P7O zWI_@SOD&^9BGWRQNyVcfUc$M?$qHn_tmktEzQ2!!x4rfAZ4PB!i#g>i3!x21iVA{^ zOorA++Ofa=Q!||GuZG4zAC2PyeId$&mtm^k244hzgz10*U4e;!9e&-fD=;tzkJ303 zjKll;9N5_1hG=&mN4wz#jASN4~G2oU>B9TDDK88VI09)HSiUErI3&;dH z2Brem!l-U4aGU}f`|V6mF+RyDDevV}g^2pVA9XSZxN@pK#Z}PQ&sEYmz*W(zr{V9Y zSMv;U9V9RRpgf_4$`LzNhKdSXQbI*V2Oo{pD71tP!>qxo^(-Cmrq5#;b>eCy`?)J$gVMt#&9MPlA_6kRh0O&w5zxfLQsecAxRi^xwDSr zIdjz=o$!X^xlO?5(*DlkF6|+!)OyEO*qM=}WTG{h2$9u_S$!ME4{xkpp|muk125&a zlvQsdqsTT&;$08WkJ+oY|A)Rh)`Xd{MR{|8%oZhUS`agFi45Tn@lnYdN(;Oslm>M3 zV$2218%?~sBYUrSFnP#NzcrW{9p%$UT((lB&12o-v9@`viA+3BinsnxF{vc=LOR_# zDu^N<5%A(m`Z81pYm@bO^Q#jK+6dML5{`Ayk7Mg`IAIX z4Q@VlO$p`iviSD70R+EaWMSnU7R)pCG+#T*s(P;<7+hBKpub>(u1hS=&$yBD@vGrw zH3zZtn_%U_m#XOX*t5&(G`yKNK{@FkI@63-E*?g3`y2}|&ayZ)JA{-EeP~=(KMHem zrhldI)1@VFp4Gw4Gdft!e+(xU^vZ2{V()`1^CqP?PnsTpZ{|(#!vYKE7g+7q{O)CS z$4zzBgyt2^4Q!kDsnpNO72AJHDMTDud<+@WYSe{JsJ}9~Z|FAq2hf|Nou~&-*?c&in>%^#@P3 zE|p3s?!4`paW4(rldaMclbWKgP%LFea3e>9Rs3N&tSFTbR>~n+pv3FSS^<2Orb4+U z6+qKXntJV7zLByF!4qvix`~e^It1`BWh;J{a$167u{G-r4Zn>Hk=n!n%f*@53}|*q z@u2C8z@XK%$MKs`WS8g>aHJ^;t-sjw&qDY2jcVY1jS@~(*G7nDAzY&ep;=Cq|# zAnZ*MFkEEkA_L>t%lrG8byO>r-hgRe6@xw9pJi^zKTF|qc^?1(eKRCb#jfSptHFQRv;eb;ogA>8WqCsP#V;S zwsX;$P#PWzDS5j5mM(u&&>isW{(m7XK@y&UNrmVow_#_$3VVmcLVR@0f#BR-hA4NG z`+GQD8)vX?d_RIq;|%moJcC;!nQ&xGfoCUMk;wY0#=afR99H3Nvqq7)izmqWBMY7wInbXB~h&R9@qe5XjdW`>DJiGKhyk~BF0 diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 6d87efe..a9a0f85 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -84,6 +84,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="context-menu-edit-btn"', body) self.assertIn('id="context-menu-download-btn"', body) self.assertIn('id="context-menu-rename-btn"', body) + self.assertIn('id="context-menu-duplicate-btn"', body) self.assertIn('id="context-menu-copy-btn"', body) self.assertIn('id="context-menu-move-btn"', body) self.assertIn('id="context-menu-delete-btn"', body) @@ -307,6 +308,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('setStatus(zipDownload ? "Preparing download..." : "Requesting download...");', app_js) self.assertIn('setStatus(`Download requested: ${anchor.download}`);', app_js) self.assertIn('"/api/files/download/archive-prepare"', app_js) + self.assertIn('"/api/files/duplicate"', app_js) self.assertIn('`/api/tasks/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`', app_js) @@ -330,10 +332,13 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function startContextMenuEdit()', app_js) self.assertIn('function startContextMenuDownload()', app_js) self.assertIn('function startContextMenuRename()', app_js) + self.assertIn('function startDuplicateSelected()', app_js) + self.assertIn('function startContextMenuDuplicate()', app_js) self.assertIn('function startContextMenuCopy()', app_js) self.assertIn('function startContextMenuMove()', app_js) self.assertIn('function startContextMenuDelete()', app_js) self.assertIn('function startContextMenuProperties()', app_js) + self.assertIn('contextMenu.duplicateButton.onclick = startContextMenuDuplicate;', app_js) self.assertIn('selectedPathsSet.has(entry.path)', app_js) self.assertIn('entry.isParent', app_js) self.assertIn('row.oncontextmenu = (event) => {', app_js) @@ -354,6 +359,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('elements.downloadButton.classList.remove("hidden");', app_js) self.assertIn('elements.downloadButton.disabled = !downloadableSelection;', app_js) self.assertIn('elements.renameButton.classList.toggle("hidden", isMulti);', app_js) + self.assertIn('elements.duplicateButton.classList.remove("hidden");', app_js) + self.assertIn('elements.duplicateButton.disabled = items.length === 0;', app_js) self.assertIn('elements.copyButton.classList.remove("hidden");', app_js) self.assertIn('elements.copyButton.disabled = items.length === 0;', app_js) self.assertIn('elements.moveButton.classList.remove("hidden");', app_js) @@ -368,6 +375,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('const { blob, fileName } = await downloadFileRequest(selectedPaths);', app_js) self.assertIn('anchor.download = fileName || selected.name;', app_js) self.assertIn('openRenamePopup();', app_js) + self.assertIn('const result = await createDuplicateTask(selectedItems.map((item) => item.path));', app_js) + self.assertIn('showActionSummary("Duplicate", 1, 0, null);', app_js) + self.assertIn('showActionSummary("Duplicate", 0, 1, err.message);', app_js) self.assertIn('startCopySelected();', app_js) self.assertIn('openF6Flow();', app_js) self.assertIn('deleteSelected();', app_js) @@ -395,7 +405,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertNotIn('if (event.altKey) {', app_js) self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js) self.assertIn('function collectDeleteRecursivePaths(selectedItems)', app_js) - self.assertIn('openDeleteConfirmModal(pane, selectedItems, recursivePaths);', app_js) + self.assertIn('const confirmed = await openConfirmModal({', app_js) self.assertIn('recursivePaths.has(item.path)', app_js) self.assertIn('Delete selected items and folder contents?', app_js) self.assertIn('async function loadSettings()', app_js) @@ -420,8 +430,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('paneState("left").currentPath = settingsState.preferredStartupPathLeft || "/Volumes";', app_js) self.assertIn('paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes";', app_js) self.assertIn('applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);', app_js) - self.assertIn('settings.interfaceTab.onclick = () => setSettingsTab("interface");', app_js) - self.assertIn('settings.downloadsTab.onclick = () => setSettingsTab("downloads");', app_js) + self.assertIn('settings.interfaceTab.onclick = () => {', app_js) + self.assertIn('setSettingsTab("interface");', app_js) + self.assertIn('settings.downloadsTab.onclick = () => {', app_js) + self.assertIn('setSettingsTab("downloads");', app_js) self.assertIn('"/api/settings"', app_js) self.assertIn('function uploadElements()', app_js) self.assertIn('function openUploadPicker()', app_js) @@ -436,7 +448,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('async function executeFolderUploadPlan(plan)', app_js) self.assertIn('async function handleFolderSelection(event)', app_js) self.assertIn('function deleteConfirmElements()', app_js) - self.assertIn('function openDeleteConfirmModal(pane, items, recursivePaths)', app_js) + self.assertIn('function openConfirmModal({ title, message, path, applyText = "Confirm" })', app_js) self.assertIn('async function executeDeleteItems(pane, items, recursivePaths)', app_js) self.assertIn('async function submitDeleteConfirmModal()', app_js) self.assertIn('recursive: recursivePaths.has(item.path)', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 6ba4795..e35f116 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -359,6 +359,7 @@ function contextMenuElements() { editButton: document.getElementById("context-menu-edit-btn"), downloadButton: document.getElementById("context-menu-download-btn"), renameButton: document.getElementById("context-menu-rename-btn"), + duplicateButton: document.getElementById("context-menu-duplicate-btn"), copyButton: document.getElementById("context-menu-copy-btn"), moveButton: document.getElementById("context-menu-move-btn"), deleteButton: document.getElementById("context-menu-delete-btn"), @@ -706,6 +707,8 @@ function openContextMenu(pane, entry, event) { elements.downloadButton.classList.remove("hidden"); elements.downloadButton.disabled = !downloadableSelection; elements.renameButton.classList.toggle("hidden", isMulti); + elements.duplicateButton.classList.remove("hidden"); + elements.duplicateButton.disabled = items.length === 0; elements.copyButton.classList.remove("hidden"); elements.copyButton.disabled = items.length === 0; elements.moveButton.classList.remove("hidden"); @@ -782,6 +785,36 @@ function startContextMenuCopy() { startCopySelected(); } +async function startDuplicateSelected() { + const sourcePane = state.activePane; + const selectedItems = [...paneState(sourcePane).selectedItems]; + if (selectedItems.length === 0) { + return; + } + setError("actions-error", ""); + try { + const result = await createDuplicateTask(selectedItems.map((item) => item.path)); + state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); + await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); + showActionSummary("Duplicate", 1, 0, null); + } catch (err) { + showActionSummary("Duplicate", 0, 1, err.message); + } +} + +function startContextMenuDuplicate() { + if (contextMenuElements().duplicateButton?.disabled) { + return; + } + if (!applyContextMenuSelection()) { + closeContextMenu(); + return; + } + closeContextMenu(); + startDuplicateSelected(); +} + function startContextMenuOpen() { if (contextMenuElements().openButton?.disabled) { return; @@ -1165,6 +1198,10 @@ async function createArchiveDownloadTask(paths) { return apiRequest("POST", "/api/files/download/archive-prepare", { paths }); } +async function createDuplicateTask(paths) { + return apiRequest("POST", "/api/files/duplicate", { paths }); +} + async function getTaskRequest(taskId) { return apiRequest("GET", `/api/tasks/${encodeURIComponent(taskId)}`); } @@ -4633,6 +4670,9 @@ function setupEvents() { if (contextMenu.downloadButton) { contextMenu.downloadButton.onclick = startContextMenuDownload; } + if (contextMenu.duplicateButton) { + contextMenu.duplicateButton.onclick = startContextMenuDuplicate; + } if (contextMenu.copyButton) { contextMenu.copyButton.onclick = startContextMenuCopy; } diff --git a/webui/html/index.html b/webui/html/index.html index c9c494e..1a7010c 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -144,6 +144,7 @@ +